diff --git a/CHANGELOG.md b/CHANGELOG.md index 95772083f3a76..8a9ca068019dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,150 @@ +2.2.7 +============= +* GitHub issues: + * [#15009](https://github.com/magento/magento2/issues/15009) -- [2.2.4] Gallery theme variables being ignored (fixed in [magento/magento2#16594](https://github.com/magento/magento2/pull/16594)) + * [#16580](https://github.com/magento/magento2/issues/16580) -- Product gallery caption issue (fixed in [magento/magento2#16594](https://github.com/magento/magento2/pull/16594)) + * [#16243](https://github.com/magento/magento2/issues/16243) -- Integration test ProcessCronQueueObserverTest.php succeeds regardless of magento config fixture (fixed in [magento/magento2#17191](https://github.com/magento/magento2/pull/17191)) + * [#17193](https://github.com/magento/magento2/issues/17193) -- Error with translation of confirmation modal buttons (fixed in [magento/magento2#17275](https://github.com/magento/magento2/pull/17275)) + * [#13445](https://github.com/magento/magento2/issues/13445) -- "Shop By" button disabling broken on the search page (fixed in [magento/magento2#15650](https://github.com/magento/magento2/pull/15650)) + * [#16302](https://github.com/magento/magento2/issues/16302) -- JS files located outside the web/js directory (fixed in [magento/magento2#16582](https://github.com/magento/magento2/pull/16582)) + * [#16653](https://github.com/magento/magento2/issues/16653) -- Not possible to create an invoice in Magento 2.3 (fixed in [magento/magento2#16656](https://github.com/magento/magento2/pull/16656)) + * [#16655](https://github.com/magento/magento2/issues/16655) -- Block totalbar not used in invoice create and credit memo create screens (fixed in [magento/magento2#16656](https://github.com/magento/magento2/pull/16656)) + * [#12250](https://github.com/magento/magento2/issues/12250) -- View.xml is inheriting image sizes from parent (so an optional field is replaced by the value of parent) (fixed in [magento/magento2#14537](https://github.com/magento/magento2/pull/14537)) + * [#13480](https://github.com/magento/magento2/issues/13480) -- Unable to activate logs after switching from production mode to developer (fixed in [magento/magento2#15335](https://github.com/magento/magento2/pull/15335)) + * [#10687](https://github.com/magento/magento2/issues/10687) -- Product image roles randomly disappear (fixed in [magento/magento2#15606](https://github.com/magento/magento2/pull/15606)) + * [#4803](https://github.com/magento/magento2/issues/4803) -- Incorrect return value from Product Attribute Repository (fixed in [magento/magento2#15691](https://github.com/magento/magento2/pull/15691)) + * [#15028](https://github.com/magento/magento2/issues/15028) -- Configurable product addtocart with restAPI not working as expected (fixed in [magento/magento2#15720](https://github.com/magento/magento2/pull/15720)) + * [#7372](https://github.com/magento/magento2/issues/7372) -- Product images gets removed from "Images And Videos" after validation alert. (fixed in [magento/magento2#16597](https://github.com/magento/magento2/pull/16597)) + * [#13177](https://github.com/magento/magento2/issues/13177) -- Can't save attributes on a configurable product (fixed in [magento/magento2#16597](https://github.com/magento/magento2/pull/16597)) + * [#16544](https://github.com/magento/magento2/issues/16544) -- Some of JS validation rules making fields required (fixed in [magento/magento2#16724](https://github.com/magento/magento2/pull/16724)) + * [#16479](https://github.com/magento/magento2/issues/16479) -- Issue in adding the wishlist of "zero price" product. (fixed in [magento/magento2#17395](https://github.com/magento/magento2/pull/17395)) + * [#15457](https://github.com/magento/magento2/issues/15457) -- Bundle Products price range is showing expired special price from bundle options (fixed in [magento/magento2#15535](https://github.com/magento/magento2/pull/15535)) + * [#16555](https://github.com/magento/magento2/issues/16555) -- "Shipping address is not set" exception in Multishipping Checkout. (fixed in [magento/magento2#16753](https://github.com/magento/magento2/pull/16753)) + * [#17289](https://github.com/magento/magento2/issues/17289) -- Magento 2.2.5: Year-to-date dropdown in Stores>Configuration>General>Reports>Dashboard (fixed in [magento/magento2#17383](https://github.com/magento/magento2/pull/17383)) + * [#16499](https://github.com/magento/magento2/issues/16499) -- User role issue with customer group (fixed in [magento/magento2#17515](https://github.com/magento/magento2/pull/17515)) + * [#12362](https://github.com/magento/magento2/issues/12362) -- Concurrent (quick reload) requests on checkout cause cart to empty - related to session_regenerate_id (fixed in [magento/magento2#14973](https://github.com/magento/magento2/pull/14973)) + * [#6305](https://github.com/magento/magento2/issues/6305) -- Can't save Customizable options (fixed in [magento/magento2#15357](https://github.com/magento/magento2/pull/15357)) + * [#13102](https://github.com/magento/magento2/issues/13102) -- review/product/listAjax/id/{{non existent id}/ (fixed in [magento/magento2#15369](https://github.com/magento/magento2/pull/15369)) + * [#17416](https://github.com/magento/magento2/issues/17416) -- Product image zoom (magnifier) is broken in Safari (fixed in [magento/magento2#17491](https://github.com/magento/magento2/pull/17491)) + * [#17492](https://github.com/magento/magento2/issues/17492) -- "- undefined" displayed in checkout summary when shipping method name is not set (fixed in [magento/magento2#17526](https://github.com/magento/magento2/pull/17526)) + * [#15041](https://github.com/magento/magento2/issues/15041) -- Adding a new fieldset to the admin category editor changes the position of the 'General' fieldset. (fixed in [magento/magento2#17540](https://github.com/magento/magento2/pull/17540)) + * [#13948](https://github.com/magento/magento2/issues/13948) -- Sidebar shortcut to admin dashboard (Magento logo on top left) has no link in web setup wizard (fixed in [magento/magento2#17543](https://github.com/magento/magento2/pull/17543)) + * [#16929](https://github.com/magento/magento2/issues/16929) -- Incorrect displaying Product Image Watermarks on Magento 2.2.5 (fixed in [magento/magento2#17013](https://github.com/magento/magento2/pull/17013)) + * [#14819](https://github.com/magento/magento2/issues/14819) -- Custom Payment Method doesn't uncheck 'My billing and shipping address are the same' (fixed in [magento/magento2#17593](https://github.com/magento/magento2/pull/17593)) + * [#13747](https://github.com/magento/magento2/issues/13747) -- Wysiwyg > Image Uploader >Max width/height (fixed in [magento/magento2#15942](https://github.com/magento/magento2/pull/15942)) + * [#6585](https://github.com/magento/magento2/issues/6585) -- Optional PO number (fixed in [magento/magento2#14393](https://github.com/magento/magento2/pull/14393)) + * [#17648](https://github.com/magento/magento2/issues/17648) -- UI validation rule for valid time am/pm doesn't work when js is minified (fixed in [magento/magento2#17652](https://github.com/magento/magento2/pull/17652)) + * [#17700](https://github.com/magento/magento2/issues/17700) -- Message list component: the message type is always error when parameters specified (fixed in [magento/magento2#17701](https://github.com/magento/magento2/pull/17701)) + * [#16927](https://github.com/magento/magento2/issues/16927) -- 2.2.5 Swagger: With JS minification enabled, the swagger-ui-bundle.js becomes corrupted (fixed in [magento/magento2#17626](https://github.com/magento/magento2/pull/17626)) + * [#14248](https://github.com/magento/magento2/issues/14248) -- Transparent background becomes black for thumbnails of PNG into Wysiwyg editor... (fixed in [magento/magento2#16733](https://github.com/magento/magento2/pull/16733)) + * [#17715](https://github.com/magento/magento2/issues/17715) -- duplicate event in Delete operation transaction "entity_manager_delete_before" (fixed in [magento/magento2#17718](https://github.com/magento/magento2/pull/17718)) + * [#17587](https://github.com/magento/magento2/issues/17587) -- Typo in Magento\Cms\Model\Wysiwyg\Images\Storage function resizeFile($source, $keepRation = true) (fixed in [magento/magento2#17776](https://github.com/magento/magento2/pull/17776)) + * [#17851](https://github.com/magento/magento2/issues/17851) -- Wishlist icon cut on Shopping cart page in mobile view (fixed in [magento/magento2#17877](https://github.com/magento/magento2/pull/17877)) + * [#17789](https://github.com/magento/magento2/issues/17789) -- Next Page button triggered when filtering Customer grid (fixed in [magento/magento2#17870](https://github.com/magento/magento2/pull/17870)) + * [#7903](https://github.com/magento/magento2/issues/7903) -- Datepicker does not scroll (fixed in [magento/magento2#16775](https://github.com/magento/magento2/pull/16775)) +* GitHub pull requests: + * [magento/magento2#16000](https://github.com/magento/magento2/pull/16000) -- Don't force enable "Use system value" checkboxes (by @likemusic) + * [magento/magento2#16505](https://github.com/magento/magento2/pull/16505) -- admin checkout agreement controllers refactor (by @AnshuMishra17) + * [magento/magento2#16594](https://github.com/magento/magento2/pull/16594) -- Fix broken commit in #15040 that accidentally reverted previous changes. (by @gwharton) + * [magento/magento2#17127](https://github.com/magento/magento2/pull/17127) -- Allow 3rd party modules to perform actions after totals calculation (by @navarr) + * [magento/magento2#17122](https://github.com/magento/magento2/pull/17122) -- Added missing exception cause for better error handling (by @woutersamaey) + * [magento/magento2#17153](https://github.com/magento/magento2/pull/17153) -- Set proper text-aligh for the element of the Subtotal column in the Creditmemo email (by @TomashKhamlai) + * [magento/magento2#17203](https://github.com/magento/magento2/pull/17203) -- [Backport] Refactored multiples conditions which could be grouped in a single on (by @mage2pratik) + * [magento/magento2#17236](https://github.com/magento/magento2/pull/17236) -- [Backport] Fixed invalid knockoutjs data binding for Braintree PayPal (by @tiagosampaio) + * [magento/magento2#17217](https://github.com/magento/magento2/pull/17217) -- [Backport] Broken Responsive Layout on Top page (by @mage2pratik) + * [magento/magento2#17246](https://github.com/magento/magento2/pull/17246) -- [Backport] FIXED: FTP user and password strings urldecoded (by @mage2pratik) + * [magento/magento2#16788](https://github.com/magento/magento2/pull/16788) -- Replace sort callbacks to spaceship operator (by @lbajsarowicz) + * [magento/magento2#17103](https://github.com/magento/magento2/pull/17103) -- Code cleanup of lib files (by @mage2pratik) + * [magento/magento2#17191](https://github.com/magento/magento2/pull/17191) -- [Backport 2.2]Filter test result collection with the cron job code defined in the c (by @gelanivishal) + * [magento/magento2#17275](https://github.com/magento/magento2/pull/17275) -- fix #17193 Error with translation of confirmation modal buttons (by @Karlasa) + * [magento/magento2#17291](https://github.com/magento/magento2/pull/17291) -- fix: remove unused ID (by @DanielRuf) + * [magento/magento2#15650](https://github.com/magento/magento2/pull/15650) -- Fixed "Shop By" button disabling broken on the search page #13445 (by @AndreaRivadossi) + * [magento/magento2#16354](https://github.com/magento/magento2/pull/16354) -- Remove unnecessary translation of HTML tags (by @Yogeshks) + * [magento/magento2#16510](https://github.com/magento/magento2/pull/16510) -- Fix the special price expression. (by @DmitryChukhnov) + * [magento/magento2#16582](https://github.com/magento/magento2/pull/16582) -- Resolved : JS files located outside the web/js directory (by @hitesh-wagento) + * [magento/magento2#16656](https://github.com/magento/magento2/pull/16656) -- [Fix #16655] Block totalbar not used in invoice create and credit memo create screens (by @dverkade) + * [magento/magento2#16848](https://github.com/magento/magento2/pull/16848) -- Replace floatval() function by using direct type casting to (float) (by @mhauri) + * [magento/magento2#16849](https://github.com/magento/magento2/pull/16849) -- Replace strval() function by using direct type casting to (string) (by @mhauri) + * [magento/magento2#17070](https://github.com/magento/magento2/pull/17070) -- Resolved special character issue for sidebar (by @deepjoshi94) + * [magento/magento2#17066](https://github.com/magento/magento2/pull/17066) -- Update CMS Page Index (by @hryvinskyi) + * [magento/magento2#17189](https://github.com/magento/magento2/pull/17189) -- Don't add empty method to the cart summary (by @arnoudhgz) + * [magento/magento2#17250](https://github.com/magento/magento2/pull/17250) -- Maintenance: Compare products. Add unit test coverage & missed class property declaration. (by @swnsma) + * [magento/magento2#17327](https://github.com/magento/magento2/pull/17327) -- fix: add missing data-th selector for tables (by @DanielRuf) + * [magento/magento2#17365](https://github.com/magento/magento2/pull/17365) -- [Backport] Fixed some minor css issue (by @arnoudhgz) + * [magento/magento2#17368](https://github.com/magento/magento2/pull/17368) -- [Braintree] Unit tests for TransactionRefund and TransactionVoid classes (by @rogyar) + * [magento/magento2#14537](https://github.com/magento/magento2/pull/14537) -- magento/magento2#12250: View.xml is inheriting image sizes from paren (by @quisse) + * [magento/magento2#15335](https://github.com/magento/magento2/pull/15335) -- Fixed issue #13480 - Unable to activate logs after switching from production mode to developer (by @jayankaghosh) + * [magento/magento2#15606](https://github.com/magento/magento2/pull/15606) -- Fix #10687 - Product image roles disappearing (by @Scarraban) + * [magento/magento2#15691](https://github.com/magento/magento2/pull/15691) -- Fix #4803: Incorrect return value from Product Attribute Repository (by @cream-julian) + * [magento/magento2#15720](https://github.com/magento/magento2/pull/15720) -- Convert to string $option->getValue, in order to be compared with oth (by @zamboten) + * [magento/magento2#16597](https://github.com/magento/magento2/pull/16597) -- Save configurable product options after validation error (by @swnsma) + * [magento/magento2#16724](https://github.com/magento/magento2/pull/16724) -- 16544: fixed behaviour when some of JS validation rules making fields (by @VitaliyBoyko) + * [magento/magento2#16955](https://github.com/magento/magento2/pull/16955) -- fix: remove disabled attribute on region list (by @DanielRuf) + * [magento/magento2#17078](https://github.com/magento/magento2/pull/17078) -- MAGETWO-84608: Cannot perform setup:install if Redis needs a password (by @guillaumegiordana) + * [magento/magento2#17405](https://github.com/magento/magento2/pull/17405) -- [Braintree] Added unit test for instant purchase PayPal token formatter (by @rogyar) + * [magento/magento2#14397](https://github.com/magento/magento2/pull/14397) -- Allows modules with underscores in name to set custom a frontend_model in system.xml (by @bentideswell) + * [magento/magento2#16570](https://github.com/magento/magento2/pull/16570) -- [update] enhance performance on large catalog (by @AurelienLavorel) + * [magento/magento2#17101](https://github.com/magento/magento2/pull/17101) -- Refactory to Magento_Backend module class. (by @tiagosampaio) + * [magento/magento2#17395](https://github.com/magento/magento2/pull/17395) -- [Backport] Fixed add to wishlist issue on product price 0 (by @sreichel) + * [magento/magento2#17437](https://github.com/magento/magento2/pull/17437) -- Improvements in UI component MassActions (by @alexeya-ven) + * [magento/magento2#15535](https://github.com/magento/magento2/pull/15535) -- FIX for issue #15457 - Bundle Products price range is showing expired (by @phoenix128) + * [magento/magento2#15507](https://github.com/magento/magento2/pull/15507) -- fix: cache count() results for loops (by @DanielRuf) + * [magento/magento2#16753](https://github.com/magento/magento2/pull/16753) -- Fix the issue with "Shipping address is not set" exception (by @dmytro-ch) + * [magento/magento2#17454](https://github.com/magento/magento2/pull/17454) -- Braintree: test coverage (by @dmytro-ch) + * [magento/magento2#17383](https://github.com/magento/magento2/pull/17383) -- Magento 2.2.5: Year-to-date dropdown in Stores>Configuration>General>Reports>Dashboard #17289 (by @teddysie) + * [magento/magento2#15171](https://github.com/magento/magento2/pull/15171) -- AD-HOC feat (Profiler): Allow supplying complex profiler configuration (by @andrewhowdencom) + * [magento/magento2#16855](https://github.com/magento/magento2/pull/16855) -- Doesn't work if use date as condition for Catalog Price Rules (by @GlennCheng) + * [magento/magento2#13649](https://github.com/magento/magento2/pull/13649) -- Fix possible undefined index when caching config data (by @mimarcel) + * [magento/magento2#17479](https://github.com/magento/magento2/pull/17479) -- updating lib LESS docs (by @Karlasa) + * [magento/magento2#17505](https://github.com/magento/magento2/pull/17505) -- Refactor: remove some code duplication (by @arnoudhgz) + * [magento/magento2#17515](https://github.com/magento/magento2/pull/17515) -- Solution for User role issue with customer group (by @emiprotech) + * [magento/magento2#17561](https://github.com/magento/magento2/pull/17561) -- Catalog: Add unit tests for Cron classes (by @dmytro-ch) + * [magento/magento2#13133](https://github.com/magento/magento2/pull/13133) -- Magento PayPal checkout fails if email sending fails / other payment does not (by @driskell) + * [magento/magento2#14973](https://github.com/magento/magento2/pull/14973) -- Fix unstable session manager (by @elioermini) + * [magento/magento2#15357](https://github.com/magento/magento2/pull/15357) -- 6305 - Resolved product custom option title save issue (by @Madhumalak) + * [magento/magento2#15369](https://github.com/magento/magento2/pull/15369) -- Fixed review list ajax if product not exist redirect to 404 page #13102 (by @ananth747) + * [magento/magento2#16021](https://github.com/magento/magento2/pull/16021) -- Introduce Block Config Source (by @thomas-blackbird) + * [magento/magento2#17491](https://github.com/magento/magento2/pull/17491) -- [Backport] Fix incorrect image magnifier size bug in Safari (by @dannynimmo) + * [magento/magento2#17526](https://github.com/magento/magento2/pull/17526) -- Fixed undefinded shipping method name issue #17492 (by @gelanivishal) + * [magento/magento2#17540](https://github.com/magento/magento2/pull/17540) -- Fix for #15041 Adding a new fieldset to the admin category editor changes the position of the 'General' fieldset (by @vasilii-b) + * [magento/magento2#17552](https://github.com/magento/magento2/pull/17552) -- Fix proxy generation return type (by @adrian-martinez-interactiv4) + * [magento/magento2#17590](https://github.com/magento/magento2/pull/17590) -- Braintree: Add unit test for CreditCard/TokenFormatter (by @eduard13) + * [magento/magento2#16777](https://github.com/magento/magento2/pull/16777) -- Fix Translation of error message on cart for deleted bundle option. (by @swnsma) + * [magento/magento2#17527](https://github.com/magento/magento2/pull/17527) -- Refactor JS code and added JS component file (by @Yogeshks) + * [magento/magento2#17543](https://github.com/magento/magento2/pull/17543) -- Link logo in web setup wizard to back-end base URL (by @arnoudhgz) + * [magento/magento2#17575](https://github.com/magento/magento2/pull/17575) -- Translated validation error messages (by @Yogeshks) + * [magento/magento2#17013](https://github.com/magento/magento2/pull/17013) -- Fixed #16929 - Incorrect displaying Product Image Watermarks on Magento 2.2.5 (by @ronak2ram) + * [magento/magento2#17484](https://github.com/magento/magento2/pull/17484) -- Fix sending duplicate emails (by @iGerchak) + * [magento/magento2#17593](https://github.com/magento/magento2/pull/17593) -- Fixing the address checkbox being unchecked on payment step. (by @eduard13) + * [magento/magento2#15942](https://github.com/magento/magento2/pull/15942) -- Making configurable settings for MAX_IMAGE_WIDTH and MAX_IMAGE_HEIGHT (by @eduard13) + * [magento/magento2#17602](https://github.com/magento/magento2/pull/17602) -- Fix Custom Attribute Group can not translate in catalog/product page (by @GraysonChiang) + * [magento/magento2#14393](https://github.com/magento/magento2/pull/14393) -- Validate that the PO Number is set on the payment instance. (by @centerax) + * [magento/magento2#17633](https://github.com/magento/magento2/pull/17633) -- Added unit test for newsletter problem model (by @rogyar) + * [magento/magento2#17652](https://github.com/magento/magento2/pull/17652) -- Update time12h javascript validation rule to be compatible with js minify (by @markoshust) + * [magento/magento2#17678](https://github.com/magento/magento2/pull/17678) -- CMS: Add missing unit tests for model classes (by @dmytro-ch) + * [magento/magento2#17521](https://github.com/magento/magento2/pull/17521) -- Translated admin menu titles (by @Yogeshks) + * [magento/magento2#17690](https://github.com/magento/magento2/pull/17690) -- Integration test for reviews delete observer (by @rogyar) + * [magento/magento2#17693](https://github.com/magento/magento2/pull/17693) -- Review: Adding missing unit test for Observer (by @eduard13) + * [magento/magento2#17701](https://github.com/magento/magento2/pull/17701) -- Message list component fix: the message type is always error when parameters specified (by @dmytro-ch) + * [magento/magento2#17710](https://github.com/magento/magento2/pull/17710) -- Sales Rule: Add unit tests for model classes (by @dmytro-ch) + * [magento/magento2#17626](https://github.com/magento/magento2/pull/17626) -- Use '.min' in filenames of already minified js files in the Swagger module so they aren't getting minified again in production, fixes #16927 - for Magento 2.2 (by @hostep) + * [magento/magento2#16733](https://github.com/magento/magento2/pull/16733) -- Fixes black background for png images in wysiwyg editors (by @eduard13) + * [magento/magento2#17718](https://github.com/magento/magento2/pull/17718) -- ISSUE-17715: Duplicate event in Delete operation transaction "entity_manager_delete_before". (by @p-bystritsky) + * [magento/magento2#17735](https://github.com/magento/magento2/pull/17735) -- Fix translation issue (by @jignesh-baldha) + * [magento/magento2#17776](https://github.com/magento/magento2/pull/17776) -- [Backport] Changed storage.php (by @MartinAarts) + * [magento/magento2#17801](https://github.com/magento/magento2/pull/17801) -- [Search] Unit test for SynonymAnalyzer model (by @furseyev) + * [magento/magento2#17817](https://github.com/magento/magento2/pull/17817) -- Update issue templates for Magento 2 GitHub project (by @ishakhsuvarov) + * [magento/magento2#17385](https://github.com/magento/magento2/pull/17385) -- Remove leading Countrycode from EU-VAT-Numbers (by @Drischie) + * [magento/magento2#17739](https://github.com/magento/magento2/pull/17739) -- Search: Add unit test for PopularSearchTerms model (by @dmytro-ch) + * [magento/magento2#17773](https://github.com/magento/magento2/pull/17773) -- Fix for ProductLink - setterName was incorrectly being set (by @insanityinside) + * [magento/magento2#17877](https://github.com/magento/magento2/pull/17877) -- Resolved : Wishlist icon cut on Shopping cart page in mobile view #17851 (by @hitesh-wagento) + * [magento/magento2#17876](https://github.com/magento/magento2/pull/17876) -- Sales: Add unit test for validator model class (by @dmytro-ch) + * [magento/magento2#17840](https://github.com/magento/magento2/pull/17840) -- API-functional test for Search (by @rogyar) + * [magento/magento2#17870](https://github.com/magento/magento2/pull/17870) -- Fix - Next Page button triggered when filtering Customer grid (by @ronak2ram) + * [magento/magento2#16800](https://github.com/magento/magento2/pull/16800) -- [2.2-dev] Move functions.php into Framework (by @fooman) + * [magento/magento2#17872](https://github.com/magento/magento2/pull/17872) -- [Backport] Replacing deprecated methods for tests. (by @tiagosampaio) + * [magento/magento2#16775](https://github.com/magento/magento2/pull/16775) -- [Forwardport] #7903 correct the position of the datepicker when you scroll (by @hitesh-wagento) + 2.2.6 ============= * GitHub issues: diff --git a/app/code/Magento/AdminNotification/composer.json b/app/code/Magento/AdminNotification/composer.json index ae1b8dc7d14ff..c577a2479f209 100644 --- a/app/code/Magento/AdminNotification/composer.json +++ b/app/code/Magento/AdminNotification/composer.json @@ -11,7 +11,7 @@ "lib-libxml": "*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/AdvancedPricingImportExport/composer.json b/app/code/Magento/AdvancedPricingImportExport/composer.json index 458827b9ab18a..4fa012f4acee8 100644 --- a/app/code/Magento/AdvancedPricingImportExport/composer.json +++ b/app/code/Magento/AdvancedPricingImportExport/composer.json @@ -13,7 +13,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Analytics/composer.json b/app/code/Magento/Analytics/composer.json index 7edb72db45e52..3eebcafaba98f 100644 --- a/app/code/Magento/Analytics/composer.json +++ b/app/code/Magento/Analytics/composer.json @@ -10,7 +10,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Authorization/composer.json b/app/code/Magento/Authorization/composer.json index 35063d1516784..1af2a1f672762 100644 --- a/app/code/Magento/Authorization/composer.json +++ b/app/code/Magento/Authorization/composer.json @@ -7,7 +7,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Authorizenet/Block/Adminhtml/Order/View/Info/PaymentDetails.php b/app/code/Magento/Authorizenet/Block/Adminhtml/Order/View/Info/PaymentDetails.php new file mode 100644 index 0000000000000..396df76d882e4 --- /dev/null +++ b/app/code/Magento/Authorizenet/Block/Adminhtml/Order/View/Info/PaymentDetails.php @@ -0,0 +1,28 @@ +setIsTransactionPending(true) ->setIsFraudDetected(true); } + + $additionalInformationKeys = explode(',', $this->getValue('paymentInfoKeys')); + foreach ($additionalInformationKeys as $paymentInfoKey) { + $paymentInfoValue = $response->getDataByKey($paymentInfoKey); + if ($paymentInfoValue !== null) { + $payment->setAdditionalInformation($paymentInfoKey, $paymentInfoValue); + } + } } /** @@ -682,6 +689,7 @@ protected function matchAmount($amount) /** * Operate with order using information from Authorize.net. + * * Authorize order or authorize and capture it. * * @param \Magento\Sales\Model\Order $order @@ -824,6 +832,7 @@ protected function declineOrder(\Magento\Sales\Model\Order $order, $message = '' ->void($response); } $order->registerCancellation($message)->save(); + $this->_eventManager->dispatch('order_cancel_after', ['order' => $order ]); } catch (\Exception $e) { //quiet decline $this->getPsrLogger()->critical($e); @@ -858,7 +867,7 @@ public function getConfigInterface() * Getter for specified value according to set payment method code * * @param mixed $key - * @param null $storeId + * @param int|string|null|\Magento\Store\Model\Store $storeId * @return mixed */ public function getValue($key, $storeId = null) @@ -918,10 +927,13 @@ public function fetchTransactionInfo(\Magento\Payment\Model\InfoInterface $payme $payment->setIsTransactionDenied(true); } $this->addStatusCommentOnUpdate($payment, $response, $transactionId); - return []; + + return $response->getData(); } /** + * Add statuc comment on update. + * * @param \Magento\Sales\Model\Order\Payment $payment * @param \Magento\Framework\DataObject $response * @param string $transactionId @@ -996,8 +1008,9 @@ protected function getTransactionResponse($transactionId) } /** - * @return \Psr\Log\LoggerInterface + * Get psr logger. * + * @return \Psr\Log\LoggerInterface * @deprecated 100.1.0 */ private function getPsrLogger() @@ -1038,7 +1051,9 @@ private function getOrderIncrementId(): string } /** - * Checks if filter action is Report Only. Transactions that trigger this filter are processed as normal, + * Checks if filter action is Report Only. + * + * Transactions that trigger this filter are processed as normal, * but are also reported in the Merchant Interface as triggering this filter. * * @param string $fdsFilterAction diff --git a/app/code/Magento/Authorizenet/composer.json b/app/code/Magento/Authorizenet/composer.json index 90f19e36777b2..0e6d1e8296c8a 100644 --- a/app/code/Magento/Authorizenet/composer.json +++ b/app/code/Magento/Authorizenet/composer.json @@ -16,7 +16,7 @@ "magento/module-config": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "proprietary" ], diff --git a/app/code/Magento/Authorizenet/etc/config.xml b/app/code/Magento/Authorizenet/etc/config.xml index eacf77cda1e77..3a192646b6f7e 100644 --- a/app/code/Magento/Authorizenet/etc/config.xml +++ b/app/code/Magento/Authorizenet/etc/config.xml @@ -32,6 +32,7 @@ https://secure.authorize.net/gateway/transact.dll https://apitest.authorize.net/xml/v1/request.api https://api2.authorize.net/xml/v1/request.api + x_card_type,x_account_number,x_avs_code,x_auth_code,x_response_reason_text,x_cvv2_resp_code diff --git a/app/code/Magento/Authorizenet/etc/di.xml b/app/code/Magento/Authorizenet/etc/di.xml index 4beb2456be110..69d24019f2fb7 100644 --- a/app/code/Magento/Authorizenet/etc/di.xml +++ b/app/code/Magento/Authorizenet/etc/di.xml @@ -35,4 +35,9 @@ + + + Magento\Authorizenet\Model\Directpost + + diff --git a/app/code/Magento/Authorizenet/i18n/en_US.csv b/app/code/Magento/Authorizenet/i18n/en_US.csv index bb59afffff2c6..6228d5102b13c 100644 --- a/app/code/Magento/Authorizenet/i18n/en_US.csv +++ b/app/code/Magento/Authorizenet/i18n/en_US.csv @@ -67,3 +67,9 @@ Debug,Debug "Minimum Order Total","Minimum Order Total" "Maximum Order Total","Maximum Order Total" "Sort Order","Sort Order" +"x_card_type","Credit Card Type" +"x_account_number", "Credit Card Number" +"x_avs_code","AVS Response Code" +"x_auth_code","Processor Authentication Code" +"x_response_reason_text","Processor Response Text" +"x_cvv2_resp_code","CVV2 Response Code" diff --git a/app/code/Magento/Authorizenet/view/adminhtml/templates/order/view/info/fraud_details.phtml b/app/code/Magento/Authorizenet/view/adminhtml/templates/order/view/info/fraud_details.phtml index 60fec263352fe..ac91fa30bfbe0 100644 --- a/app/code/Magento/Authorizenet/view/adminhtml/templates/order/view/info/fraud_details.phtml +++ b/app/code/Magento/Authorizenet/view/adminhtml/templates/order/view/info/fraud_details.phtml @@ -44,8 +44,8 @@ $fraudDetails = $payment->getAdditionalInformation('fraud_details'); - escapeHtml(__('Fraud Filters')) ?>: -
+ escapeHtml(__('Fraud Filters')) ?>: +
escapeHtml($filter['name']) ?>: escapeHtml($filter['action']) ?> diff --git a/app/code/Magento/Backend/Block/System/Store/Delete/Form.php b/app/code/Magento/Backend/Block/System/Store/Delete/Form.php index e479e8f560dae..90b11ac84e470 100644 --- a/app/code/Magento/Backend/Block/System/Store/Delete/Form.php +++ b/app/code/Magento/Backend/Block/System/Store/Delete/Form.php @@ -5,6 +5,9 @@ */ namespace Magento\Backend\Block\System\Store\Delete; +use Magento\Backup\Helper\Data as BackupHelper; +use Magento\Framework\App\ObjectManager; + /** * Adminhtml cms block edit form * @@ -12,6 +15,29 @@ */ class Form extends \Magento\Backend\Block\Widget\Form\Generic { + /** + * @var BackupHelper + */ + private $backup; + + /** + * @param \Magento\Backend\Block\Template\Context $context + * @param \Magento\Framework\Registry $registry + * @param \Magento\Framework\Data\FormFactory $formFactory + * @param array $data + * @param BackupHelper|null $backup + */ + public function __construct( + \Magento\Backend\Block\Template\Context $context, + \Magento\Framework\Registry $registry, + \Magento\Framework\Data\FormFactory $formFactory, + array $data = [], + BackupHelper $backup = null + ) { + parent::__construct($context, $registry, $formFactory, $data); + $this->backup = $backup ?? ObjectManager::getInstance()->get(BackupHelper::class); + } + /** * Init form * @@ -25,7 +51,7 @@ protected function _construct() } /** - * {@inheritdoc} + * @inheritDoc */ protected function _prepareForm() { @@ -45,6 +71,12 @@ protected function _prepareForm() $fieldset->addField('item_id', 'hidden', ['name' => 'item_id', 'value' => $dataObject->getId()]); + $backupOptions = ['0' => __('No')]; + $backupSelected = '0'; + if ($this->backup->isEnabled()) { + $backupOptions['1'] = __('Yes'); + $backupSelected = '1'; + } $fieldset->addField( 'create_backup', 'select', @@ -52,8 +84,8 @@ protected function _prepareForm() 'label' => __('Create DB Backup'), 'title' => __('Create DB Backup'), 'name' => 'create_backup', - 'options' => ['1' => __('Yes'), '0' => __('No')], - 'value' => '1' + 'options' => $backupOptions, + 'value' => $backupSelected ] ); diff --git a/app/code/Magento/Backend/Block/Widget/Form/Element/Dependence.php b/app/code/Magento/Backend/Block/Widget/Form/Element/Dependence.php index eff49c3b75ab2..b6efe6edcf211 100644 --- a/app/code/Magento/Backend/Block/Widget/Form/Element/Dependence.php +++ b/app/code/Magento/Backend/Block/Widget/Form/Element/Dependence.php @@ -139,7 +139,7 @@ protected function _toHtml() } /** - * Field dependences JSON map generator + * Field dependencies JSON map generator * @return string */ protected function _getDependsJson() diff --git a/app/code/Magento/Backend/Block/Widget/Grid/Massaction/AbstractMassaction.php b/app/code/Magento/Backend/Block/Widget/Grid/Massaction/AbstractMassaction.php index 99b9bb41ba1a1..ddabeb90921c2 100644 --- a/app/code/Magento/Backend/Block/Widget/Grid/Massaction/AbstractMassaction.php +++ b/app/code/Magento/Backend/Block/Widget/Grid/Massaction/AbstractMassaction.php @@ -6,6 +6,7 @@ namespace Magento\Backend\Block\Widget\Grid\Massaction; use Magento\Backend\Block\Widget\Grid\Massaction\VisibilityCheckerInterface as VisibilityChecker; +use Magento\Framework\Data\Collection\AbstractDb; use Magento\Framework\DataObject; /** @@ -51,7 +52,7 @@ public function __construct( } /** - * @return void + * @inheritdoc */ protected function _construct() { @@ -216,6 +217,7 @@ public function getGridJsObjectName() * Retrieve JSON string of selected checkboxes * * @return string + * @SuppressWarnings(PHPMD.RequestAwareBlockMethod) */ public function getSelectedJson() { @@ -230,6 +232,7 @@ public function getSelectedJson() * Retrieve array of selected checkboxes * * @return string[] + * @SuppressWarnings(PHPMD.RequestAwareBlockMethod) */ public function getSelected() { @@ -251,6 +254,8 @@ public function getApplyButtonHtml() } /** + * Get mass action javascript code. + * * @return string */ public function getJavaScript() @@ -267,6 +272,8 @@ public function getJavaScript() } /** + * Get grid ids in JSON format. + * * @return string */ public function getGridIdsJson() @@ -282,7 +289,11 @@ public function getGridIdsJson() } else { $massActionIdField = $this->getParentBlock()->getMassactionIdField(); } - + if ($allIdsCollection instanceof AbstractDb) { + $allIdsCollection->getSelect()->limit(); + $allIdsCollection->clear(); + } + $gridIds = $allIdsCollection->setPageSize(0)->getColumnValues($massActionIdField); if (!empty($gridIds)) { return join(",", $gridIds); @@ -291,6 +302,8 @@ public function getGridIdsJson() } /** + * Get Html id. + * * @return string */ public function getHtmlId() diff --git a/app/code/Magento/Backend/Controller/Adminhtml/System/Design/Save.php b/app/code/Magento/Backend/Controller/Adminhtml/System/Design/Save.php index 0228b48f7f11e..a2a53f3f787e3 100644 --- a/app/code/Magento/Backend/Controller/Adminhtml/System/Design/Save.php +++ b/app/code/Magento/Backend/Controller/Adminhtml/System/Design/Save.php @@ -6,6 +6,9 @@ */ namespace Magento\Backend\Controller\Adminhtml\System\Design; +/** + * Save design action. + */ class Save extends \Magento\Backend\Controller\Adminhtml\System\Design { /** @@ -26,6 +29,8 @@ protected function _filterPostData($data) } /** + * Save design action. + * * @return \Magento\Backend\Model\View\Result\Redirect */ public function execute() @@ -54,10 +59,10 @@ public function execute() } catch (\Exception $e) { $this->messageManager->addErrorMessage($e->getMessage()); $this->_objectManager->get(\Magento\Backend\Model\Session::class)->setDesignData($data); - return $resultRedirect->setPath('adminhtml/*/', ['id' => $design->getId()]); + return $resultRedirect->setPath('*/*/edit', ['id' => $design->getId()]); } } - return $resultRedirect->setPath('adminhtml/*/'); + return $resultRedirect->setPath('*/*/'); } } diff --git a/app/code/Magento/Backend/Controller/Adminhtml/System/Store.php b/app/code/Magento/Backend/Controller/Adminhtml/System/Store.php index 0beeb5168b6d1..a9be14b77b29c 100644 --- a/app/code/Magento/Backend/Controller/Adminhtml/System/Store.php +++ b/app/code/Magento/Backend/Controller/Adminhtml/System/Store.php @@ -14,6 +14,7 @@ * Store controller * * @author Magento Core Team + * @SuppressWarnings(PHPMD.AllPurposeAction) */ abstract class Store extends Action { @@ -86,6 +87,8 @@ protected function createPage() * Backup database * * @return bool + * + * @deprecated Backup module is to be removed. */ protected function _backupDatabase() { diff --git a/app/code/Magento/Backend/Model/AdminPathConfig.php b/app/code/Magento/Backend/Model/AdminPathConfig.php index e7338adca4a2a..9d32514db48d1 100644 --- a/app/code/Magento/Backend/Model/AdminPathConfig.php +++ b/app/code/Magento/Backend/Model/AdminPathConfig.php @@ -48,10 +48,7 @@ public function __construct( } /** - * {@inheritdoc} - * - * @param \Magento\Framework\App\RequestInterface $request - * @return string + * @inheritdoc */ public function getCurrentSecureUrl(\Magento\Framework\App\RequestInterface $request) { @@ -59,28 +56,30 @@ public function getCurrentSecureUrl(\Magento\Framework\App\RequestInterface $req } /** - * {@inheritdoc} - * - * @param string $path - * @return bool + * @inheritdoc */ public function shouldBeSecure($path) { - return parse_url( - (string)$this->coreConfig->getValue(Store::XML_PATH_UNSECURE_BASE_URL, 'default'), - PHP_URL_SCHEME - ) === 'https' - || $this->backendConfig->isSetFlag(Store::XML_PATH_SECURE_IN_ADMINHTML) - && parse_url( - (string)$this->coreConfig->getValue(Store::XML_PATH_SECURE_BASE_URL, 'default'), - PHP_URL_SCHEME - ) === 'https'; + $baseUrl = (string)$this->coreConfig->getValue(Store::XML_PATH_UNSECURE_BASE_URL, 'default'); + if (parse_url($baseUrl, PHP_URL_SCHEME) === 'https') { + return true; + } + + if ($this->backendConfig->isSetFlag(Store::XML_PATH_SECURE_IN_ADMINHTML)) { + if ($this->backendConfig->isSetFlag('admin/url/use_custom')) { + $adminBaseUrl = (string)$this->coreConfig->getValue('admin/url/custom', 'default'); + } else { + $adminBaseUrl = (string)$this->coreConfig->getValue(Store::XML_PATH_SECURE_BASE_URL, 'default'); + } + + return parse_url($adminBaseUrl, PHP_URL_SCHEME) === 'https'; + } + + return false; } /** - * {@inheritdoc} - * - * @return string + * @inheritdoc */ public function getDefaultPath() { diff --git a/app/code/Magento/Backend/Model/Search/Customer.php b/app/code/Magento/Backend/Model/Search/Customer.php index 35a7359ce9980..e76a1b77ab2d6 100644 --- a/app/code/Magento/Backend/Model/Search/Customer.php +++ b/app/code/Magento/Backend/Model/Search/Customer.php @@ -89,7 +89,7 @@ public function load() $this->searchCriteriaBuilder->setCurrentPage($this->getStart()); $this->searchCriteriaBuilder->setPageSize($this->getLimit()); - $searchFields = ['firstname', 'lastname', 'company']; + $searchFields = ['firstname', 'lastname', 'billing_company']; $filters = []; foreach ($searchFields as $field) { $filters[] = $this->filterBuilder diff --git a/app/code/Magento/Backend/Test/Mftf/ActionGroup/LoginAsAnyUserActionGroup.xml b/app/code/Magento/Backend/Test/Mftf/ActionGroup/LoginAsAnyUserActionGroup.xml new file mode 100644 index 0000000000000..b762f5095db7e --- /dev/null +++ b/app/code/Magento/Backend/Test/Mftf/ActionGroup/LoginAsAnyUserActionGroup.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + diff --git a/app/code/Magento/Backend/Test/Mftf/Page/AdminConfigurationGeneralSectionPage.xml b/app/code/Magento/Backend/Test/Mftf/Page/AdminConfigurationGeneralSectionPage.xml index c0c4f4bd9d3a5..c92c025b6272b 100644 --- a/app/code/Magento/Backend/Test/Mftf/Page/AdminConfigurationGeneralSectionPage.xml +++ b/app/code/Magento/Backend/Test/Mftf/Page/AdminConfigurationGeneralSectionPage.xml @@ -7,5 +7,6 @@
+
diff --git a/app/code/Magento/Backend/Test/Mftf/Page/AdminConfigurationStoresPage.xml b/app/code/Magento/Backend/Test/Mftf/Page/AdminConfigurationStoresPage.xml index 05073acff3ca9..d1bf3c2cb2ed6 100644 --- a/app/code/Magento/Backend/Test/Mftf/Page/AdminConfigurationStoresPage.xml +++ b/app/code/Magento/Backend/Test/Mftf/Page/AdminConfigurationStoresPage.xml @@ -7,7 +7,7 @@ --> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd">
diff --git a/app/code/Magento/Backend/Test/Mftf/Section/AdminMainActionsSection.xml b/app/code/Magento/Backend/Test/Mftf/Section/AdminMainActionsSection.xml index bba375c2d6bfd..3a0737bcae4a1 100644 --- a/app/code/Magento/Backend/Test/Mftf/Section/AdminMainActionsSection.xml +++ b/app/code/Magento/Backend/Test/Mftf/Section/AdminMainActionsSection.xml @@ -10,7 +10,9 @@ xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd">
- + + +
diff --git a/app/code/Magento/Backend/Test/Mftf/Section/AdminMessagesSection.xml b/app/code/Magento/Backend/Test/Mftf/Section/AdminMessagesSection.xml index 72a00ed6db9b6..5040a08967fa3 100644 --- a/app/code/Magento/Backend/Test/Mftf/Section/AdminMessagesSection.xml +++ b/app/code/Magento/Backend/Test/Mftf/Section/AdminMessagesSection.xml @@ -6,10 +6,13 @@ */ --> - +
+ +
diff --git a/app/code/Magento/Backend/Test/Mftf/Section/AdminPopupModalSection.xml b/app/code/Magento/Backend/Test/Mftf/Section/AdminPopupModalSection.xml new file mode 100644 index 0000000000000..4ea184598663f --- /dev/null +++ b/app/code/Magento/Backend/Test/Mftf/Section/AdminPopupModalSection.xml @@ -0,0 +1,14 @@ + + + +
+ + +
+
diff --git a/app/code/Magento/Backend/Test/Mftf/Section/AdminSlideOutDialogSection.xml b/app/code/Magento/Backend/Test/Mftf/Section/AdminSlideOutDialogSection.xml new file mode 100644 index 0000000000000..a2645c9cbf96d --- /dev/null +++ b/app/code/Magento/Backend/Test/Mftf/Section/AdminSlideOutDialogSection.xml @@ -0,0 +1,17 @@ + + + + +
+ + + + +
+
diff --git a/app/code/Magento/Backend/Test/Mftf/Section/LocaleOptionsSection.xml b/app/code/Magento/Backend/Test/Mftf/Section/LocaleOptionsSection.xml new file mode 100644 index 0000000000000..f9cfe7105d9a1 --- /dev/null +++ b/app/code/Magento/Backend/Test/Mftf/Section/LocaleOptionsSection.xml @@ -0,0 +1,15 @@ + + + + +
+ + +
+
diff --git a/app/code/Magento/Backend/Test/Unit/Model/AdminPathConfigTest.php b/app/code/Magento/Backend/Test/Unit/Model/AdminPathConfigTest.php index 4911dc1e9968e..b373459b7864d 100644 --- a/app/code/Magento/Backend/Test/Unit/Model/AdminPathConfigTest.php +++ b/app/code/Magento/Backend/Test/Unit/Model/AdminPathConfigTest.php @@ -76,17 +76,35 @@ public function testGetCurrentSecureUrl() * @param $unsecureBaseUrl * @param $useSecureInAdmin * @param $secureBaseUrl + * @param $useCustomUrl + * @param $customUrl * @param $expected * @dataProvider shouldBeSecureDataProvider */ - public function testShouldBeSecure($unsecureBaseUrl, $useSecureInAdmin, $secureBaseUrl, $expected) - { - $coreConfigValueMap = [ + public function testShouldBeSecure( + $unsecureBaseUrl, + $useSecureInAdmin, + $secureBaseUrl, + $useCustomUrl, + $customUrl, + $expected + ) { + $coreConfigValueMap = $this->returnValueMap([ [\Magento\Store\Model\Store::XML_PATH_UNSECURE_BASE_URL, 'default', null, $unsecureBaseUrl], [\Magento\Store\Model\Store::XML_PATH_SECURE_BASE_URL, 'default', null, $secureBaseUrl], - ]; - $this->coreConfig->expects($this->any())->method('getValue')->will($this->returnValueMap($coreConfigValueMap)); - $this->backendConfig->expects($this->any())->method('isSetFlag')->willReturn($useSecureInAdmin); + ['admin/url/custom', 'default', null, $customUrl], + ]); + $backendConfigFlagsMap = $this->returnValueMap([ + [\Magento\Store\Model\Store::XML_PATH_SECURE_IN_ADMINHTML, $useSecureInAdmin], + ['admin/url/use_custom', $useCustomUrl], + ]); + $this->coreConfig->expects($this->atLeast(1))->method('getValue') + ->will($coreConfigValueMap); + $this->coreConfig->expects($this->atMost(2))->method('getValue') + ->will($coreConfigValueMap); + + $this->backendConfig->expects($this->atMost(2))->method('isSetFlag') + ->will($backendConfigFlagsMap); $this->assertEquals($expected, $this->adminPathConfig->shouldBeSecure('')); } @@ -96,13 +114,13 @@ public function testShouldBeSecure($unsecureBaseUrl, $useSecureInAdmin, $secureB public function shouldBeSecureDataProvider() { return [ - ['http://localhost/', false, 'default', false], - ['http://localhost/', true, 'default', false], - ['https://localhost/', false, 'default', true], - ['https://localhost/', true, 'default', true], - ['http://localhost/', false, 'https://localhost/', false], - ['http://localhost/', true, 'https://localhost/', true], - ['https://localhost/', true, 'https://localhost/', true], + ['http://localhost/', false, 'default', false, '', false], + ['http://localhost/', true, 'default', false, '', false], + ['https://localhost/', false, 'default', false, '', true], + ['https://localhost/', true, 'default', false, '', true], + ['http://localhost/', false, 'https://localhost/', false, '', false], + ['http://localhost/', true, 'https://localhost/', false, '', true], + ['https://localhost/', true, 'https://localhost/', false, '', true], ]; } diff --git a/app/code/Magento/Backend/composer.json b/app/code/Magento/Backend/composer.json index 845bc4ec87402..dfd71f4ecd4d0 100644 --- a/app/code/Magento/Backend/composer.json +++ b/app/code/Magento/Backend/composer.json @@ -24,7 +24,7 @@ "magento/module-theme": "100.2.*" }, "type": "magento2-module", - "version": "100.2.6", + "version": "100.2.7", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Backend/view/adminhtml/templates/system/cache/additional.phtml b/app/code/Magento/Backend/view/adminhtml/templates/system/cache/additional.phtml index 8e30afdf51f7f..b4bc42b95d0aa 100644 --- a/app/code/Magento/Backend/view/adminhtml/templates/system/cache/additional.phtml +++ b/app/code/Magento/Backend/view/adminhtml/templates/system/cache/additional.phtml @@ -11,10 +11,10 @@ $permissions = $block->getData('permissions'); ?> hasAccessToAdditionalActions()): ?>
+

+ escapeHtml(__('Additional Cache Management')); ?> +

hasAccessToFlushCatalogImages()): ?> -

- escapeHtml(__('Additional Cache Management')); ?> -

diff --git a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Checkbox.php b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Checkbox.php index b220e2c98d77c..46db8a9907341 100644 --- a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Checkbox.php +++ b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Checkbox.php @@ -20,9 +20,7 @@ class Checkbox extends \Magento\Bundle\Block\Catalog\Product\View\Type\Bundle\Op protected $_template = 'Magento_Bundle::product/composite/fieldset/options/type/checkbox.phtml'; /** - * @param string $elementId - * @param string $containerId - * @return string + * @inheritdoc */ public function setValidationContainer($elementId, $containerId) { @@ -34,4 +32,15 @@ public function setValidationContainer($elementId, $containerId) '\'; '; } + + /** + * @inheritdoc + */ + public function getSelectionPrice($selection) + { + $price = parent::getSelectionPrice($selection); + $qty = $selection->getSelectionQty(); + + return $price * $qty; + } } diff --git a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Multi.php b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Multi.php index a4b8c6bde73aa..629f08dc75106 100644 --- a/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Multi.php +++ b/app/code/Magento/Bundle/Block/Adminhtml/Catalog/Product/Composite/Fieldset/Options/Type/Multi.php @@ -20,9 +20,7 @@ class Multi extends \Magento\Bundle\Block\Catalog\Product\View\Type\Bundle\Optio protected $_template = 'Magento_Bundle::product/composite/fieldset/options/type/multi.phtml'; /** - * @param string $elementId - * @param string $containerId - * @return string + * @inheritdoc */ public function setValidationContainer($elementId, $containerId) { @@ -34,4 +32,15 @@ public function setValidationContainer($elementId, $containerId) '\'; '; } + + /** + * @inheritdoc + */ + public function getSelectionPrice($selection) + { + $price = parent::getSelectionPrice($selection); + $qty = $selection->getSelectionQty(); + + return $price * $qty; + } } diff --git a/app/code/Magento/Bundle/Model/Plugin/UpdatePriceInQuoteItemOptions.php b/app/code/Magento/Bundle/Model/Plugin/UpdatePriceInQuoteItemOptions.php new file mode 100644 index 0000000000000..ab56874786500 --- /dev/null +++ b/app/code/Magento/Bundle/Model/Plugin/UpdatePriceInQuoteItemOptions.php @@ -0,0 +1,55 @@ +serializer = $serializer; + } + + /** + * Update price on quote item options level + * + * @param OrigQuoteItem $subject + * @param AbstractItem $result + * @return AbstractItem + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterCalcRowTotal(OrigQuoteItem $subject, AbstractItem $result): AbstractItem + { + $bundleAttributes = $result->getProduct()->getCustomOption('bundle_selection_attributes'); + if ($bundleAttributes !== null) { + $actualAmount = $result->getPrice() * $result->getQty(); + $parsedValue = $this->serializer->unserialize($bundleAttributes->getValue()); + if (is_array($parsedValue) && array_key_exists('price', $parsedValue)) { + $parsedValue['price'] = $actualAmount; + } + $bundleAttributes->setValue($this->serializer->serialize($parsedValue)); + } + + return $result; + } +} diff --git a/app/code/Magento/Bundle/Model/Product/Type.php b/app/code/Magento/Bundle/Model/Product/Type.php index 79a615f542c43..fd024fa87109e 100644 --- a/app/code/Magento/Bundle/Model/Product/Type.php +++ b/app/code/Magento/Bundle/Model/Product/Type.php @@ -741,7 +741,7 @@ protected function _prepareProduct(\Magento\Framework\DataObject $buyRequest, $p $price = $product->getPriceModel() ->getSelectionFinalTotalPrice($product, $selection, 0, $qty); $attributes = [ - 'price' => $this->priceCurrency->convert($price), + 'price' => $price, 'qty' => $qty, 'option_label' => $selection->getOption() ->getTitle(), diff --git a/app/code/Magento/Bundle/Pricing/Adjustment/Calculator.php b/app/code/Magento/Bundle/Pricing/Adjustment/Calculator.php index adb0777151b9e..2f0a99072594b 100644 --- a/app/code/Magento/Bundle/Pricing/Adjustment/Calculator.php +++ b/app/code/Magento/Bundle/Pricing/Adjustment/Calculator.php @@ -281,7 +281,7 @@ public function calculateBundleAmount($basePriceValue, $bundleProduct, $selectio * @param float $basePriceValue * @param Product $bundleProduct * @param \Magento\Bundle\Pricing\Price\BundleSelectionPrice[] $selectionPriceList - * @param null|bool|string|arrayy $exclude + * @param null|bool|string|array $exclude * @return \Magento\Framework\Pricing\Amount\AmountInterface */ protected function calculateFixedBundleAmount($basePriceValue, $bundleProduct, $selectionPriceList, $exclude) diff --git a/app/code/Magento/Bundle/Test/Mftf/ActionGroup/BundleProductsOnAdminActionGroup.xml b/app/code/Magento/Bundle/Test/Mftf/ActionGroup/BundleProductsOnAdminActionGroup.xml index 84e56e82410ff..c600e80f7265f 100644 --- a/app/code/Magento/Bundle/Test/Mftf/ActionGroup/BundleProductsOnAdminActionGroup.xml +++ b/app/code/Magento/Bundle/Test/Mftf/ActionGroup/BundleProductsOnAdminActionGroup.xml @@ -47,4 +47,18 @@ + + + + + + + + + + + + + + diff --git a/app/code/Magento/Bundle/Test/Mftf/ActionGroup/StorefrontProductCartActionGroup.xml b/app/code/Magento/Bundle/Test/Mftf/ActionGroup/StorefrontProductCartActionGroup.xml index fda5d10295676..06d7cccb3623f 100644 --- a/app/code/Magento/Bundle/Test/Mftf/ActionGroup/StorefrontProductCartActionGroup.xml +++ b/app/code/Magento/Bundle/Test/Mftf/ActionGroup/StorefrontProductCartActionGroup.xml @@ -23,4 +23,20 @@ + + + + + + + + + + + + + + + + diff --git a/app/code/Magento/Bundle/Test/Mftf/Data/BundleProductData.xml b/app/code/Magento/Bundle/Test/Mftf/Data/BundleProductData.xml index 2977f423d7e67..5a4f9827cd57c 100644 --- a/app/code/Magento/Bundle/Test/Mftf/Data/BundleProductData.xml +++ b/app/code/Magento/Bundle/Test/Mftf/Data/BundleProductData.xml @@ -17,6 +17,8 @@ BundleProduct BundleProduct 1 + 4 + bundle bundleproduct 4 TestOption diff --git a/app/code/Magento/Bundle/Test/Mftf/Page/StorefrontProductPage.xml b/app/code/Magento/Bundle/Test/Mftf/Page/StorefrontProductPage.xml index a495b6be183ba..89cc6a6d5d2fe 100644 --- a/app/code/Magento/Bundle/Test/Mftf/Page/StorefrontProductPage.xml +++ b/app/code/Magento/Bundle/Test/Mftf/Page/StorefrontProductPage.xml @@ -7,7 +7,7 @@ --> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd">
diff --git a/app/code/Magento/Bundle/Test/Mftf/Section/AdminProductFormBundleSection.xml b/app/code/Magento/Bundle/Test/Mftf/Section/AdminProductFormBundleSection.xml index a843b942174d5..8f60227926099 100644 --- a/app/code/Magento/Bundle/Test/Mftf/Section/AdminProductFormBundleSection.xml +++ b/app/code/Magento/Bundle/Test/Mftf/Section/AdminProductFormBundleSection.xml @@ -16,5 +16,8 @@ + + +
diff --git a/app/code/Magento/Bundle/Test/Mftf/Section/StorefrontBundledSection.xml b/app/code/Magento/Bundle/Test/Mftf/Section/StorefrontBundledSection.xml index 568cd5e2bba99..473c3036cab2c 100644 --- a/app/code/Magento/Bundle/Test/Mftf/Section/StorefrontBundledSection.xml +++ b/app/code/Magento/Bundle/Test/Mftf/Section/StorefrontBundledSection.xml @@ -10,6 +10,7 @@ xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd">
+ diff --git a/app/code/Magento/Bundle/Test/Mftf/Section/StorefrontProductInfoMainSection.xml b/app/code/Magento/Bundle/Test/Mftf/Section/StorefrontProductInfoMainSection.xml index 41c00b5eda184..b1acc97cc0261 100644 --- a/app/code/Magento/Bundle/Test/Mftf/Section/StorefrontProductInfoMainSection.xml +++ b/app/code/Magento/Bundle/Test/Mftf/Section/StorefrontProductInfoMainSection.xml @@ -7,11 +7,12 @@ --> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd">
+
diff --git a/app/code/Magento/Bundle/Test/Mftf/Test/CurrencyChangingBundleProductInCartTest.xml b/app/code/Magento/Bundle/Test/Mftf/Test/CurrencyChangingBundleProductInCartTest.xml new file mode 100644 index 0000000000000..f0c370bc2d515 --- /dev/null +++ b/app/code/Magento/Bundle/Test/Mftf/Test/CurrencyChangingBundleProductInCartTest.xml @@ -0,0 +1,69 @@ + + + + + + + + + <description value="User should be able change the currency and add one more product in cart and get right price in previous currency"/> + <severity value="MAJOR"/> + <testCaseId value="MAGETWO-96305"/> + <group value="Bundle"/> + </annotations> + <before> + <actionGroup ref="LoginAsAdmin" stepKey="login" /> + <createData entity="CurrencySettingWithEuroAndUSD" stepKey="configureCurrencyOptions"/> + <createData entity="_defaultCategory" stepKey="createPreReqCategory"/> + <createData entity="SimpleProduct" stepKey="createPreReqSimpleProduct1"> + <requiredEntity createDataKey="createPreReqCategory"/> + </createData> + <createData entity="SimpleProduct2" stepKey="createPreReqSimpleProduct2"> + <requiredEntity createDataKey="createPreReqCategory"/> + </createData> + </before> + <after> + <deleteData createDataKey="createPreReqCategory" stepKey="deletePreReqCategory"/> + <deleteData createDataKey="createPreReqSimpleProduct1" stepKey="deletePreReqSimpleProduct1"/> + <deleteData createDataKey="createPreReqSimpleProduct2" stepKey="deletePreReqSimpleProduct2"/> + <createData entity="DefaultCurrencySetting" stepKey="restoreCurrencyOptions"/> + <!-- Delete the bundled product --> + <actionGroup ref="deleteProductUsingProductGrid" stepKey="deleteProductOnProductsGridPageByName"> + <argument name="product" value="BundleProduct"/> + </actionGroup> + <!--Clear Configs--> + <actionGroup ref="logout" stepKey="logout"/> + </after> + <!-- Navigate to the Products>Inventory>Catalog --> + <!-- Click on "+" dropdown and select Bundle Product type --> + <actionGroup ref="OpenNewBundleProductPage" stepKey="openNewBundleProductPage"/> + <!-- Add Option, a "Radio Buttons" type option --> + <actionGroup ref="CreateBundleProductForTwoSimpleProductsWithRadioTypeOptions" stepKey="addBundleOptionWithTwoProducts2"> + <argument name="bundleProduct" value="BundleProduct"/> + <argument name="simpleProductFirst" value="$$createPreReqSimpleProduct1$$"/> + <argument name="simpleProductSecond" value="$$createPreReqSimpleProduct2$$"/> + </actionGroup> + <!-- Save product --> + <actionGroup ref="SaveProductOnProductPageOnAdmin" stepKey="saveProductOnProductPageOnAdmin"/> + <!-- Go to storefront BundleProduct --> + <amOnPage url="{{StorefrontProductPage.url(BundleProduct.name)}}" stepKey="goToStorefrontProductPage"/> + <waitForPageLoad stepKey="waitForStorefrontProductPage"/> + <actionGroup ref="StoreFrontAddProductToCartFromBundleWithCurrencyActionGroup" stepKey="addProduct1ToCartAndChangeCurrencyToEuro"> + <argument name="product" value="$$createPreReqSimpleProduct1$$"/> + <argument name="currency" value="EUR - Euro"/> + </actionGroup> + <actionGroup ref="StoreFrontAddProductToCartFromBundleWithCurrencyActionGroup" stepKey="addProduct2ToCartAndChangeCurrencyToUSD"> + <argument name="product" value="$$createPreReqSimpleProduct1$$"/> + <argument name="currency" value="USD - US Dollar"/> + </actionGroup> + <click selector="{{StorefrontMinicartSection.showCart}}" stepKey="openMiniCart"/> + <waitForPageLoad stepKey="waitForMiniCart"/> + <see selector="{{StorefrontMinicartSection.miniCartSubtotalField}}" userInput="$4,000.00" stepKey="seeCartSubtotal"/> + </test> +</tests> diff --git a/app/code/Magento/Bundle/Test/Unit/Model/Plugin/UpdatePriceInQuoteItemOptionsTest.php b/app/code/Magento/Bundle/Test/Unit/Model/Plugin/UpdatePriceInQuoteItemOptionsTest.php new file mode 100644 index 0000000000000..0405a22773c37 --- /dev/null +++ b/app/code/Magento/Bundle/Test/Unit/Model/Plugin/UpdatePriceInQuoteItemOptionsTest.php @@ -0,0 +1,130 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +namespace Magento\Bundle\Test\Unit\Model\Plugin; + +use Magento\Bundle\Model\Plugin\UpdatePriceInQuoteItemOptions; +use Magento\Catalog\Model\Product; +use Magento\Framework\Serialize\SerializerInterface; +use Magento\Quote\Model\Quote\Item\AbstractItem; +use Magento\Quote\Model\Quote\Item as QuoteItem; +use Magento\Quote\Model\Quote\Item\Option; + +/** + * Test for Magento\Bundle\Model\Plugin\UpdatePriceInQuoteItemOptions class. + */ +class UpdatePriceInQuoteItemOptionsTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var SerializerInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $serializerMock; + + /** + * @var QuoteItem|\PHPUnit_Framework_MockObject_MockObject + */ + private $subjectMock; + + /** + * @var AbstractItem|\PHPUnit_Framework_MockObject_MockObject + */ + private $resultMock; + + /** + * @var Product|\PHPUnit_Framework_MockObject_MockObject + */ + private $productMock; + + /** + * @var Option|\PHPUnit_Framework_MockObject_MockObject + */ + private $quoteItemOptionMock; + + /** + * @var UpdatePriceInQuoteItemOptions + */ + private $model; + + /** + * @inheritdoc + */ + protected function setUp() + { + $this->serializerMock = $this->createMock(SerializerInterface::class); + $this->subjectMock = $this->createMock(QuoteItem::class); + $this->resultMock = $this->createMock(AbstractItem::class); + $this->productMock = $this->createMock(Product::class); + $this->quoteItemOptionMock = $this->createMock(Option::class); + + $this->model = new UpdatePriceInQuoteItemOptions($this->serializerMock); + } + + /** + * @return void + */ + public function testAfterCalcRowTotalWithBundleOption() + { + $bundleAttributeValue = '{"price":100,"qty":1,"option_label":"option1","option_id":"1"}'; + $parsedValue = [ + 'price' => 100, + 'qty' => 1, + 'option_label' => 'option1', + 'option_id' => "1", + ]; + + $this->resultMock->expects($this->once()) + ->method('getProduct') + ->willReturn($this->productMock); + $this->productMock->expects($this->once()) + ->method('getCustomOption') + ->with('bundle_selection_attributes') + ->willReturn($this->quoteItemOptionMock); + $this->resultMock->expects($this->once()) + ->method('getPrice') + ->willReturn(100); + $this->resultMock->expects($this->once()) + ->method('getQty') + ->willReturn(1); + $this->quoteItemOptionMock->expects($this->once()) + ->method('getValue') + ->willReturn($bundleAttributeValue); + $this->serializerMock->expects($this->once()) + ->method('unserialize') + ->with($bundleAttributeValue) + ->willReturn($parsedValue); + $this->serializerMock->expects($this->once()) + ->method('serialize') + ->with($parsedValue) + ->willReturn($bundleAttributeValue); + + $this->model->afterCalcRowTotal($this->subjectMock, $this->resultMock); + } + + /** + * @return void + */ + public function testAfterCalcRowTotalWithoutBundleOption() + { + $this->resultMock->expects($this->once()) + ->method('getProduct') + ->willReturn($this->productMock); + $this->productMock->expects($this->once()) + ->method('getCustomOption') + ->with('bundle_selection_attributes') + ->willReturn(null); + $this->resultMock->expects($this->never()) + ->method('getPrice'); + $this->resultMock->expects($this->never()) + ->method('getQty'); + $this->quoteItemOptionMock->expects($this->never()) + ->method('getValue'); + $this->serializerMock->expects($this->never()) + ->method('unserialize'); + $this->serializerMock->expects($this->never()) + ->method('serialize'); + + $this->model->afterCalcRowTotal($this->subjectMock, $this->resultMock); + } +} diff --git a/app/code/Magento/Bundle/Test/Unit/Model/Product/TypeTest.php b/app/code/Magento/Bundle/Test/Unit/Model/Product/TypeTest.php index a413be0bed5ba..2b4f81337da52 100644 --- a/app/code/Magento/Bundle/Test/Unit/Model/Product/TypeTest.php +++ b/app/code/Magento/Bundle/Test/Unit/Model/Product/TypeTest.php @@ -513,10 +513,6 @@ function ($key) use ($optionCollection, $selectionCollection) { ->method('getSelectionId') ->willReturn(314); - $this->priceCurrency->expects($this->once()) - ->method('convert') - ->willReturn(3.14); - $result = $this->model->prepareForCartAdvanced($buyRequest, $product); $this->assertEquals([$product, $productType], $result); } @@ -737,10 +733,6 @@ function ($key) use ($optionCollection, $selectionCollection) { ->method('prepareForCart') ->willReturn([]); - $this->priceCurrency->expects($this->once()) - ->method('convert') - ->willReturn(3.14); - $result = $this->model->prepareForCartAdvanced($buyRequest, $product); $this->assertEquals('We can\'t add this item to your shopping cart right now.', $result); } @@ -961,10 +953,6 @@ function ($key) use ($optionCollection, $selectionCollection) { ->method('prepareForCart') ->willReturn('string'); - $this->priceCurrency->expects($this->once()) - ->method('convert') - ->willReturn(3.14); - $result = $this->model->prepareForCartAdvanced($buyRequest, $product); $this->assertEquals('string', $result); } diff --git a/app/code/Magento/Bundle/Test/Unit/Pricing/Price/SpecialPriceTest.php b/app/code/Magento/Bundle/Test/Unit/Pricing/Price/SpecialPriceTest.php index f38dfc5538cf3..3e60e057fe62b 100644 --- a/app/code/Magento/Bundle/Test/Unit/Pricing/Price/SpecialPriceTest.php +++ b/app/code/Magento/Bundle/Test/Unit/Pricing/Price/SpecialPriceTest.php @@ -6,6 +6,7 @@ namespace Magento\Bundle\Test\Unit\Pricing\Price; use \Magento\Bundle\Pricing\Price\SpecialPrice; +use Magento\Store\Api\Data\WebsiteInterface; class SpecialPriceTest extends \PHPUnit\Framework\TestCase { @@ -77,12 +78,6 @@ public function testGetValue($regularPrice, $specialPrice, $isScopeDateInInterva ->method('getSpecialPrice') ->will($this->returnValue($specialPrice)); - $store = $this->getMockBuilder(\Magento\Store\Model\Store::class) - ->disableOriginalConstructor() - ->getMock(); - $this->saleable->expects($this->once()) - ->method('getStore') - ->will($this->returnValue($store)); $this->saleable->expects($this->once()) ->method('getSpecialFromDate') ->will($this->returnValue($specialFromDate)); @@ -92,7 +87,7 @@ public function testGetValue($regularPrice, $specialPrice, $isScopeDateInInterva $this->localeDate->expects($this->once()) ->method('isScopeDateInInterval') - ->with($store, $specialFromDate, $specialToDate) + ->with(WebsiteInterface::ADMIN_CODE, $specialFromDate, $specialToDate) ->will($this->returnValue($isScopeDateInInterval)); $this->priceCurrencyMock->expects($this->never()) diff --git a/app/code/Magento/Bundle/Ui/DataProvider/Product/Form/Modifier/BundlePanel.php b/app/code/Magento/Bundle/Ui/DataProvider/Product/Form/Modifier/BundlePanel.php index b265f6cb4c2b9..150247729f125 100644 --- a/app/code/Magento/Bundle/Ui/DataProvider/Product/Form/Modifier/BundlePanel.php +++ b/app/code/Magento/Bundle/Ui/DataProvider/Product/Form/Modifier/BundlePanel.php @@ -14,6 +14,7 @@ use Magento\Framework\UrlInterface; use Magento\Ui\Component\Container; use Magento\Ui\Component\Form; +use Magento\Ui\Component\Form\Fieldset; use Magento\Ui\Component\Modal; /** @@ -69,13 +70,26 @@ public function __construct( } /** - * {@inheritdoc} + * @inheritdoc + * * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ public function modifyMeta(array $meta) { $meta = $this->removeFixedTierPrice($meta); - $path = $this->arrayManager->findPath(static::CODE_BUNDLE_DATA, $meta, null, 'children'); + + $groupCode = static::CODE_BUNDLE_DATA; + $path = $this->arrayManager->findPath($groupCode, $meta, null, 'children'); + if (empty($path)) { + $meta[$groupCode]['children'] = []; + $meta[$groupCode]['arguments']['data']['config'] = [ + 'componentType' => Fieldset::NAME, + 'label' => __('Bundle Items'), + 'collapsible' => true, + ]; + + $path = $this->arrayManager->findPath($groupCode, $meta, null, 'children'); + } $meta = $this->arrayManager->merge( $path, @@ -220,7 +234,7 @@ private function removeFixedTierPrice(array $meta) } /** - * {@inheritdoc} + * @inheritdoc */ public function modifyData(array $data) { diff --git a/app/code/Magento/Bundle/composer.json b/app/code/Magento/Bundle/composer.json index fe883e783d6ff..d12d5e715eb3c 100644 --- a/app/code/Magento/Bundle/composer.json +++ b/app/code/Magento/Bundle/composer.json @@ -26,7 +26,7 @@ "magento/module-sales-rule": "101.0.*" }, "type": "magento2-module", - "version": "100.2.5", + "version": "100.2.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Bundle/etc/di.xml b/app/code/Magento/Bundle/etc/di.xml index d0e956efee694..6fe4935100720 100644 --- a/app/code/Magento/Bundle/etc/di.xml +++ b/app/code/Magento/Bundle/etc/di.xml @@ -123,6 +123,9 @@ </argument> </arguments> </type> + <type name="Magento\Quote\Model\Quote\Item"> + <plugin name="update_price_for_bundle_in_quote_item_option" type="Magento\Bundle\Model\Plugin\UpdatePriceInQuoteItemOptions"/> + </type> <type name="Magento\Quote\Model\Quote\Item\ToOrderItem"> <plugin name="append_bundle_data_to_order" type="Magento\Bundle\Model\Plugin\QuoteItem"/> </type> diff --git a/app/code/Magento/Bundle/view/frontend/templates/sales/order/items/renderer.phtml b/app/code/Magento/Bundle/view/frontend/templates/sales/order/items/renderer.phtml index 063d66edb9e70..74e1c5f874954 100644 --- a/app/code/Magento/Bundle/view/frontend/templates/sales/order/items/renderer.phtml +++ b/app/code/Magento/Bundle/view/frontend/templates/sales/order/items/renderer.phtml @@ -7,95 +7,111 @@ // @codingStandardsIgnoreFile /** @var $block \Magento\Bundle\Block\Sales\Order\Items\Renderer */ +$parentItem = $block->getItem(); +$items = array_merge([$parentItem], $parentItem->getChildrenItems()); +$index = 0; +$prevOptionId = ''; ?> -<?php $parentItem = $block->getItem() ?> -<?php $items = array_merge([$parentItem], $parentItem->getChildrenItems()); ?> -<?php $_index = 0 ?> -<?php $_prevOptionId = '' ?> +<?php foreach ($items as $item): ?> -<?php foreach ($items as $_item): ?> - - <?php if ($block->getItemOptions() || $parentItem->getDescription() || $this->helper('Magento\GiftMessage\Helper\Message')->isMessagesAllowed('order_item', $parentItem) && $parentItem->getGiftMessageId()): ?> - <?php $_showlastRow = true ?> + <?php if ($block->getItemOptions() + || $parentItem->getDescription() + || $this->helper('Magento\GiftMessage\Helper\Message')->isMessagesAllowed('order_item', $parentItem) + && $parentItem->getGiftMessageId()): ?> + <?php $showLastRow = true; ?> <?php else: ?> - <?php $_showlastRow = false ?> + <?php $showLastRow = false; ?> <?php endif; ?> - <?php if ($_item->getParentItem()): ?> - <?php $attributes = $block->getSelectionAttributes($_item) ?> - <?php if ($_prevOptionId != $attributes['option_id']): ?> + <?php if ($item->getParentItem()): ?> + <?php $attributes = $block->getSelectionAttributes($item) ?> + <?php if ($prevOptionId != $attributes['option_id']): ?> <tr class="options-label"> - <td class="col label" colspan="5"><?= /* @escapeNotVerified */ $attributes['option_label'] ?></td> + <td class="col label" colspan="5"><?= $block->escapeHtml($attributes['option_label']); ?></td> </tr> - <?php $_prevOptionId = $attributes['option_id'] ?> + <?php $prevOptionId = $attributes['option_id'] ?> <?php endif; ?> <?php endif; ?> -<tr id="order-item-row-<?= /* @escapeNotVerified */ $_item->getId() ?>" class="<?php if ($_item->getParentItem()): ?>item-options-container<?php else: ?>item-parent<?php endif; ?>"<?php if ($_item->getParentItem()): ?> data-th="<?= /* @escapeNotVerified */ $attributes['option_label'] ?>"<?php endif; ?>> - <?php if (!$_item->getParentItem()): ?> - <td class="col name" data-th="<?= $block->escapeHtml(__('Product Name')) ?>"> - <strong class="product name product-item-name"><?= $block->escapeHtml($_item->getName()) ?></strong> +<tr id="order-item-row-<?= /* @noEscape */ $item->getId() ?>" + class="<?php if ($item->getParentItem()): ?> + item-options-container + <?php else: ?> + item-parent + <?php endif; ?>" + <?php if ($item->getParentItem()): ?> + data-th="<?= $block->escapeHtml($attributes['option_label']); ?>" + <?php endif; ?>> + <?php if (!$item->getParentItem()): ?> + <td class="col name" data-th="<?= $block->escapeHtml(__('Product Name')); ?>"> + <strong class="product name product-item-name"><?= $block->escapeHtml($item->getName()); ?></strong> </td> <?php else: ?> - <td class="col value" data-th="<?= $block->escapeHtml(__('Product Name')) ?>"><?= $block->getValueHtml($_item) ?></td> + <td class="col value" data-th="<?= $block->escapeHtml(__('Product Name')); ?>"> + <?= $block->getValueHtml($item); ?> + </td> <?php endif; ?> - <td class="col sku" data-th="<?= $block->escapeHtml(__('SKU')) ?>"><?= /* @escapeNotVerified */ $block->prepareSku($_item->getSku()) ?></td> - <td class="col price" data-th="<?= $block->escapeHtml(__('Price')) ?>"> - <?php if (!$_item->getParentItem()): ?> - <?= $block->getItemPriceHtml() ?> + <td class="col sku" data-th="<?= $block->escapeHtml(__('SKU')); ?>"> + <?= /* @noEscape */ $block->prepareSku($item->getSku()); ?> + </td> + <td class="col price" data-th="<?= $block->escapeHtml(__('Price')); ?>"> + <?php if (!$item->getParentItem()): ?> + <?= /* @noEscape */ $block->getItemPriceHtml(); ?> <?php else: ?>   <?php endif; ?> </td> - <td class="col qty" data-th="<?= $block->escapeHtml(__('Quantity')) ?>"> + <td class="col qty" data-th="<?= $block->escapeHtml(__('Quantity')); ?>"> <?php if ( - ($_item->getParentItem() && $block->isChildCalculated()) || - (!$_item->getParentItem() && !$block->isChildCalculated()) || ($_item->getQtyShipped() > 0 && $_item->getParentItem() && $block->isShipmentSeparately())):?> + ($item->getParentItem() && $block->isChildCalculated()) || + (!$item->getParentItem() && !$block->isChildCalculated()) || + ($item->getQtyShipped() > 0 && $item->getParentItem() && $block->isShipmentSeparately())): ?> <ul class="items-qty"> <?php endif; ?> - <?php if (($_item->getParentItem() && $block->isChildCalculated()) || - (!$_item->getParentItem() && !$block->isChildCalculated())): ?> - <?php if ($_item->getQtyOrdered() > 0): ?> + <?php if (($item->getParentItem() && $block->isChildCalculated()) || + (!$item->getParentItem() && !$block->isChildCalculated())): ?> + <?php if ($item->getQtyOrdered() > 0): ?> <li class="item"> - <span class="title"><?= /* @escapeNotVerified */ __('Ordered') ?></span> - <span class="content"><?= /* @escapeNotVerified */ $_item->getQtyOrdered()*1 ?></span> + <span class="title"><?= $block->escapeHtml(__('Ordered')); ?></span> + <span class="content"><?= /* @noEscape */ $item->getQtyOrdered() * 1; ?></span> </li> <?php endif; ?> - <?php if ($_item->getQtyShipped() > 0 && !$block->isShipmentSeparately()): ?> + <?php if ($item->getQtyShipped() > 0 && !$block->isShipmentSeparately()): ?> <li class="item"> - <span class="title"><?= /* @escapeNotVerified */ __('Shipped') ?></span> - <span class="content"><?= /* @escapeNotVerified */ $_item->getQtyShipped()*1 ?></span> + <span class="title"><?= $block->escapeHtml(__('Shipped')); ?></span> + <span class="content"><?= /* @noEscape */ $item->getQtyShipped() * 1; ?></span> </li> <?php endif; ?> - <?php if ($_item->getQtyCanceled() > 0): ?> + <?php if ($item->getQtyCanceled() > 0): ?> <li class="item"> - <span class="title"><?= /* @escapeNotVerified */ __('Canceled') ?></span> - <span class="content"><?= /* @escapeNotVerified */ $_item->getQtyCanceled()*1 ?></span> + <span class="title"><?= $block->escapeHtml(__('Canceled')); ?></span> + <span class="content"><?= /* @noEscape */ $item->getQtyCanceled() * 1; ?></span> </li> <?php endif; ?> - <?php if ($_item->getQtyRefunded() > 0): ?> + <?php if ($item->getQtyRefunded() > 0): ?> <li class="item"> - <span class="title"><?= /* @escapeNotVerified */ __('Refunded') ?></span> - <span class="content"><?= /* @escapeNotVerified */ $_item->getQtyRefunded()*1 ?></span> + <span class="title"><?= $block->escapeHtml(__('Refunded')); ?></span> + <span class="content"><?= /* @noEscape */ $item->getQtyRefunded() * 1; ?></span> </li> <?php endif; ?> - <?php elseif ($_item->getQtyShipped() > 0 && $_item->getParentItem() && $block->isShipmentSeparately()): ?> + <?php elseif ($item->getQtyShipped() > 0 && $item->getParentItem() && $block->isShipmentSeparately()): ?> <li class="item"> - <span class="title"><?= /* @escapeNotVerified */ __('Shipped') ?></span> - <span class="content"><?= /* @escapeNotVerified */ $_item->getQtyShipped()*1 ?></span> + <span class="title"><?= $block->escapeHtml(__('Shipped')); ?></span> + <span class="content"><?= /* @noEscape */ $item->getQtyShipped() * 1; ?></span> </li> <?php else: ?> -   + <span class="content"><?= /* @noEscape */ $parentItem->getQtyOrdered() * 1; ?></span> <?php endif; ?> <?php if ( - ($_item->getParentItem() && $block->isChildCalculated()) || - (!$_item->getParentItem() && !$block->isChildCalculated()) || ($_item->getQtyShipped() > 0 && $_item->getParentItem() && $block->isShipmentSeparately())):?> + ($item->getParentItem() && $block->isChildCalculated()) || + (!$item->getParentItem() && !$block->isChildCalculated()) || + ($item->getQtyShipped() > 0 && $item->getParentItem() && $block->isShipmentSeparately())):?> </ul> <?php endif; ?> </td> <td class="col subtotal" data-th="<?= $block->escapeHtml(__('Subtotal')) ?>"> - <?php if (!$_item->getParentItem()): ?> - <?= $block->getItemRowTotalHtml() ?> + <?php if (!$item->getParentItem()): ?> + <?= /* @noEscape */ $block->getItemRowTotalHtml(); ?> <?php else: ?>   <?php endif; ?> @@ -103,33 +119,38 @@ </tr> <?php endforeach; ?> -<?php if ($_showlastRow && (($_options = $block->getItemOptions()) || $block->escapeHtml($_item->getDescription()))): ?> +<?php if ($showLastRow && (($options = $block->getItemOptions()) || $block->escapeHtml($item->getDescription()))): ?> <tr> <td class="col options" colspan="5"> - <?php if ($_options = $block->getItemOptions()): ?> + <?php if ($options = $block->getItemOptions()): ?> <dl class="item-options"> - <?php foreach ($_options as $_option) : ?> - <dt><?= $block->escapeHtml($_option['label']) ?></dt> + <?php foreach ($options as $option) : ?> + <dt><?= $block->escapeHtml($option['label']) ?></dt> <?php if (!$block->getPrintStatus()): ?> - <?php $_formatedOptionValue = $block->getFormatedOptionValue($_option) ?> - <dd<?php if (isset($_formatedOptionValue['full_view'])): ?> class="tooltip wrapper"<?php endif; ?>> - <?= /* @escapeNotVerified */ $_formatedOptionValue['value'] ?> - <?php if (isset($_formatedOptionValue['full_view'])): ?> + <?php $formattedOptionValue = $block->getFormatedOptionValue($option) ?> + <dd<?php if (isset($formattedOptionValue['full_view'])): ?> + class="tooltip wrapper" + <?php endif; ?>> + <?= /* @noEscape */ $formattedOptionValue['value'] ?> + <?php if (isset($formattedOptionValue['full_view'])): ?> <div class="tooltip content"> <dl class="item options"> - <dt><?= $block->escapeHtml($_option['label']) ?></dt> - <dd><?= /* @escapeNotVerified */ $_formatedOptionValue['full_view'] ?></dd> + <dt><?= $block->escapeHtml($option['label']); ?></dt> + <dd><?= /* @noEscape */ $formattedOptionValue['full_view']; ?></dd> </dl> </div> <?php endif; ?> </dd> <?php else: ?> - <dd><?= $block->escapeHtml((isset($_option['print_value']) ? $_option['print_value'] : $_option['value'])) ?></dd> + <dd><?= $block->escapeHtml((isset($option['print_value']) ? + $option['print_value'] : + $option['value'])); ?> + </dd> <?php endif; ?> <?php endforeach; ?> </dl> <?php endif; ?> - <?= $block->escapeHtml($_item->getDescription()) ?> + <?= $block->escapeHtml($item->getDescription()); ?> </td> </tr> <?php endif; ?> diff --git a/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/BundleTest.php b/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/BundleTest.php index 773fa6a5349a5..59a704d5305e4 100644 --- a/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/BundleTest.php +++ b/app/code/Magento/BundleImportExport/Test/Unit/Model/Import/Product/Type/BundleTest.php @@ -242,7 +242,7 @@ public function testSaveData($skus, $bunch, $allowImport) 'price_type' => 'fixed', 'shipment_type' => '1', 'default_qty' => '1', - 'is_defaul' => '1', + 'is_default' => '1', 'position' => '1', 'option_id' => '1'] ] @@ -264,7 +264,7 @@ public function testSaveData($skus, $bunch, $allowImport) 'price_type' => 'percent', 'shipment_type' => 0, 'default_qty' => '2', - 'is_defaul' => '1', + 'is_default' => '1', 'position' => '6', 'option_id' => '6'] ] @@ -324,7 +324,7 @@ public function saveDataProvider() . 'price_type=fixed,' . 'shipment_type=separately,' . 'default_qty=1,' - . 'is_defaul=1,' + . 'is_default=1,' . 'position=1,' . 'option_id=1 | name=Bundle2,' . 'type=dropdown,' @@ -333,7 +333,7 @@ public function saveDataProvider() . 'price=10,' . 'price_type=fixed,' . 'default_qty=1,' - . 'is_defaul=1,' + . 'is_default=1,' . 'position=2,' . 'option_id=2' ], diff --git a/app/code/Magento/BundleImportExport/composer.json b/app/code/Magento/BundleImportExport/composer.json index b21da5c7ae5b9..bfb8bd2b663a1 100644 --- a/app/code/Magento/BundleImportExport/composer.json +++ b/app/code/Magento/BundleImportExport/composer.json @@ -12,7 +12,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CacheInvalidate/Model/PurgeCache.php b/app/code/Magento/CacheInvalidate/Model/PurgeCache.php index 8acf170d43cfb..8e0c00b587460 100644 --- a/app/code/Magento/CacheInvalidate/Model/PurgeCache.php +++ b/app/code/Magento/CacheInvalidate/Model/PurgeCache.php @@ -7,6 +7,9 @@ use Magento\Framework\Cache\InvalidateLogger; +/** + * Purge cache action. + */ class PurgeCache { const HEADER_X_MAGENTO_TAGS_PATTERN = 'X-Magento-Tags-Pattern'; @@ -26,6 +29,18 @@ class PurgeCache */ private $logger; + /** + * Batch size of the purge request. + * + * Based on default Varnish 4 http_req_hdr_len size minus a 512 bytes margin for method, + * header name, line feeds etc. + * + * @see https://varnish-cache.org/docs/4.1/reference/varnishd.html + * + * @var int + */ + private $requestSize = 7680; + /** * Constructor * @@ -44,18 +59,68 @@ public function __construct( } /** - * Send curl purge request - * to invalidate cache by tags pattern + * Send curl purge request to invalidate cache by tags pattern. * * @param string $tagsPattern * @return bool Return true if successful; otherwise return false */ public function sendPurgeRequest($tagsPattern) { + $successful = true; $socketAdapter = $this->socketAdapterFactory->create(); $servers = $this->cacheServer->getUris(); - $headers = [self::HEADER_X_MAGENTO_TAGS_PATTERN => $tagsPattern]; $socketAdapter->setOptions(['timeout' => 10]); + + $formattedTagsChunks = $this->splitTags($tagsPattern); + foreach ($formattedTagsChunks as $formattedTagsChunk) { + if (!$this->sendPurgeRequestToServers($socketAdapter, $servers, $formattedTagsChunk)) { + $successful = false; + } + } + + return $successful; + } + + /** + * Split tags by batches + * + * @param string $tagsPattern + * @return \Generator + */ + private function splitTags(string $tagsPattern) : \Generator + { + $tagsBatchSize = 0; + $formattedTagsChunk = []; + $formattedTags = explode('|', $tagsPattern); + foreach ($formattedTags as $formattedTag) { + if ($tagsBatchSize + strlen($formattedTag) > $this->requestSize - count($formattedTagsChunk) - 1) { + yield implode('|', $formattedTagsChunk); + $formattedTagsChunk = []; + $tagsBatchSize = 0; + } + + $tagsBatchSize += strlen($formattedTag); + $formattedTagsChunk[] = $formattedTag; + } + if (!empty($formattedTagsChunk)) { + yield implode('|', $formattedTagsChunk); + } + } + + /** + * Send curl purge request to servers to invalidate cache by tags pattern. + * + * @param \Zend\Http\Client\Adapter\Socket $socketAdapter + * @param \Zend\Uri\Uri[] $servers + * @param string $formattedTagsChunk + * @return bool Return true if successful; otherwise return false + */ + private function sendPurgeRequestToServers( + \Zend\Http\Client\Adapter\Socket $socketAdapter, + array $servers, + string $formattedTagsChunk + ): bool { + $headers = [self::HEADER_X_MAGENTO_TAGS_PATTERN => $formattedTagsChunk]; foreach ($servers as $server) { $headers['Host'] = $server->getHost(); try { @@ -69,12 +134,13 @@ public function sendPurgeRequest($tagsPattern) $socketAdapter->read(); $socketAdapter->close(); } catch (\Exception $e) { - $this->logger->critical($e->getMessage(), compact('server', 'tagsPattern')); + $this->logger->critical($e->getMessage(), compact('server', 'formattedTagsChunk')); + return false; } } + $this->logger->execute(compact('servers', 'formattedTagsChunk')); - $this->logger->execute(compact('servers', 'tagsPattern')); return true; } } diff --git a/app/code/Magento/CacheInvalidate/composer.json b/app/code/Magento/CacheInvalidate/composer.json index 825c9937c16d1..e35efe0cd2e4c 100644 --- a/app/code/Magento/CacheInvalidate/composer.json +++ b/app/code/Magento/CacheInvalidate/composer.json @@ -7,7 +7,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Captcha/composer.json b/app/code/Magento/Captcha/composer.json index a8a63e19e67df..09104f37814ee 100644 --- a/app/code/Magento/Captcha/composer.json +++ b/app/code/Magento/Captcha/composer.json @@ -13,7 +13,7 @@ "zendframework/zend-session": "^2.7.3" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Catalog/Block/Adminhtml/Category/AbstractCategory.php b/app/code/Magento/Catalog/Block/Adminhtml/Category/AbstractCategory.php index 331679874629b..bfeab3f71ebc1 100644 --- a/app/code/Magento/Catalog/Block/Adminhtml/Category/AbstractCategory.php +++ b/app/code/Magento/Catalog/Block/Adminhtml/Category/AbstractCategory.php @@ -7,6 +7,11 @@ use Magento\Framework\Data\Tree\Node; use Magento\Store\Model\Store; +use Magento\Framework\Registry; +use Magento\Catalog\Model\ResourceModel\Category\Tree; +use Magento\Catalog\Model\CategoryFactory; +use Magento\Backend\Block\Template\Context; +use Magento\Catalog\Model\Category; /** * Class AbstractCategory @@ -16,17 +21,17 @@ class AbstractCategory extends \Magento\Backend\Block\Template /** * Core registry * - * @var \Magento\Framework\Registry + * @var Registry */ protected $_coreRegistry = null; /** - * @var \Magento\Catalog\Model\ResourceModel\Category\Tree + * @var Tree */ protected $_categoryTree; /** - * @var \Magento\Catalog\Model\CategoryFactory + * @var CategoryFactory */ protected $_categoryFactory; @@ -36,17 +41,17 @@ class AbstractCategory extends \Magento\Backend\Block\Template protected $_withProductCount; /** - * @param \Magento\Backend\Block\Template\Context $context - * @param \Magento\Catalog\Model\ResourceModel\Category\Tree $categoryTree - * @param \Magento\Framework\Registry $registry - * @param \Magento\Catalog\Model\CategoryFactory $categoryFactory + * @param Context $context + * @param Tree $categoryTree + * @param Registry $registry + * @param CategoryFactory $categoryFactory * @param array $data */ public function __construct( - \Magento\Backend\Block\Template\Context $context, - \Magento\Catalog\Model\ResourceModel\Category\Tree $categoryTree, - \Magento\Framework\Registry $registry, - \Magento\Catalog\Model\CategoryFactory $categoryFactory, + Context $context, + Tree $categoryTree, + Registry $registry, + CategoryFactory $categoryFactory, array $data = [] ) { $this->_categoryTree = $categoryTree; @@ -67,36 +72,47 @@ public function getCategory() } /** + * Get category id + * * @return int|string|null */ public function getCategoryId() { if ($this->getCategory()) { - return $this->getCategory()->getId(); + return $this->getCategory() + ->getId(); } - return \Magento\Catalog\Model\Category::TREE_ROOT_ID; + return Category::TREE_ROOT_ID; } /** + * Get category name + * * @return string */ public function getCategoryName() { - return $this->getCategory()->getName(); + return $this->getCategory() + ->getName(); } /** + * Get category path + * * @return mixed */ public function getCategoryPath() { if ($this->getCategory()) { - return $this->getCategory()->getPath(); + return $this->getCategory() + ->getPath(); } - return \Magento\Catalog\Model\Category::TREE_ROOT_ID; + return Category::TREE_ROOT_ID; } /** + * Check store root category + * * @return bool */ public function hasStoreRootCategory() @@ -109,15 +125,20 @@ public function hasStoreRootCategory() } /** + * Get store from request + * * @return Store */ public function getStore() { - $storeId = (int)$this->getRequest()->getParam('store'); + $storeId = (int)$this->getRequest() + ->getParam('store'); return $this->_storeManager->getStore($storeId); } /** + * Get root category for tree + * * @param mixed|null $parentNodeCategory * @param int $recursionLevel * @return Node|array|null @@ -130,13 +151,14 @@ public function getRoot($parentNodeCategory = null, $recursionLevel = 3) } $root = $this->_coreRegistry->registry('root'); if ($root === null) { - $storeId = (int)$this->getRequest()->getParam('store'); + $storeId = (int)$this->getRequest() + ->getParam('store'); if ($storeId) { $store = $this->_storeManager->getStore($storeId); $rootId = $store->getRootCategoryId(); } else { - $rootId = \Magento\Catalog\Model\Category::TREE_ROOT_ID; + $rootId = Category::TREE_ROOT_ID; } $tree = $this->_categoryTree->load(null, $recursionLevel); @@ -149,10 +171,11 @@ public function getRoot($parentNodeCategory = null, $recursionLevel = 3) $root = $tree->getNodeById($rootId); - if ($root && $rootId != \Magento\Catalog\Model\Category::TREE_ROOT_ID) { + if ($root) { $root->setIsVisible(true); - } elseif ($root && $root->getId() == \Magento\Catalog\Model\Category::TREE_ROOT_ID) { - $root->setName(__('Root')); + if ($root->getId() == Category::TREE_ROOT_ID) { + $root->setName(__('Root')); + } } $this->_coreRegistry->register('root', $root); @@ -162,22 +185,28 @@ public function getRoot($parentNodeCategory = null, $recursionLevel = 3) } /** + * Get Default Store Id + * * @return int */ protected function _getDefaultStoreId() { - return \Magento\Store\Model\Store::DEFAULT_STORE_ID; + return Store::DEFAULT_STORE_ID; } /** + * Get category collection + * * @return \Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection */ public function getCategoryCollection() { - $storeId = $this->getRequest()->getParam('store', $this->_getDefaultStoreId()); + $storeId = $this->getRequest() + ->getParam('store', $this->_getDefaultStoreId()); $collection = $this->getData('category_collection'); if ($collection === null) { - $collection = $this->_categoryFactory->create()->getCollection(); + $collection = $this->_categoryFactory->create() + ->getCollection(); $collection->addAttributeToSelect( 'name' @@ -212,11 +241,11 @@ public function getRootByIds($ids) if (null === $root) { $ids = $this->_categoryTree->getExistingCategoryIdsBySpecifiedIds($ids); $tree = $this->_categoryTree->loadByIds($ids); - $rootId = \Magento\Catalog\Model\Category::TREE_ROOT_ID; + $rootId = Category::TREE_ROOT_ID; $root = $tree->getNodeById($rootId); - if ($root && $rootId != \Magento\Catalog\Model\Category::TREE_ROOT_ID) { + if ($root && $rootId != Category::TREE_ROOT_ID) { $root->setIsVisible(true); - } elseif ($root && $root->getId() == \Magento\Catalog\Model\Category::TREE_ROOT_ID) { + } elseif ($root && $root->getId() == Category::TREE_ROOT_ID) { $root->setName(__('Root')); } @@ -227,6 +256,8 @@ public function getRootByIds($ids) } /** + * Get category node for tree + * * @param mixed $parentNodeCategory * @param int $recursionLevel * @return Node @@ -237,9 +268,9 @@ public function getNode($parentNodeCategory, $recursionLevel = 2) $node = $this->_categoryTree->loadNode($nodeId); $node->loadChildren($recursionLevel); - if ($node && $nodeId != \Magento\Catalog\Model\Category::TREE_ROOT_ID) { + if ($node && $nodeId != Category::TREE_ROOT_ID) { $node->setIsVisible(true); - } elseif ($node && $node->getId() == \Magento\Catalog\Model\Category::TREE_ROOT_ID) { + } elseif ($node && $node->getId() == Category::TREE_ROOT_ID) { $node->setName(__('Root')); } @@ -249,17 +280,26 @@ public function getNode($parentNodeCategory, $recursionLevel = 2) } /** + * Get category save url + * * @param array $args * @return string */ public function getSaveUrl(array $args = []) { - $params = ['_current' => false, '_query' => false, 'store' => $this->getStore()->getId()]; + $params = [ + '_current' => false, + '_query' => false, + 'store' => $this->getStore() + ->getId() + ]; $params = array_merge($params, $args); return $this->getUrl('catalog/*/save', $params); } /** + * Get category edit url + * * @return string */ public function getEditUrl() @@ -279,7 +319,7 @@ public function getRootIds() { $ids = $this->getData('root_ids'); if ($ids === null) { - $ids = [\Magento\Catalog\Model\Category::TREE_ROOT_ID]; + $ids = [Category::TREE_ROOT_ID]; foreach ($this->_storeManager->getGroups() as $store) { $ids[] = $store->getRootCategoryId(); } diff --git a/app/code/Magento/Catalog/Block/Product/ProductList/Toolbar.php b/app/code/Magento/Catalog/Block/Product/ProductList/Toolbar.php index 674b20a49c837..30811150af143 100644 --- a/app/code/Magento/Catalog/Block/Product/ProductList/Toolbar.php +++ b/app/code/Magento/Catalog/Block/Product/ProductList/Toolbar.php @@ -7,6 +7,10 @@ use Magento\Catalog\Helper\Product\ProductList; use Magento\Catalog\Model\Product\ProductList\Toolbar as ToolbarModel; +use Magento\Catalog\Model\Product\ProductList\ToolbarMemorizer; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\App\Http\Context; +use Magento\Framework\Data\Form\FormKey; /** * Product list toolbar @@ -77,6 +81,7 @@ class Toolbar extends \Magento\Framework\View\Element\Template /** * @var bool $_paramsMemorizeAllowed + * @deprecated */ protected $_paramsMemorizeAllowed = true; @@ -96,6 +101,7 @@ class Toolbar extends \Magento\Framework\View\Element\Template * Catalog session * * @var \Magento\Catalog\Model\Session + * @deprecated */ protected $_catalogSession; @@ -104,6 +110,11 @@ class Toolbar extends \Magento\Framework\View\Element\Template */ protected $_toolbarModel; + /** + * @var ToolbarMemorizer + */ + private $toolbarMemorizer; + /** * @var ProductList */ @@ -119,6 +130,16 @@ class Toolbar extends \Magento\Framework\View\Element\Template */ protected $_postDataHelper; + /** + * @var Context + */ + private $httpContext; + + /** + * @var FormKey + */ + private $formKey; + /** * @param \Magento\Framework\View\Element\Template\Context $context * @param \Magento\Catalog\Model\Session $catalogSession @@ -128,6 +149,11 @@ class Toolbar extends \Magento\Framework\View\Element\Template * @param ProductList $productListHelper * @param \Magento\Framework\Data\Helper\PostHelper $postDataHelper * @param array $data + * @param ToolbarMemorizer|null $toolbarMemorizer + * @param Context|null $httpContext + * @param FormKey|null $formKey + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( \Magento\Framework\View\Element\Template\Context $context, @@ -137,7 +163,10 @@ public function __construct( \Magento\Framework\Url\EncoderInterface $urlEncoder, ProductList $productListHelper, \Magento\Framework\Data\Helper\PostHelper $postDataHelper, - array $data = [] + array $data = [], + ToolbarMemorizer $toolbarMemorizer = null, + Context $httpContext = null, + FormKey $formKey = null ) { $this->_catalogSession = $catalogSession; $this->_catalogConfig = $catalogConfig; @@ -145,6 +174,15 @@ public function __construct( $this->urlEncoder = $urlEncoder; $this->_productListHelper = $productListHelper; $this->_postDataHelper = $postDataHelper; + $this->toolbarMemorizer = $toolbarMemorizer ?: ObjectManager::getInstance()->get( + ToolbarMemorizer::class + ); + $this->httpContext = $httpContext ?: ObjectManager::getInstance()->get( + Context::class + ); + $this->formKey = $formKey ?: ObjectManager::getInstance()->get( + FormKey::class + ); parent::__construct($context, $data); } @@ -152,6 +190,7 @@ public function __construct( * Disable list state params memorizing * * @return $this + * @deprecated */ public function disableParamsMemorizing() { @@ -165,6 +204,7 @@ public function disableParamsMemorizing() * @param string $param parameter name * @param mixed $value parameter value * @return $this + * @deprecated */ protected function _memorizeParam($param, $value) { @@ -244,13 +284,13 @@ public function getCurrentOrder() $defaultOrder = $keys[0]; } - $order = $this->_toolbarModel->getOrder(); + $order = $this->toolbarMemorizer->getOrder(); if (!$order || !isset($orders[$order])) { $order = $defaultOrder; } - if ($order != $defaultOrder) { - $this->_memorizeParam('sort_order', $order); + if ($this->toolbarMemorizer->isMemorizingAllowed()) { + $this->httpContext->setValue(ToolbarModel::ORDER_PARAM_NAME, $order, $defaultOrder); } $this->setData('_current_grid_order', $order); @@ -270,13 +310,13 @@ public function getCurrentDirection() } $directions = ['asc', 'desc']; - $dir = strtolower($this->_toolbarModel->getDirection()); + $dir = strtolower($this->toolbarMemorizer->getDirection()); if (!$dir || !in_array($dir, $directions)) { $dir = $this->_direction; } - if ($dir != $this->_direction) { - $this->_memorizeParam('sort_direction', $dir); + if ($this->toolbarMemorizer->isMemorizingAllowed()) { + $this->httpContext->setValue(ToolbarModel::DIRECTION_PARAM_NAME, $dir, $this->_direction); } $this->setData('_current_grid_direction', $dir); @@ -392,6 +432,8 @@ public function getPagerUrl($params = []) } /** + * Get pager encoded url. + * * @param array $params * @return string */ @@ -412,11 +454,15 @@ public function getCurrentMode() return $mode; } $defaultMode = $this->_productListHelper->getDefaultViewMode($this->getModes()); - $mode = $this->_toolbarModel->getMode(); + $mode = $this->toolbarMemorizer->getMode(); if (!$mode || !isset($this->_availableMode[$mode])) { $mode = $defaultMode; } + if ($this->toolbarMemorizer->isMemorizingAllowed()) { + $this->httpContext->setValue(ToolbarModel::MODE_PARAM_NAME, $mode, $defaultMode); + } + $this->setData('_current_grid_mode', $mode); return $mode; } @@ -568,13 +614,13 @@ public function getLimit() $defaultLimit = $keys[0]; } - $limit = $this->_toolbarModel->getLimit(); + $limit = $this->toolbarMemorizer->getLimit(); if (!$limit || !isset($limits[$limit])) { $limit = $defaultLimit; } - if ($limit != $defaultLimit) { - $this->_memorizeParam('limit_page', $limit); + if ($this->toolbarMemorizer->isMemorizingAllowed()) { + $this->httpContext->setValue(ToolbarModel::LIMIT_PARAM_NAME, $limit, $defaultLimit); } $this->setData('_current_limit', $limit); @@ -582,6 +628,8 @@ public function getLimit() } /** + * Check if limit is current used in toolbar. + * * @param int $limit * @return bool */ @@ -591,6 +639,8 @@ public function isLimitCurrent($limit) } /** + * Pager number of items from which products started on current page. + * * @return int */ public function getFirstNum() @@ -600,6 +650,8 @@ public function getFirstNum() } /** + * Pager number of items products finished on current page. + * * @return int */ public function getLastNum() @@ -609,6 +661,8 @@ public function getLastNum() } /** + * Total number of products in current category. + * * @return int */ public function getTotalNum() @@ -617,6 +671,8 @@ public function getTotalNum() } /** + * Check if current page is the first. + * * @return bool */ public function isFirstPage() @@ -625,6 +681,8 @@ public function isFirstPage() } /** + * Return last page number. + * * @return int */ public function getLastPageNum() @@ -692,6 +750,8 @@ public function getWidgetOptionsJson(array $customOptions = []) 'orderDefault' => $this->getOrderField(), 'limitDefault' => $this->_productListHelper->getDefaultLimitPerPageValue($defaultMode), 'url' => $this->getPagerUrl(), + 'formKey' => $this->formKey->getFormKey(), + 'post' => $this->toolbarMemorizer->isMemorizingAllowed() ? true : false, ]; $options = array_replace_recursive($options, $customOptions); return json_encode(['productListToolbarForm' => $options]); diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Save.php b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Save.php index 91a98424c9ae1..3568d15b8048d 100644 --- a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Save.php +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Save.php @@ -18,6 +18,8 @@ use Magento\Eav\Model\Adminhtml\System\Config\Source\Inputtype\Validator; use Magento\Eav\Model\Adminhtml\System\Config\Source\Inputtype\ValidatorFactory; use Magento\Eav\Model\ResourceModel\Entity\Attribute\Group\CollectionFactory; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Serialize\Serializer\FormData; use Magento\Framework\Cache\FrontendInterface; use Magento\Framework\Controller\ResultFactory; use Magento\Framework\Controller\Result\Json; @@ -68,6 +70,11 @@ class Save extends Attribute */ private $layoutFactory; + /** + * @var FormData + */ + private $formDataSerializer; + /** * @param Context $context * @param FrontendInterface $attributeLabelCache @@ -80,6 +87,7 @@ class Save extends Attribute * @param FilterManager $filterManager * @param Product $productHelper * @param LayoutFactory $layoutFactory + * @param FormData|null $formDataSerializer * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -93,7 +101,8 @@ public function __construct( CollectionFactory $groupCollectionFactory, FilterManager $filterManager, Product $productHelper, - LayoutFactory $layoutFactory + LayoutFactory $layoutFactory, + FormData $formDataSerializer = null ) { parent::__construct($context, $attributeLabelCache, $coreRegistry, $resultPageFactory); $this->buildFactory = $buildFactory; @@ -103,19 +112,37 @@ public function __construct( $this->validatorFactory = $validatorFactory; $this->groupCollectionFactory = $groupCollectionFactory; $this->layoutFactory = $layoutFactory; + $this->formDataSerializer = $formDataSerializer ?? ObjectManager::getInstance()->get(FormData::class); } /** - * @return Redirect + * @inheritdoc + * * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ public function execute() { + try { + $optionData = $this->formDataSerializer->unserialize( + $this->getRequest()->getParam('serialized_options', '[]') + ); + } catch (\InvalidArgumentException $e) { + $message = __("The attribute couldn't be saved due to an error. Verify your information and try again. " + . "If the error persists, please try again later."); + $this->messageManager->addErrorMessage($message); + + return $this->returnResult('catalog/*/edit', ['_current' => true], ['error' => true]); + } + $data = $this->getRequest()->getPostValue(); + $data = array_replace_recursive( + $data, + $optionData + ); + if ($data) { - $this->preprocessOptionsData($data); $setId = $this->getRequest()->getParam('set'); $attributeSet = null; @@ -124,7 +151,7 @@ public function execute() $name = trim($name); try { - /** @var $attributeSet Set */ + /** @var Set $attributeSet */ $attributeSet = $this->buildFactory->create() ->setEntityTypeId($this->_entityTypeId) ->setSkeletonId($setId) @@ -147,7 +174,7 @@ public function execute() $attributeId = $this->getRequest()->getParam('attribute_id'); - /** @var $model ProductAttributeInterface */ + /** @var ProductAttributeInterface $model */ $model = $this->attributeFactory->create(); if ($attributeId) { $model->load($attributeId); @@ -180,7 +207,7 @@ public function execute() //validate frontend_input if (isset($data['frontend_input'])) { - /** @var $inputType Validator */ + /** @var Validator $inputType */ $inputType = $this->validatorFactory->create(); if (!$inputType->isValid($data['frontend_input'])) { foreach ($inputType->getMessages() as $message) { @@ -313,29 +340,8 @@ public function execute() } /** - * Extract options data from serialized options field and append to data array. - * - * This logic is required to overcome max_input_vars php limit - * that may vary and/or be inaccessible to change on different instances. + * Provides an initialized Result object. * - * @param array $data - * @return void - */ - private function preprocessOptionsData(&$data) - { - if (isset($data['serialized_options'])) { - $serializedOptions = json_decode($data['serialized_options'], JSON_OBJECT_AS_ARRAY); - foreach ($serializedOptions as $serializedOption) { - $option = []; - $serializedOptionWithParsedAmpersand = str_replace('&', '%26', $serializedOption); - parse_str($serializedOptionWithParsedAmpersand, $option); - $data = array_replace_recursive($data, $option); - } - } - unset($data['serialized_options']); - } - - /** * @param string $path * @param array $params * @param array $response diff --git a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Validate.php b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Validate.php index 7fe012a87d929..3a81b4633b2ff 100644 --- a/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Validate.php +++ b/app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Validate.php @@ -6,8 +6,15 @@ */ namespace Magento\Catalog\Controller\Adminhtml\Product\Attribute; +use Magento\Framework\Serialize\Serializer\FormData; +use Magento\Framework\App\ObjectManager; use Magento\Framework\DataObject; +/** + * Product attribute validate controller. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class Validate extends \Magento\Catalog\Controller\Adminhtml\Product\Attribute { const DEFAULT_MESSAGE_KEY = 'message'; @@ -27,6 +34,11 @@ class Validate extends \Magento\Catalog\Controller\Adminhtml\Product\Attribute */ private $multipleAttributeList; + /** + * @var FormData + */ + private $formDataSerializer; + /** * Constructor * @@ -37,6 +49,7 @@ class Validate extends \Magento\Catalog\Controller\Adminhtml\Product\Attribute * @param \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory * @param \Magento\Framework\View\LayoutFactory $layoutFactory * @param array $multipleAttributeList + * @param FormData|null $formDataSerializer */ public function __construct( \Magento\Backend\App\Action\Context $context, @@ -45,16 +58,19 @@ public function __construct( \Magento\Framework\View\Result\PageFactory $resultPageFactory, \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory, \Magento\Framework\View\LayoutFactory $layoutFactory, - array $multipleAttributeList = [] + array $multipleAttributeList = [], + FormData $formDataSerializer = null ) { parent::__construct($context, $attributeLabelCache, $coreRegistry, $resultPageFactory); $this->resultJsonFactory = $resultJsonFactory; $this->layoutFactory = $layoutFactory; $this->multipleAttributeList = $multipleAttributeList; + $this->formDataSerializer = $formDataSerializer ?? ObjectManager::getInstance()->get(FormData::class); } /** - * @return \Magento\Framework\Controller\ResultInterface + * @inheritdoc + * * @SuppressWarnings(PHPMD.NPathComplexity) * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ @@ -62,6 +78,16 @@ public function execute() { $response = new DataObject(); $response->setError(false); + try { + $optionsData = $this->formDataSerializer->unserialize( + $this->getRequest()->getParam('serialized_options', '[]') + ); + } catch (\InvalidArgumentException $e) { + $message = __("The attribute couldn't be validated due to an error. Verify your information and try again. " + . "If the error persists, please try again later."); + $this->setMessageToResponse($response, [$message]); + $response->setError(true); + } $attributeCode = $this->getRequest()->getParam('attribute_code'); $frontendLabel = $this->getRequest()->getParam('frontend_label'); @@ -74,7 +100,7 @@ public function execute() $attributeCode ); - if ($attribute->getId() && !$attributeId) { + if ($attribute->getId() && !$attributeId || $attributeCode === 'product_type') { $message = strlen($this->getRequest()->getParam('attribute_code')) ? __('An attribute with this code already exists.') : __('An attribute with the same code (%1) already exists.', $attributeCode); @@ -101,10 +127,10 @@ public function execute() } $multipleOption = $this->getRequest()->getParam("frontend_input"); - $multipleOption = null == $multipleOption ? 'select' : $multipleOption; + $multipleOption = (null === $multipleOption) ? 'select' : $multipleOption; if (isset($this->multipleAttributeList[$multipleOption]) && !(null == ($multipleOption))) { - $options = $this->getRequest()->getParam($this->multipleAttributeList[$multipleOption]); + $options = $optionsData[$this->multipleAttributeList[$multipleOption]] ?? null; $this->checkUniqueOption( $response, $options @@ -122,7 +148,8 @@ public function execute() } /** - * Throws Exception if not unique values into options + * Throws Exception if not unique values into options. + * * @param array $optionsValues * @param array $deletedOptions * @return bool @@ -156,6 +183,8 @@ private function setMessageToResponse($response, $messages) } /** + * Performs checking the uniqueness of the attribute options. + * * @param DataObject $response * @param array|null $options * @return $this diff --git a/app/code/Magento/Catalog/Controller/Category/View.php b/app/code/Magento/Catalog/Controller/Category/View.php index e31b1a17ecdc3..c7d237da2c4b8 100644 --- a/app/code/Magento/Catalog/Controller/Category/View.php +++ b/app/code/Magento/Catalog/Controller/Category/View.php @@ -8,13 +8,17 @@ use Magento\Catalog\Api\CategoryRepositoryInterface; use Magento\Catalog\Model\Layer\Resolver; +use Magento\Catalog\Model\Product\ProductList\ToolbarMemorizer; use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\View\Result\PageFactory; +use Magento\Framework\App\Action\Action; /** + * View a category on storefront. Needs to be accessible by POST because of the store switching. + * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ -class View extends \Magento\Framework\App\Action\Action +class View extends Action { /** * Core registry @@ -69,6 +73,11 @@ class View extends \Magento\Framework\App\Action\Action */ protected $categoryRepository; + /** + * @var ToolbarMemorizer + */ + private $toolbarMemorizer; + /** * Constructor * @@ -82,6 +91,7 @@ class View extends \Magento\Framework\App\Action\Action * @param \Magento\Framework\Controller\Result\ForwardFactory $resultForwardFactory * @param Resolver $layerResolver * @param CategoryRepositoryInterface $categoryRepository + * @param ToolbarMemorizer|null $toolbarMemorizer * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -94,7 +104,8 @@ public function __construct( PageFactory $resultPageFactory, \Magento\Framework\Controller\Result\ForwardFactory $resultForwardFactory, Resolver $layerResolver, - CategoryRepositoryInterface $categoryRepository + CategoryRepositoryInterface $categoryRepository, + ToolbarMemorizer $toolbarMemorizer = null ) { parent::__construct($context); $this->_storeManager = $storeManager; @@ -106,6 +117,7 @@ public function __construct( $this->resultForwardFactory = $resultForwardFactory; $this->layerResolver = $layerResolver; $this->categoryRepository = $categoryRepository; + $this->toolbarMemorizer = $toolbarMemorizer ?: $context->getObjectManager()->get(ToolbarMemorizer::class); } /** @@ -130,6 +142,7 @@ protected function _initCategory() } $this->_catalogSession->setLastVisitedCategoryId($category->getId()); $this->_coreRegistry->register('current_category', $category); + $this->toolbarMemorizer->memorizeParams(); try { $this->_eventManager->dispatch( 'catalog_controller_category_init_after', @@ -195,7 +208,7 @@ public function execute() if ($layoutUpdates && is_array($layoutUpdates)) { foreach ($layoutUpdates as $layoutUpdate) { $page->addUpdate($layoutUpdate); - $page->addPageLayoutHandles(['layout_update' => md5($layoutUpdate)], null, false); + $page->addPageLayoutHandles(['layout_update' => sha1($layoutUpdate)], null, false); } } diff --git a/app/code/Magento/Catalog/Model/Category/Attribute/Source/Layout.php b/app/code/Magento/Catalog/Model/Category/Attribute/Source/Layout.php index 1890ea0f7d99e..20ea899a3d0d7 100644 --- a/app/code/Magento/Catalog/Model/Category/Attribute/Source/Layout.php +++ b/app/code/Magento/Catalog/Model/Category/Attribute/Source/Layout.php @@ -17,6 +17,12 @@ class Layout extends \Magento\Eav\Model\Entity\Attribute\Source\AbstractSource */ protected $pageLayoutBuilder; + /** + * @inheritdoc + * @deprecated since the cache is now handled by \Magento\Theme\Model\PageLayout\Config\Builder::$configFiles + */ + protected $_options = null; + /** * @param \Magento\Framework\View\Model\PageLayout\Config\BuilderInterface $pageLayoutBuilder */ @@ -26,14 +32,14 @@ public function __construct(\Magento\Framework\View\Model\PageLayout\Config\Buil } /** - * {@inheritdoc} + * @inheritdoc */ public function getAllOptions() { - if (!$this->_options) { - $this->_options = $this->pageLayoutBuilder->getPageLayoutsConfig()->toOptionArray(); - array_unshift($this->_options, ['value' => '', 'label' => __('No layout updates')]); - } - return $this->_options; + $options = $this->pageLayoutBuilder->getPageLayoutsConfig()->toOptionArray(); + array_unshift($options, ['value' => '', 'label' => __('No layout updates')]); + $this->_options = $options; + + return $options; } } diff --git a/app/code/Magento/Catalog/Model/Category/Product/PositionResolver.php b/app/code/Magento/Catalog/Model/Category/Product/PositionResolver.php index 97941f2d23b9f..edaac39864c5a 100644 --- a/app/code/Magento/Catalog/Model/Category/Product/PositionResolver.php +++ b/app/code/Magento/Catalog/Model/Category/Product/PositionResolver.php @@ -52,6 +52,8 @@ public function getPositions(int $categoryId) $categoryId )->order( 'ccp.position ' . \Magento\Framework\DB\Select::SQL_ASC + )->order( + 'ccp.product_id ' . \Magento\Framework\DB\Select::SQL_DESC ); return array_flip($connection->fetchCol($select)); diff --git a/app/code/Magento/Catalog/Model/CategoryList.php b/app/code/Magento/Catalog/Model/CategoryList.php index 790ea6b921fbe..86692c7d6bc61 100644 --- a/app/code/Magento/Catalog/Model/CategoryList.php +++ b/app/code/Magento/Catalog/Model/CategoryList.php @@ -15,6 +15,9 @@ use Magento\Framework\Api\SearchCriteriaInterface; use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface; +/** + * Class for getting category list. + */ class CategoryList implements CategoryListInterface { /** @@ -64,7 +67,7 @@ public function __construct( } /** - * {@inheritdoc} + * @inheritdoc */ public function getList(SearchCriteriaInterface $searchCriteria) { @@ -75,8 +78,8 @@ public function getList(SearchCriteriaInterface $searchCriteria) $this->collectionProcessor->process($searchCriteria, $collection); $items = []; - foreach ($collection->getAllIds() as $id) { - $items[] = $this->categoryRepository->get($id); + foreach ($collection->getItems() as $category) { + $items[] = $this->categoryRepository->get($category->getId()); } /** @var CategorySearchResultsInterface $searchResult */ diff --git a/app/code/Magento/Catalog/Model/Indexer/Product/Flat/Action/Indexer.php b/app/code/Magento/Catalog/Model/Indexer/Product/Flat/Action/Indexer.php index f95807f615390..25a9eebb5a0a9 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Product/Flat/Action/Indexer.php +++ b/app/code/Magento/Catalog/Model/Indexer/Product/Flat/Action/Indexer.php @@ -9,6 +9,7 @@ use Magento\Catalog\Api\Data\ProductInterface; use Magento\Framework\App\ResourceConnection; use Magento\Framework\EntityManager\MetadataPool; +use Magento\Store\Model\Store; /** * Class Indexer @@ -84,7 +85,7 @@ public function write($storeId, $productId, $valueFieldSuffix = '') [ 'entity_id' => 'e.entity_id', 'attribute_id' => 't.attribute_id', - 'value' => $this->_connection->getIfNullSql('`t2`.`value`', '`t`.`value`'), + 'value' => 't.value' ] ); @@ -99,32 +100,30 @@ public function write($storeId, $productId, $valueFieldSuffix = '') sprintf('e.%s = t.%s ', $linkField, $linkField) . $this->_connection->quoteInto( ' AND t.attribute_id IN (?)', array_keys($ids) - ) . ' AND t.store_id = 0', - [] - )->joinLeft( - ['t2' => $tableName], - sprintf('t.%s = t2.%s ', $linkField, $linkField) . - ' AND t.attribute_id = t2.attribute_id ' . - $this->_connection->quoteInto( - ' AND t2.store_id = ?', - $storeId - ), + ) . ' AND ' . $this->_connection->quoteInto('t.store_id IN(?)', [ + Store::DEFAULT_STORE_ID, + $storeId + ]), [] )->where( 'e.entity_id = ' . $productId - ); + )->order('t.store_id ASC'); $cursor = $this->_connection->query($select); while ($row = $cursor->fetch(\Zend_Db::FETCH_ASSOC)) { $updateData[$ids[$row['attribute_id']]] = $row['value']; $valueColumnName = $ids[$row['attribute_id']] . $valueFieldSuffix; if (isset($describe[$valueColumnName])) { - $valueColumns[$row['value']] = $valueColumnName; + $valueColumns[$row['attribute_id']] = [ + 'value' => $row['value'], + 'column_name' => $valueColumnName + ]; } } //Update not simple attributes (eg. dropdown) if (!empty($valueColumns)) { - $valueIds = array_keys($valueColumns); + $valueIds = array_column($valueColumns, 'value'); + $optionIdToAttributeName = array_column($valueColumns, 'column_name', 'value'); $select = $this->_connection->select()->from( ['t' => $this->_productIndexerHelper->getTable('eav_attribute_option_value')], @@ -133,14 +132,14 @@ public function write($storeId, $productId, $valueFieldSuffix = '') $this->_connection->quoteInto('t.option_id IN (?)', $valueIds) )->where( $this->_connection->quoteInto('t.store_id IN(?)', [ - \Magento\Store\Model\Store::DEFAULT_STORE_ID, + Store::DEFAULT_STORE_ID, $storeId ]) ) ->order('t.store_id ASC'); $cursor = $this->_connection->query($select); while ($row = $cursor->fetch(\Zend_Db::FETCH_ASSOC)) { - $valueColumnName = $valueColumns[$row['option_id']]; + $valueColumnName = $optionIdToAttributeName[$row['option_id']]; if (isset($describe[$valueColumnName])) { $updateData[$valueColumnName] = $row['value']; } @@ -150,6 +149,7 @@ public function write($storeId, $productId, $valueFieldSuffix = '') $columnNames = array_keys($columns); $columnNames[] = 'attribute_set_id'; $columnNames[] = 'type_id'; + $columnNames[] = $linkField; $select->from( ['e' => $entityTableName], $columnNames @@ -159,6 +159,7 @@ public function write($storeId, $productId, $valueFieldSuffix = '') $cursor = $this->_connection->query($select); $row = $cursor->fetch(\Zend_Db::FETCH_ASSOC); if (!empty($row)) { + $linkFieldId = $linkField; foreach ($row as $columnName => $value) { $updateData[$columnName] = $value; } @@ -170,7 +171,7 @@ public function write($storeId, $productId, $valueFieldSuffix = '') if (!empty($updateData)) { $updateData += ['entity_id' => $productId]; if ($linkField !== $metadata->getIdentifierField()) { - $updateData += [$linkField => $productId]; + $updateData += [$linkField => $linkFieldId]; } $updateFields = []; foreach ($updateData as $key => $value) { diff --git a/app/code/Magento/Catalog/Model/Indexer/Product/Flat/Action/Row.php b/app/code/Magento/Catalog/Model/Indexer/Product/Flat/Action/Row.php index 6d0727259d9db..8ccd48f1360c0 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Product/Flat/Action/Row.php +++ b/app/code/Magento/Catalog/Model/Indexer/Product/Flat/Action/Row.php @@ -95,15 +95,17 @@ public function execute($id = null) /* @var $status \Magento\Eav\Model\Entity\Attribute */ $status = $this->_productIndexerHelper->getAttribute(ProductInterface::STATUS); $statusTable = $status->getBackend()->getTable(); + $catalogProductEntityTable = $this->_productIndexerHelper->getTable('catalog_product_entity'); $statusConditions = [ - 'store_id IN(0,' . (int)$store->getId() . ')', - 'attribute_id = ' . (int)$status->getId(), - $linkField . ' = ' . (int)$id, + 's.store_id IN(0,' . (int)$store->getId() . ')', + 's.attribute_id = ' . (int)$status->getId(), + 'e.entity_id = ' . (int)$id, ]; $select = $this->_connection->select(); - $select->from($statusTable, ['value']) + $select->from(['e' => $catalogProductEntityTable], ['s.value']) ->where(implode(' AND ', $statusConditions)) - ->order('store_id DESC') + ->joinLeft(['s' => $statusTable], "e.{$linkField} = s.{$linkField}", []) + ->order('s.store_id DESC') ->limit(1); $result = $this->_connection->query($select); $status = $result->fetchColumn(0); diff --git a/app/code/Magento/Catalog/Model/Indexer/Product/Flat/FlatTableBuilder.php b/app/code/Magento/Catalog/Model/Indexer/Product/Flat/FlatTableBuilder.php index abe1b3eedbc84..e8a60d50405a5 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Product/Flat/FlatTableBuilder.php +++ b/app/code/Magento/Catalog/Model/Indexer/Product/Flat/FlatTableBuilder.php @@ -352,12 +352,20 @@ protected function _updateTemporaryTableByStoreValues( } //Update not simple attributes (eg. dropdown) - if (isset($flatColumns[$attributeCode . $valueFieldSuffix])) { - $select = $this->_connection->select()->joinInner( - ['t' => $this->_productIndexerHelper->getTable('eav_attribute_option_value')], - 't.option_id = et.' . $attributeCode . ' AND t.store_id=' . $storeId, - [$attributeCode . $valueFieldSuffix => 't.value'] - ); + $columnName = $attributeCode . $valueFieldSuffix; + if (isset($flatColumns[$columnName])) { + $select = $this->_connection->select(); + $select->joinLeft( + ['t0' => $this->_productIndexerHelper->getTable('eav_attribute_option_value')], + 't0.option_id = et.' . $attributeCode . ' AND t0.store_id = 0', + [] + )->joinLeft( + ['ts' => $this->_productIndexerHelper->getTable('eav_attribute_option_value')], + 'ts.option_id = et.' . $attributeCode . ' AND ts.store_id = ' . $storeId, + [] + )->columns( + [$columnName => $this->_connection->getIfNullSql('ts.value', 't0.value')] + )->where($attributeCode . ' IS NOT NULL'); if (!empty($changedIds)) { $select->where($this->_connection->quoteInto('et.entity_id IN (?)', $changedIds)); } @@ -381,6 +389,8 @@ protected function _getTemporaryTableName($tableName) } /** + * Get MetadataPool + * * @return \Magento\Framework\EntityManager\MetadataPool */ private function getMetadataPool() diff --git a/app/code/Magento/Catalog/Model/Indexer/Product/Flat/TableBuilder.php b/app/code/Magento/Catalog/Model/Indexer/Product/Flat/TableBuilder.php index 5f8be83872021..a32379b8c0a67 100644 --- a/app/code/Magento/Catalog/Model/Indexer/Product/Flat/TableBuilder.php +++ b/app/code/Magento/Catalog/Model/Indexer/Product/Flat/TableBuilder.php @@ -34,13 +34,6 @@ class TableBuilder */ private $tableBuilderFactory; - /** - * Check whether builder was executed - * - * @var bool - */ - protected $_isExecuted = false; - /** * Constructor * @@ -70,9 +63,6 @@ public function __construct( */ public function build($storeId, $changedIds, $valueFieldSuffix) { - if ($this->_isExecuted) { - return; - } $entityTableName = $this->_productIndexerHelper->getTable('catalog_product_entity'); $attributes = $this->_productIndexerHelper->getAttributes(); $eavAttributes = $this->_productIndexerHelper->getTablesStructure($attributes); @@ -117,7 +107,6 @@ public function build($storeId, $changedIds, $valueFieldSuffix) //Fill temporary tables with attributes grouped by it type $this->_fillTemporaryTable($tableName, $columns, $changedIds, $valueFieldSuffix, $storeId); } - $this->_isExecuted = true; } /** diff --git a/app/code/Magento/Catalog/Model/Product.php b/app/code/Magento/Catalog/Model/Product.php index 8a9233f176c61..831644a553b4b 100644 --- a/app/code/Magento/Catalog/Model/Product.php +++ b/app/code/Magento/Catalog/Model/Product.php @@ -714,7 +714,7 @@ public function getIdBySku($sku) public function getCategoryId() { $category = $this->_registry->registry('current_category'); - if ($category) { + if ($category && in_array($category->getId(), $this->getCategoryIds())) { return $category->getId(); } return false; diff --git a/app/code/Magento/Catalog/Model/Product/Attribute/OptionManagement.php b/app/code/Magento/Catalog/Model/Product/Attribute/OptionManagement.php index f2039a5002dcc..6cca2c07e2dd8 100644 --- a/app/code/Magento/Catalog/Model/Product/Attribute/OptionManagement.php +++ b/app/code/Magento/Catalog/Model/Product/Attribute/OptionManagement.php @@ -47,7 +47,7 @@ public function add($attributeCode, $option) /** @var \Magento\Eav\Api\Data\AttributeOptionInterface $attributeOption */ $attributeOption = $attributeOption->getLabel(); }); - if (in_array($option->getLabel(), $currentOptions)) { + if (in_array($option->getLabel(), $currentOptions, true)) { return false; } } diff --git a/app/code/Magento/Catalog/Model/Product/Attribute/Source/Layout.php b/app/code/Magento/Catalog/Model/Product/Attribute/Source/Layout.php index 63b1444d1db07..dbc7535dccfa9 100644 --- a/app/code/Magento/Catalog/Model/Product/Attribute/Source/Layout.php +++ b/app/code/Magento/Catalog/Model/Product/Attribute/Source/Layout.php @@ -17,6 +17,12 @@ class Layout extends \Magento\Eav\Model\Entity\Attribute\Source\AbstractSource */ protected $pageLayoutBuilder; + /** + * @inheritdoc + * @deprecated since the cache is now handled by \Magento\Theme\Model\PageLayout\Config\Builder::$configFiles + */ + protected $_options = null; + /** * @param \Magento\Framework\View\Model\PageLayout\Config\BuilderInterface $pageLayoutBuilder */ @@ -26,14 +32,14 @@ public function __construct(\Magento\Framework\View\Model\PageLayout\Config\Buil } /** - * @return array + * @inheritdoc */ public function getAllOptions() { - if (!$this->_options) { - $this->_options = $this->pageLayoutBuilder->getPageLayoutsConfig()->toOptionArray(); - array_unshift($this->_options, ['value' => '', 'label' => __('No layout updates')]); - } - return $this->_options; + $options = $this->pageLayoutBuilder->getPageLayoutsConfig()->toOptionArray(); + array_unshift($options, ['value' => '', 'label' => __('No layout updates')]); + $this->_options = $options; + + return $options; } } diff --git a/app/code/Magento/Catalog/Model/Product/Gallery/CreateHandler.php b/app/code/Magento/Catalog/Model/Product/Gallery/CreateHandler.php index 1a3d03bf2c353..8b9703b6623ad 100644 --- a/app/code/Magento/Catalog/Model/Product/Gallery/CreateHandler.php +++ b/app/code/Magento/Catalog/Model/Product/Gallery/CreateHandler.php @@ -308,7 +308,7 @@ protected function duplicate($product) $this->resourceModel->duplicate( $this->getAttribute()->getAttributeId(), - isset($mediaGalleryData['duplicate']) ? $mediaGalleryData['duplicate'] : [], + $mediaGalleryData['duplicate'] ?? [], $product->getOriginalLinkId(), $product->getData($this->metadata->getLinkField()) ); diff --git a/app/code/Magento/Catalog/Model/Product/ProductList/ToolbarMemorizer.php b/app/code/Magento/Catalog/Model/Product/ProductList/ToolbarMemorizer.php new file mode 100644 index 0000000000000..46a73e104b87f --- /dev/null +++ b/app/code/Magento/Catalog/Model/Product/ProductList/ToolbarMemorizer.php @@ -0,0 +1,183 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\Catalog\Model\Product\ProductList; + +use Magento\Catalog\Model\Session as CatalogSession; +use Magento\Framework\App\Config\ScopeConfigInterface; + +/** + * Responds for saving toolbar settings to catalog session. + */ +class ToolbarMemorizer +{ + /** + * XML PATH to enable/disable saving toolbar parameters to session + */ + const XML_PATH_CATALOG_REMEMBER_PAGINATION = 'catalog/frontend/remember_pagination'; + + /** + * @var CatalogSession + */ + private $catalogSession; + + /** + * @var Toolbar + */ + private $toolbarModel; + + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + + /** + * @var string|bool + */ + private $order; + + /** + * @var string|bool + */ + private $direction; + + /** + * @var string|bool + */ + private $mode; + + /** + * @var string|bool + */ + private $limit; + + /** + * @var bool + */ + private $isMemorizingAllowed; + + /** + * @param Toolbar $toolbarModel + * @param CatalogSession $catalogSession + * @param ScopeConfigInterface $scopeConfig + */ + public function __construct( + Toolbar $toolbarModel, + CatalogSession $catalogSession, + ScopeConfigInterface $scopeConfig + ) { + $this->toolbarModel = $toolbarModel; + $this->catalogSession = $catalogSession; + $this->scopeConfig = $scopeConfig; + } + + /** + * Get sort order. + * + * @return string|bool|null + */ + public function getOrder() + { + if ($this->order === null) { + $this->order = $this->toolbarModel->getOrder() ?? + ($this->isMemorizingAllowed() ? $this->catalogSession->getData(Toolbar::ORDER_PARAM_NAME) : null); + } + + return $this->order; + } + + /** + * Get sort direction. + * + * @return string|bool|null + */ + public function getDirection() + { + if ($this->direction === null) { + $this->direction = $this->toolbarModel->getDirection() ?? + ($this->isMemorizingAllowed() ? $this->catalogSession->getData(Toolbar::DIRECTION_PARAM_NAME) : null); + } + + return $this->direction; + } + + /** + * Get sort mode. + * + * @return string|bool|null + */ + public function getMode() + { + if ($this->mode === null) { + $this->mode = $this->toolbarModel->getMode() ?? + ($this->isMemorizingAllowed() ? $this->catalogSession->getData(Toolbar::MODE_PARAM_NAME) : null); + } + + return $this->mode; + } + + /** + * Get products per page limit. + * + * @return string|bool|null + */ + public function getLimit() + { + if ($this->limit === null) { + $this->limit = $this->toolbarModel->getLimit() ?? + ($this->isMemorizingAllowed() ? $this->catalogSession->getData(Toolbar::LIMIT_PARAM_NAME) : null); + } + + return $this->limit; + } + + /** + * Method to save all catalog parameters in catalog session. + * + * @return void + */ + public function memorizeParams() + { + if (!$this->catalogSession->getParamsMemorizeDisabled() && $this->isMemorizingAllowed()) { + $this->memorizeParam(Toolbar::ORDER_PARAM_NAME, $this->getOrder()) + ->memorizeParam(Toolbar::DIRECTION_PARAM_NAME, $this->getDirection()) + ->memorizeParam(Toolbar::MODE_PARAM_NAME, $this->getMode()) + ->memorizeParam(Toolbar::LIMIT_PARAM_NAME, $this->getLimit()); + } + } + + /** + * Check configuration for enabled/disabled toolbar memorizing. + * + * @return bool + */ + public function isMemorizingAllowed(): bool + { + if ($this->isMemorizingAllowed === null) { + $this->isMemorizingAllowed = $this->scopeConfig->isSetFlag(self::XML_PATH_CATALOG_REMEMBER_PAGINATION); + } + + return $this->isMemorizingAllowed; + } + + /** + * Memorize parameter value for session. + * + * @param string $param parameter name + * @param mixed $value parameter value + * @return ToolbarMemorizer + */ + private function memorizeParam(string $param, $value): ToolbarMemorizer + { + if ($value && $this->catalogSession->getData($param) != $value) { + $this->catalogSession->setData($param, $value); + } + + return $this; + } +} diff --git a/app/code/Magento/Catalog/Model/Product/Type/Price.php b/app/code/Magento/Catalog/Model/Product/Type/Price.php index f6caa299d66d7..a4ee944a9bff2 100644 --- a/app/code/Magento/Catalog/Model/Product/Type/Price.php +++ b/app/code/Magento/Catalog/Model/Product/Type/Price.php @@ -11,6 +11,7 @@ use Magento\Store\Model\Store; use Magento\Catalog\Api\Data\ProductTierPriceExtensionFactory; use Magento\Framework\App\ObjectManager; +use Magento\Store\Api\Data\WebsiteInterface; /** * Product type price model @@ -184,6 +185,8 @@ public function getFinalPrice($qty, $product) } /** + * Retrieve final price for child product. + * * @param Product $product * @param float $productQty * @param Product $childProduct @@ -428,6 +431,8 @@ public function setTierPrices($product, array $tierPrices = null) } /** + * Retrieve customer group id from product. + * * @param Product $product * @return int */ @@ -453,7 +458,7 @@ protected function _applySpecialPrice($product, $finalPrice) $product->getSpecialPrice(), $product->getSpecialFromDate(), $product->getSpecialToDate(), - $product->getStore() + WebsiteInterface::ADMIN_CODE ); } @@ -601,7 +606,7 @@ public function calculatePrice( $specialPrice, $specialPriceFrom, $specialPriceTo, - $sId + WebsiteInterface::ADMIN_CODE ); if ($rulePrice === false) { diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Category.php b/app/code/Magento/Catalog/Model/ResourceModel/Category.php index 56b8a19d14255..7efcaed43cab2 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Category.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Category.php @@ -484,8 +484,20 @@ public function getProductsPosition($category) $this->getCategoryProductTable(), ['product_id', 'position'] )->where( - 'category_id = :category_id' + "{$this->getTable('catalog_category_product')}.category_id = ?", + $category->getId() ); + $websiteId = $category->getStore()->getWebsiteId(); + if ($websiteId) { + $select->join( + ['product_website' => $this->getTable('catalog_product_website')], + "product_website.product_id = {$this->getTable('catalog_category_product')}.product_id", + [] + )->where( + 'product_website.website_id = ?', + $websiteId + ); + } $bind = ['category_id' => (int)$category->getId()]; return $this->getConnection()->fetchPairs($select, $bind); diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Category/Flat.php b/app/code/Magento/Catalog/Model/ResourceModel/Category/Flat.php index 9db2c8248ce52..05950531e2178 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Category/Flat.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Category/Flat.php @@ -699,8 +699,20 @@ public function getProductsPosition($category) $this->getTable('catalog_category_product'), ['product_id', 'position'] )->where( - 'category_id = :category_id' + "{$this->getTable('catalog_category_product')}.category_id = ?", + $category->getId() ); + $websiteId = $category->getStore()->getWebsiteId(); + if ($websiteId) { + $select->join( + ['product_website' => $this->getTable('catalog_product_website')], + "product_website.product_id = {$this->getTable('catalog_category_product')}.product_id", + [] + )->where( + 'product_website.website_id = ?', + $websiteId + ); + } $bind = ['category_id' => (int)$category->getId()]; return $this->getConnection()->fetchPairs($select, $bind); diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Category/RedundantCategoryImageChecker.php b/app/code/Magento/Catalog/Model/ResourceModel/Category/RedundantCategoryImageChecker.php new file mode 100644 index 0000000000000..b683bcd803bd3 --- /dev/null +++ b/app/code/Magento/Catalog/Model/ResourceModel/Category/RedundantCategoryImageChecker.php @@ -0,0 +1,52 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Catalog\Model\ResourceModel\Category; + +use Magento\Catalog\Api\CategoryListInterface; +use Magento\Framework\Api\SearchCriteriaBuilder; + +/** + * Check if Image is currently used in any category as Category Image. + */ +class RedundantCategoryImageChecker +{ + /** + * @var SearchCriteriaBuilder + */ + private $searchCriteriaBuilder; + + /** + * @var CategoryListInterface + */ + private $categoryList; + + public function __construct( + CategoryListInterface $categoryList, + SearchCriteriaBuilder $searchCriteriaBuilder + ) { + $this->categoryList = $categoryList; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + } + + /** + * Checks if Image is currently used in any category as Category Image. + * + * Returns true if not. + * + * @param string $imageName + * @return bool + */ + public function execute(string $imageName): bool + { + /** @var SearchCriteriaBuilder $searchCriteriaBuilder */ + $searchCriteria = $this->searchCriteriaBuilder->addFilter('image', $imageName)->create(); + $categories = $this->categoryList->getList($searchCriteria)->getItems(); + + return empty($categories); + } +} diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php index 749a3d754570f..7db77faaafcaf 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Collection.php @@ -2205,7 +2205,7 @@ private function getTierPriceSelect(array $productIds) $this->getLinkField() . ' IN(?)', $productIds )->order( - $this->getLinkField() + 'qty' ); return $select; } diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Gallery.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Gallery.php index b68c43e40ff2f..9a7af68948a21 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product/Gallery.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Gallery.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Catalog\Model\ResourceModel\Product; use Magento\Store\Model\Store; @@ -141,7 +142,7 @@ public function loadProductGalleryByAttributeId($product, $attributeId) */ protected function createBaseLoadSelect($entityId, $storeId, $attributeId) { - $select = $this->createBatchBaseSelect($storeId, $attributeId); + $select = $this->createBatchBaseSelect($storeId, $attributeId); $select = $select->where( 'entity.' . $this->metadata->getLinkField() . ' = ?', @@ -362,9 +363,9 @@ public function deleteGalleryValueInStore($valueId, $entityId, $storeId) $conditions = implode( ' AND ', [ - $this->getConnection()->quoteInto('value_id = ?', (int) $valueId), - $this->getConnection()->quoteInto($this->metadata->getLinkField() . ' = ?', (int) $entityId), - $this->getConnection()->quoteInto('store_id = ?', (int) $storeId) + $this->getConnection()->quoteInto('value_id = ?', (int)$valueId), + $this->getConnection()->quoteInto($this->metadata->getLinkField() . ' = ?', (int)$entityId), + $this->getConnection()->quoteInto('store_id = ?', (int)$storeId) ] ); @@ -392,7 +393,7 @@ public function duplicate($attributeId, $newFiles, $originalProductId, $newProdu $select = $this->getConnection()->select()->from( [$this->getMainTableAlias() => $this->getMainTable()], - ['value_id', 'value'] + ['value_id', 'value', 'media_type', 'disabled'] )->joinInner( ['entity' => $this->getTable(self::GALLERY_VALUE_TO_ENTITY_TABLE)], $this->getMainTableAlias() . '.value_id = entity.value_id', @@ -409,16 +410,16 @@ public function duplicate($attributeId, $newFiles, $originalProductId, $newProdu // Duplicate main entries of gallery foreach ($this->getConnection()->fetchAll($select) as $row) { - $data = [ - 'attribute_id' => $attributeId, - 'value' => isset($newFiles[$row['value_id']]) ? $newFiles[$row['value_id']] : $row['value'], - ]; + $data = $row; + $data['attribute_id'] = $attributeId; + $data['value'] = $newFiles[$row['value_id']] ?? $row['value']; + unset($data['value_id']); $valueIdMap[$row['value_id']] = $this->insertGallery($data); $this->bindValueToEntity($valueIdMap[$row['value_id']], $newProductId); } - if (count($valueIdMap) == 0) { + if (count($valueIdMap) === 0) { return []; } diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Product/Image.php b/app/code/Magento/Catalog/Model/ResourceModel/Product/Image.php index 5f83f9826abb5..77f67480619e0 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Product/Image.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Product/Image.php @@ -13,7 +13,7 @@ use Magento\Framework\App\ResourceConnection; /** - * Class for fast retrieval of all product images + * Class for retrieval of all product images */ class Image { @@ -76,15 +76,24 @@ public function getAllProductImages(): \Generator /** * Get the number of unique pictures of products + * * @return int */ public function getCountAllProductImages(): int { - $select = $this->getVisibleImagesSelect()->reset('columns')->columns('count(*)'); + $select = $this->getVisibleImagesSelect() + ->reset('columns') + ->reset('distinct') + ->columns( + new \Zend_Db_Expr('count(distinct value)') + ); + return (int) $this->connection->fetchOne($select); } /** + * Return Select to fetch all products images + * * @return Select */ private function getVisibleImagesSelect(): Select diff --git a/app/code/Magento/Catalog/Plugin/Framework/App/Action/ContextPlugin.php b/app/code/Magento/Catalog/Plugin/Framework/App/Action/ContextPlugin.php new file mode 100644 index 0000000000000..544319e739de5 --- /dev/null +++ b/app/code/Magento/Catalog/Plugin/Framework/App/Action/ContextPlugin.php @@ -0,0 +1,74 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\Catalog\Plugin\Framework\App\Action; + +use Magento\Catalog\Model\Product\ProductList\Toolbar as ToolbarModel; +use Magento\Catalog\Model\Product\ProductList\ToolbarMemorizer; +use Magento\Catalog\Model\Session as CatalogSession; +use Magento\Framework\App\Http\Context as HttpContext; + +/** + * Before dispatch plugin for all frontend controllers to update http context. + */ +class ContextPlugin +{ + /** + * @var ToolbarMemorizer + */ + private $toolbarMemorizer; + + /** + * @var CatalogSession + */ + private $catalogSession; + + /** + * @var HttpContext + */ + private $httpContext; + + /** + * @param ToolbarMemorizer $toolbarMemorizer + * @param CatalogSession $catalogSession + * @param HttpContext $httpContext + */ + public function __construct( + ToolbarMemorizer $toolbarMemorizer, + CatalogSession $catalogSession, + HttpContext $httpContext + ) { + $this->toolbarMemorizer = $toolbarMemorizer; + $this->catalogSession = $catalogSession; + $this->httpContext = $httpContext; + } + + /** + * Update http context with catalog sensitive information. + * + * @return void + */ + public function beforeDispatch() + { + if ($this->toolbarMemorizer->isMemorizingAllowed()) { + $params = [ + ToolbarModel::ORDER_PARAM_NAME, + ToolbarModel::DIRECTION_PARAM_NAME, + ToolbarModel::MODE_PARAM_NAME, + ToolbarModel::LIMIT_PARAM_NAME, + ]; + + foreach ($params as $param) { + $paramValue = $this->catalogSession->getData($param); + if ($paramValue) { + $this->httpContext->setValue($param, $paramValue, false); + } + } + } + } +} diff --git a/app/code/Magento/Catalog/Plugin/Model/ResourceModel/Category/RemoveRedundantImagePlugin.php b/app/code/Magento/Catalog/Plugin/Model/ResourceModel/Category/RemoveRedundantImagePlugin.php new file mode 100644 index 0000000000000..59f1051b8ed56 --- /dev/null +++ b/app/code/Magento/Catalog/Plugin/Model/ResourceModel/Category/RemoveRedundantImagePlugin.php @@ -0,0 +1,77 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Catalog\Plugin\Model\ResourceModel\Category; + +use Magento\Catalog\Model\ImageUploader; +use Magento\Catalog\Model\ResourceModel\Category as CategoryResource; +use Magento\Catalog\Model\ResourceModel\Category\RedundantCategoryImageChecker; +use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\Filesystem; +use Magento\Framework\Model\AbstractModel; + +/** + * Remove old Category Image file from pub/media/catalog/category directory if such Image is not used anymore. + */ +class RemoveRedundantImagePlugin +{ + /** + * @var Filesystem + */ + private $filesystem; + + /** + * @var ImageUploader + */ + private $imageUploader; + + /** + * @var RedundantCategoryImageChecker + */ + private $redundantCategoryImageChecker; + + public function __construct( + Filesystem $filesystem, + ImageUploader $imageUploader, + RedundantCategoryImageChecker $redundantCategoryImageChecker + ) { + $this->filesystem = $filesystem; + $this->imageUploader = $imageUploader; + $this->redundantCategoryImageChecker = $redundantCategoryImageChecker; + } + + /** + * Removes Image file if it is not used anymore. + * + * @param CategoryResource $subject + * @param CategoryResource $result + * @param AbstractModel $category + * @return CategoryResource + * + * @throws \Magento\Framework\Exception\FileSystemException + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterSave( + CategoryResource $subject, + CategoryResource $result, + AbstractModel $category + ): CategoryResource { + $originalImage = $category->getOrigData('image'); + if (null !== $originalImage + && $originalImage !== $category->getImage() + && $this->redundantCategoryImageChecker->execute($originalImage) + ) { + $basePath = $this->imageUploader->getBasePath(); + $baseImagePath = $this->imageUploader->getFilePath($basePath, $originalImage); + /** @var \Magento\Framework\Filesystem\Directory\WriteInterface $mediaDirectory */ + $mediaDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA); + $mediaDirectory->delete($baseImagePath); + } + + return $result; + } +} diff --git a/app/code/Magento/Catalog/Pricing/Price/SpecialPrice.php b/app/code/Magento/Catalog/Pricing/Price/SpecialPrice.php index b1bfc6ff4ad6f..77c48fdb1667e 100644 --- a/app/code/Magento/Catalog/Pricing/Price/SpecialPrice.php +++ b/app/code/Magento/Catalog/Pricing/Price/SpecialPrice.php @@ -11,6 +11,7 @@ use Magento\Framework\Pricing\Price\AbstractPrice; use Magento\Framework\Pricing\Price\BasePriceProviderInterface; use Magento\Framework\Stdlib\DateTime\TimezoneInterface; +use Magento\Store\Api\Data\WebsiteInterface; /** * Special price model @@ -46,6 +47,8 @@ public function __construct( } /** + * Retrieve special price. + * * @return bool|float */ public function getValue() @@ -96,19 +99,19 @@ public function getSpecialToDate() } /** - * @return bool + * @inheritdoc */ public function isScopeDateInInterval() { return $this->localeDate->isScopeDateInInterval( - $this->product->getStore(), + WebsiteInterface::ADMIN_CODE, $this->getSpecialFromDate(), $this->getSpecialToDate() ); } /** - * @return bool + * @inheritdoc */ public function isPercentageDiscount() { diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AddProductToCartActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AddProductToCartActionGroup.xml index 7dafeff34a2ea..02dadf0d00337 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AddProductToCartActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AddProductToCartActionGroup.xml @@ -7,7 +7,7 @@ --> <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/actionGroupSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <actionGroup name="AddSimpleProductToCart"> <arguments> <argument name="product" defaultValue="product"/> @@ -15,7 +15,8 @@ <amOnPage stepKey="navigateProductPage" url="/{{product.name}}.html"/> <click stepKey="addToCart" selector="{{StorefrontProductPageSection.addToCartBtn}}"/> <waitForElementVisible selector="{{StorefrontProductPageSection.successMsg}}" time="30" stepKey="waitForProductAdded"/> - </actionGroup>. + </actionGroup> + <!--Click Add to Cart button in storefront product page--> <actionGroup name="addToCartFromStorefrontProductPage"> <arguments> @@ -26,6 +27,15 @@ <waitForElementNotVisible selector="{{StorefrontProductPageSection.addToCartButtonTitleIsAdded}}" stepKey="waitForElementNotVisibleAddToCartButtonTitleIsAdded"/> <waitForElementVisible selector="{{StorefrontProductPageSection.addToCartButtonTitleIsAddToCart}}" stepKey="waitForElementVisibleAddToCartButtonTitleIsAddToCart"/> <waitForPageLoad stepKey="waitForPageLoad"/> + <waitForElementVisible selector="{{StorefrontMessagesSection.success}}" time="30" stepKey="waitForProductAddedMessage"/> <see selector="{{StorefrontMessagesSection.success}}" userInput="You added {{productName}} to your shopping cart." stepKey="seeAddToCartSuccessMessage"/> </actionGroup> + + <actionGroup name="StorefrontAddProductToCartQuantityActionGroup" extends="addToCartFromStorefrontProductPage"> + <arguments> + <argument name="quantity" type="string" defaultValue="1"/> + </arguments> + <waitForElementVisible selector="{{StorefrontProductPageSection.qtyInput}}" time="30" before="addToCart" stepKey="waitQuantityFieldVisible"/> + <fillField selector="{{StorefrontProductPageSection.qtyInput}}" userInput="{{quantity}}" after="waitQuantityFieldVisible" stepKey="fillQuantityField"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCategoryActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCategoryActionGroup.xml index 127b69e5c3dc4..6d43031e2f8aa 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCategoryActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminCategoryActionGroup.xml @@ -87,4 +87,39 @@ <see selector="{{AdminCategoryProductsGridSection.skuColumn}}" userInput="{{product.sku}}" stepKey="seeProductSkuInGrid"/> <see selector="{{AdminCategoryProductsGridSection.priceColumn}}" userInput="{{product.price}}" stepKey="seeProductPriceInGrid"/> </actionGroup> + + <actionGroup name="AdminNavigateToCategoryInTree"> + <arguments> + <argument name="category"/> + </arguments> + <amOnPage url="{{AdminCategoryPage.page}}" stepKey="amOnCategoryPage"/> + <click selector="{{AdminCategorySidebarTreeSection.expandAll}}" stepKey="expandAll"/> + <waitForPageLoad stepKey="waitForTreeToExpand"/> + <click selector="{{AdminCategorySidebarTreeSection.categoryInTree(category.name)}}" stepKey="navigateToCreatedCategory" /> + <waitForPageLoad stepKey="waitForCategoryPageLoaded"/> + </actionGroup> + + <actionGroup name="ChangeSeoUrlKey"> + <arguments> + <argument name="value" type="string"/> + </arguments> + <conditionalClick selector="{{AdminCategorySEOSection.SectionHeader}}" dependentSelector="{{AdminCategorySEOSection.UrlKeyInput}}" visible="false" stepKey="openSeoSection"/> + <fillField selector="{{AdminCategorySEOSection.UrlKeyInput}}" userInput="{{value}}" stepKey="enterURLKey"/> + <click selector="{{AdminMainActionsSection.save}}" stepKey="saveCategory"/> + <seeElement selector="{{AdminMessagesSection.success}}" stepKey="assertSuccessMessage"/> + </actionGroup> + + <actionGroup name="ChangeSeoUrlKeyForSubCategory" extends="ChangeSeoUrlKey"> + <arguments> + <argument name="value" type="string"/> + </arguments> + <uncheckOption selector="{{AdminCategorySEOSection.urlKeyDefaultValueCheckbox}}" before="enterURLKey" stepKey="uncheckDefaultValue"/> + </actionGroup> + + <!-- Save category form --> + <actionGroup name="saveCategoryForm"> + <seeInCurrentUrl url="{{AdminCategoryPage.url}}" stepKey="seeOnCategoryPage"/> + <click selector="{{AdminMainActionsSection.save}}" stepKey="saveCategory"/> + <seeElement selector="{{AdminMessagesSection.success}}" stepKey="assertSuccess"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductActionGroup.xml index 1c4e7a8df2436..d64dfb928e651 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductActionGroup.xml @@ -7,7 +7,7 @@ --> <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/actionGroupSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <!--Navigate to create product page from product grid page--> <actionGroup name="goToCreateProductPage"> <arguments> @@ -16,7 +16,7 @@ <click selector="{{AdminProductGridActionSection.addProductToggle}}" stepKey="clickAddProductToggle"/> <waitForElementVisible selector="{{AdminProductGridActionSection.addTypeProduct(product.type_id)}}" stepKey="waitForAddProductDropdown" time="30"/> <click selector="{{AdminProductGridActionSection.addTypeProduct(product.type_id)}}" stepKey="clickAddProductType"/> - <waitForPageLoad stepKey="waitForCreateProductPageLoad"/> + <waitForPageLoad time="30" stepKey="waitForCreateProductPageLoad"/> <seeInCurrentUrl url="{{AdminProductCreatePage.url(AddToDefaultSet.attributeSetId, product.type_id)}}" stepKey="seeNewProductUrl"/> <see selector="{{AdminHeaderSection.pageTitle}}" userInput="New Product" stepKey="seeNewProductTitle"/> </actionGroup> @@ -97,11 +97,10 @@ <argument name="website"/> </arguments> <scrollTo selector="{{ProductInWebsitesSection.sectionHeader}}" stepKey="scrollToWebsites"/> - <click selector="{{ProductInWebsitesSection.sectionHeader}}" stepKey="clickToOpenProductInWebsite"/> + <conditionalClick selector="{{ProductInWebsitesSection.sectionHeader}}" dependentSelector="{{ProductInWebsitesSection.website(website.name)}}" visible="false" stepKey="clickToOpenProductInWebsite"/> <waitForPageLoad stepKey="waitForPageOpened"/> <click selector="{{ProductInWebsitesSection.website(website.name)}}" stepKey="selectWebsite"/> <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="clickSaveProduct"/> - <waitForPageLoad time='60' stepKey="waitForPageOpened1"/> </actionGroup> <actionGroup name="ProductSetAdvancedPricing"> @@ -149,7 +148,6 @@ </arguments> <scrollTo selector="{{ProductInWebsitesSection.sectionHeader}}" stepKey="scrollToWebsites"/> <click selector="{{ProductInWebsitesSection.sectionHeader}}" stepKey="clickToOpenProductInWebsite"/> - <waitForPageLoad stepKey="waitForPageOpened"/> <checkOption selector="{{ProductInWebsitesSection.website(website)}}" stepKey="selectWebsite"/> </actionGroup> @@ -169,4 +167,94 @@ <waitForPageLoad stepKey="waitForPageLoad"/> <dontSeeElement selector="{{AdminProductImagesSection.imageFile(image.filename)}}" stepKey="seeImage"/> </actionGroup> + + <!--Check tier price with a discount percentage on product--> + <actionGroup name="AssertDiscountsPercentageOfProduct"> + <arguments> + <argument name="amount" type="string" defaultValue="45"/> + </arguments> + <click selector="{{AdminProductFormSection.advancedPricingLink}}" stepKey="clickOnAdvancedPricingButton"/> + <waitForElementVisible selector="{{AdminProductFormAdvancedPricingSection.customerGroupPriceAddButton}}" stepKey="waitForCustomerGroupPriceAddButton"/> + <grabValueFrom selector="{{AdminProductFormAdvancedPricingSection.productTierPricePercentageValuePriceInput('0')}}" stepKey="grabProductTierPriceInput"/> + <assertEquals stepKey="assertProductTierPriceInput"> + <expectedResult type="string">{{amount}}</expectedResult> + <actualResult type="string">$grabProductTierPriceInput</actualResult> + </assertEquals> + </actionGroup> + + <actionGroup name="CreatedProductConnectToWebsite"> + <arguments> + <argument name="website"/> + <argument name="product"/> + </arguments> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductGridPage"/> + <conditionalClick selector="{{AdminProductGridFilterSection.clearFilters}}" dependentSelector="{{AdminProductGridFilterSection.clearFilters}}" visible="true" stepKey="clickClearFilters"/> + <click selector="{{AdminProductGridFilterSection.filters}}" stepKey="openProductFilters"/> + <fillField selector="{{AdminProductGridFilterSection.skuFilter}}" userInput="{{product.sku}}" stepKey="fillProductSkuFilter"/> + <click selector="{{AdminProductGridFilterSection.applyFilters}}" stepKey="clickApplyFilters"/> + <waitForElementNotVisible selector="{{AdminProductGridSection.loadingMask}}" stepKey="waitForFilteredGridLoad" time="30"/> + <click selector="{{AdminProductGridSection.firstRow}}" stepKey="openProduct"/> + <waitForPageLoad stepKey="waitForProductPage"/> + <scrollTo selector="{{ProductInWebsitesSection.sectionHeader}}" stepKey="scrollToWebsites"/> + <click selector="{{ProductInWebsitesSection.sectionHeader}}" stepKey="openWebsitesList"/> + <click selector="{{ProductInWebsitesSection.website(website.name)}}" stepKey="selectWebsite"/> + <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="clickSaveProduct"/> + <see selector="{{AdminMessagesSection.success}}" userInput="You saved the product." stepKey="seeSaveConfirmation"/> + </actionGroup> + + <!--Create simple product and assign to category in Admin--> + <actionGroup name="AdminCreateSimpleProductAndAssignToCategory"> + <arguments> + <argument name="category"/> + <argument name="simpleProduct"/> + </arguments> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndex"/> + <click selector="{{AdminProductGridActionSection.addProductToggle}}" stepKey="clickAddProductDropdown"/> + <click selector="{{AdminProductGridActionSection.addSimpleProduct}}" stepKey="clickAddSimpleProduct"/> + <fillField userInput="{{simpleProduct.name}}" selector="{{AdminProductFormSection.productName}}" stepKey="fillName"/> + <fillField userInput="{{simpleProduct.sku}}" selector="{{AdminProductFormSection.productSku}}" stepKey="fillSKU"/> + <fillField userInput="{{simpleProduct.price}}" selector="{{AdminProductFormSection.productPrice}}" stepKey="fillPrice"/> + <fillField userInput="{{simpleProduct.quantity}}" selector="{{AdminProductFormSection.productQuantity}}" stepKey="fillQuantity"/> + <searchAndMultiSelectOption selector="{{AdminProductFormSection.categoriesDropdown}}" parameterArray="[{{category.name}}]" stepKey="searchAndSelectCategory"/> + <click selector="{{AdminProductSEOSection.sectionHeader}}" stepKey="openSeoSection"/> + <fillField userInput="{{simpleProduct.urlKey}}" selector="{{AdminProductSEOSection.urlKeyInput}}" stepKey="fillUrlKey"/> + <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="saveProduct"/> + <seeElement selector="{{AdminMessagesSection.success}}" stepKey="assertSaveMessageSuccess"/> + <seeInField userInput="{{simpleProduct.name}}" selector="{{AdminProductFormSection.productName}}" stepKey="assertFieldName"/> + <seeInField userInput="{{simpleProduct.sku}}" selector="{{AdminProductFormSection.productSku}}" stepKey="assertFieldSku"/> + <seeInField userInput="{{simpleProduct.price}}" selector="{{AdminProductFormSection.productPrice}}" stepKey="assertFieldPrice"/> + <click selector="{{AdminProductSEOSection.sectionHeader}}" stepKey="openSeoSectionAssert"/> + <seeInField userInput="{{simpleProduct.urlKey}}" selector="{{AdminProductSEOSection.urlKeyInput}}" stepKey="assertFieldUrlKey"/> + </actionGroup> + + <!--Create a Simple Product--> + <actionGroup name="CreateSimpleProductAndAddToWebsite"> + <arguments> + <argument name="product"/> + <argument name="website" type="string"/> + </arguments> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToCatalogProductGrid"/> + <click selector="{{AdminProductGridActionSection.addProductToggle}}" stepKey="clickAddProductDropdown"/> + <click selector="{{AdminProductGridActionSection.addSimpleProduct}}" stepKey="clickAddSimpleProduct"/> + <fillField userInput="{{product.name}}" selector="{{AdminProductFormSection.productName}}" stepKey="fillProductName"/> + <fillField userInput="{{product.sku}}" selector="{{AdminProductFormSection.productSku}}" stepKey="fillProductSKU"/> + <fillField userInput="{{product.price}}" selector="{{AdminProductFormSection.productPrice}}" stepKey="fillProductPrice"/> + <fillField userInput="{{product.quantity}}" selector="{{AdminProductFormSection.productQuantity}}" stepKey="fillProductQuantity"/> + <click selector="{{ProductInWebsitesSection.sectionHeader}}" stepKey="openProductInWebsites"/> + <click selector="{{ProductInWebsitesSection.website(website)}}" stepKey="selectWebsite"/> + <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="clickSave"/> + <waitForPageLoad stepKey="waitForProductPageSave"/> + <see selector="{{AdminMessagesSection.success}}" userInput="You saved the product." stepKey="seeSaveConfirmation"/> + </actionGroup> + + <actionGroup name="AdminAssignProductToCategory"> + <arguments> + <argument name="productId" type="string"/> + <argument name="categoryName" type="string"/> + </arguments> + <amOnPage url="{{AdminProductEditPage.url(productId)}}" stepKey="amOnPage"/> + <searchAndMultiSelectOption selector="{{AdminProductFormSection.categoriesDropdown}}" parameterArray="[{{categoryName}}]" stepKey="selectCategory"/> + <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="clickOnSaveButton"/> + <see selector="{{AdminMessagesSection.success}}" userInput="You saved the product." stepKey="seeSaveProductMessage"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductAttributeActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductAttributeActionGroup.xml index dcc97fedbb8bf..1c6f115c2cfce 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductAttributeActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductAttributeActionGroup.xml @@ -36,4 +36,42 @@ <click selector="{{AdminProductAttributeGridSection.attributeCode(attributeCode)}}" stepKey="clickRowToEdit"/> <waitForPageLoad stepKey="waitForColorAttributePageLoad"/> </actionGroup> + <!--Save product attribute and see success message--> + <actionGroup name="SaveProductAttribute"> + <scrollToTopOfPage stepKey="scrollToTop"/> + <click selector="{{AdminMainActionsSection.save}}" stepKey="clickSaveAttribute"/> + <see selector="{{AdminMessagesSection.success}}" userInput="You saved the product attribute." stepKey="seeSuccessMessage"/> + </actionGroup> + <actionGroup name="deleteProductAttribute"> + <arguments> + <argument name="ProductAttribute"/> + </arguments> + <amOnPage url="{{AdminProductAttributeGridPage.url}}" stepKey="navigateToProductAttributeGrid"/> + <waitForPageLoad stepKey="waitForAttributeGridPageLoad"/> + <fillField selector="{{AdminProductAttributeGridSection.gridFilterAttributeCode}}" + userInput="{{ProductAttribute.attribute_code}}" stepKey="setAttributeCode"/> + <click selector="{{AdminProductAttributeGridSection.search}}" stepKey="searchForAttributeFromTheGrid"/> + <click selector="{{AdminProductAttributeGridSection.firstRow}}" stepKey="clickOnAttributeRow"/> + <waitForPageLoad stepKey="waitForAttributeEditPageLoad" /> + <click selector="{{AttributePropertiesSection.deleteAttribute}}" stepKey="deleteAttribute"/> + <waitForElement selector="{{AdminConfirmationModalSection.message}}" stepKey="waitForDeleteConfirmation"/> + <see selector="{{AdminConfirmationModalSection.message}}" userInput="Are you sure you want to do this?" stepKey="seeConfirmationMessage"/> + <click selector="{{AdminConfirmationModalSection.ok}}" stepKey="confirmDeleteAttribute"/> + <waitForPageLoad stepKey="waitForPageLoadAfterDeleteAttribute"/> + <see selector="{{AdminMessagesSection.success}}" userInput="You deleted the product attribute." stepKey="seeDeleteSuccessMessage"/> + </actionGroup> + <actionGroup name="navigateToCreatedProductAttribute"> + <arguments> + <argument name="productAttribute"/> + </arguments> + <amOnPage url="{{AdminProductAttributeGridPage.url}}" stepKey="navigateToProductAttributeGrid"/> + <waitForPageLoad stepKey="waitForAttributesGridPageLoad"/> + <conditionalClick selector="{{AdminDataGridHeaderSection.clearFilters}}" dependentSelector="{{AdminDataGridHeaderSection.clearFilters}}" visible="true" stepKey="clearExistingFilters"/> + <waitForPageLoad stepKey="waitForAttributesGridPageLoad1"/> + <fillField selector="{{AdminProductAttributeGridSection.gridFilterAttributeCode}}" + userInput="{{productAttribute.attribute_code}}" stepKey="setAttributeCode"/> + <click selector="{{AdminDataGridHeaderSection.applyFilters}}" stepKey="searchForAttributeFromTheGrid"/> + <click selector="{{AdminDataGridTableSection.row('1')}}" stepKey="clickOnAttributeRow"/> + <waitForPageLoad stepKey="waitForAttributePageLoad" /> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductAttributeSetActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductAttributeSetActionGroup.xml index 17f27056c555e..9817e24bed963 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductAttributeSetActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductAttributeSetActionGroup.xml @@ -49,6 +49,7 @@ <arguments> <argument name="attributeSetName" type="string"/> </arguments> + <scrollToTopOfPage stepKey="scrollToTopOfPage"/> <click selector="{{AdminProductFormSection.attributeSet}}" stepKey="startEditAttrSet"/> <fillField selector="{{AdminProductFormSection.attributeSetFilter}}" userInput="{{attributeSetName}}" stepKey="searchForAttrSet"/> <waitForAjaxLoad stepKey="waitForLoad"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductGridActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductGridActionGroup.xml index 3c4e83aabd047..a6213c459396a 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductGridActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminProductGridActionGroup.xml @@ -7,7 +7,7 @@ --> <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/actionGroupSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <!--Filter the product grid by new from date filter--> <actionGroup name="filterProductGridBySetNewFromDate"> <conditionalClick selector="{{AdminProductGridFilterSection.clearFilters}}" dependentSelector="{{AdminProductGridFilterSection.clearFilters}}" visible="true" stepKey="clickClearFilters"/> @@ -33,6 +33,7 @@ <actionGroup name="deleteProductUsingProductGrid"> <arguments> <argument name="product"/> + <argument name="productCount" type="string" defaultValue="1"/> </arguments> <!--TODO use other action group for filtering grid when MQE-539 is implemented --> <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="visitAdminProductPage"/> @@ -48,10 +49,20 @@ <click selector="{{AdminProductGridSection.bulkActionOption('Delete')}}" stepKey="clickDeleteAction"/> <waitForElementVisible selector="{{AdminProductGridConfirmActionSection.title}}" stepKey="waitForConfirmModal"/> <click selector="{{AdminProductGridConfirmActionSection.ok}}" stepKey="confirmProductDelete"/> - <see selector="{{AdminMessagesSection.success}}" userInput="A total of 1 record(s) have been deleted." stepKey="seeSuccessMessage"/> + <see selector="{{AdminMessagesSection.success}}" userInput="A total of {{productCount}} record(s) have been deleted." stepKey="seeSuccessMessage"/> <conditionalClick selector="{{AdminProductGridFilterSection.clearFilters}}" dependentSelector="{{AdminProductGridFilterSection.clearFilters}}" visible="true" stepKey="clickClearFiltersInitial2"/> </actionGroup> + <actionGroup name="DeleteProductByName" extends="deleteProductUsingProductGrid"> + <arguments> + <argument name="product" type="string"/> + </arguments> + <remove keyForRemoval="fillProductSkuFilter"/> + <fillField selector="{{AdminProductGridFilterSection.nameFilter}}" userInput="{{product}}" stepKey="fillProductSkuFilter" after="openProductFilters"/> + <remove keyForRemoval="seeProductSkuInGrid"/> + <see selector="{{AdminProductGridSection.productGridCell('1', 'Name')}}" userInput="{{product}}" stepKey="seeProductNameInGrid" after="clickApplyFilters"/> + </actionGroup> + <!--Disabled a product by filtering grid and using change status action--> <actionGroup name="ChangeStatusProductUsingProductGridActionGroup"> <arguments> @@ -71,7 +82,14 @@ <click selector="{{AdminProductGridSection.bulkActionDropdown}}" stepKey="clickActionDropdown"/> <click selector="{{AdminProductGridSection.bulkActionOption('Change status')}}" stepKey="clickChangeStatusAction"/> <click selector="{{AdminProductGridSection.changeStatus('status')}}" stepKey="clickChangeStatusDisabled" parameterized="true"/> + <waitForPageLoad stepKey="waitForStatusToBeChanged"/> <see selector="{{AdminMessagesSection.success}}" userInput="A total of 1 record(s) have been updated." stepKey="seeSuccessMessage"/> <conditionalClick selector="{{AdminProductGridFilterSection.clearFilters}}" dependentSelector="{{AdminProductGridFilterSection.clearFilters}}" visible="true" stepKey="clickClearFiltersInitial2"/> </actionGroup> + + <!-- Sort products by ID descending --> + <actionGroup name="sortProductsByIdDescending"> + <conditionalClick selector="{{AdminProductGridTableHeaderSection.id('ascend')}}" dependentSelector="{{AdminProductGridTableHeaderSection.id('descend')}}" visible="false" stepKey="sortById"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/SearchForProductOnBackendActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/SearchForProductOnBackendActionGroup.xml index ebeee87b1c89e..6cb939a7af410 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/SearchForProductOnBackendActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/SearchForProductOnBackendActionGroup.xml @@ -7,7 +7,7 @@ --> <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/actionGroupSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <actionGroup name="SearchForProductOnBackendActionGroup"> <arguments> <argument name="product" defaultValue="product"/> @@ -19,4 +19,11 @@ <fillField stepKey="fillSkuFieldOnFiltersSection" userInput="{{product.sku}}" selector="{{AdminProductFiltersSection.SkuInput}}"/> <click stepKey="clickApplyFiltersButton" selector="{{AdminProductFiltersSection.Apply}}"/> </actionGroup> + <actionGroup name="SearchForProductOnBackendByNameActionGroup" extends="SearchForProductOnBackendActionGroup"> + <arguments> + <argument name="productName" type="string"/> + </arguments> + <remove keyForRemoval="fillSkuFieldOnFiltersSection"/> + <fillField userInput="{{productName}}" selector="{{AdminProductFiltersSection.NameInput}}" after="cleanFiltersIfTheySet" stepKey="fillNameFieldOnFiltersSection"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontAddToCartCustomOptionsProductPageActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontAddToCartCustomOptionsProductPageActionGroup.xml index 4938b6a9592f1..91afd2eb5d4c7 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontAddToCartCustomOptionsProductPageActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontAddToCartCustomOptionsProductPageActionGroup.xml @@ -13,7 +13,6 @@ <argument name="productName"/> </arguments> <click selector="{{StorefrontProductPageSection.addToCartBtn}}" stepKey="addToCart"/> - <waitForPageLoad stepKey="waitForPageLoad"/> <see selector="{{StorefrontProductPageSection.successMsg}}" userInput="You added {{productName}} to your shopping cart." stepKey="seeAddToCartSuccessMessage"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontCategoryActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontCategoryActionGroup.xml index 8f0df2fc899a9..2551bd61580ed 100644 --- a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontCategoryActionGroup.xml +++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/StorefrontCategoryActionGroup.xml @@ -47,4 +47,34 @@ <!-- Go to storefront category page --> <amOnPage url="{{StorefrontCategoryPage.url(category)}}?product_list_mode={{mode}}&product_list_order={{sortBy}}&product_list_dir={{sort}}" stepKey="onCategoryPage"/> </actionGroup> + + <actionGroup name="VerifyCategoryPageParameters"> + <arguments> + <argument name="categoryName" type="string"/> + <argument name="mode" type="string"/> + <argument name="numOfProductsPerPage" type="string"/> + <argument name="sortBy" type="string" defaultValue="position"/> + </arguments> + <amOnPage url="{{StorefrontCategoryPage.url(categoryName)}}" stepKey="navigateToCategoryPage"/> + <seeInTitle userInput="{{categoryName}}" stepKey="assertCategoryNameInTitle"/> + <see userInput="{{categoryName}}" selector="{{StorefrontCategoryMainSection.categoryTitle}}" stepKey="assertCategoryName"/> + <see userInput="{{mode}}" selector="{{StorefrontCategoryPagerSection.modeGridIsActive}}" stepKey="assertViewMode"/> + <see userInput="{{numOfProductsPerPage}}" selector="{{StorefrontCategoryPagerSection.perPageSelected}}" stepKey="assertNumberOfProductsPerPage"/> + <see userInput="{{sortBy}}" selector="{{StorefrontCategoryPagerSection.sortedBy}}" stepKey="assertSortedBy"/> + </actionGroup> + + <actionGroup name="StorefrontGoToSubCategoryPage"> + <arguments> + <argument name="parentCategory"/> + <argument name="subCategory"/> + <argument name="urlPath" type="string"/> + </arguments> + <moveMouseOver selector="{{StorefrontHeaderSection.NavigationCategoryByName(parentCategory.name)}}" stepKey="moveMouseOnMainCategory"/> + <waitForElementVisible selector="{{StorefrontHeaderSection.NavigationCategoryByName(subCategory.name)}}" stepKey="waitForSubCategoryVisible"/> + <click selector="{{StorefrontHeaderSection.NavigationCategoryByName(subCategory.name)}}" stepKey="goToCategory"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + <seeInCurrentUrl url="{{urlPath}}.html" stepKey="checkUrl"/> + <seeInTitle userInput="{{subCategory.name}}" stepKey="assertCategoryNameInTitle"/> + <see userInput="{{subCategory.name}}" selector="{{StorefrontCategoryMainSection.categoryTitle}}" stepKey="assertCategoryName"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/CatalogStorefrontConfigData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/CatalogStorefrontConfigData.xml new file mode 100644 index 0000000000000..850190f0fd24c --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Data/CatalogStorefrontConfigData.xml @@ -0,0 +1,49 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <entity name="RememberPaginationCatalogStorefrontConfig" type="catalog_storefront_config"> + <requiredEntity type="grid_per_page_values">GridPerPageValues</requiredEntity> + <requiredEntity type="remember_pagination">RememberCategoryPagination</requiredEntity> + </entity> + + <entity name="GridPerPageValues" type="grid_per_page_values"> + <data key="value">9,12,20,24</data> + </entity> + + <entity name="RememberCategoryPagination" type="remember_pagination"> + <data key="value">1</data> + </entity> + + <entity name="DefaultGridPerPageValues" type="grid_per_page_values"> + <data key="value">9,15,30</data> + </entity> + + <entity name="DefaultRememberCategoryPagination" type="remember_pagination"> + <data key="value">1</data> + </entity> + + <entity name="DefaultCatalogStorefrontConfiguration" type="default_catalog_storefront_config"> + <requiredEntity type="catalogStorefrontFlagZero">DefaultCatalogStorefrontFlagZero</requiredEntity> + <data key="grid_per_page_values">DefaultGridPerPageValues</data> + <data key="remember_pagination">DefaultRememberCategoryPagination</data> + </entity> + + <entity name="DefaultCatalogStorefrontFlagZero" type="catalogStorefrontFlagZero"> + <data key="value">0</data> + </entity> + + <entity name="DefaultListAllowAll" type="list_allow_all"> + <data key="value">0</data> + </entity> + + <entity name="DefaultFlatCatalogProduct" type="flat_catalog_product"> + <data key="value">0</data> + </entity> +</entities> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/CategoryData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/CategoryData.xml index 8d7c182d648c2..e798cda2daa5a 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/CategoryData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/CategoryData.xml @@ -39,4 +39,12 @@ <entity name="DefaultRootCategoryGetter" type="category"> <var key="category" entityKey="category" entityType="category"/> </entity> + <entity name="GearCategory" type="category"> + <data key="name" unique="suffix">Gear</data> + <data key="url_key" unique="suffix">gear</data> + </entity> + <entity name="BagsCategory" type="category"> + <data key="name" unique="suffix">Bags</data> + <data key="url_key" unique="suffix">bags</data> + </entity> </entities> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeOptionData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeOptionData.xml index 25512c0f266e9..ace2f2e6a02ab 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeOptionData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeOptionData.xml @@ -7,7 +7,7 @@ --> <entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/DataGenerator/etc/dataProfileSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> <entity name="productAttributeOption1" type="ProductAttributeOption"> <var key="attribute_code" entityKey="attribute_code" entityType="ProductAttribute"/> <data key="label" unique="suffix">option1</data> @@ -59,4 +59,20 @@ <requiredEntity type="StoreLabel">Option6Store0</requiredEntity> <requiredEntity type="StoreLabel">Option6Store1</requiredEntity> </entity> + <entity name="ProductAttributeOption7" type="ProductAttributeOption"> + <var key="attribute_code" entityKey="attribute_code" entityType="ProductAttribute"/> + <data key="label" unique="suffix">Green</data> + <data key="is_default">false</data> + <data key="sort_order">3</data> + <requiredEntity type="StoreLabel">Option7Store0</requiredEntity> + <requiredEntity type="StoreLabel">Option8Store1</requiredEntity> + </entity> + <entity name="ProductAttributeOption8" type="ProductAttributeOption"> + <var key="attribute_code" entityKey="attribute_code" entityType="ProductAttribute"/> + <data key="label" unique="suffix">Red</data> + <data key="is_default">false</data> + <data key="sort_order">3</data> + <requiredEntity type="StoreLabel">Option9Store0</requiredEntity> + <requiredEntity type="StoreLabel">Option10Store1</requiredEntity> + </entity> </entities> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeSetData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeSetData.xml index 68c0a54ff88fc..1ee1d08e192d6 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeSetData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/ProductAttributeSetData.xml @@ -13,5 +13,6 @@ <data key="attributeSetId">4</data> <data key="attributeGroupId">7</data> <data key="sortOrder">0</data> + <data key="label">Default</data> </entity> </entities> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/ProductConfigurableAttributeData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/ProductConfigurableAttributeData.xml index 4deebbe09af34..0446a9db14f1a 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/ProductConfigurableAttributeData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/ProductConfigurableAttributeData.xml @@ -7,10 +7,11 @@ --> <entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/DataGenerator/etc/dataProfileSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/dataProfileSchema.xsd"> <entity name="colorProductAttribute" type="product_attribute"> <data key="default_label" unique="suffix">Color</data> <data key="attribute_quantity">1</data> + <data key="input_type">Dropdown</data> </entity> <entity name="colorProductAttribute1" type="product_attribute"> <data key="name" unique="suffix">White</data> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/ProductData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/ProductData.xml index a6f2e60e843be..c8c24d1f41e19 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/ProductData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/ProductData.xml @@ -113,6 +113,17 @@ <requiredEntity type="product_extension_attribute">EavStockItem</requiredEntity> <requiredEntity type="custom_attributes">CustomAttributeProductAttribute</requiredEntity> </entity> + <entity name="SimpleTwo" type="product2"> + <data key="sku" unique="suffix">SimpleTwo</data> + <data key="type_id">simple</data> + <data key="attribute_set_id">4</data> + <data key="name" unique="suffix">SimpleProduct</data> + <data key="price">1.23</data> + <data key="visibility">4</data> + <data key="status">1</data> + <requiredEntity type="product_extension_attribute">EavStockItem</requiredEntity> + <requiredEntity type="custom_attribute">CustomAttributeProductUrlKey</requiredEntity> + </entity> <entity name="SimpleOption" type="product2"> <data key="sku" unique="suffix">SimpleOne</data> <data key="type_id">simple</data> @@ -124,6 +135,9 @@ <requiredEntity type="product_extension_attribute">EavStockItem</requiredEntity> <requiredEntity type="custom_attribute">CustomAttributeProductAttribute</requiredEntity> </entity> + <entity name="SetProductVisibilityHidden" type="product2"> + <data key="visibility">1</data> + </entity> <entity name="ProductImage" type="uploadImage"> <data key="title" unique="suffix">Image1</data> <data key="price">1.00</data> @@ -246,6 +260,19 @@ <requiredEntity type="product_extension_attribute">EavStockItem</requiredEntity> <requiredEntity type="custom_attribute">CustomAttributeProductAttribute</requiredEntity> </entity> + <entity name="ApiSimpleTwoHidden" type="product2"> + <data key="sku" unique="suffix">api-simple-product-two</data> + <data key="type_id">simple</data> + <data key="attribute_set_id">4</data> + <data key="visibility">1</data> + <data key="name" unique="suffix">Api Simple Product Two</data> + <data key="price">234.00</data> + <data key="urlKey" unique="suffix">api-simple-product-two</data> + <data key="status">1</data> + <data key="quantity">100</data> + <requiredEntity type="product_extension_attribute">EavStockItem</requiredEntity> + <requiredEntity type="custom_attribute">CustomAttributeProductAttribute</requiredEntity> + </entity> <entity name="ProductWithOptions2" type="product"> <var key="sku" entityType="product" entityKey="sku" /> <requiredEntity type="product_option">ProductOptionDropDownWithLongValuesTitle</requiredEntity> @@ -268,4 +295,8 @@ <data key="quantity">1</data> <requiredEntity type="product_extension_attribute">EavStock1</requiredEntity> </entity> + <entity name="ApiSimpleWithQty100" extends="ApiSimpleOne"> + <data key="quantity">100</data> + <requiredEntity type="product_extension_attribute">EavStock100</requiredEntity> + </entity> </entities> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/ProductExtensionAttributeData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/ProductExtensionAttributeData.xml index 5424e48261085..5b2dc5e691a2b 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/ProductExtensionAttributeData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/ProductExtensionAttributeData.xml @@ -14,4 +14,7 @@ <entity name="EavStock1" type="product_extension_attribute"> <requiredEntity type="stock_item">Qty_1</requiredEntity> </entity> + <entity name="EavStock100" type="product_extension_attribute"> + <requiredEntity type="stock_item">Qty_100</requiredEntity> + </entity> </entities> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/StockItemData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/StockItemData.xml index 99e072b91c3a9..a071c068b575d 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/StockItemData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/StockItemData.xml @@ -16,4 +16,8 @@ <data key="qty">1</data> <data key="is_in_stock">true</data> </entity> + <entity name="Qty_100" type="stock_item"> + <data key="qty">100</data> + <data key="is_in_stock">true</data> + </entity> </entities> diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/StoreLabelData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/StoreLabelData.xml index a703e56beda01..37489ac8143b9 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/StoreLabelData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/StoreLabelData.xml @@ -7,7 +7,7 @@ --> <entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/DataGenerator/etc/dataProfileSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> <entity name="Option1Store0" type="StoreLabel"> <data key="store_id">0</data> <data key="label">option1</data> @@ -56,4 +56,20 @@ <data key="store_id">1</data> <data key="label">option6</data> </entity> + <entity name="Option7Store0" type="StoreLabel"> + <data key="store_id">0</data> + <data key="label">Green</data> + </entity> + <entity name="Option8Store1" type="StoreLabel"> + <data key="store_id">1</data> + <data key="label">Green</data> + </entity> + <entity name="Option9Store0" type="StoreLabel"> + <data key="store_id">0</data> + <data key="label">Red</data> + </entity> + <entity name="Option10Store1" type="StoreLabel"> + <data key="store_id">1</data> + <data key="label">Red</data> + </entity> </entities> diff --git a/app/code/Magento/Catalog/Test/Mftf/Metadata/catalog_configuration-meta.xml b/app/code/Magento/Catalog/Test/Mftf/Metadata/catalog_configuration-meta.xml new file mode 100644 index 0000000000000..4de036565eee3 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Metadata/catalog_configuration-meta.xml @@ -0,0 +1,118 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<operations xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataOperation.xsd"> + <operation name="CatalogStorefrontConfiguration" dataType="catalog_storefront_config" type="create" auth="adminFormKey" url="/admin/system_config/save/section/catalog/" method="POST" successRegex="/messages-message-success/"> + <object key="groups" dataType="catalog_storefront_config"> + <object key="frontend" dataType="catalog_storefront_config"> + <object key="fields" dataType="catalog_storefront_config"> + <object key="list_mode" dataType="list_mode"> + <field key="value">string</field> + </object> + <object key="grid_per_page_values" dataType="grid_per_page_values"> + <field key="value">string</field> + </object> + <object key="grid_per_page" dataType="grid_per_page"> + <field key="value">string</field> + </object> + <object key="list_per_page_values" dataType="list_per_page_values"> + <field key="value">string</field> + </object> + <object key="list_per_page" dataType="list_per_page"> + <field key="value">string</field> + </object> + <object key="default_sort_by" dataType="default_sort_by"> + <field key="value">string</field> + </object> + <object key="list_allow_all" dataType="list_allow_all"> + <field key="value">integer</field> + </object> + <object key="remember_pagination" dataType="remember_pagination"> + <field key="value">integer</field> + </object> + <object key="flat_catalog_category" dataType="flat_catalog_category"> + <field key="value">integer</field> + </object> + <object key="flat_catalog_product" dataType="flat_catalog_product"> + <field key="value">integer</field> + </object> + <object key="swatches_per_product" dataType="swatches_per_product"> + <field key="value">string</field> + </object> + <object key="show_swatches_in_product_list" dataType="show_swatches_in_product_list"> + <field key="value">integer</field> + </object> + </object> + </object> + </object> + </operation> + + <operation name="DefaultCatalogStorefrontConfiguration" dataType="default_catalog_storefront_config" type="create" auth="adminFormKey" url="/admin/system_config/save/section/catalog/" method="POST"> + <object key="groups" dataType="default_catalog_storefront_config"> + <object key="frontend" dataType="default_catalog_storefront_config"> + <object key="fields" dataType="default_catalog_storefront_config"> + <object key="list_mode" dataType="default_catalog_storefront_config"> + <object key="inherit" dataType="catalogStorefrontFlagZero"> + <field key="value">integer</field> + </object> + </object> + <object key="grid_per_page_values" dataType="default_catalog_storefront_config"> + <object key="inherit" dataType="catalogStorefrontFlagZero"> + <field key="value">integer</field> + </object> + </object> + <object key="grid_per_page" dataType="default_catalog_storefront_config"> + <object key="inherit" dataType="catalogStorefrontFlagZero"> + <field key="value">integer</field> + </object> + </object> + <object key="list_per_page_values" dataType="default_catalog_storefront_config"> + <object key="inherit" dataType="catalogStorefrontFlagZero"> + <field key="value">integer</field> + </object> + </object> + <object key="list_per_page" dataType="default_catalog_storefront_config"> + <object key="inherit" dataType="catalogStorefrontFlagZero"> + <field key="value">integer</field> + </object> + </object> + <object key="default_sort_by" dataType="default_catalog_storefront_config"> + <object key="inherit" dataType="catalogStorefrontFlagZero"> + <field key="value">integer</field> + </object> + </object> + <object key="remember_pagination" dataType="default_catalog_storefront_config"> + <object key="inherit" dataType="catalogStorefrontFlagZero"> + <field key="value">integer</field> + </object> + </object> + <object key="flat_catalog_category" dataType="default_catalog_storefront_config"> + <object key="inherit" dataType="catalogStorefrontFlagZero"> + <field key="value">integer</field> + </object> + </object> + <object key="swatches_per_product" dataType="default_catalog_storefront_config"> + <object key="inherit" dataType="catalogStorefrontFlagZero"> + <field key="value">integer</field> + </object> + </object> + <object key="show_swatches_in_product_list" dataType="default_catalog_storefront_config"> + <object key="inherit" dataType="catalogStorefrontFlagZero"> + <field key="value">integer</field> + </object> + </object> + <object key="list_allow_all" dataType="list_allow_all"> + <field key="value">integer</field> + </object> + <object key="flat_catalog_product" dataType="flat_catalog_product"> + <field key="value">integer</field> + </object> + </object> + </object> + </object> + </operation> +</operations> diff --git a/app/code/Magento/Catalog/Test/Mftf/Page/AdminCategoryEditPage.xml b/app/code/Magento/Catalog/Test/Mftf/Page/AdminCategoryEditPage.xml index 68c432a136ac9..c031578e2a208 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Page/AdminCategoryEditPage.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Page/AdminCategoryEditPage.xml @@ -16,5 +16,7 @@ <section name="AdminCategoryBasicFieldSection"/> <section name="AdminCategorySEOSection"/> <section name="AdminCategoryModalSection"/> + <section name="AdminCategoryContentSection"/> + <section name="AdminCategoryDisplaySettingsSection"/> </page> </pages> diff --git a/app/code/Magento/Catalog/Test/Mftf/Page/AdminCategoryPage.xml b/app/code/Magento/Catalog/Test/Mftf/Page/AdminCategoryPage.xml index 45485509f5e3e..54e9194ca450d 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Page/AdminCategoryPage.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Page/AdminCategoryPage.xml @@ -7,8 +7,8 @@ --> <pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/PageObject.xsd"> - <page name="AdminCategoryPage" url="catalog/category/" area="admin" module="Catalog"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminCategoryPage" url="catalog/category/" area="admin" module="Magento_Catalog"> <section name="AdminCategorySidebarActionSection"/> <section name="AdminCategorySidebarTreeSection"/> <section name="AdminCategoryBasicFieldSection"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Page/AdminProductAttributeEditPage.xml b/app/code/Magento/Catalog/Test/Mftf/Page/AdminProductAttributeEditPage.xml index e7de264dc2b75..04070ba17356d 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Page/AdminProductAttributeEditPage.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Page/AdminProductAttributeEditPage.xml @@ -12,5 +12,7 @@ <section name="AdminProductAttributeEditSection"/> <section name="AdminConfirmationModalSection"/> <section name="AdminEditAttributeStorefrontPropertiesSection"/> + <section name="AdminMainActionsSection"/> + <section name="AdminMessagesSection"/> </page> </pages> diff --git a/app/code/Magento/Catalog/Test/Mftf/Page/AdminProductAttributeFormPage.xml b/app/code/Magento/Catalog/Test/Mftf/Page/AdminProductAttributeFormPage.xml index 8213ec6ab5fbe..685781dabab85 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Page/AdminProductAttributeFormPage.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Page/AdminProductAttributeFormPage.xml @@ -10,5 +10,8 @@ <page name="ProductAttributePage" url="catalog/product_attribute/new/" area="admin" module="Magento_Catalog"> <section name="AttributePropertiesSection"/> <section name="StorefrontPropertiesSection"/> + <section name="AdvancedAttributePropertiesSection"/> + <section name="AdminAttributeOptionsSection"/> + <section name="AttributeManageSwatchSection"/> </page> </pages> diff --git a/app/code/Magento/Catalog/Test/Mftf/Page/AdminProductIndexPage.xml b/app/code/Magento/Catalog/Test/Mftf/Page/AdminProductIndexPage.xml index d77a041baf662..03915ba68b642 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Page/AdminProductIndexPage.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Page/AdminProductIndexPage.xml @@ -12,6 +12,7 @@ <section name="AdminProductGridActionSection" /> <section name="AdminProductGridFilterSection" /> <section name="AdminProductGridSection" /> + <section name="AdminProductGridTableHeaderSection" /> <section name="AdminMessagesSection" /> <section name="AdminProductFiltersSection" /> </page> diff --git a/app/code/Magento/Catalog/Test/Mftf/Page/StorefrontCategoryPage.xml b/app/code/Magento/Catalog/Test/Mftf/Page/StorefrontCategoryPage.xml index b32c3ded033f9..0ada59c623451 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Page/StorefrontCategoryPage.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Page/StorefrontCategoryPage.xml @@ -11,5 +11,6 @@ <page name="StorefrontCategoryPage" url="/{{var1}}.html" area="storefront" module="Magento_Catalog" parameterized="true"> <section name="StorefrontCategoryMainSection"/> <section name="WYSIWYGToolbarSection"/> + <section name="StorefrontCategoryPagerSection"/> </page> </pages> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryBasicFieldSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryBasicFieldSection.xml index 555b3c2a6e00e..a32ed228c8570 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryBasicFieldSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryBasicFieldSection.xml @@ -7,7 +7,7 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminCategoryBasicFieldSection"> <element name="IncludeInMenu" type="checkbox" selector="input[name='include_in_menu']"/> <element name="includeInMenuLabel" type="text" selector="input[name='include_in_menu']+label"/> @@ -17,6 +17,9 @@ <element name="enableUseDefault" type="checkbox" selector="input[name='use_default[is_active]']"/> <element name="CategoryNameInput" type="input" selector="input[name='name']"/> <element name="categoryNameUseDefault" type="checkbox" selector="input[name='use_default[name]']"/> + <element name="requiredFieldIndicator" type="text" selector=" return window.getComputedStyle(document.querySelector('._required[data-index=name]>.admin__field-label span'), ':after').getPropertyValue('content');"/> + <element name="requiredFieldIndicatorColor" type="text" selector=" return window.getComputedStyle(document.querySelector('._required[data-index=name]>.admin__field-label span'), ':after').getPropertyValue('color');"/> + <element name="panelFieldControl" type="input" selector='//aside//div[@data-index="{{arg1}}"]/descendant::*[@name="{{arg2}}"]' parameterized="true"/> </section> <section name="CatalogWYSIWYGSection"> <element name="ShowHideBtn" type="button" selector="#togglecategory_form_description"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryContentSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryContentSection.xml new file mode 100644 index 0000000000000..faa320cd114de --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryContentSection.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminCategoryContentSection"> + <element name="sectionHeader" type="button" selector="div[data-index='content']" timeout="30"/> + <element name="addCMSBlock" type="select" selector="[name='landing_page']"/> + </section> +</sections> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryDisplaySettingsSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryDisplaySettingsSection.xml new file mode 100644 index 0000000000000..d545feca3e711 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCategoryDisplaySettingsSection.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminCategoryDisplaySettingsSection"> + <element name="settingsHeader" type="button" selector="[data-index='display_settings'] strong.admin__collapsible-title" timeout="30"/> + <element name="displayMode" type="button" selector="[name='display_mode']"/> + </section> +</sections> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCreateProductAttributeSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCreateProductAttributeSection.xml index e241370220921..8f7425b5a19e0 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminCreateProductAttributeSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminCreateProductAttributeSection.xml @@ -11,9 +11,36 @@ <section name="AttributePropertiesSection"> <element name="AdvancedProperties" type="button" selector="#advanced_fieldset-wrapper"/> <element name="Save" type="button" selector="#save"/> + <element name="defaultLabel" type="input" selector="#attribute_label"/> + <element name="inputType" type="select" selector="#frontend_input"/> + <element name="deleteAttribute" type="button" selector="#delete" timeout="30"/> + </section> + <section name="StorefrontPropertiesSection"> + <element name="pageTitle" type="text" selector="//span[text()='Storefront Properties']" /> + <element name="storeFrontPropertiesTab" selector="#product_attribute_tabs_front" type="button"/> + <element name="enableWYSIWYG" type="select" selector="#enabled"/> + <element name="useForPromoRuleConditions" type="select" selector="#is_used_for_promo_rules"/> + </section> + <section name="AdvancedAttributePropertiesSection"> + <element name="advancedAttributePropertiesSectionToggle" + type="button" selector="#advanced_fieldset-wrapper"/> + <element name="attributeCode" type="text" selector="#attribute_code"/> + <element name="scope" type="select" selector="#is_global"/> + <element name="addToColumnOptions" type="select" selector="#is_used_in_grid"/> + <element name="useInFilterOptions" type="select" selector="#is_filterable_in_grid"/> + <element name="addSwatch" type="button" selector="#add_new_swatch_text_option_button"/> + </section> + <section name="AdminAttributeOptionsSection"> + <element name="addOption" type="button" selector="#add_new_option_button"/> + <element name="nthOptionAdminLabel" type="input" + selector="(//*[@id='manage-options-panel']//tr[{{var}}]//input[contains(@name, 'option[value]')])[1]" parameterized="true"/> </section> <section name="StorefrontPropertiesSection"> <element name="storefrontPropertiesTab" selector="#product_attribute_tabs_front" type="button" timeout="30"/> <element name="useForPromoRuleConditions" type="select" selector="#is_used_for_promo_rules"/> </section> + <section name="AttributeManageSwatchSection"> + <element name="swatchField" type="input" selector="input[name='swatchtext[value][option_{{option_index}}][{{index}}]'][placeholder='Swatch']" parameterized="true"/> + <element name="descriptionField" type="input" selector="input[name='optiontext[value][option_{{option_index}}][{{index}}]'][placeholder='Description']" parameterized="true"/> + </section> </sections> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeGridSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeGridSection.xml index 020ae95b11c2b..f27c27fbd20f4 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeGridSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductAttributeGridSection.xml @@ -12,6 +12,7 @@ <element name="attributeCode" type="text" selector="//td[contains(text(),'{{var1}}')]" parameterized="true"/> <element name="createNewAttributeBtn" type="button" selector="#add"/> <element name="gridFilterFrontEndLabel" type="input" selector="#attributeGrid_filter_frontend_label"/> + <element name="gridFilterAttributeCode" type="input" selector="#attributeGrid_filter_attribute_code"/> <element name="search" type="button" selector="button[data-action=grid-filter-apply]" timeout="30"/> <element name="resetFilter" type="button" selector="button[data-action='grid-filter-reset']" timeout="30"/> <element name="firstRow" type="button" selector="//*[@id='attributeGrid_table']/tbody/tr[1]" timeout="30"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductCustomizableOptionsSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductCustomizableOptionsSection.xml index fec0a9f672990..303fa5ec6b942 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductCustomizableOptionsSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductCustomizableOptionsSection.xml @@ -10,7 +10,7 @@ xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> <section name="AdminProductCustomizableOptionsSection"> <element name="checkIfCustomizableOptionsTabOpen" type="text" selector="//span[text()='Customizable Options']/parent::strong/parent::*[@data-state-collapsible='closed']"/> - <element name="customizableOptions" type="text" selector="//strong[contains(@class, 'admin__collapsible-title')]/span[text()='Customizable Options']"/> + <element name="customizableOptions" type="text" selector="//strong[contains(@class, 'admin__collapsible-title')]/span[text()='Customizable Options']" timeout="30"/> <element name="useDefaultOptionTitle" type="text" selector="[data-index='options'] tr.data-row [data-index='title'] [name^='options_use_default']"/> <element name="useDefaultOptionValueTitleByIndex" type="text" selector="[data-index='options'] [data-index='values'] tr[data-repeat-index='{{var1}}'] [name^='options_use_default']" parameterized="true"/> <element name="addOptionBtn" type="button" selector="button[data-index='button_add']"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormAdvancedPricingSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormAdvancedPricingSection.xml index 0f1fe8abeb3b5..ef66a41e27d06 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormAdvancedPricingSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormAdvancedPricingSection.xml @@ -18,6 +18,7 @@ <element name="productTierPriceValueTypeSelect" type="select" selector="[name='product[tier_price][{{var1}}][value_type]']" parameterized="true"/> <element name="productTierPriceFixedPriceInput" type="input" selector="[name='product[tier_price][{{var1}}][price]']" parameterized="true"/> <element name="productTierPricePercentageValuePriceInput" type="input" selector="[name='product[tier_price][{{var1}}][percentage_value]']" parameterized="true"/> + <element name="productTierPricePercentageError" type="text" selector="div[data-index='percentage_value'] label.admin__field-error" /> <element name="specialPrice" type="input" selector="input[name='product[special_price]']"/> <element name="doneButton" type="button" selector=".product_form_product_form_advanced_pricing_modal button.action-primary" timeout="5"/> </section> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormSection.xml index 6e86420ff9de5..7a97a75556769 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormSection.xml @@ -7,7 +7,7 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminProductFormSection"> <element name="attributeSet" type="select" selector="div[data-index='attribute_set_id'] .admin__field-control"/> <element name="attributeSetFilter" type="input" selector="div[data-index='attribute_set_id'] .admin__field-control input"/> @@ -21,6 +21,7 @@ <element name="productStatusUseDefault" type="checkbox" selector="input[name='use_default[status]']"/> <element name="productPrice" type="input" selector=".admin__field[data-index=price] input"/> <element name="advancedPricingLink" type="button" selector="button[data-index='advanced_pricing_button']"/> + <element name="productTaxClass" type="select" selector="select[name='product[tax_class_id]']"/> <element name="productTaxClassUseDefault" type="checkbox" selector="input[name='use_default[tax_class_id]']"/> <element name="categoriesDropdown" type="multiselect" selector="div[data-index='category_ids']"/> <element name="productQuantity" type="input" selector=".admin__field[data-index=qty] input"/> @@ -37,6 +38,8 @@ <element name="addAttributeBtn" type="button" selector="#addAttribute"/> <element name="attributeSetFilterResultByName" type="text" selector="//label/span[text() = '{{var}}']" timeout="30" parameterized="true"/> <element name="attributeSetDropDown" type="select" selector="div[data-index='attribute_set_id'] .action-select.admin__action-multiselect"/> + <element name="requiredNameIndicator" type="text" selector=" return window.getComputedStyle(document.querySelector('._required[data-index=name]>.admin__field-label span'), ':after').getPropertyValue('content');"/> + <element name="requiredSkuIndicator" type="text" selector=" return window.getComputedStyle(document.querySelector('._required[data-index=sku]>.admin__field-label span'), ':after').getPropertyValue('content');"/> </section> <section name="ProductInWebsitesSection"> <element name="sectionHeader" type="button" selector="div[data-index='websites']" timeout="30"/> @@ -185,5 +188,6 @@ </section> <section name="AdminChooseAffectedAttributeSetPopup"> <element name="confirm" type="button" selector="button[data-index='confirm_button']" timeout="30"/> + <element name="closePopUp" type="button" selector=".modal-popup._show [data-role='closeBtn']" timeout="30"/> </section> </sections> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductGridSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductGridSection.xml index 6ffecc341123d..e6d9cae3e9442 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductGridSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductGridSection.xml @@ -7,8 +7,9 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminProductGridSection"> + <element name="productRowBySku" type="block" selector="//div[@id='container']//tr//td[count(../../..//th[./*[.='SKU']]/preceding-sibling::th) + 1][./*[.='{{sku}}']]" parameterized="true" /> <element name="loadingMask" type="text" selector=".admin__data-grid-loading-mask[data-component*='product_listing']"/> <element name="columnHeader" type="button" selector="//div[@data-role='grid-wrapper']//table[contains(@class, 'data-grid')]/thead/tr/th[contains(@class, 'data-grid-th')]/span[text() = '{{label}}']" parameterized="true" timeout="30"/> <element name="productGridElement1" type="input" selector="#addselector" /> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductGridTableHeaderSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductGridTableHeaderSection.xml new file mode 100644 index 0000000000000..7341a6ded7a09 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductGridTableHeaderSection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminProductGridTableHeaderSection"> + <element name="id" type="button" selector=".//*[@class='sticky-header']/following-sibling::*//th[@class='data-grid-th _sortable _draggable _{{order}}']/span[text()='ID']" parameterized="true"/> + </section> +</sections> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontCategoryPagerSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontCategoryPagerSection.xml new file mode 100644 index 0000000000000..64470ec1f40e4 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontCategoryPagerSection.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontCategoryPagerSection"> + <element name="perPage" type="select" selector="//*[@id='authenticationPopup']/following-sibling::div[3]//*[@id='limiter']"/> + <element name="perPageSelected" type="select" selector="//*[@id='authenticationPopup']/following-sibling::div[3]//*[@id='limiter']/option[@selected='selected']"/> + <element name="sortedBy" type="select" selector="//*[@id='authenticationPopup']/following-sibling::div[1]//*[@id='sorter']"/> + <element name="modeGridIsActive" type="text" selector="//*[@id='authenticationPopup']/following-sibling::div[1]//*[@class='modes']/strong[@class='modes-mode active mode-grid']/span"/> + </section> +</sections> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontMessagesSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontMessagesSection.xml index f1456ac8ee387..1b97d0c0795a4 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontMessagesSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontMessagesSection.xml @@ -7,10 +7,11 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="StorefrontMessagesSection"> <element name="test" type="input" selector=".test"/> <element name="success" type="text" selector="div.message-success.success.message"/> <element name="error" type="text" selector="div.message-error.error.message"/> + <element name="notice" type="text" selector="div.message-notice"/> </section> </sections> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontProductInfoMainSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontProductInfoMainSection.xml index 65fe9b06be66c..ae0cb3f970108 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontProductInfoMainSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontProductInfoMainSection.xml @@ -7,12 +7,14 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="StorefrontProductInfoMainSection"> <element name="stock" type="input" selector=".stock.available"/> <element name="productName" type="text" selector=".base"/> <element name="productSku" type="text" selector=".product.attribute.sku>.value"/> <element name="productPrice" type="text" selector=".price"/> + <element name="specialPrice" type="text" selector=".special-price"/> + <element name="specialPriceValue" type="text" selector=".special-price .price"/> <element name="qty" type="input" selector="#qty"/> <element name="productStockStatus" type="text" selector=".stock[title=Availability]>span"/> <element name="productDescription" type="text" selector="#description .value"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontProductPageSection.xml b/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontProductPageSection.xml index c960f8432e64d..61eabb6cb34fd 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontProductPageSection.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Section/StorefrontProductPageSection.xml @@ -7,10 +7,10 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="StorefrontProductPageSection"> - <element name="QtyInput" type="button" selector="input.input-text.qty"/> - <element name="addToCartBtn" type="button" selector="button.action.tocart.primary"/> + <element name="qtyInput" type="button" selector="input.input-text.qty"/> + <element name="addToCartBtn" type="button" selector="button.action.tocart.primary" timeout="30"/> <element name="successMsg" type="button" selector="div.message-success"/> <element name="addToWishlist" type="button" selector="a.action.towishlist" timeout="30"/> <element name="addToCartButtonTitleIsAdding" type="text" selector="//button/span[text()='Adding...']"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminCreateProductDropdownAttributeTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminCreateProductDropdownAttributeTest.xml new file mode 100644 index 0000000000000..9bc17d19c4285 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminCreateProductDropdownAttributeTest.xml @@ -0,0 +1,71 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminCreateProductDropdownAttributeTest"> + <annotations> + <features value="Catalog"/> + <stories value="Create/configure Dropdown product attribute"/> + <title value="Admin should be able to create dropdown product attribute"/> + <description value="Admin should be able to create dropdown product attribute"/> + <severity value="CRITICAL"/> + <testCaseId value="MAGETWO-95868"/> + <group value="Catalog"/> + </annotations> + <before> + <actionGroup ref="LoginAsAdmin" stepKey="login"/> + </before> + <after> + <!-- Remove attribute --> + <actionGroup ref="deleteProductAttribute" stepKey="deleteAttribute"> + <argument name="ProductAttribute" value="productAttributeWithDropdownTwoOptions"/> + </actionGroup> + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <amOnPage url="{{ProductAttributePage.url}}" stepKey="navigateToNewProductAttributePage"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + + <!-- Set attribute properties --> + <fillField selector="{{AttributePropertiesSection.defaultLabel}}" + userInput="{{ProductAttributeFrontendLabel.label}}" stepKey="fillDefaultLabel"/> + <selectOption selector="{{AttributePropertiesSection.inputType}}" + userInput="{{productAttributeWithDropdownTwoOptions.frontend_input}}" stepKey="fillInputType"/> + + <!-- Set advanced attribute properties --> + <click selector="{{AdvancedAttributePropertiesSection.advancedAttributePropertiesSectionToggle}}" + stepKey="showAdvancedAttributePropertiesSection"/> + <waitForElementVisible selector="{{AdvancedAttributePropertiesSection.attributeCode}}" + stepKey="waitForSlideOut"/> + <fillField selector="{{AdvancedAttributePropertiesSection.attributeCode}}" + userInput="{{productAttributeWithDropdownTwoOptions.attribute_code}}" + stepKey="fillAttributeCode"/> + + <!-- Add new attribute options --> + <click selector="{{AdminAttributeOptionsSection.addOption}}" stepKey="clickAddOption1"/> + <fillField selector="{{AdminAttributeOptionsSection.nthOptionAdminLabel('1')}}" + userInput="Fish and Chips" stepKey="fillAdminValue1"/> + + <click selector="{{AdminAttributeOptionsSection.addOption}}" stepKey="clickAddOption2"/> + <fillField selector="{{AdminAttributeOptionsSection.nthOptionAdminLabel('2')}}" + userInput="Fish & Chips" stepKey="fillAdminValue2"/> + + <!-- Save the new product attribute --> + <click selector="{{AdminMainActionsSection.save}}" stepKey="clickSave1"/> + <waitForElementVisible selector="{{AdminMessagesSection.successMessage}}" + stepKey="waitForSuccessMessage"/> + + <actionGroup ref="navigateToCreatedProductAttribute" stepKey="navigateToAttribute"> + <argument name="productAttribute" value="productAttributeWithDropdownTwoOptions"/> + </actionGroup> + <!-- Check attribute data --> + <grabValueFrom selector="{{AdminAttributeOptionsSection.nthOptionAdminLabel('2')}}" + stepKey="secondOptionAdminLabel"/> + <assertEquals actual="$secondOptionAdminLabel" expected="'Fish & Chips'" + stepKey="assertSecondOption"/> + </test> +</tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminFilteringCategoryProductsUsingScopeSelectorTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminFilteringCategoryProductsUsingScopeSelectorTest.xml index ba0eb20ce7733..96403d5dc887a 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminFilteringCategoryProductsUsingScopeSelectorTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminFilteringCategoryProductsUsingScopeSelectorTest.xml @@ -7,7 +7,7 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/testSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> <test name="AdminFilteringCategoryProductsUsingScopeSelectorTest"> <annotations> <features value="Catalog"/> @@ -19,19 +19,19 @@ </annotations> <before> <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> - <!--Create website, Sore adn Store View--> - <actionGroup ref="AdminCreateWebsiteActionGroup" stepKey="adminCreateWebsite"> - <argument name="newWebsiteName" value="secondWebsite"/> - <argument name="websiteCode" value="second_website"/> + <!--Create website, Sore and Store View--> + <actionGroup ref="AdminCreateWebsiteActionGroup" stepKey="createSecondWebsite"> + <argument name="newWebsiteName" value="{{SecondWebsite.name}}"/> + <argument name="websiteCode" value="{{SecondWebsite.code}}"/> </actionGroup> - <actionGroup ref="AdminCreateNewStoreGroupActionGroup" stepKey="adminCreateStore"> - <argument name="website" value="secondWebsite"/> - <argument name="storeGroupName" value="Second Store"/> - <argument name="storeGroupCode" value="second_store"/> + <actionGroup ref="AdminCreateNewStoreGroupActionGroup" stepKey="createSecondStoreGroup"> + <argument name="website" value="{{SecondWebsite.name}}"/> + <argument name="storeGroupName" value="{{SecondStoreGroupUnique.name}}"/> + <argument name="storeGroupCode" value="{{SecondStoreGroupUnique.code}}"/> </actionGroup> - <actionGroup ref="AdminCreateStoreViewActionGroup" stepKey="adminCreateStoreView"> - <argument name="storeGroup" value="secondStoreGroup"/> - <argument name="customStore" value="secondStore"/> + <actionGroup ref="AdminCreateStoreViewActionGroup" stepKey="createSecondStoreView"> + <argument name="storeGroup" value="SecondStoreGroupUnique"/> + <argument name="customStore" value="SecondStoreUnique"/> </actionGroup> <!--Create Simple Product and Category --> @@ -74,7 +74,7 @@ <waitForPageLoad time="30" stepKey="waitForProductEditOpen1"/> <actionGroup ref="SelectProductInWebsitesActionGroup" stepKey="selectProductInWebsites"> - <argument name="website" value="secondWebsite"/> + <argument name="website" value="{{SecondWebsite.name}}"/> </actionGroup> <uncheckOption selector="{{ProductInWebsitesSection.website('Main Website')}}" stepKey="uncheckWebsite1"/> <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="clickSaveProduct1"/> @@ -89,22 +89,26 @@ <waitForPageLoad time="30" stepKey="waitForProductEditOpen2"/> <actionGroup ref="SelectProductInWebsitesActionGroup" stepKey="selectProductInWebsites1"> - <argument name="website" value="secondWebsite"/> + <argument name="website" value="{{SecondWebsite.name}}"/> </actionGroup> <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="clickSaveProduct2"/> <see selector="{{AdminProductMessagesSection.successMessage}}" userInput="You saved the product." stepKey="seeSuccessMessage2"/> </before> <after> - <actionGroup ref="AdminDeleteWebsiteActionGroup" stepKey="deleteWebsite"> - <argument name="websiteName" value="secondWebsite"/> - </actionGroup> - <actionGroup ref="clearFiltersAdminDataGrid" stepKey="clearProductFilters"/> <deleteData createDataKey="createProduct0" stepKey="deleteProduct"/> <deleteData createDataKey="createProduct1" stepKey="deleteProduct1"/> <deleteData createDataKey="createProduct2" stepKey="deleteProduct2"/> <deleteData createDataKey="createProduct12" stepKey="deleteProduct3"/> <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <!--Delete website--> + <actionGroup ref="AdminDeleteWebsiteActionGroup" stepKey="deleteSecondWebsite"> + <argument name="websiteName" value="{{SecondWebsite.name}}"/> + </actionGroup> + <!--Clear products filter--> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndex"/> + <actionGroup ref="clearFiltersAdminDataGrid" stepKey="clearProductsFilters"/> + <actionGroup ref="logout" stepKey="logout"/> </after> <!-- Step 1-2: Open Category page and Set scope selector to All Store Views--> @@ -156,7 +160,7 @@ <click selector="{{AdminCategoryMainActionsSection.categoryStoreViewDropdownToggle}}" stepKey="clickStoresList1"/> <waitForPageLoad stepKey="waitForCategoryPageLoad3"/> - <click selector="{{AdminCategoryMainActionsSection.categoryStoreViewOption(secondStore.name)}}" + <click selector="{{AdminCategoryMainActionsSection.categoryStoreViewOption(SecondStoreUnique.name)}}" stepKey="clickStoreView1"/> <waitForElementVisible selector="{{AdminCategoryMainActionsSection.categoryStoreViewModalAccept}}" stepKey="waitForPopup2"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductStatusAttributeDisabledByDefaultTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductStatusAttributeDisabledByDefaultTest.xml index 9ec47e5a36464..2ae817c732434 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductStatusAttributeDisabledByDefaultTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminProductStatusAttributeDisabledByDefaultTest.xml @@ -28,7 +28,7 @@ <click selector="{{AdminProductAttributeGridSection.firstRow}}" stepKey="clickOnAttributeRow1"/> <waitForPageLoad stepKey="wait2"/> <click selector="{{AdminNewAttributePanelSection.isDefault('1')}}" stepKey="resetOptionForStatusAttribute"/> - <click selector="{{AttributePropertiesSection.save}}" stepKey="saveAttribute1"/> + <click selector="{{AttributePropertiesSection.Save}}" stepKey="saveAttribute1"/> <waitForPageLoad stepKey="waitForSaveAttribute1"/> <actionGroup ref="ClearCacheActionGroup" stepKey="clearCache1"/> <actionGroup ref="logout" stepKey="logoutOfAdmin"/> @@ -42,7 +42,7 @@ <click selector="{{AdminProductAttributeGridSection.firstRow}}" stepKey="clickOnAttributeRow"/> <waitForPageLoad stepKey="wait2"/> <click selector="{{AdminNewAttributePanelSection.isDefault('2')}}" stepKey="chooseDisabledOptionForStatus"/> - <click selector="{{AttributePropertiesSection.save}}" stepKey="saveAttribute"/> + <click selector="{{AttributePropertiesSection.Save}}" stepKey="saveAttribute"/> <waitForPageLoad stepKey="waitForAttributeToSave"/> <actionGroup ref="ClearCacheActionGroup" stepKey="clearCache"/> <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndex"/> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminRequiredFieldsHaveRequiredFieldIndicatorTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminRequiredFieldsHaveRequiredFieldIndicatorTest.xml new file mode 100644 index 0000000000000..532ef76121857 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminRequiredFieldsHaveRequiredFieldIndicatorTest.xml @@ -0,0 +1,53 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminRequiredFieldsHaveRequiredFieldIndicatorTest"> + <annotations> + <stories value="MAGETWO-73342: Clicking on area around the label of a toggle element results in the element's state being changed"/> + <title value="Required fields should have the required asterisk indicator "/> + <description value="Verify that Required fields should have the required indicator icon next to the field name"/> + <severity value="MAJOR"/> + <testCaseId value="MAGETWO-97037"/> + <group value="catalog"/> + <group value="cms"/> + </annotations> + <before> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + </before> + <after> + <actionGroup ref="logout" stepKey="logoutFromAdmin"/> + </after> + + <amOnPage url="{{AdminCategoryPage.url}}" stepKey="navigateToCategoryPage"/> + <click selector="{{AdminCategorySidebarActionSection.AddSubcategoryButton}}" stepKey="clickOnAddSubCategory"/> + + <!-- Verify that the Category Name field has the required field name indicator --> + <executeJS function="{{AdminCategoryBasicFieldSection.requiredFieldIndicator}}" stepKey="getRequiredFieldIndicator"/> + <assertEquals expected='"*"' expectedType="string" actualType="variable" actual="getRequiredFieldIndicator" stepKey="assertRequiredFieldIndicator"/> + + <executeJS function="{{AdminCategoryBasicFieldSection.requiredFieldIndicatorColor}}" stepKey="getRequiredFieldIndicatorColor"/> + <assertEquals expected="rgb(226, 38, 38)" expectedType="string" actualType="variable" actual="getRequiredFieldIndicatorColor" stepKey="assertRequiredFieldIndicator1"/> + + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndexPage"/> + <click selector="{{AdminProductGridActionSection.addProductToggle}}" stepKey="addProductDropdown"/> + <click selector="{{AdminProductGridActionSection.addSimpleProduct}}" stepKey="addSimpleProduct"/> + + <!-- Verify that the Product Name and Sku fields have the required field name indicator --> + <executeJS function="{{AdminProductFormSection.requiredNameIndicator}}" stepKey="productNameRequiredFieldIndicator"/> + <assertEquals expected='"*"' expectedType="string" actualType="variable" actual="productNameRequiredFieldIndicator" stepKey="assertRequiredFieldIndicator2"/> + <executeJS function="{{AdminProductFormSection.requiredSkuIndicator}}" stepKey="productSkuRequiredFieldIndicator"/> + <assertEquals expected='"*"' expectedType="string" actualType="variable" actual="productSkuRequiredFieldIndicator" stepKey="assertRequiredFieldIndicator3"/> + + <!-- Verify that the CMS page have the required field name indicator next to Page Title --> + <amOnPage url="{{CmsPagesPage.url}}" stepKey="amOnPagePagesGrid"/> + <click selector="{{CmsPagesPageActionsSection.addNewPage}}" stepKey="clickAddNewPage"/> + <executeJS function="{{CmsNewPagePageBasicFieldsSection.requiredFieldIndicator}}" stepKey="pageTitleRequiredFieldIndicator"/> + <assertEquals expected='"*"' expectedType="string" actualType="variable" actual="pageTitleRequiredFieldIndicator" stepKey="assertRequiredFieldIndicator4"/> + </test> +</tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/AdminValidateApplyingTierPriceWithEmptyDiscountValueTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/AdminValidateApplyingTierPriceWithEmptyDiscountValueTest.xml new file mode 100644 index 0000000000000..8555615cc8781 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/AdminValidateApplyingTierPriceWithEmptyDiscountValueTest.xml @@ -0,0 +1,55 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminValidateApplyingTierPriceWithEmptyDiscountValueTest"> + <annotations> + <features value="Apply tier price"/> + <title value="Apply Tier Price with empty discount value"/> + <description value="Validate applying tier price to product"/> + <stories value="Apply Tier Price with empty discount value" /> + <severity value="CRITICAL"/> + <testCaseId value="MAGETWO-96484"/> + <group value="product"/> + </annotations> + <before> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <createData entity="SimpleProduct" stepKey="createSimpleProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + </before> + <after> + <deleteData createDataKey="createSimpleProduct" stepKey="deleteSimpleProduct"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndex"/> + <actionGroup ref="AdminResetProductGridToDefaultViewActionGroup" stepKey="resetGridToDefaultKeywordSearch"/> + <actionGroup ref="logout" stepKey="logoutFromAdmin"/> + </after> + + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <actionGroup ref="SearchForProductOnBackendActionGroup" stepKey="searchForSimpleProduct"> + <argument name="product" value="$$createSimpleProduct$$"/> + </actionGroup> + <actionGroup ref="OpenEditProductOnBackendActionGroup" stepKey="openEditProductPage"> + <argument name="product" value="$$createSimpleProduct$$"/> + </actionGroup> + <scrollToTopOfPage stepKey="scrollToTopOfPage"/> + <click selector="{{AdminProductFormSection.advancedPricingLink}}" stepKey="clickOnAdvancedPricingButton"/> + <waitForElement selector="{{AdminProductFormAdvancedPricingSection.customerGroupPriceAddButton}}" stepKey="waitForCustomerGroupPriceAddButton"/> + <click selector="{{AdminProductFormAdvancedPricingSection.customerGroupPriceAddButton}}" stepKey="addCustomerGroupPrice"/> + <fillField selector="{{AdminProductFormAdvancedPricingSection.productTierPriceQtyInput('0')}}" userInput="1" stepKey="fillProductTierPriceQtyInput"/> + <selectOption selector="{{AdminProductFormAdvancedPricingSection.productTierPriceValueTypeSelect('0')}}" userInput="Discount" stepKey="selectProductTierPriceValueType"/> + <clearField selector="{{AdminProductFormAdvancedPricingSection.productTierPricePercentageValuePriceInput('0')}}" stepKey="clearPercentageValueField"/> + <click selector="{{AdminProductFormAdvancedPricingSection.doneButton}}" stepKey="clickDoneButton"/> + <see selector="{{AdminProductFormAdvancedPricingSection.productTierPricePercentageError}}" userInput="This is a required field." stepKey="assertPercentageError"/> + <fillField selector="{{AdminProductFormAdvancedPricingSection.productTierPricePercentageValuePriceInput('0')}}" userInput="10" stepKey="setPercentageValue"/> + <click selector="{{AdminProductFormAdvancedPricingSection.doneButton}}" stepKey="clickDoneButton1"/> + <dontSee selector="{{AdminProductFormAdvancedPricingSection.productTierPricePercentageError}}" userInput="This is a required field." stepKey="assertNoPercentageError"/> + <actionGroup ref="SaveProductOnProductPageOnAdmin" stepKey="saveProduct"/> + </test> +</tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/CheckTierPricingOfProductsTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/CheckTierPricingOfProductsTest.xml index 4058112a2d76c..a1d9b4fb7b9a2 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/CheckTierPricingOfProductsTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/CheckTierPricingOfProductsTest.xml @@ -113,6 +113,9 @@ <argument name="website" value="SecondWebsite"/> </actionGroup> + <!--Flush cache--> + <magentoCLI command="cache:flush" stepKey="cleanCache"/> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndex"/> <waitForPageLoad stepKey="waitForProductsIndexPageToLoad"/> <actionGroup ref="resetAdminDataGridToDefaultView" stepKey="resetProductsGrid"/> @@ -155,10 +158,9 @@ <!--Create new order--> <actionGroup ref="navigateToNewOrderPageExistingCustomer" stepKey="startToCreateNewOrder"> <argument name="customer" value="$$createCustomer$$"/> + <argument name="storeView" value="SecondStoreUnique"/> </actionGroup> - <click selector="{{AdminOrderFormSelectWebsiteSection.website(SecondStoreUnique.name)}}" stepKey="selectStore"/> - <waitForPageLoad stepKey="waitForPageOpened"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoadingMask"/> + <click selector="{{AdminOrderFormItemsSection.addProducts}}" stepKey="clickToAddProduct"/> <waitForPageLoad stepKey="waitForProductsOpened"/> <!--TEST CASE #1--> @@ -328,13 +330,12 @@ <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> <createData entity="DefaultConfigCatalogPrice" stepKey="resetPriceScopeConfiguration"/> - - <actionGroup ref="AdminDeleteWebsiteActionGroup" stepKey="deleteSecondWebsite"> - <argument name="websiteName" value="{{SecondWebsite.name}}"/> - </actionGroup> <actionGroup ref="DeleteCartPriceRuleByName" stepKey="deleteCartPriceRule"> <argument name="ruleName" value="ship"/> </actionGroup> + <actionGroup ref="AdminDeleteWebsiteActionGroup" stepKey="deleteSecondWebsite"> + <argument name="websiteName" value="{{SecondWebsite.name}}"/> + </actionGroup> <actionGroup ref="logout" stepKey="logout"/> </after> </test> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontPurchaseProductCustomOptionsDifferentStoreViewsTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontPurchaseProductCustomOptionsDifferentStoreViewsTest.xml index d60130c545f10..68dbe628e9817 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontPurchaseProductCustomOptionsDifferentStoreViewsTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontPurchaseProductCustomOptionsDifferentStoreViewsTest.xml @@ -16,6 +16,9 @@ <severity value="CRITICAL"/> <testCaseId value="MAGETWO-77831"/> <group value="product"/> + <skip> + <issueId value="MAGETWO-98182"/> + </skip> </annotations> <before> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontPurchaseProductWithCustomOptionsTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontPurchaseProductWithCustomOptionsTest.xml index 31ed251a34795..823e000bb9c27 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontPurchaseProductWithCustomOptionsTest.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontPurchaseProductWithCustomOptionsTest.xml @@ -7,7 +7,7 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/testSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> <test name="StorefrontPurchaseProductWithCustomOptionsTest"> <annotations> <features value="Purchase a product with Custom Options of different types"/> @@ -20,30 +20,36 @@ </annotations> <before> <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + <!--Create Simple Product with Custom Options--> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <createData entity="_defaultProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + <field key="price">17</field> + </createData> + <updateData createDataKey="createProduct" entity="productWithOptions" stepKey="updateProductWithOption"/> + <!-- Logout customer before in case of it logged in from previous test --> + <actionGroup ref="CustomerLogoutStorefrontActionGroup" stepKey="logoutCustomer"/> </before> <after> <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <!-- Delete product and category --> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <actionGroup ref="AdminOrdersGridClearFiltersActionGroup" stepKey="clearOrderListingFilters"/> + <actionGroup ref="logout" stepKey="logoutAdmin"/> + <!-- Logout customer --> + <actionGroup ref="CustomerLogoutStorefrontActionGroup" stepKey="logoutCustomer"/> </after> - <!--Create Simple Product with Custom Options--> + <!-- Login Customer Storefront --> - <createData entity="_defaultCategory" stepKey="createCategory"/> - <createData entity="_defaultProduct" stepKey="createProduct"> - <requiredEntity createDataKey="createCategory"/> - <field key="price">17</field> - </createData> - <updateData createDataKey="createProduct" entity="productWithOptions" stepKey="updateProductWithOption"/> - - <!-- Login Customer Storeront --> - - <amOnPage url="{{StorefrontCustomerSignInPage.url}}" stepKey="amOnSignInPage"/> - <fillField userInput="$$createCustomer.email$$" selector="{{StorefrontCustomerSignInFormSection.emailField}}" stepKey="fillEmail"/> - <fillField userInput="$$createCustomer.password$$" selector="{{StorefrontCustomerSignInFormSection.passwordField}}" stepKey="fillPassword"/> - <click selector="{{StorefrontCustomerSignInFormSection.signInAccountButton}}" stepKey="clickSignInAccountButton"/> + <actionGroup ref="CustomerLoginOnStorefront" stepKey="loginCustomerOnStorefront"> + <argument name="customer" value="$$createCustomer$$"/> + </actionGroup> <!-- Checking the correctness of displayed prices for user parameters --> - <amOnPage url="{{StorefrontHomePage.url}}$createProduct.custom_attributes[url_key]$.html" stepKey="amOnProduct3Page"/> + <amOnPage url="{{StorefrontHomePage.url}}$$createProduct.custom_attributes[url_key]$$.html" stepKey="amOnProduct3Page"/> <seeElement selector="{{StorefrontProductInfoMainSection.productAttributeOptionsPrice(ProductOptionField.title, ProductOptionField.price)}}" stepKey="checkFieldProductOption"/> <seeElement selector="{{StorefrontProductInfoMainSection.productAttributeOptionsPrice(ProductOptionArea.title, '1.7')}}" stepKey="checkAreaProductOption"/> @@ -53,8 +59,12 @@ <seeElement selector="{{StorefrontProductInfoMainSection.productAttributeOptionsMultiselect(ProductOptionMultiSelect.title, ProductOptionValueMultiSelect1.price)}}" stepKey="checkMultiSelectProductOption"/> <seeElement selector="{{StorefrontProductInfoMainSection.productAttributeOptionsData(ProductOptionDate.title, ProductOptionDate.price)}}" stepKey="checkDataProductOption"/> - <!-- Adding items to the checkout --> + <!--Generate year--> + <generateDate date="Now" format="Y" stepKey="year"/> + <generateDate date="Now" format="y" stepKey="shortYear"/> + <!-- Adding items to the checkout --> + <fillField userInput="OptionField" selector="{{StorefrontProductInfoMainSection.productOptionFieldInput(ProductOptionField.title)}}" stepKey="fillProductOptionInputField"/> <fillField userInput="OptionArea" selector="{{StorefrontProductInfoMainSection.productOptionAreaInput(ProductOptionArea.title)}}" stepKey="fillProductOptionInputArea"/> <attachFile userInput="{{productWithOptions.file}}" selector="{{StorefrontProductInfoMainSection.addLinkFileUploadFile(ProductOptionFile.title)}}" stepKey="fillUploadFile"/> @@ -64,10 +74,10 @@ <selectOption userInput="{{ProductOptionValueMultiSelect1.price}}" selector="{{StorefrontProductInfoMainSection.productOptionSelect(ProductOptionMultiSelect.title)}}" stepKey="selectProductOptionMultiSelect"/> <selectOption userInput="01" selector="{{StorefrontProductInfoMainSection.productOptionDataMonth(ProductOptionDate.title)}}" stepKey="selectProductOptionDate"/> <selectOption userInput="01" selector="{{StorefrontProductInfoMainSection.productOptionDataDay(ProductOptionDate.title)}}" stepKey="selectProductOptionDate1"/> - <selectOption userInput="2018" selector="{{StorefrontProductInfoMainSection.productOptionDataYear(ProductOptionDate.title)}}" stepKey="selectProductOptionDate2"/> + <selectOption userInput="$year" selector="{{StorefrontProductInfoMainSection.productOptionDataYear(ProductOptionDate.title)}}" stepKey="selectProductOptionDate2"/> <selectOption userInput="01" selector="{{StorefrontProductInfoMainSection.productOptionDateAndTimeMonth(ProductOptionDateTime.title)}}" stepKey="selectProductOptionDateAndTimeMonth"/> <selectOption userInput="01" selector="{{StorefrontProductInfoMainSection.productOptionDateAndTimeDay(ProductOptionDateTime.title)}}" stepKey="selectProductOptionDateAndTimeDay"/> - <selectOption userInput="2018" selector="{{StorefrontProductInfoMainSection.productOptionDateAndTimeYear(ProductOptionDateTime.title)}}" stepKey="selectProductOptionDateAndTimeYear"/> + <selectOption userInput="$year" selector="{{StorefrontProductInfoMainSection.productOptionDateAndTimeYear(ProductOptionDateTime.title)}}" stepKey="selectProductOptionDateAndTimeYear"/> <selectOption userInput="01" selector="{{StorefrontProductInfoMainSection.productOptionDateAndTimeHour(ProductOptionDateTime.title)}}" stepKey="selectProductOptionDateAndTimeHour"/> <selectOption userInput="00" selector="{{StorefrontProductInfoMainSection.productOptionDateAndTimeMinute(ProductOptionDateTime.title)}}" stepKey="selectProductOptionDateAndTimeMinute"/> <selectOption userInput="01" selector="{{StorefrontProductInfoMainSection.productOptionTimeHour(ProductOptionTime.title)}}" stepKey="selectProductOptionTimeHour"/> @@ -76,7 +86,7 @@ <grabTextFrom selector="{{StorefrontProductInfoMainSection.productPrice}}" stepKey="finalProductPrice"/> <actionGroup ref="StorefrontAddToCartCustomOptionsProductPageActionGroup" stepKey="addToCartFromStorefrontProductPage"> - <argument name="productName" value="$createProduct.name$"/> + <argument name="productName" value="$$createProduct.name$$"/> </actionGroup> <!-- Checking the correctness of displayed custom options for user parameters on checkout --> @@ -90,26 +100,29 @@ <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskForCartItem"/> <waitForElement selector="{{CheckoutPaymentSection.cartItemsAreaActive}}" time="30" stepKey="waitForCartItemsAreaActive"/> - <see selector="{{CheckoutPaymentSection.cartItems}}" userInput="$createProduct.name$" stepKey="seeProductInCart"/> - - <conditionalClick selector="{{CheckoutPaymentSection.productOptionsByProductItemName($createProduct.name$)}}" dependentSelector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($createProduct.name$)}}" visible="false" stepKey="exposeProductOptions"/> - - <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($createProduct.name$)}}" userInput="{{ProductOptionField.title}}" stepKey="seeProductOptionFieldInput1"/> - <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($createProduct.name$)}}" userInput="{{ProductOptionArea.title}}" stepKey="seeProductOptionAreaInput1"/> - <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($createProduct.name$)}}" userInput="{{productWithOptions.file}}" stepKey="seeProductOptionFileInput1"/> - <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($createProduct.name$)}}" userInput="{{ProductOptionValueDropdown1.title}}" stepKey="seeProductOptionValueDropdown1Input1"/> - <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($createProduct.name$)}}" userInput="{{ProductOptionValueRadioButtons1.title}}" stepKey="seeProductOptionValueRadioButtons1Input1"/> - <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($createProduct.name$)}}" userInput="{{ProductOptionValueCheckbox.title}}" stepKey="seeProductOptionValueCheckboxInput1" /> - <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($createProduct.name$)}}" userInput="{{ProductOptionValueMultiSelect1.title}}" stepKey="seeproductAttributeOptionsMultiselect1Input1" /> - <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($createProduct.name$)}}" userInput="Jan 1, 2018" stepKey="seeProductOptionDateAndTimeInput" /> - <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($createProduct.name$)}}" userInput="1/1/18, 1:00 AM" stepKey="seeProductOptionDataInput" /> - <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($createProduct.name$)}}" userInput="1:00 AM" stepKey="seeProductOptionTimeInput" /> - + <see selector="{{CheckoutPaymentSection.cartItems}}" userInput="$$createProduct.name$$" stepKey="seeProductInCart"/> + + <conditionalClick selector="{{CheckoutPaymentSection.productOptionsByProductItemName($$createProduct.name$$)}}" dependentSelector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($$createProduct.name$$)}}" visible="false" stepKey="exposeProductOptions"/> + + <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($$createProduct.name$$)}}" userInput="{{ProductOptionField.title}}" stepKey="seeProductOptionFieldInput1"/> + <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($$createProduct.name$$)}}" userInput="{{ProductOptionArea.title}}" stepKey="seeProductOptionAreaInput1"/> + <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($$createProduct.name$$)}}" userInput="{{productWithOptions.file}}" stepKey="seeProductOptionFileInput1"/> + <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($$createProduct.name$$)}}" userInput="{{ProductOptionValueDropdown1.title}}" stepKey="seeProductOptionValueDropdown1Input1"/> + <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($$createProduct.name$$)}}" userInput="{{ProductOptionValueRadioButtons1.title}}" stepKey="seeProductOptionValueRadioButtons1Input1"/> + <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($$createProduct.name$$)}}" userInput="{{ProductOptionValueCheckbox.title}}" stepKey="seeProductOptionValueCheckboxInput1" /> + <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($$createProduct.name$$)}}" userInput="{{ProductOptionValueMultiSelect1.title}}" stepKey="seeproductAttributeOptionsMultiselect1Input1" /> + <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($$createProduct.name$$)}}" userInput="Jan 1, $year" stepKey="seeProductOptionDateAndTimeInput" /> + <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($$createProduct.name$$)}}" userInput="1/1/$shortYear, 1:00 AM" stepKey="seeProductOptionDataInput" /> + <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($$createProduct.name$$)}}" userInput="1:00 AM" stepKey="seeProductOptionTimeInput" /> + <!--Select shipping method--> + <actionGroup ref="CheckoutSelectFlatRateShippingMethodActionGroup" stepKey="selectFlatRateShippingMethod"/> + <waitForElement selector="{{CheckoutShippingSection.next}}" time="30" stepKey="waitForNextButton"/> <click selector="{{CheckoutShippingSection.next}}" stepKey="clickNext"/> - + <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskAfterClickNext"/> + <!--Select payment method--> + <actionGroup ref="CheckoutSelectCheckMoneyOrderPaymentActionGroup" stepKey="selectPaymentMethod"/> <!-- Place Order --> - - <waitForElement selector="{{CheckoutPaymentSection.placeOrder}}" time="30" stepKey="waitForPlaceOrderButton"/> + <waitForElementVisible selector="{{CheckoutPaymentSection.placeOrder}}" time="30" stepKey="waitForPlaceOrderButton"/> <click selector="{{CheckoutPaymentSection.placeOrder}}" stepKey="clickPlaceOrder"/> <grabTextFrom selector="{{CheckoutSuccessMainSection.orderNumber22}}" stepKey="grabOrderNumber"/> @@ -118,13 +131,11 @@ <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin1"/> - <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPage"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoadingMask3"/> - <actionGroup ref="clearFiltersAdminDataGrid" stepKey="clearGridFilter"/> - <fillField selector="{{OrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="fillOrderNum"/> - <click selector="{{OrdersGridSection.submitSearch}}" stepKey="submitSearch"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoadingMask4"/> - <click selector="{{OrdersGridSection.firstRow}}" stepKey="clickOrderRow"/> + <actionGroup ref="filterOrderGridById" stepKey="filterByOrderId"> + <argument name="orderId" value="$grabOrderNumber"/> + </actionGroup> + <click selector="{{AdminDataGridTableSection.firstRow}}" stepKey="clickOrderRow"/> + <waitForPageLoad stepKey="waitForOrderPageOpened"/> <!-- Checking the correctness of displayed custom options for user parameters on Order --> @@ -135,13 +146,14 @@ <see selector="{{AdminOrderItemsOrderedSection.productNameOptions}}" userInput="{{ProductOptionValueRadioButtons1.title}}" stepKey="seeAdminOrderProductOptionValueRadioButton1"/> <see selector="{{AdminOrderItemsOrderedSection.productNameOptions}}" userInput="{{ProductOptionValueCheckbox.title}}" stepKey="seeAdminOrderProductOptionValueCheckbox" /> <see selector="{{AdminOrderItemsOrderedSection.productNameOptions}}" userInput="{{ProductOptionValueMultiSelect1.title}}" stepKey="seeAdminOrderproductAttributeOptionsMultiselect1" /> - <see selector="{{AdminOrderItemsOrderedSection.productNameOptions}}" userInput="Jan 1, 2018" stepKey="seeAdminOrderProductOptionDateAndTime" /> - <see selector="{{AdminOrderItemsOrderedSection.productNameOptions}}" userInput="1/1/18, 1:00 AM" stepKey="seeAdminOrderProductOptionData" /> + <see selector="{{AdminOrderItemsOrderedSection.productNameOptions}}" userInput="Jan 1, $year" stepKey="seeAdminOrderProductOptionDateAndTime" /> + <see selector="{{AdminOrderItemsOrderedSection.productNameOptions}}" userInput="1/1/$shortYear, 1:00 AM" stepKey="seeAdminOrderProductOptionData" /> <see selector="{{AdminOrderItemsOrderedSection.productNameOptions}}" userInput="1:00 AM" stepKey="seeAdminOrderProductOptionTime" /> <!-- Reorder and Checking the correctness of displayed custom options for user parameters on Order and correctness of displayed price Subtotal--> <click selector="{{OrderDetailsMainActionsSection.reorder}}" stepKey="clickReorder"/> + <actionGroup ref="AdminCheckoutSelectCheckMoneyOrderBillingMethodActionGroup" stepKey="selectBillingMethod"/> <click selector="{{AdminOrderFormActionSection.submitOrder}}" stepKey="trySubmitOrder"/> <see selector="{{AdminOrderItemsOrderedSection.productNameOptions}}" userInput="{{ProductOptionField.title}}" stepKey="seeAdminOrderProductOptionField1" /> @@ -151,30 +163,25 @@ <see selector="{{AdminOrderItemsOrderedSection.productNameOptions}}" userInput="{{ProductOptionValueRadioButtons1.title}}" stepKey="seeAdminOrderProductOptionValueRadioButton11"/> <see selector="{{AdminOrderItemsOrderedSection.productNameOptions}}" userInput="{{ProductOptionValueCheckbox.title}}" stepKey="seeAdminOrderProductOptionValueCheckbox1" /> <see selector="{{AdminOrderItemsOrderedSection.productNameOptions}}" userInput="{{ProductOptionValueMultiSelect1.title}}" stepKey="seeAdminOrderproductAttributeOptionsMultiselect11" /> - <see selector="{{AdminOrderItemsOrderedSection.productNameOptions}}" userInput="Jan 1, 2018" stepKey="seeAdminOrderProductOptionDateAndTime1" /> - <see selector="{{AdminOrderItemsOrderedSection.productNameOptions}}" userInput="1/1/18, 1:00 AM" stepKey="seeAdminOrderProductOptionData1" /> + <see selector="{{AdminOrderItemsOrderedSection.productNameOptions}}" userInput="Jan 1, $year" stepKey="seeAdminOrderProductOptionDateAndTime1" /> + <see selector="{{AdminOrderItemsOrderedSection.productNameOptions}}" userInput="1/1/$shortYear, 1:00 AM" stepKey="seeAdminOrderProductOptionData1" /> <see selector="{{AdminOrderItemsOrderedSection.productNameOptions}}" userInput="1:00 AM" stepKey="seeAdminOrderProductOptionTime1" /> <see selector="{{AdminOrderTotalSection.subTotal}}" userInput="{$finalProductPrice}" stepKey="seeOrderSubTotal"/> <!-- Go to Customer Order Page and Checking the correctness of displayed custom options for user parameters on Order --> - <amOnPage url="{{StorefrontCustomerOrderViewPage.url({$grabOrderNumber})}}" stepKey="amOnProduct4Page"/> - - <see selector="{{StorefrontCustomerOrderSection.productCustomOptions($createProduct.name$, ProductOptionField.title, ProductOptionField.title)}}" userInput="{{ProductOptionField.title}}" stepKey="seeStorefontOrderProductOptionField1" /> - <see selector="{{StorefrontCustomerOrderSection.productCustomOptions($createProduct.name$, ProductOptionArea.title, ProductOptionArea.title)}}" userInput="{{ProductOptionArea.title}}" stepKey="seeStorefontOrderProductOptionArea1"/> - <see selector="{{StorefrontCustomerOrderSection.productCustomOptionsFile($createProduct.name$, ProductOptionFile.title, productWithOptions.file)}}" userInput="{{productWithOptions.file}}" stepKey="seeStorefontOrderProductOptionFile1"/> - <see selector="{{StorefrontCustomerOrderSection.productCustomOptions($createProduct.name$, ProductOptionDropDown.title, ProductOptionValueDropdown1.title)}}" userInput="{{ProductOptionValueDropdown1.title}}" stepKey="seeStorefontOrderProductOptionValueDropdown11"/> - <see selector="{{StorefrontCustomerOrderSection.productCustomOptions($createProduct.name$, ProductOptionRadiobutton.title, ProductOptionValueRadioButtons1.title)}}" userInput="{{ProductOptionValueRadioButtons1.title}}" stepKey="seeStorefontOrderProductOptionValueRadioButtons11"/> - <see selector="{{StorefrontCustomerOrderSection.productCustomOptions($createProduct.name$, ProductOptionCheckbox.title, ProductOptionValueCheckbox.title)}}" userInput="{{ProductOptionValueCheckbox.title}}" stepKey="seeStorefontOrderProductOptionValueCheckbox1" /> - <see selector="{{StorefrontCustomerOrderSection.productCustomOptions($createProduct.name$, ProductOptionMultiSelect.title, ProductOptionValueMultiSelect1.title)}}" userInput="{{ProductOptionValueMultiSelect1.title}}" stepKey="seeStorefontOrderproductAttributeOptionsMultiselect11" /> - <see selector="{{StorefrontCustomerOrderSection.productCustomOptions($createProduct.name$, ProductOptionDate.title, 'Jan 1, 2018')}}" userInput="Jan 1, 2018" stepKey="seeStorefontOrderProductOptionDateAndTime1" /> - <see selector="{{StorefrontCustomerOrderSection.productCustomOptions($createProduct.name$, ProductOptionDateTime.title, '1/1/18, 1:00 AM')}}" userInput="1/1/18, 1:00 AM" stepKey="seeStorefontOrderProductOptionData1" /> - <see selector="{{StorefrontCustomerOrderSection.productCustomOptions($createProduct.name$, ProductOptionTime.title, '1:00 AM')}}" userInput="1:00 AM" stepKey="seeStorefontOrderProductOptionTime1" /> - - <!-- Delete product and category --> - - <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> - <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <amOnPage url="{{StorefrontCustomerOrderViewPage.url({$grabOrderNumber})}}" stepKey="amOnOrderPage"/> + + <see selector="{{StorefrontCustomerOrderSection.productCustomOptions($$createProduct.name$$, ProductOptionField.title, ProductOptionField.title)}}" userInput="{{ProductOptionField.title}}" stepKey="seeStorefontOrderProductOptionField1" /> + <see selector="{{StorefrontCustomerOrderSection.productCustomOptions($$createProduct.name$$, ProductOptionArea.title, ProductOptionArea.title)}}" userInput="{{ProductOptionArea.title}}" stepKey="seeStorefontOrderProductOptionArea1"/> + <see selector="{{StorefrontCustomerOrderSection.productCustomOptionsFile($$createProduct.name$$, ProductOptionFile.title, productWithOptions.file)}}" userInput="{{productWithOptions.file}}" stepKey="seeStorefontOrderProductOptionFile1"/> + <see selector="{{StorefrontCustomerOrderSection.productCustomOptions($$createProduct.name$$, ProductOptionDropDown.title, ProductOptionValueDropdown1.title)}}" userInput="{{ProductOptionValueDropdown1.title}}" stepKey="seeStorefontOrderProductOptionValueDropdown11"/> + <see selector="{{StorefrontCustomerOrderSection.productCustomOptions($$createProduct.name$$, ProductOptionRadiobutton.title, ProductOptionValueRadioButtons1.title)}}" userInput="{{ProductOptionValueRadioButtons1.title}}" stepKey="seeStorefontOrderProductOptionValueRadioButtons11"/> + <see selector="{{StorefrontCustomerOrderSection.productCustomOptions($$createProduct.name$$, ProductOptionCheckbox.title, ProductOptionValueCheckbox.title)}}" userInput="{{ProductOptionValueCheckbox.title}}" stepKey="seeStorefontOrderProductOptionValueCheckbox1" /> + <see selector="{{StorefrontCustomerOrderSection.productCustomOptions($$createProduct.name$$, ProductOptionMultiSelect.title, ProductOptionValueMultiSelect1.title)}}" userInput="{{ProductOptionValueMultiSelect1.title}}" stepKey="seeStorefontOrderproductAttributeOptionsMultiselect11" /> + <see selector="{{StorefrontCustomerOrderSection.productCustomOptions($$createProduct.name$$, ProductOptionDate.title, 'Jan 1, $year')}}" userInput="Jan 1, $year" stepKey="seeStorefontOrderProductOptionDateAndTime1" /> + <see selector="{{StorefrontCustomerOrderSection.productCustomOptions($$createProduct.name$$, ProductOptionDateTime.title, '1/1/$shortYear, 1:00 AM')}}" userInput="1/1/$shortYear, 1:00 AM" stepKey="seeStorefontOrderProductOptionData1" /> + <see selector="{{StorefrontCustomerOrderSection.productCustomOptions($$createProduct.name$$, ProductOptionTime.title, '1:00 AM')}}" userInput="1:00 AM" stepKey="seeStorefontOrderProductOptionTime1" /> </test> </tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontPurchaseProductWithCustomOptionsWithLongValuesTitle.xml b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontPurchaseProductWithCustomOptionsWithLongValuesTitle.xml index b5f7ddd8f00cf..df237e65440a8 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontPurchaseProductWithCustomOptionsWithLongValuesTitle.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontPurchaseProductWithCustomOptionsWithLongValuesTitle.xml @@ -55,10 +55,14 @@ <see selector="{{CheckoutPaymentSection.cartItems}}" userInput="$$createProduct.name$$" stepKey="seeProductInCart"/> <conditionalClick selector="{{CheckoutPaymentSection.productOptionsByProductItemName($$createProduct.name$$)}}" dependentSelector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($$createProduct.name$$)}}" visible="false" stepKey="exposeProductOptions"/> <see selector="{{CheckoutPaymentSection.productOptionsActiveByProductItemName($$createProduct.name$$)}}" userInput="{{ProductOptionValueDropdownLongTitle1.title}}" stepKey="seeProductOptionValueDropdown1Input1"/> + <!--Select shipping method--> + <actionGroup ref="CheckoutSelectFlatRateShippingMethodActionGroup" stepKey="selectFlatRateShippingMethod"/> + <waitForElement selector="{{CheckoutShippingSection.next}}" time="30" stepKey="waitForNextButton"/> <click selector="{{CheckoutShippingSection.next}}" stepKey="clickNext"/> + <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskAfterClickNext"/> <!-- Place Order --> - <actionGroup ref="CheckoutSelectCheckMoneyOrderPaymentActionGroup" stepKey="selectPayment"/> - <waitForElement selector="{{CheckoutPaymentSection.placeOrder}}" time="30" stepKey="waitForPlaceOrderButton"/> + <actionGroup ref="CheckoutSelectCheckMoneyOrderPaymentActionGroup" stepKey="selectPaymentMethod"/> + <waitForElementVisible selector="{{CheckoutPaymentSection.placeOrder}}" time="30" stepKey="waitForPlaceOrderButton"/> <click selector="{{CheckoutPaymentSection.placeOrder}}" stepKey="clickPlaceOrder"/> <grabTextFrom selector="{{CheckoutSuccessMainSection.orderNumber22}}" stepKey="grabOrderNumber"/> <!-- Login to Admin and open Order --> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontRememberCategoryPaginationTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontRememberCategoryPaginationTest.xml new file mode 100644 index 0000000000000..9ded627056600 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontRememberCategoryPaginationTest.xml @@ -0,0 +1,70 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontRememberCategoryPaginationTest"> + <annotations> + <title value="Verify that Number of Products per page retained when visiting a different category"/> + <stories value="MAGETWO-73687: Number of Products displayed per page not retained when visiting a different category"/> + <description value="Verify that Number of Products per page retained when visiting a different category"/> + <features value="Catalog"/> + <severity value="MAJOR"/> + <testCaseId value="MAGETWO-96307"/> + <group value="catalog"/> + </annotations> + + <before> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <createData entity="SimpleProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + + <createData entity="_defaultCategory" stepKey="createCategory1"/> + <createData entity="SimpleProduct" stepKey="createProduct1"> + <requiredEntity createDataKey="createCategory1"/> + </createData> + + <createData entity="RememberPaginationCatalogStorefrontConfig" stepKey="setRememberPaginationCatalogStorefrontConfig"/> + <magentoCLI command="cache:flush" stepKey="clearCache"/> + </before> + + <after> + <createData entity="DefaultCatalogStorefrontConfiguration" stepKey="setDefaultCatalogStorefrontConfiguration"/> + + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createProduct1" stepKey="deleteProduct1"/> + <deleteData createDataKey="createCategory1" stepKey="deleteCategory1"/> + + <magentoCLI command="cache:flush" stepKey="flushCache"/> + </after> + + <actionGroup ref="GoToStorefrontCategoryPageByParameters" stepKey="goToStorefrontCategory1Page"> + <argument name="category" value="$$createCategory.name$$"/> + <argument name="mode" value="grid"/> + </actionGroup> + + <selectOption selector="{{StorefrontCategoryPagerSection.perPage}}" userInput="12" stepKey="setPerPage" /> + <waitForPageLoad stepKey="waitForPageLoad"/> + + <actionGroup ref="VerifyCategoryPageParameters" stepKey="verifyCategoryPageParameters"> + <argument name="categoryName" value="$$createCategory.name$$"/> + <argument name="mode" value="grid"/> + <argument name="numOfProductsPerPage" value="12"/> + </actionGroup> + + <amOnPage url="{{StorefrontCategoryPage.url($$createCategory1.name$$)}}" stepKey="navigateToCategory1Page"/> + <waitForPageLoad stepKey="waitForCategory1PageToLoad"/> + + <actionGroup ref="VerifyCategoryPageParameters" stepKey="verifyCategory1PageParameters"> + <argument name="categoryName" value="$$createCategory1.name$$"/> + <argument name="mode" value="grid"/> + <argument name="numOfProductsPerPage" value="12"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontSpecialPriceForDifferentTimezonesForWebsitesTest.xml b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontSpecialPriceForDifferentTimezonesForWebsitesTest.xml new file mode 100644 index 0000000000000..b9308edbb387f --- /dev/null +++ b/app/code/Magento/Catalog/Test/Mftf/Test/StorefrontSpecialPriceForDifferentTimezonesForWebsitesTest.xml @@ -0,0 +1,76 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontSpecialPriceForDifferentTimezonesForWebsitesTest"> + <annotations> + <features value="Catalog"/> + <title value="Check that special price displayed when 'default config' scope timezone does not match 'website' scope timezone"/> + <description value="Check that special price displayed when 'default config' scope timezone does not match 'website' scope timezone"/> + <stories value="Verify product special price"/> + <severity value="MAJOR"/> + <testCaseId value="MC-13788"/> + <useCaseId value="MAGETWO-95452"/> + <group value="Catalog"/> + </annotations> + <before> + <actionGroup ref="LoginAsAdmin" stepKey="login"/> + <createData entity="SimpleProduct3" stepKey="createProduct"/> + + <!--Create customer--> + <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + + <!--Set timezone for default config--> + <amOnPage url="{{AdminConfigurationGeneralSectionPage.url('#general_locale-link')}}" stepKey="openLocaleSection"/> + <selectOption selector="{{LocaleOptionsSection.timezone}}" userInput="Central European Standard Time (Europe/Paris)" stepKey="setTimezone"/> + <click selector="{{AdminMainActionsSection.save}}" stepKey="saveConfig"/> + <!--Set timezone for Main Website--> + <actionGroup ref="AdminSwitchWebsiteActionGroup" stepKey="adminSwitchStoreViewActionGroup"> + <argument name="scopeName" value="_defaultWebsite.name"/> + </actionGroup> + <uncheckOption selector="{{LocaleOptionsSection.useDefault}}" stepKey="uncheckUseDefault"/> + <selectOption selector="{{LocaleOptionsSection.timezone}}" userInput="Greenwich Mean Time (Africa/Abidjan)" stepKey="setTimezone1"/> + <click selector="{{AdminMainActionsSection.save}}" stepKey="saveConfig1"/> + </before> + <after> + <!--Delete create data--> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + + <!--Reset timezone--> + <amOnPage url="{{AdminConfigurationGeneralSectionPage.url('#general_locale-link')}}" stepKey="openLocaleSectionReset"/> + <selectOption selector="{{LocaleOptionsSection.timezone}}" userInput="{{_ENV.DEFAULT_TIMEZONE}}" stepKey="resetTimezone"/> + <click selector="{{AdminMainActionsSection.save}}" stepKey="saveConfigReset"/> + + <actionGroup ref="AdminSwitchWebsiteActionGroup" stepKey="AdminSwitchStoreViewActionGroup"> + <argument name="scopeName" value="_defaultWebsite.name"/> + </actionGroup> + <checkOption selector="{{LocaleOptionsSection.useDefault}}" stepKey="checkUseDefault"/> + <click selector="{{AdminMainActionsSection.save}}" stepKey="saveConfigReset1"/> + + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <!--Set special price to created product--> + <amOnPage url="{{AdminProductEditPage.url($$createProduct.id$$)}}" stepKey="openAdminEditPage"/> + <actionGroup ref="AddSpecialPriceToProductActionGroup" stepKey="setSpecialPriceToCreatedProduct"> + <argument name="price" value="15"/> + </actionGroup> + <actionGroup ref="saveProductForm" stepKey="saveProductForm"/> + + <!--Login to storefront from customer and check price--> + <actionGroup ref="CustomerLoginOnStorefront" stepKey="logInFromCustomer"> + <argument name="customer" value="$$createCustomer$$"/> + </actionGroup> + + <!--Go to the product page and check special price--> + <amOnPage url="{{StorefrontProductPage.url($$createProduct.name$$)}}" stepKey="amOnSimpleProductPage"/> + <see selector="{{StorefrontProductInfoMainSection.specialPriceValue}}" userInput='$15.00' stepKey="assertSpecialPrice"/> + </test> +</tests> diff --git a/app/code/Magento/Catalog/Test/Unit/Block/Product/ProductList/ToolbarTest.php b/app/code/Magento/Catalog/Test/Unit/Block/Product/ProductList/ToolbarTest.php index ac963326dbfa1..1a6c25bbce2d0 100644 --- a/app/code/Magento/Catalog/Test/Unit/Block/Product/ProductList/ToolbarTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Block/Product/ProductList/ToolbarTest.php @@ -18,6 +18,11 @@ class ToolbarTest extends \PHPUnit\Framework\TestCase */ protected $model; + /** + * @var \Magento\Catalog\Model\Product\ProductList\ToolbarMemorizer | \PHPUnit_Framework_MockObject_MockObject + */ + private $memorizer; + /** * @var \Magento\Framework\Url | \PHPUnit_Framework_MockObject_MockObject */ @@ -62,6 +67,16 @@ protected function setUp() 'getLimit', 'getCurrentPage' ]); + $this->memorizer = $this->createPartialMock( + \Magento\Catalog\Model\Product\ProductList\ToolbarMemorizer::class, + [ + 'getDirection', + 'getOrder', + 'getMode', + 'getLimit', + 'isMemorizingAllowed', + ] + ); $this->layout = $this->createPartialMock(\Magento\Framework\View\Layout::class, ['getChildName', 'getBlock']); $this->pagerBlock = $this->createPartialMock(\Magento\Theme\Block\Html\Pager::class, [ 'setUseContainer', @@ -116,6 +131,7 @@ protected function setUp() 'context' => $context, 'catalogConfig' => $this->catalogConfig, 'toolbarModel' => $this->model, + 'toolbarMemorizer' => $this->memorizer, 'urlEncoder' => $this->urlEncoder, 'productListHelper' => $this->productListHelper ] @@ -155,7 +171,7 @@ public function testGetPagerEncodedUrl() public function testGetCurrentOrder() { $order = 'price'; - $this->model->expects($this->once()) + $this->memorizer->expects($this->once()) ->method('getOrder') ->will($this->returnValue($order)); $this->catalogConfig->expects($this->once()) @@ -169,7 +185,7 @@ public function testGetCurrentDirection() { $direction = 'desc'; - $this->model->expects($this->once()) + $this->memorizer->expects($this->once()) ->method('getDirection') ->will($this->returnValue($direction)); @@ -183,7 +199,7 @@ public function testGetCurrentMode() $this->productListHelper->expects($this->once()) ->method('getAvailableViewMode') ->will($this->returnValue(['list' => 'List'])); - $this->model->expects($this->once()) + $this->memorizer->expects($this->once()) ->method('getMode') ->will($this->returnValue($mode)); @@ -232,11 +248,11 @@ public function testGetLimit() $mode = 'list'; $limit = 10; - $this->model->expects($this->once()) + $this->memorizer->expects($this->once()) ->method('getMode') ->will($this->returnValue($mode)); - $this->model->expects($this->once()) + $this->memorizer->expects($this->once()) ->method('getLimit') ->will($this->returnValue($limit)); $this->productListHelper->expects($this->once()) @@ -266,7 +282,7 @@ public function testGetPagerHtml() $this->productListHelper->expects($this->exactly(2)) ->method('getAvailableLimit') ->will($this->returnValue([10 => 10, 20 => 20])); - $this->model->expects($this->once()) + $this->memorizer->expects($this->once()) ->method('getLimit') ->will($this->returnValue($limit)); $this->pagerBlock->expects($this->once()) diff --git a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/SaveTest.php b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/SaveTest.php index f493cbc88f18e..a1aaab0995d73 100644 --- a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/SaveTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/SaveTest.php @@ -3,8 +3,10 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Catalog\Test\Unit\Controller\Adminhtml\Product\Attribute; +use Magento\Catalog\Api\Data\ProductAttributeInterface; use Magento\Catalog\Controller\Adminhtml\Product\Attribute\Save; use Magento\Catalog\Test\Unit\Controller\Adminhtml\Product\AttributeTest; use Magento\Catalog\Model\Product\AttributeSet\BuildFactory; @@ -13,11 +15,16 @@ use Magento\Eav\Api\Data\AttributeSetInterface; use Magento\Eav\Model\Adminhtml\System\Config\Source\Inputtype\ValidatorFactory; use Magento\Eav\Model\ResourceModel\Entity\Attribute\Group\CollectionFactory; +use Magento\Framework\Message\ManagerInterface; +use Magento\Framework\Serialize\Serializer\FormData; +use Magento\Framework\Controller\ResultFactory; use Magento\Framework\Filter\FilterManager; use Magento\Catalog\Helper\Product as ProductHelper; +use Magento\Framework\View\Element\Messages; use Magento\Framework\View\LayoutFactory; use Magento\Backend\Model\View\Result\Redirect as ResultRedirect; use Magento\Eav\Model\Adminhtml\System\Config\Source\Inputtype\Validator as InputTypeValidator; +use Magento\Framework\View\LayoutInterface; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -79,6 +86,21 @@ class SaveTest extends AttributeTest */ protected $inputTypeValidatorMock; + /** + * @var ManagerInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $messageManagerMock; + + /** + * @var FormData|\PHPUnit_Framework_MockObject_MockObject + */ + private $formDataSerializerMock; + + /** + * @var ProductAttributeInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $productAttributeMock; + protected function setUp() { parent::setUp(); @@ -108,6 +130,7 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); $this->redirectMock = $this->getMockBuilder(ResultRedirect::class) + ->setMethods(['setData', 'setPath']) ->disableOriginalConstructor() ->getMock(); $this->attributeSetMock = $this->getMockBuilder(AttributeSetInterface::class) @@ -119,6 +142,15 @@ protected function setUp() $this->inputTypeValidatorMock = $this->getMockBuilder(InputTypeValidator::class) ->disableOriginalConstructor() ->getMock(); + $this->messageManagerMock = $this->getMockBuilder(\Magento\Framework\Message\ManagerInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->formDataSerializerMock = $this->getMockBuilder(FormData::class) + ->disableOriginalConstructor() + ->getMock(); + $this->productAttributeMock = $this->getMockBuilder(ProductAttributeInterface::class) + ->setMethods(['getId', 'get']) + ->getMockForAbstractClass(); $this->buildFactoryMock->expects($this->any()) ->method('create') @@ -126,6 +158,9 @@ protected function setUp() $this->validatorFactoryMock->expects($this->any()) ->method('create') ->willReturn($this->inputTypeValidatorMock); + $this->attributeFactoryMock + ->method('create') + ->willReturn($this->productAttributeMock); } /** @@ -135,6 +170,7 @@ protected function getModel() { return $this->objectManager->getObject(Save::class, [ 'context' => $this->contextMock, + 'messageManager' => $this->messageManagerMock, 'attributeLabelCache' => $this->attributeLabelCacheMock, 'coreRegistry' => $this->coreRegistryMock, 'resultPageFactory' => $this->resultPageFactoryMock, @@ -145,11 +181,22 @@ protected function getModel() 'validatorFactory' => $this->validatorFactoryMock, 'groupCollectionFactory' => $this->groupCollectionFactoryMock, 'layoutFactory' => $this->layoutFactoryMock, + 'formDataSerializer' => $this->formDataSerializerMock, ]); } public function testExecuteWithEmptyData() { + $this->requestMock->expects($this->any()) + ->method('getParam') + ->willReturnMap([ + ['isAjax', null, null], + ['serialized_options', '[]', ''], + ]); + $this->formDataSerializerMock->expects($this->once()) + ->method('unserialize') + ->with('') + ->willReturn([]); $this->requestMock->expects($this->once()) ->method('getPostValue') ->willReturn([]); @@ -170,6 +217,22 @@ public function testExecute() 'frontend_input' => 'test_frontend_input', ]; + $this->requestMock->expects($this->any()) + ->method('getParam') + ->willReturnMap([ + ['isAjax', null, null], + ['serialized_options', '[]', ''], + ]); + $this->formDataSerializerMock->expects($this->once()) + ->method('unserialize') + ->with('') + ->willReturn([]); + $this->productAttributeMock->expects($this->once()) + ->method('getId') + ->willReturn(1); + $this->productAttributeMock->expects($this->once()) + ->method('getAttributeCode') + ->willReturn('test_code'); $this->requestMock->expects($this->once()) ->method('getPostValue') ->willReturn($data); @@ -203,4 +266,74 @@ public function testExecute() $this->assertInstanceOf(ResultRedirect::class, $this->getModel()->execute()); } + + /** + * @return void + * @throws \Magento\Framework\Exception\NotFoundException + */ + public function testExecuteWithOptionsDataError() + { + $serializedOptions = '{"key":"value"}'; + $message = "The attribute couldn't be saved due to an error. Verify your information and try again. " + . "If the error persists, please try again later."; + + $this->requestMock->expects($this->any()) + ->method('getParam') + ->willReturnMap([ + ['isAjax', null, true], + ['serialized_options', '[]', $serializedOptions], + ]); + $this->formDataSerializerMock->expects($this->once()) + ->method('unserialize') + ->with($serializedOptions) + ->willThrowException(new \InvalidArgumentException('Some exception')); + $this->messageManagerMock->expects($this->once()) + ->method('addErrorMessage') + ->with($message); + $this->addReturnResultConditions('catalog/*/edit', ['_current' => true], ['error' => true]); + + $this->getModel()->execute(); + } + + /** + * @param string $path + * @param array $params + * @param array $response + * @return void + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + private function addReturnResultConditions(string $path = '', array $params = [], array $response = []) + { + $layoutMock = $this->getMockBuilder(LayoutInterface::class) + ->setMethods(['initMessages', 'getMessagesBlock']) + ->getMockForAbstractClass(); + $this->layoutFactoryMock->expects($this->once()) + ->method('create') + ->with() + ->willReturn($layoutMock); + $layoutMock->expects($this->once()) + ->method('initMessages') + ->with(); + $messageBlockMock = $this->getMockBuilder(Messages::class) + ->disableOriginalConstructor() + ->getMock(); + $layoutMock->expects($this->once()) + ->method('getMessagesBlock') + ->willReturn($messageBlockMock); + $messageBlockMock->expects($this->once()) + ->method('getGroupedHtml') + ->willReturn('message1'); + $this->resultFactoryMock->expects($this->once()) + ->method('create') + ->with(ResultFactory::TYPE_JSON) + ->willReturn($this->redirectMock); + $response = array_merge($response, [ + 'messages' => ['message1'], + 'params' => $params, + ]); + $this->redirectMock->expects($this->once()) + ->method('setData') + ->with($response) + ->willReturnSelf(); + } } diff --git a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/ValidateTest.php b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/ValidateTest.php index 9c747393cc72a..750d38f60e13d 100644 --- a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/ValidateTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/ValidateTest.php @@ -9,6 +9,7 @@ use Magento\Catalog\Model\ResourceModel\Eav\Attribute; use Magento\Catalog\Test\Unit\Controller\Adminhtml\Product\AttributeTest; use Magento\Eav\Model\Entity\Attribute\Set as AttributeSet; +use Magento\Framework\Serialize\Serializer\FormData; use Magento\Framework\Controller\Result\Json as ResultJson; use Magento\Framework\Controller\Result\JsonFactory as ResultJsonFactory; use Magento\Framework\Escaper; @@ -61,6 +62,11 @@ class ValidateTest extends AttributeTest */ protected $layoutMock; + /** + * @var FormData|\PHPUnit_Framework_MockObject_MockObject + */ + private $formDataSerializer; + protected function setUp() { parent::setUp(); @@ -86,6 +92,9 @@ protected function setUp() ->getMock(); $this->layoutMock = $this->getMockBuilder(LayoutInterface::class) ->getMockForAbstractClass(); + $this->formDataSerializer = $this->getMockBuilder(FormData::class) + ->disableOriginalConstructor() + ->getMock(); $this->contextMock->expects($this->any()) ->method('getObjectManager') @@ -100,25 +109,28 @@ protected function getModel() return $this->objectManager->getObject( Validate::class, [ - 'context' => $this->contextMock, - 'attributeLabelCache' => $this->attributeLabelCacheMock, - 'coreRegistry' => $this->coreRegistryMock, - 'resultPageFactory' => $this->resultPageFactoryMock, - 'resultJsonFactory' => $this->resultJsonFactoryMock, - 'layoutFactory' => $this->layoutFactoryMock, - 'multipleAttributeList' => ['select' => 'option'] + 'context' => $this->contextMock, + 'attributeLabelCache' => $this->attributeLabelCacheMock, + 'coreRegistry' => $this->coreRegistryMock, + 'resultPageFactory' => $this->resultPageFactoryMock, + 'resultJsonFactory' => $this->resultJsonFactoryMock, + 'layoutFactory' => $this->layoutFactoryMock, + 'multipleAttributeList' => ['select' => 'option'], + 'formDataSerializer' => $this->formDataSerializer, ] ); } public function testExecute() { + $serializedOptions = '{"key":"value"}'; $this->requestMock->expects($this->any()) ->method('getParam') ->willReturnMap([ ['frontend_label', null, 'test_frontend_label'], ['attribute_code', null, 'test_attribute_code'], ['new_attribute_set_name', null, 'test_attribute_set_name'], + ['serialized_options', '[]', $serializedOptions], ]); $this->objectManagerMock->expects($this->exactly(2)) ->method('create') @@ -160,6 +172,7 @@ public function testExecute() */ public function testUniqueValidation(array $options, $isError) { + $serializedOptions = '{"key":"value"}'; $countFunctionCalls = ($isError) ? 6 : 5; $this->requestMock->expects($this->exactly($countFunctionCalls)) ->method('getParam') @@ -167,10 +180,15 @@ public function testUniqueValidation(array $options, $isError) ['frontend_label', null, null], ['attribute_code', null, "test_attribute_code"], ['new_attribute_set_name', null, 'test_attribute_set_name'], - ['option', null, $options], - ['message_key', null, Validate::DEFAULT_MESSAGE_KEY] + ['message_key', null, Validate::DEFAULT_MESSAGE_KEY], + ['serialized_options', '[]', $serializedOptions], ]); + $this->formDataSerializer->expects($this->once()) + ->method('unserialize') + ->with($serializedOptions) + ->willReturn($options); + $this->objectManagerMock->expects($this->once()) ->method('create') ->willReturn($this->attributeMock); @@ -203,68 +221,84 @@ public function provideUniqueData() return [ 'no values' => [ [ - 'delete' => [ - "option_0" => "", - "option_1" => "", - "option_2" => "", - ] - ], false + 'option' => [ + 'delete' => [ + "option_0" => "", + "option_1" => "", + "option_2" => "", + ], + ], + + ], + false, ], 'valid options' => [ [ - 'value' => [ - "option_0" => [1, 0], - "option_1" => [2, 0], - "option_2" => [3, 0], + 'option' => [ + 'value' => [ + "option_0" => [1, 0], + "option_1" => [2, 0], + "option_2" => [3, 0], + ], + 'delete' => [ + "option_0" => "", + "option_1" => "", + "option_2" => "", + ], ], - 'delete' => [ - "option_0" => "", - "option_1" => "", - "option_2" => "", - ] - ], false + ], + false, ], 'duplicate options' => [ [ - 'value' => [ - "option_0" => [1, 0], - "option_1" => [1, 0], - "option_2" => [3, 0], + 'option' => [ + 'value' => [ + "option_0" => [1, 0], + "option_1" => [1, 0], + "option_2" => [3, 0], + ], + 'delete' => [ + "option_0" => "", + "option_1" => "", + "option_2" => "", + ], ], - 'delete' => [ - "option_0" => "", - "option_1" => "", - "option_2" => "", - ] - ], true + ], + true, ], 'duplicate and deleted' => [ [ - 'value' => [ - "option_0" => [1, 0], - "option_1" => [1, 0], - "option_2" => [3, 0], + 'option' => [ + 'value' => [ + "option_0" => [1, 0], + "option_1" => [1, 0], + "option_2" => [3, 0], + ], + 'delete' => [ + "option_0" => "", + "option_1" => "1", + "option_2" => "", + ], ], - 'delete' => [ - "option_0" => "", - "option_1" => "1", - "option_2" => "", - ] - ], false + ], + false, ], 'empty and deleted' => [ [ - 'value' => [ - "option_0" => [1, 0], - "option_1" => [2, 0], - "option_2" => ["", ""], + 'option' => [ + 'value' => [ + "option_0" => [1, 0], + "option_1" => [2, 0], + "option_2" => ["", ""], + ], + 'delete' => [ + "option_0" => "", + "option_1" => "", + "option_2" => "1", + ], ], - 'delete' => [ - "option_0" => "", - "option_1" => "", - "option_2" => "1", - ] - ], false + ], + false, ], ]; } @@ -278,6 +312,7 @@ public function provideUniqueData() */ public function testEmptyOption(array $options, $result) { + $serializedOptions = '{"key":"value"}'; $this->requestMock->expects($this->any()) ->method('getParam') ->willReturnMap([ @@ -285,10 +320,15 @@ public function testEmptyOption(array $options, $result) ['frontend_input', 'select', 'multipleselect'], ['attribute_code', null, "test_attribute_code"], ['new_attribute_set_name', null, 'test_attribute_set_name'], - ['option', null, $options], ['message_key', Validate::DEFAULT_MESSAGE_KEY, 'message'], + ['serialized_options', '[]', $serializedOptions], ]); + $this->formDataSerializer->expects($this->once()) + ->method('unserialize') + ->with($serializedOptions) + ->willReturn($options); + $this->objectManagerMock->expects($this->once()) ->method('create') ->willReturn($this->attributeMock); @@ -320,32 +360,38 @@ public function provideEmptyOption() return [ 'empty admin scope options' => [ [ - 'value' => [ - "option_0" => [''], + 'option' => [ + 'value' => [ + "option_0" => [''], + ], ], ], (object) [ 'error' => true, 'message' => 'The value of Admin scope can\'t be empty.', - ] + ], ], 'not empty admin scope options' => [ [ - 'value' => [ - "option_0" => ['asdads'], + 'option' => [ + 'value' => [ + "option_0" => ['asdads'], + ], ], ], (object) [ 'error' => false, - ] + ], ], 'empty admin scope options and deleted' => [ [ - 'value' => [ - "option_0" => [''], - ], - 'delete' => [ - 'option_0' => '1', + 'option' => [ + 'value' => [ + "option_0" => [''], + ], + 'delete' => [ + 'option_0' => '1', + ], ], ], (object) [ @@ -354,11 +400,13 @@ public function provideEmptyOption() ], 'empty admin scope options and not deleted' => [ [ - 'value' => [ - "option_0" => [''], - ], - 'delete' => [ - 'option_0' => '0', + 'option' => [ + 'value' => [ + "option_0" => [''], + ], + 'delete' => [ + 'option_0' => '0', + ], ], ], (object) [ @@ -368,4 +416,55 @@ public function provideEmptyOption() ], ]; } + + /** + * @return void + * @throws \Magento\Framework\Exception\NotFoundException + */ + public function testExecuteWithOptionsDataError() + { + $serializedOptions = '{"key":"value"}'; + $message = "The attribute couldn't be validated due to an error. Verify your information and try again. " + . "If the error persists, please try again later."; + $this->requestMock->expects($this->any()) + ->method('getParam') + ->willReturnMap([ + ['frontend_label', null, 'test_frontend_label'], + ['attribute_code', null, 'test_attribute_code'], + ['new_attribute_set_name', null, 'test_attribute_set_name'], + ['message_key', Validate::DEFAULT_MESSAGE_KEY, 'message'], + ['serialized_options', '[]', $serializedOptions], + ]); + + $this->formDataSerializer->expects($this->once()) + ->method('unserialize') + ->with($serializedOptions) + ->willThrowException(new \InvalidArgumentException('Some exception')); + + $this->objectManagerMock->expects($this->once()) + ->method('create') + ->willReturnMap([ + [\Magento\Catalog\Model\ResourceModel\Eav\Attribute::class, [], $this->attributeMock], + [\Magento\Eav\Model\Entity\Attribute\Set::class, [], $this->attributeSetMock] + ]); + + $this->attributeMock->expects($this->once()) + ->method('loadByCode') + ->willReturnSelf(); + $this->attributeSetMock->expects($this->never()) + ->method('setEntityTypeId') + ->willReturnSelf(); + $this->resultJsonFactoryMock->expects($this->once()) + ->method('create') + ->willReturn($this->resultJson); + $this->resultJson->expects($this->once()) + ->method('setJsonData') + ->with(json_encode([ + 'error' => true, + 'message' => $message, + ])) + ->willReturnSelf(); + + $this->getModel()->execute(); + } } diff --git a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/AttributeTest.php b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/AttributeTest.php index b85b03852b621..3a0b2b4bf7229 100644 --- a/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/AttributeTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/AttributeTest.php @@ -9,8 +9,9 @@ use Magento\Catalog\Controller\Adminhtml\Product\Attribute; use Magento\Framework\App\RequestInterface; use Magento\Framework\Cache\FrontendInterface; +use Magento\Framework\Message\ManagerInterface; use Magento\Framework\Registry; -use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; use Magento\Framework\View\Result\PageFactory; use Magento\Framework\Controller\ResultFactory; @@ -20,7 +21,7 @@ class AttributeTest extends \PHPUnit\Framework\TestCase { /** - * @var ObjectManager + * @var ObjectManagerHelper */ protected $objectManager; @@ -54,9 +55,14 @@ class AttributeTest extends \PHPUnit\Framework\TestCase */ protected $resultFactoryMock; + /** + * @var ManagerInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $messageManager; + protected function setUp() { - $this->objectManager = new ObjectManager($this); + $this->objectManager = new ObjectManagerHelper($this); $this->contextMock = $this->getMockBuilder(Context::class) ->disableOriginalConstructor() ->getMock(); @@ -74,6 +80,9 @@ protected function setUp() $this->resultFactoryMock = $this->getMockBuilder(ResultFactory::class) ->disableOriginalConstructor() ->getMock(); + $this->messageManager = $this->getMockBuilder(ManagerInterface::class) + ->disableOriginalConstructor() + ->getMock(); $this->contextMock->expects($this->any()) ->method('getRequest') @@ -81,6 +90,9 @@ protected function setUp() $this->contextMock->expects($this->any()) ->method('getResultFactory') ->willReturn($this->resultFactoryMock); + $this->contextMock->expects($this->once()) + ->method('getMessageManager') + ->willReturn($this->messageManager); } /** diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Category/Product/PositionResolverTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Category/Product/PositionResolverTest.php index 9545e5eb4b37d..a2424c7521e18 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Category/Product/PositionResolverTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Category/Product/PositionResolverTest.php @@ -105,7 +105,7 @@ public function testGetPositions() $this->select->expects($this->once()) ->method('where') ->willReturnSelf(); - $this->select->expects($this->once()) + $this->select->expects($this->exactly(2)) ->method('order') ->willReturnSelf(); $this->select->expects($this->once()) diff --git a/app/code/Magento/Catalog/Test/Unit/Model/CategoryListTest.php b/app/code/Magento/Catalog/Test/Unit/Model/CategoryListTest.php index b8b76524099f4..ab0c0ea38d79c 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/CategoryListTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/CategoryListTest.php @@ -86,14 +86,16 @@ public function testGetList() $categoryIdSecond = 2; $categoryFirst = $this->getMockBuilder(Category::class)->disableOriginalConstructor()->getMock(); + $categoryFirst->expects($this->atLeastOnce())->method('getId')->willReturn($categoryIdFirst); $categorySecond = $this->getMockBuilder(Category::class)->disableOriginalConstructor()->getMock(); + $categorySecond->expects($this->atLeastOnce())->method('getId')->willReturn($categoryIdSecond); /** @var SearchCriteriaInterface|\PHPUnit_Framework_MockObject_MockObject $searchCriteria */ $searchCriteria = $this->createMock(SearchCriteriaInterface::class); $collection = $this->getMockBuilder(Collection::class)->disableOriginalConstructor()->getMock(); $collection->expects($this->once())->method('getSize')->willReturn($totalCount); - $collection->expects($this->once())->method('getAllIds')->willReturn([$categoryIdFirst, $categoryIdSecond]); + $collection->expects($this->once())->method('getItems')->willReturn([$categoryFirst, $categorySecond]); $this->collectionProcessorMock->expects($this->once()) ->method('process') diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Flat/Action/RowTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Flat/Action/RowTest.php index f095867ed5c39..2de3b4ddf3f50 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Flat/Action/RowTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Flat/Action/RowTest.php @@ -100,10 +100,10 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); $this->connection->expects($this->any())->method('select')->willReturn($selectMock); - $selectMock->expects($this->any())->method('from')->with( - $attributeTable, - ['value'] - )->willReturnSelf(); + $selectMock->method('from') + ->willReturnSelf(); + $selectMock->method('joinLeft') + ->willReturnSelf(); $selectMock->expects($this->any())->method('where')->willReturnSelf(); $selectMock->expects($this->any())->method('order')->willReturnSelf(); $selectMock->expects($this->any())->method('limit')->willReturnSelf(); diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/ProductList/ToolbarMemorizerTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/ProductList/ToolbarMemorizerTest.php new file mode 100644 index 0000000000000..5cb341a36b4cc --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/ProductList/ToolbarMemorizerTest.php @@ -0,0 +1,213 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\Catalog\Test\Unit\Model\Product\ProductList; + +use Magento\Catalog\Model\Product\ProductList\Toolbar; +use Magento\Catalog\Model\Product\ProductList\ToolbarMemorizer; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Catalog\Model\Session as CatalogSession; +use Magento\Framework\App\Config\ScopeConfigInterface; + +/** + * Class for testing toolbal memorizer. + */ +class ToolbarMemorizerTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var ToolbarMemorizer + */ + private $model; + + /** + * @var \PHPUnit_Framework_MockObject_MockObject|Toolbar + */ + private $toolbarMock; + + /** + * @var \PHPUnit_Framework_MockObject_MockObject|CatalogSession + */ + private $catalogSessionMock; + + /** + * @var \PHPUnit_Framework_MockObject_MockObject|ScopeConfigInterface + */ + private $scopeConfigMock; + + /** + * @var ObjectManager $objectManager + */ + private $objectManager; + + /** + * @inheritdoc + */ + protected function setUp() + { + $this->toolbarMock = $this->getMockBuilder(Toolbar::class) + ->disableOriginalConstructor() + ->setMethods(['getOrder', 'getDirection', 'getLimit', 'getMode']) + ->getMock(); + $this->catalogSessionMock = $this->getMockBuilder(CatalogSession::class) + ->disableOriginalConstructor() + ->setMethods(['getParamsMemorizeDisabled', 'getData']) + ->getMock(); + $this->scopeConfigMock = $this->getMockBuilder(ScopeConfigInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->objectManager = new ObjectManager($this); + $this->model = $this->objectManager->getObject( + ToolbarMemorizer::class, + [ + 'toolbarModel' => $this->toolbarMock, + 'catalogSession' => $this->catalogSessionMock, + 'scopeConfig' => $this->scopeConfigMock, + ] + ); + } + + /** + * @return array + */ + public function getMainDataProvider(): array + { + return [ + ['any_value',null,null,null,'any_value'], + [null, 'any_value', false, null, 'any_value'], + [null, null, false, null, null], + [null, null, true, 'data', 'data'], + ]; + } + + /** + * Test get order. + * + * @param string|null $variable + * @param string|null $variableValue + * @param bool|null $flag + * @param string|null $data + * @param string|null $expected + * @return void + * + * @dataProvider getMainDataProvider + */ + public function testGetOrder($variable, $variableValue, $flag, $data, $expected) + { + $this->objectManager->setBackwardCompatibleProperty($this->model, 'order', $variable); + $this->toolbarMock->method('getOrder')->willReturn($variableValue); + $this->scopeConfigMock->method('isSetFlag')->willReturn($flag); + $this->catalogSessionMock->method('getData')->willReturn($data); + $this->assertEquals($expected, $this->model->getOrder()); + } + + /** + * Test get direction. + * + * @param string|null $variable + * @param string|null $variableValue + * @param bool|null $flag + * @param string|null $data + * @param string|null $expected + * @return void + * + * @dataProvider getMainDataProvider + */ + public function testGetDirection($variable, $variableValue, $flag, $data, $expected) + { + $this->objectManager->setBackwardCompatibleProperty($this->model, 'direction', $variable); + $this->toolbarMock->method('getDirection')->willReturn($variableValue); + $this->scopeConfigMock->method('isSetFlag')->willReturn($flag); + $this->catalogSessionMock->method('getData')->willReturn($data); + $this->assertEquals($expected, $this->model->getDirection()); + } + + /** + * Test get mode. + * + * @param string|null $variable + * @param string|null $variableValue + * @param bool|null $flag + * @param string|null $data + * @param string|null $expected + * @return void + * + * @dataProvider getMainDataProvider + */ + public function testGetMode($variable, $variableValue, $flag, $data, $expected) + { + $this->objectManager->setBackwardCompatibleProperty($this->model, 'mode', $variable); + $this->toolbarMock->method('getMode')->willReturn($variableValue); + $this->scopeConfigMock->method('isSetFlag')->willReturn($flag); + $this->catalogSessionMock->method('getData')->willReturn($data); + $this->assertEquals($expected, $this->model->getMode()); + } + + /** + * Test getting limit. + * + * @param string|null $variable + * @param string|null $variableValue + * @param bool|null $flag + * @param string|null $data + * @param string|null $expected + * @return void + * + * @dataProvider getMainDataProvider + */ + public function testGetLimit($variable, $variableValue, $flag, $data, $expected) + { + $this->objectManager->setBackwardCompatibleProperty($this->model, 'limit', $variable); + $this->toolbarMock->method('getLimit')->willReturn($variableValue); + $this->scopeConfigMock->method('isSetFlag')->willReturn($flag); + $this->catalogSessionMock->method('getData')->willReturn($data); + $this->assertEquals($expected, $this->model->getLimit()); + } + + /** + * Test memorizing parameters. + * + * @return void + */ + public function testMemorizeParams() + { + $this->catalogSessionMock->method('getParamsMemorizeDisabled')->willReturn(false); + $this->objectManager->setBackwardCompatibleProperty($this->model, 'isMemorizingAllowed', true); + $this->model->memorizeParams(); + } + + /** + * @return array + */ + public function getMemorizedDataProvider(): array + { + return [ + [null, false, false], + [null, true, true], + [false, false, false], + [false, true, false], + [true, false, true], + [true, true, true], + ]; + } + + /** + * Test method isMemorizingAllowed. + * + * @aram bool|null $variableValue + * @param bool $flag + * @param bool $expected + * @return void + * + * @dataProvider getMemorizedDataProvider + */ + public function testIsMemorizingAllowed($variableValue, bool $flag, bool $expected) + { + $this->objectManager->setBackwardCompatibleProperty($this->model, 'isMemorizingAllowed', $variableValue); + $this->scopeConfigMock->method('isSetFlag')->willReturn($flag); + $this->assertEquals($expected, $this->model->isMemorizingAllowed()); + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ProductTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ProductTest.php index 483283e777118..677a45c41f846 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/ProductTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/ProductTest.php @@ -543,6 +543,7 @@ public function testSetCategoryCollection() public function testGetCategory() { + $this->model->setData('category_ids', [10]); $this->category->expects($this->any())->method('getId')->will($this->returnValue(10)); $this->registry->expects($this->any())->method('registry')->will($this->returnValue($this->category)); $this->categoryRepository->expects($this->any())->method('get')->will($this->returnValue($this->category)); @@ -551,7 +552,8 @@ public function testGetCategory() public function testGetCategoryId() { - $this->category->expects($this->once())->method('getId')->will($this->returnValue(10)); + $this->model->setData('category_ids', [10]); + $this->category->expects($this->any())->method('getId')->will($this->returnValue(10)); $this->registry->expects($this->at(0))->method('registry'); $this->registry->expects($this->at(1))->method('registry')->will($this->returnValue($this->category)); @@ -559,6 +561,14 @@ public function testGetCategoryId() $this->assertEquals(10, $this->model->getCategoryId()); } + public function testGetCategoryIdWhenProductNotInCurrentCategory() + { + $this->model->setData('category_ids', [12]); + $this->category->expects($this->once())->method('getId')->will($this->returnValue(10)); + $this->registry->expects($this->any())->method('registry')->will($this->returnValue($this->category)); + $this->assertFalse($this->model->getCategoryId()); + } + public function testGetIdBySku() { $this->resource->expects($this->once())->method('getIdBySku')->will($this->returnValue(5)); diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/CollectionTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/CollectionTest.php index dbbb3fb29513b..6d3316a0610cd 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/CollectionTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/CollectionTest.php @@ -318,7 +318,7 @@ public function testAddTierPriceDataByGroupId() [ '(customer_group_id=? AND all_groups=0) OR all_groups=1', $customerGroupId] ) ->willReturnSelf(); - $select->expects($this->once())->method('order')->with('entity_id')->willReturnSelf(); + $select->expects($this->once())->method('order')->with('qty')->willReturnSelf(); $this->connectionMock->expects($this->once()) ->method('fetchAll') ->with($select) @@ -370,7 +370,7 @@ public function testAddTierPriceData() $select->expects($this->exactly(1))->method('where') ->with('entity_id IN(?)', [1]) ->willReturnSelf(); - $select->expects($this->once())->method('order')->with('entity_id')->willReturnSelf(); + $select->expects($this->once())->method('order')->with('qty')->willReturnSelf(); $this->connectionMock->expects($this->once()) ->method('fetchAll') ->with($select) diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/ImageTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/ImageTest.php new file mode 100644 index 0000000000000..44f66b6cbf66e --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Product/ImageTest.php @@ -0,0 +1,237 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Catalog\Test\Unit\Model\ResourceModel\Product; + +use Magento\Catalog\Model\ResourceModel\Product\Image; +use Magento\Framework\DB\Adapter\AdapterInterface; +use Magento\Framework\DB\Query\Generator; +use Magento\Framework\DB\Select; +use Magento\Framework\App\ResourceConnection; +use Magento\Catalog\Model\ResourceModel\Product\Gallery; +use PHPUnit_Framework_MockObject_MockObject as MockObject; +use Magento\Framework\DB\Query\BatchIteratorInterface; + +class ImageTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var \Magento\Framework\TestFramework\Unit\Helper\ObjectManager + */ + protected $objectManager; + + /** + * @var AdapterInterface | MockObject + */ + protected $connectionMock; + + /** + * @var Generator | MockObject + */ + protected $generatorMock; + + /** + * @var ResourceConnection | MockObject + */ + protected $resourceMock; + + protected function setUp() + { + $this->objectManager = + new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->connectionMock = $this->createMock(AdapterInterface::class); + $this->resourceMock = $this->createMock(ResourceConnection::class); + $this->resourceMock->method('getConnection') + ->willReturn($this->connectionMock); + $this->resourceMock->method('getTableName') + ->willReturnArgument(0); + $this->generatorMock = $this->createMock(Generator::class); + } + + /** + * @return MockObject + */ + protected function getVisibleImagesSelectMock(): MockObject + { + $selectMock = $this->getMockBuilder(Select::class) + ->disableOriginalConstructor() + ->getMock(); + $selectMock->expects($this->once()) + ->method('distinct') + ->willReturnSelf(); + $selectMock->expects($this->once()) + ->method('from') + ->with( + ['images' => Gallery::GALLERY_TABLE], + 'value as filepath' + )->willReturnSelf(); + $selectMock->expects($this->once()) + ->method('where') + ->with('disabled = 0') + ->willReturnSelf(); + + return $selectMock; + } + + /** + * @param int $imagesCount + * @dataProvider dataProvider + */ + public function testGetCountAllProductImages(int $imagesCount) + { + $selectMock = $this->getVisibleImagesSelectMock(); + $selectMock->expects($this->exactly(2)) + ->method('reset') + ->withConsecutive( + ['columns'], + ['distinct'] + )->willReturnSelf(); + $selectMock->expects($this->once()) + ->method('columns') + ->with(new \Zend_Db_Expr('count(distinct value)')) + ->willReturnSelf(); + + $this->connectionMock->expects($this->once()) + ->method('select') + ->willReturn($selectMock); + $this->connectionMock->expects($this->once()) + ->method('fetchOne') + ->with($selectMock) + ->willReturn($imagesCount); + + $imageModel = $this->objectManager->getObject( + Image::class, + [ + 'generator' => $this->generatorMock, + 'resourceConnection' => $this->resourceMock + ] + ); + + $this->assertSame( + $imagesCount, + $imageModel->getCountAllProductImages() + ); + } + + /** + * @param int $imagesCount + * @param int $batchSize + * @dataProvider dataProvider + */ + public function testGetAllProductImages( + int $imagesCount, + int $batchSize + ) { + $this->connectionMock->expects($this->once()) + ->method('select') + ->willReturn($this->getVisibleImagesSelectMock()); + + $batchCount = (int)ceil($imagesCount / $batchSize); + $fetchResultsCallback = $this->getFetchResultCallbackForBatches($imagesCount, $batchSize); + $this->connectionMock->expects($this->exactly($batchCount)) + ->method('fetchAll') + ->will($this->returnCallback($fetchResultsCallback)); + + /** @var Select | MockObject $selectMock */ + $selectMock = $this->getMockBuilder(Select::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->generatorMock->expects($this->once()) + ->method('generate') + ->with( + 'value_id', + $selectMock, + $batchSize, + BatchIteratorInterface::NON_UNIQUE_FIELD_ITERATOR + )->will( + $this->returnCallback( + $this->getBatchIteratorCallback($selectMock, $batchCount) + ) + ); + + $imageModel = $this->objectManager->getObject( + Image::class, + [ + 'generator' => $this->generatorMock, + 'resourceConnection' => $this->resourceMock, + 'batchSize' => $batchSize + ] + ); + + $this->assertCount($imagesCount, $imageModel->getAllProductImages()); + } + + /** + * @param int $imagesCount + * @param int $batchSize + * @return \Closure + */ + protected function getFetchResultCallbackForBatches( + int $imagesCount, + int $batchSize + ): \Closure { + $fetchResultsCallback = function () use (&$imagesCount, $batchSize) { + $batchSize = + ($imagesCount >= $batchSize) ? $batchSize : $imagesCount; + $imagesCount -= $batchSize; + + $getFetchResults = function ($batchSize): array { + $result = []; + $count = $batchSize; + while ($count) { + $count--; + $result[$count] = $count; + } + + return $result; + }; + + return $getFetchResults($batchSize); + }; + + return $fetchResultsCallback; + } + + /** + * @param Select | MockObject $selectMock + * @param int $batchCount + * @return \Closure + */ + protected function getBatchIteratorCallback( + MockObject $selectMock, + int $batchCount + ): \Closure { + $iteratorCallback = function () use ($batchCount, $selectMock): array { + $result = []; + $count = $batchCount; + while ($count) { + $count--; + $result[$count] = $selectMock; + } + + return $result; + }; + + return $iteratorCallback; + } + + /** + * Data Provider + * @return array + */ + public function dataProvider(): array + { + return [ + [300, 300], + [300, 100], + [139, 100], + [67, 10], + [154, 47], + [0, 100] + ]; + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Plugin/Framework/App/Action/ContextPluginTest.php b/app/code/Magento/Catalog/Test/Unit/Plugin/Framework/App/Action/ContextPluginTest.php new file mode 100644 index 0000000000000..efd78cac6e512 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Unit/Plugin/Framework/App/Action/ContextPluginTest.php @@ -0,0 +1,78 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\Catalog\Test\Unit\Plugin\Framework\App\Action; + +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; +use Magento\Catalog\Plugin\Framework\App\Action\ContextPlugin; +use Magento\Catalog\Model\Product\ProductList\ToolbarMemorizer; +use Magento\Catalog\Model\Session as CatalogSession; +use Magento\Framework\App\Http\Context as HttpContext; + +/** + * Class for testing ContextPlugin class. + */ +class ContextPluginTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var ContextPlugin + */ + private $model; + + /** + * @var \PHPUnit_Framework_MockObject_MockObject|ToolbarMemorizer + */ + private $toolbarMemorizerMock; + + /** + * @var \PHPUnit_Framework_MockObject_MockObject|CatalogSession + */ + private $catalogSessionMock; + + /** + * @var \PHPUnit_Framework_MockObject_MockObject|HttpContext + */ + private $httpContextMock; + + /** + * @inheritdoc + */ + protected function setUp() + { + $this->toolbarMemorizerMock = $this->getMockBuilder(ToolbarMemorizer::class) + ->disableOriginalConstructor() + ->getMock(); + $this->catalogSessionMock = $this->getMockBuilder(CatalogSession::class) + ->disableOriginalConstructor() + ->getMock(); + $this->httpContextMock = $this->getMockBuilder(HttpContext::class) + ->disableOriginalConstructor() + ->getMock(); + + $objectManagerHelper = new ObjectManagerHelper($this); + $this->model = $objectManagerHelper->getObject( + ContextPlugin::class, + [ + 'toolbarMemorizer' => $this->toolbarMemorizerMock, + 'catalogSession' => $this->catalogSessionMock, + 'httpContext' => $this->httpContextMock, + ] + ); + } + + /** + * Test beforeDispatch method. + * + * @return void + */ + public function testBeforeDispatch() + { + $this->toolbarMemorizerMock->method('isMemorizingAllowed')->willReturn(true); + $this->catalogSessionMock->method('getData')->willReturn('any_value'); + + $this->model->beforeDispatch(); + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Listing/Collector/ImageTest.php b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Listing/Collector/ImageTest.php index 12bc9acfa4c51..2d6b082e35b17 100644 --- a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Listing/Collector/ImageTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Listing/Collector/ImageTest.php @@ -6,15 +6,16 @@ namespace Magento\Catalog\Test\Unit\Ui\DataProvider\Product\Listing\Collector; +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Api\Data\ProductRender\ImageInterface; use Magento\Catalog\Api\Data\ProductRenderInterface; +use Magento\Catalog\Helper\Image as ImageHelper; +use Magento\Catalog\Helper\ImageFactory; use Magento\Catalog\Model\Product; -use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Ui\DataProvider\Product\Listing\Collector\Image; use Magento\Framework\View\DesignInterface; +use Magento\Framework\View\DesignLoader; use Magento\Store\Model\StoreManagerInterface; -use Magento\Catalog\Helper\ImageFactory; -use Magento\Catalog\Api\Data\ProductRender\ImageInterface; -use Magento\Catalog\Helper\Image as ImageHelper; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -42,8 +43,21 @@ class ImageTest extends \PHPUnit\Framework\TestCase /** @var \Magento\Catalog\Api\Data\ProductRender\ImageInterfaceFactory|\PHPUnit_Framework_MockObject_MockObject */ private $imageInterfaceFactory; + /** @var DesignLoader|\PHPUnit_Framework_MockObject_MockObject */ + private $designLoader; + + /** + * @var \Magento\Framework\TestFramework\Unit\Helper\ObjectManager + */ + protected $objectManager; + + /** + * @inheritdoc + */ public function setUp() { + $this->objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->imageFactory = $this->getMockBuilder(ImageFactory::class) ->disableOriginalConstructor() ->getMock(); @@ -60,14 +74,21 @@ public function setUp() ->getMock(); $this->storeManager = $this->createMock(StoreManagerInterface::class); $this->design = $this->createMock(DesignInterface::class); - $this->model = new Image( - $this->imageFactory, - $this->state, - $this->storeManager, - $this->design, - $this->imageInterfaceFactory, - $this->imageCodes - ); + $this->designLoader = $this->createMock(DesignLoader::class); + + $this->model = $this->objectManager + ->getObject( + Image::class, + [ + 'imageFactory' => $this->imageFactory, + 'state' => $this->state, + 'storeManager' => $this->storeManager, + 'design' => $this->design, + 'imageRenderInfoFactory' => $this->imageInterfaceFactory, + 'imageCodes' => $this->imageCodes, + 'designLoader' => $this->designLoader, + ] + ); } public function testGet() @@ -165,6 +186,7 @@ public function testEmulateImageCreating() $imageMock->expects($this->once()) ->method('setUrl') ->with('url'); + $this->designLoader->expects($this->once())->method('load'); $this->assertEquals( $imageHelperMock, diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AdvancedPricing.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AdvancedPricing.php index 37b0b328a522b..e3da613cb1634 100644 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AdvancedPricing.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AdvancedPricing.php @@ -139,7 +139,8 @@ public function __construct( } /** - * {@inheritdoc} + * @inheritdoc + * * @since 101.0.0 */ public function modifyMeta(array $meta) @@ -158,7 +159,8 @@ public function modifyMeta(array $meta) } /** - * {@inheritdoc} + * @inheritdoc + * * @since 101.0.0 */ public function modifyData(array $data) @@ -381,6 +383,7 @@ private function addAdvancedPriceLink() ); $advancedPricingButton['arguments']['data']['config'] = [ + 'dataScope' => 'advanced_pricing_button', 'displayAsLink' => true, 'formElement' => Container::NAME, 'componentType' => Container::NAME, diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/TierPrice.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/TierPrice.php index 0eddca3322205..1a9b9f205d701 100644 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/TierPrice.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/TierPrice.php @@ -45,7 +45,7 @@ public function __construct( } /** - * {@inheritdoc} + * @inheritdoc * @since 101.1.0 */ public function modifyData(array $data) @@ -54,8 +54,11 @@ public function modifyData(array $data) } /** - * {@inheritdoc} + * Add tier price info to meta array. + * * @since 101.1.0 + * @param array $meta + * @return array */ public function modifyMeta(array $meta) { @@ -150,8 +153,8 @@ private function getUpdatedTierPriceStructure(array $priceMeta) 'dataType' => Price::NAME, 'addbefore' => '%', 'validation' => [ - 'validate-number' => true, - 'less-than-equals-to' => 100 + 'required-entry' => true, + 'validate-positive-percent-decimal' => true, ], 'visible' => $firstOption && $firstOption['value'] == ProductPriceOptionsInterface::VALUE_PERCENT, diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Listing/Collector/Image.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Listing/Collector/Image.php index 216bc16968fcb..34879ab29c185 100644 --- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Listing/Collector/Image.php +++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Listing/Collector/Image.php @@ -17,12 +17,14 @@ use Magento\Framework\View\DesignInterface; use Magento\Store\Model\StoreManager; use Magento\Store\Model\StoreManagerInterface; +use Magento\Framework\View\DesignLoader; /** * Collect enough information about image rendering on front * If you want to add new image, that should render on front you need * to configure this class in di.xml * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class Image implements ProductRenderCollectorInterface { @@ -59,6 +61,11 @@ class Image implements ProductRenderCollectorInterface */ private $imageRenderInfoFactory; + /** + * @var DesignLoader + */ + private $designLoader; + /** * Image constructor. * @param ImageFactory $imageFactory @@ -67,6 +74,7 @@ class Image implements ProductRenderCollectorInterface * @param DesignInterface $design * @param ImageInterfaceFactory $imageRenderInfoFactory * @param array $imageCodes + * @param DesignLoader|null $designLoader */ public function __construct( ImageFactory $imageFactory, @@ -74,7 +82,8 @@ public function __construct( StoreManagerInterface $storeManager, DesignInterface $design, ImageInterfaceFactory $imageRenderInfoFactory, - array $imageCodes = [] + array $imageCodes = [], + DesignLoader $designLoader = null ) { $this->imageFactory = $imageFactory; $this->imageCodes = $imageCodes; @@ -82,6 +91,8 @@ public function __construct( $this->storeManager = $storeManager; $this->design = $design; $this->imageRenderInfoFactory = $imageRenderInfoFactory; + $this->designLoader = $designLoader ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(DesignLoader::class); } /** @@ -124,6 +135,8 @@ public function collect(ProductInterface $product, ProductRenderInterface $produ } /** + * Callback for emulating image creation. + * * Callback in which we emulate initialize default design theme, depends on current store, be settings store id * from render info * @@ -136,7 +149,7 @@ public function collect(ProductInterface $product, ProductRenderInterface $produ public function emulateImageCreating(ProductInterface $product, $imageCode, $storeId, ImageInterface $image) { $this->storeManager->setCurrentStore($storeId); - $this->design->setDefaultDesignTheme(); + $this->designLoader->load(); $imageHelper = $this->imageFactory->create(); $imageHelper->init($product, $imageCode); diff --git a/app/code/Magento/Catalog/ViewModel/Product/Breadcrumbs.php b/app/code/Magento/Catalog/ViewModel/Product/Breadcrumbs.php index e897c330b7e0f..5188391a6d32d 100644 --- a/app/code/Magento/Catalog/ViewModel/Product/Breadcrumbs.php +++ b/app/code/Magento/Catalog/ViewModel/Product/Breadcrumbs.php @@ -107,7 +107,7 @@ public function getJsonConfiguration() return $this->escaper->escapeHtml($this->json->serialize([ 'breadcrumbs' => [ 'categoryUrlSuffix' => $this->escaper->escapeHtml($this->getCategoryUrlSuffix()), - 'userCategoryPathInUrl' => (int)$this->isCategoryUsedInProductUrl(), + 'useCategoryPathInUrl' => (int)$this->isCategoryUsedInProductUrl(), 'product' => $this->getProductName() ] ])); diff --git a/app/code/Magento/Catalog/composer.json b/app/code/Magento/Catalog/composer.json index 4535e527d2dec..127b14e3b9d85 100644 --- a/app/code/Magento/Catalog/composer.json +++ b/app/code/Magento/Catalog/composer.json @@ -34,7 +34,7 @@ "magento/module-catalog-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "102.0.6", + "version": "102.0.7", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Catalog/etc/adminhtml/system.xml b/app/code/Magento/Catalog/etc/adminhtml/system.xml index 7d62d46210ea2..9c99a72c12d1c 100644 --- a/app/code/Magento/Catalog/etc/adminhtml/system.xml +++ b/app/code/Magento/Catalog/etc/adminhtml/system.xml @@ -98,6 +98,11 @@ <comment>E.g. {{media url="path/to/image.jpg"}} {{skin url="path/to/picture.gif"}}. Dynamic directives parsing impacts catalog performance.</comment> <source_model>Magento\Config\Model\Config\Source\Yesno</source_model> </field> + <field id="remember_pagination" translate="label comment" type="select" sortOrder="7" showInDefault="1" showInWebsite="0" showInStore="0" canRestore="1"> + <label>Remember Category Pagination</label> + <comment>Changing may affect SEO and cache storage consumption.</comment> + <source_model>Magento\Config\Model\Config\Source\Yesno</source_model> + </field> </group> <group id="placeholder" translate="label" sortOrder="300" showInDefault="1" showInWebsite="1" showInStore="1"> <label>Product Image Placeholders</label> diff --git a/app/code/Magento/Catalog/etc/config.xml b/app/code/Magento/Catalog/etc/config.xml index b7733a1ab0239..fe1c8e7b87a7a 100644 --- a/app/code/Magento/Catalog/etc/config.xml +++ b/app/code/Magento/Catalog/etc/config.xml @@ -30,6 +30,7 @@ <flat_catalog_category>0</flat_catalog_category> <default_sort_by>position</default_sort_by> <parse_url_directives>1</parse_url_directives> + <remember_pagination>0</remember_pagination> </frontend> <product> <flat> diff --git a/app/code/Magento/Catalog/etc/di.xml b/app/code/Magento/Catalog/etc/di.xml index 5b87c7d6ac030..1d3e9a931b677 100644 --- a/app/code/Magento/Catalog/etc/di.xml +++ b/app/code/Magento/Catalog/etc/di.xml @@ -907,6 +907,14 @@ <type name="Magento\Quote\Model\Quote\Item\ToOrderItem"> <plugin name="copy_quote_files_to_order" type="Magento\Catalog\Model\Plugin\QuoteItemProductOption"/> </type> + <type name="Magento\Catalog\Model\ResourceModel\Category"> + <plugin name="remove_redundant_image" type="Magento\Catalog\Plugin\Model\ResourceModel\Category\RemoveRedundantImagePlugin"/> + </type> + <type name="Magento\Catalog\Plugin\Model\ResourceModel\Category\RemoveRedundantImagePlugin"> + <arguments> + <argument name="imageUploader" xsi:type="object">Magento\Catalog\CategoryImageUpload</argument> + </arguments> + </type> <preference for="Magento\Catalog\Model\ResourceModel\Product\BaseSelectProcessorInterface" type="Magento\Catalog\Model\ResourceModel\Product\CompositeWithWebsiteProcessor" /> <type name="Magento\Catalog\Model\ResourceModel\Product\CompositeBaseSelectProcessor"> <arguments> diff --git a/app/code/Magento/Catalog/etc/frontend/di.xml b/app/code/Magento/Catalog/etc/frontend/di.xml index 2e98c980f5686..95391b656380f 100644 --- a/app/code/Magento/Catalog/etc/frontend/di.xml +++ b/app/code/Magento/Catalog/etc/frontend/di.xml @@ -95,4 +95,7 @@ <plugin name="get_catalog_category_product_index_table_name" type="Magento\Catalog\Model\Indexer\Category\Product\Plugin\TableResolver"/> <plugin name="get_catalog_product_price_index_table_name" type="Magento\Catalog\Model\Indexer\Product\Price\Plugin\TableResolver"/> </type> + <type name="Magento\Framework\App\Action\AbstractAction"> + <plugin name="catalog_app_action_dispatch_controller_context_plugin" type="Magento\Catalog\Plugin\Framework\App\Action\ContextPlugin" /> + </type> </config> diff --git a/app/code/Magento/Catalog/i18n/en_US.csv b/app/code/Magento/Catalog/i18n/en_US.csv index 35a2c224c4ed2..1e0dcfa0232a7 100644 --- a/app/code/Magento/Catalog/i18n/en_US.csv +++ b/app/code/Magento/Catalog/i18n/en_US.csv @@ -798,3 +798,4 @@ Details,Details "Add To Compare","Add To Compare" "Learn more","Learn more" "Recently Viewed","Recently Viewed" +"You added product %1 to the <a href=""%2"">comparison list</a>.","You added product %1 to the <a href=""%2"">comparison list</a>." diff --git a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/checkboxes/tree.phtml b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/checkboxes/tree.phtml index 00a1580923a7b..ee67acd0ebd46 100644 --- a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/checkboxes/tree.phtml +++ b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/checkboxes/tree.phtml @@ -20,7 +20,7 @@ "categoryCheckboxTree": { "dataUrl": "<?= $block->escapeUrl($block->getLoadTreeUrl()) ?>", "divId": "<?= /* @noEscape */ $divId ?>", - "rootVisible": <?= /* @noEscape */ $block->getRoot()->getIsVisible() ? 'true' : 'false' ?>, + "rootVisible": false, "useAjax": <?= $block->escapeHtml($block->getUseAjax()) ?>, "currentNodeId": <?= (int)$block->getCategoryId() ?>, "jsFormObject": "<?= /* @noEscape */ $block->getJsFormObject() ?>", @@ -28,7 +28,7 @@ "checked": "<?= $block->escapeHtml($block->getRoot()->getChecked()) ?>", "allowdDrop": <?= /* @noEscape */ $block->getRoot()->getIsVisible() ? 'true' : 'false' ?>, "rootId": <?= (int)$block->getRoot()->getId() ?>, - "expanded": <?= (int)$block->getIsWasExpanded() ?>, + "expanded": true, "categoryId": <?= (int)$block->getCategoryId() ?>, "treeJson": <?= /* @noEscape */ $block->getTreeJson() ?> } diff --git a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/tree.phtml b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/tree.phtml index 9865589556e7b..ba386f89d6ccd 100644 --- a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/tree.phtml +++ b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/tree.phtml @@ -302,6 +302,7 @@ } <?php endif;?> //updateContent(url); //commented since ajax requests replaced with http ones to load a category + jQuery('#tree-div').find('.x-tree-node-el').first().remove(); } jQuery(function () { diff --git a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/widget/tree.phtml b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/widget/tree.phtml index dbe66ef1aecd3..69737b8a37c1c 100644 --- a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/widget/tree.phtml +++ b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/category/widget/tree.phtml @@ -160,7 +160,7 @@ jQuery(function() loader: categoryLoader, enableDD: false, containerScroll: true, - rootVisible: '<?= /* @escapeNotVerified */ $block->getRoot()->getIsVisible() ?>', + rootVisible: false, useAjax: true, currentNodeId: <?= (int) $block->getCategoryId() ?>, addNodeTo: false @@ -177,7 +177,7 @@ jQuery(function() text: 'Psw', draggable: false, id: <?= (int) $block->getRoot()->getId() ?>, - expanded: <?= (int) $block->getIsWasExpanded() ?>, + expanded: true, category_id: <?= (int) $block->getCategoryId() ?> }; diff --git a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/attribute/js.phtml b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/attribute/js.phtml index 3cbfa0f29d74f..07a801f42a786 100644 --- a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/attribute/js.phtml +++ b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/attribute/js.phtml @@ -40,13 +40,16 @@ function getFrontTab() { function checkOptionsPanelVisibility(){ if($('manage-options-panel')){ - var panel = $('manage-options-panel').up('.fieldset'); + var panel = $('manage-options-panel').up('.fieldset'), + activePanelClass = 'selected-type-options'; if($('frontend_input') && ($('frontend_input').value=='select' || $('frontend_input').value=='multiselect')){ panel.show(); + panel.addClass(activePanelClass); } else { panel.hide(); + panel.removeClass(activePanelClass); } } } diff --git a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/attribute/set/main.phtml b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/attribute/set/main.phtml index fec7e61032ef0..78f74459838ac 100644 --- a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/attribute/set/main.phtml +++ b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/attribute/set/main.phtml @@ -80,7 +80,7 @@ // set the root node this.root = new Ext.tree.TreeNode({ text: 'ROOT', - allowDrug:false, + allowDrag:false, allowDrop:true, id:'1' }); @@ -187,7 +187,17 @@ if( config[i].children ) { for( j in config[i].children ) { if(config[i].children[j].id) { + newNode = new Ext.tree.TreeNode(config[i].children[j]); + + if (typeof newNode.ui.onTextChange === 'function') { + newNode.ui.onTextChange = function (_3, _4, _5) { + if (this.rendered) { + this.textNode.innerText = _4; + } + } + } + } node.appendChild(newNode); newNode.addListener('click', editSet.unregister); } @@ -195,12 +205,19 @@ } } } - } + editSet = function() { return { register : function(node) { editSet.currentNode = node; + if (typeof node.ui.onTextChange === 'function') { + node.ui.onTextChange = function (_3, _4, _5) { + if (this.rendered) { + this.textNode.innerText = _4; + } + } + } }, unregister : function() { @@ -293,6 +310,14 @@ allowDrag : true }); + if (typeof newNode.ui.onTextChange === 'function') { + newNode.ui.onTextChange = function (_3, _4, _5) { + if (this.rendered) { + this.textNode.innerText = _4; + } + } + } + TreePanels.root.appendChild(newNode); newNode.addListener('beforemove', editSet.groupBeforeMove); newNode.addListener('beforeinsert', editSet.groupBeforeInsert); diff --git a/app/code/Magento/Catalog/view/adminhtml/web/catalog/category/edit.js b/app/code/Magento/Catalog/view/adminhtml/web/catalog/category/edit.js index 75ee3019cf4b6..41f7a874c26f3 100644 --- a/app/code/Magento/Catalog/view/adminhtml/web/catalog/category/edit.js +++ b/app/code/Magento/Catalog/view/adminhtml/web/catalog/category/edit.js @@ -82,7 +82,7 @@ define([ return function (config, element) { config = config || {}; jQuery(element).on('click', function () { - categorySubmit(config.url, config.ajax); + categorySubmit(); }); }; }); diff --git a/app/code/Magento/Catalog/view/adminhtml/web/catalog/product/composite/configure.js b/app/code/Magento/Catalog/view/adminhtml/web/catalog/product/composite/configure.js index 6903a17bcdcca..a2804a8723ce0 100644 --- a/app/code/Magento/Catalog/view/adminhtml/web/catalog/product/composite/configure.js +++ b/app/code/Magento/Catalog/view/adminhtml/web/catalog/product/composite/configure.js @@ -469,26 +469,6 @@ define([ } }, - /** - * toggles Selects states (for IE) except those to be shown in popup - */ - /*_toggleSelectsExceptBlock: function(flag) { - if(Prototype.Browser.IE){ - if (this.blockForm) { - var states = new Array; - var selects = this.blockForm.getElementsByTagName("select"); - for(var i=0; i<selects.length; i++){ - states[i] = selects[i].style.visibility - } - } - if (this.blockForm) { - for(i=0; i<selects.length; i++){ - selects[i].style.visibility = states[i] - } - } - } - },*/ - /** * Close configuration window */ diff --git a/app/code/Magento/Catalog/view/adminhtml/web/js/options.js b/app/code/Magento/Catalog/view/adminhtml/web/js/options.js index 6ea005915763c..7adc0dcfdf408 100644 --- a/app/code/Magento/Catalog/view/adminhtml/web/js/options.js +++ b/app/code/Magento/Catalog/view/adminhtml/web/js/options.js @@ -20,7 +20,6 @@ define([ return function (config) { var optionPanel = jQuery('#manage-options-panel'), - optionsValues = [], editForm = jQuery('#edit_form'), attributeOption = { table: $('attribute-options-table'), @@ -145,7 +144,9 @@ define([ return optionDefaultInputType; } - }; + }, + tableBody = jQuery(), + activePanelClass = 'selected-type-options'; if ($('add_new_option_button')) { Event.observe('add_new_option_button', 'click', attributeOption.add.bind(attributeOption, {}, true)); @@ -180,30 +181,32 @@ define([ }); }); } - editForm.on('submit', function () { - optionPanel.find('input') - .each(function () { - if (this.disabled) { - return; + editForm.on('beforeSubmit', function () { + var optionContainer = optionPanel.find('table tbody'), + optionsValues; + + if (optionPanel.hasClass(activePanelClass)) { + optionsValues = jQuery.map( + optionContainer.find('tr'), + function (row) { + return jQuery(row).find('input, select, textarea').serialize(); } - - if (this.type === 'checkbox' || this.type === 'radio') { - if (this.checked) { - optionsValues.push(this.name + '=' + jQuery(this).val()); - } - } else { - optionsValues.push(this.name + '=' + jQuery(this).val()); - } - }); - jQuery('<input>') - .attr({ - type: 'hidden', - name: 'serialized_options' - }) - .val(JSON.stringify(optionsValues)) - .prependTo(editForm); - optionPanel.find('table') - .replaceWith(jQuery('<div>').text(jQuery.mage.__('Sending attribute values as package.'))); + ); + jQuery('<input>') + .attr({ + type: 'hidden', + name: 'serialized_options' + }) + .val(JSON.stringify(optionsValues)) + .prependTo(editForm); + } + tableBody = optionContainer.detach(); + }); + editForm.on('afterValidate.error highlight.validate', function () { + if (optionPanel.hasClass(activePanelClass)) { + optionPanel.find('table').append(tableBody); + jQuery('input[name="serialized_options"]').remove(); + } }); window.attributeOption = attributeOption; window.optionDefaultInputType = attributeOption.getOptionInputType(); diff --git a/app/code/Magento/Catalog/view/base/web/js/price-box.js b/app/code/Magento/Catalog/view/base/web/js/price-box.js index de68d769885fd..783d39cddbc76 100644 --- a/app/code/Magento/Catalog/view/base/web/js/price-box.js +++ b/app/code/Magento/Catalog/view/base/web/js/price-box.js @@ -78,11 +78,7 @@ define([ pricesCode = [], priceValue, origin, finalPrice; - if (typeof newPrices !== 'undefined' && newPrices.hasOwnProperty('prices')) { - this.cache.additionalPriceObject = {}; - } else { - this.cache.additionalPriceObject = this.cache.additionalPriceObject || {}; - } + this.cache.additionalPriceObject = this.cache.additionalPriceObject || {}; if (newPrices) { $.extend(this.cache.additionalPriceObject, newPrices); diff --git a/app/code/Magento/Catalog/view/frontend/web/js/product/list/toolbar.js b/app/code/Magento/Catalog/view/frontend/web/js/product/list/toolbar.js index 88be03a04e71a..b8b6ff65be2b4 100644 --- a/app/code/Magento/Catalog/view/frontend/web/js/product/list/toolbar.js +++ b/app/code/Magento/Catalog/view/frontend/web/js/product/list/toolbar.js @@ -27,7 +27,9 @@ define([ directionDefault: 'asc', orderDefault: 'position', limitDefault: '9', - url: '' + url: '', + formKey: '', + post: false }, /** @inheritdoc */ @@ -89,7 +91,7 @@ define([ baseUrl = urlPaths[0], urlParams = urlPaths[1] ? urlPaths[1].split('&') : [], paramData = {}, - parameters, i; + parameters, i, form, params, key, input, formKey; for (i = 0; i < urlParams.length; i++) { parameters = urlParams[i].split('='); @@ -99,12 +101,38 @@ define([ } paramData[paramName] = paramValue; - if (paramValue == defaultValue) { //eslint-disable-line eqeqeq - delete paramData[paramName]; - } - paramData = $.param(paramData); + if (this.options.post) { + form = document.createElement('form'); + params = [this.options.mode, this.options.direction, this.options.order, this.options.limit]; + + for (key in paramData) { + if (params.indexOf(key) !== -1) { //eslint-disable-line max-depth + input = document.createElement('input'); + input.name = key; + input.value = paramData[key]; + form.appendChild(input); + delete paramData[key]; + } + } + formKey = document.createElement('input'); + formKey.name = 'form_key'; + formKey.value = this.options.formKey; + form.appendChild(formKey); + + paramData = $.param(paramData); + baseUrl += paramData.length ? '?' + paramData : ''; - location.href = baseUrl + (paramData.length ? '?' + paramData : ''); + form.action = baseUrl; + form.method = 'POST'; + document.body.appendChild(form); + form.submit(); + } else { + if (paramValue == defaultValue) { //eslint-disable-line eqeqeq + delete paramData[paramName]; + } + paramData = $.param(paramData); + location.href = baseUrl + (paramData.length ? '?' + paramData : ''); + } } }); diff --git a/app/code/Magento/CatalogAnalytics/composer.json b/app/code/Magento/CatalogAnalytics/composer.json index ead59ef212600..c2f5a050d0a9b 100644 --- a/app/code/Magento/CatalogAnalytics/composer.json +++ b/app/code/Magento/CatalogAnalytics/composer.json @@ -7,7 +7,7 @@ "magento/module-catalog": "102.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogImportExport/Model/Export/Product.php b/app/code/Magento/CatalogImportExport/Model/Export/Product.php index bf46c0efb2a74..dc172bacb32f9 100644 --- a/app/code/Magento/CatalogImportExport/Model/Export/Product.php +++ b/app/code/Magento/CatalogImportExport/Model/Export/Product.php @@ -5,6 +5,7 @@ */ namespace Magento\CatalogImportExport\Model\Export; +use Magento\Catalog\Model\ResourceModel\Product\Option\Collection; use Magento\ImportExport\Model\Import; use \Magento\Store\Model\Store; use \Magento\CatalogImportExport\Model\Import\Product as ImportProduct; @@ -202,7 +203,7 @@ class Product extends \Magento\ImportExport\Model\Export\Entity\AbstractEntity protected $_itemFactory; /** - * @var \Magento\Catalog\Model\ResourceModel\Product\Option\Collection + * @var Collection */ protected $_optionColFactory; @@ -1161,6 +1162,10 @@ protected function isValidAttributeValue($code, $value) $isValid = false; } + if (is_array($value)) { + $isValid = false; + } + return $isValid; } @@ -1273,11 +1278,23 @@ private function appendMultirowData(&$dataRow, $multiRawData) } if (!empty($multiRawData['customOptionsData'][$productLinkId][$storeId])) { + $shouldBeMerged = true; $customOptionsRows = $multiRawData['customOptionsData'][$productLinkId][$storeId]; - $multiRawData['customOptionsData'][$productLinkId][$storeId] = []; - $customOptions = implode(ImportProduct::PSEUDO_MULTI_LINE_SEPARATOR, $customOptionsRows); - $dataRow = array_merge($dataRow, ['custom_options' => $customOptions]); + if ($storeId != Store::DEFAULT_STORE_ID + && !empty($multiRawData['customOptionsData'][$productLinkId][Store::DEFAULT_STORE_ID]) + ) { + $defaultCustomOptions = $multiRawData['customOptionsData'][$productLinkId][Store::DEFAULT_STORE_ID]; + if (!array_diff($defaultCustomOptions, $customOptionsRows)) { + $shouldBeMerged = false; + } + } + + if ($shouldBeMerged) { + $multiRawData['customOptionsData'][$productLinkId][$storeId] = []; + $customOptions = implode(ImportProduct::PSEUDO_MULTI_LINE_SEPARATOR, $customOptionsRows); + $dataRow = array_merge($dataRow, ['custom_options' => $customOptions]); + } } if (empty($dataRow)) { @@ -1369,56 +1386,55 @@ protected function optionRowToCellString($option) protected function getCustomOptionsData($productIds) { $customOptionsData = []; + $defaultOptionsData = []; foreach (array_keys($this->_storeIdToCode) as $storeId) { $options = $this->_optionColFactory->create(); - /* @var \Magento\Catalog\Model\ResourceModel\Product\Option\Collection $options*/ - $options->reset()->addOrder( - 'sort_order', - \Magento\Catalog\Model\ResourceModel\Product\Option\Collection::SORT_ORDER_ASC - )->addTitleToResult( - $storeId - )->addPriceToResult( - $storeId - )->addProductToFilter( - $productIds - )->addValuesToResult( - $storeId - ); + /* @var Collection $options*/ + $options->reset() + ->addOrder('sort_order', Collection::SORT_ORDER_ASC) + ->addTitleToResult($storeId) + ->addPriceToResult($storeId) + ->addProductToFilter($productIds) + ->addValuesToResult($storeId); foreach ($options as $option) { + $optionData = $option->toArray(); $row = []; $productId = $option['product_id']; $row['name'] = $option['title']; $row['type'] = $option['type']; - if (Store::DEFAULT_STORE_ID === $storeId) { - $row['required'] = $option['is_require']; - $row['price'] = $option['price']; - $row['price_type'] = ($option['price_type'] === 'percent') ? 'percent' : 'fixed'; - $row['sku'] = $option['sku']; - if ($option['max_characters']) { - $row['max_characters'] = $option['max_characters']; - } - - foreach (['file_extension', 'image_size_x', 'image_size_y'] as $fileOptionKey) { - if (!isset($option[$fileOptionKey])) { - continue; - } - $row[$fileOptionKey] = $option[$fileOptionKey]; + $row['required'] = $this->getOptionValue('is_require', $defaultOptionsData, $optionData); + $row['price'] = $this->getOptionValue('price', $defaultOptionsData, $optionData); + $row['sku'] = $this->getOptionValue('sku', $defaultOptionsData, $optionData); + if (array_key_exists('max_characters', $optionData) + || array_key_exists('max_characters', $defaultOptionsData) + ) { + $row['max_characters'] = $this->getOptionValue('max_characters', $defaultOptionsData, $optionData); + } + foreach (['file_extension', 'image_size_x', 'image_size_y'] as $fileOptionKey) { + if (isset($option[$fileOptionKey]) || isset($defaultOptionsData[$fileOptionKey])) { + $row[$fileOptionKey] = $this->getOptionValue($fileOptionKey, $defaultOptionsData, $optionData); } } + $percentType = $this->getOptionValue('price_type', $defaultOptionsData, $optionData); + $row['price_type'] = ($percentType === 'percent') ? 'percent' : 'fixed'; + + if (Store::DEFAULT_STORE_ID === $storeId) { + $optionId = $option['option_id']; + $defaultOptionsData[$optionId] = $option->toArray(); + } + $values = $option->getValues(); if ($values) { foreach ($values as $value) { $row['option_title'] = $value['title']; - if (Store::DEFAULT_STORE_ID === $storeId) { - $row['option_title'] = $value['title']; - $row['price'] = $value['price']; - $row['price_type'] = ($value['price_type'] === 'percent') ? 'percent' : 'fixed'; - $row['sku'] = $value['sku']; - } + $row['option_title'] = $value['title']; + $row['price'] = $value['price']; + $row['price_type'] = ($value['price_type'] === 'percent') ? 'percent' : 'fixed'; + $row['sku'] = $value['sku']; $customOptionsData[$productId][$storeId][] = $this->optionRowToCellString($row); } } else { @@ -1432,6 +1448,29 @@ protected function getCustomOptionsData($productIds) return $customOptionsData; } + /** + * Get value for custom option according to store or default value + * + * @param string $optionName + * @param array $defaultOptionsData + * @param array $optionData + * @return mixed + */ + private function getOptionValue(string $optionName, array $defaultOptionsData, array $optionData) + { + $optionId = $optionData['option_id']; + + if (isset($optionData[$optionName])) { + return $optionData[$optionName]; + } + + if (isset($defaultOptionsData[$optionId][$optionName])) { + return $defaultOptionsData[$optionId][$optionName]; + } + + return null; + } + /** * Clean up already loaded attribute collection. * diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product.php b/app/code/Magento/CatalogImportExport/Model/Import/Product.php index 84fa13e6599d9..3578123e94dc1 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product.php @@ -1372,7 +1372,7 @@ protected function _saveProductCategories(array $categoriesData) $delProductId[] = $productId; foreach (array_keys($categories) as $categoryId) { - $categoriesIn[] = ['product_id' => $productId, 'category_id' => $categoryId, 'position' => 1]; + $categoriesIn[] = ['product_id' => $productId, 'category_id' => $categoryId, 'position' => 0]; } } if (Import::BEHAVIOR_APPEND != $this->getBehavior()) { @@ -1579,8 +1579,12 @@ protected function _saveProducts() continue; } if ($this->getErrorAggregator()->hasToBeTerminated()) { - $this->getErrorAggregator()->addRowToSkip($rowNum); - continue; + if (ProcessingErrorAggregatorInterface::VALIDATION_STRATEGY_SKIP_ERRORS + !== $this->_parameters[Import::FIELD_NAME_VALIDATION_STRATEGY] + ) { + $this->getErrorAggregator()->addRowToSkip($rowNum); + continue; + } } $rowScope = $this->getRowScope($rowData); @@ -1735,6 +1739,7 @@ protected function _saveProducts() if ($uploadedFile) { $uploadedImages[$columnImage] = $uploadedFile; } else { + unset($rowData[$column]); $this->addRowError( ValidatorInterface::ERROR_MEDIA_URL_NOT_ACCESSIBLE, $rowNum, @@ -2992,9 +2997,7 @@ private function formatStockDataForRow(array $rowData) ) ) { $stockItemDo->setData($row); - $row['is_in_stock'] = $stockItemDo->getBackorders() && isset($row['is_in_stock']) - ? $row['is_in_stock'] - : $this->stockStateProvider->verifyStock($stockItemDo); + $row['is_in_stock'] = $row['is_in_stock'] ?? $this->stockStateProvider->verifyStock($stockItemDo); if ($this->stockStateProvider->verifyNotification($stockItemDo)) { $row['low_stock_date'] = $this->dateTime->gmDate( 'Y-m-d H:i:s', diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php b/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php index 99fe2e58c2405..fd6212bb636a5 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractType.php @@ -195,6 +195,8 @@ public function __construct( } /** + * Initialize template for error message. + * * @param array $templateCollection * @return $this */ @@ -377,6 +379,8 @@ public function retrieveAttributeFromCache($attributeCode) } /** + * Adding attribute option. + * * In case we've dynamically added new attribute option during import we need to add it to our cache * in order to keep it up to date. * @@ -508,8 +512,10 @@ public function isSuitable() } /** - * Prepare attributes values for save: exclude non-existent, static or with empty values attributes; - * set default values if needed + * Adding default attribute to product before save. + * + * Prepare attributes values for save: exclude non-existent, static or with empty values attributes, + * set default values if needed. * * @param array $rowData * @param bool $withDefaultValue @@ -537,9 +543,9 @@ public function prepareAttributesWithDefaultValueForSave(array $rowData, $withDe } else { $resultAttrs[$attrCode] = $rowData[$attrCode]; } - } elseif (array_key_exists($attrCode, $rowData)) { + } elseif (array_key_exists($attrCode, $rowData) && empty($rowData['_store'])) { $resultAttrs[$attrCode] = $rowData[$attrCode]; - } elseif ($withDefaultValue && null !== $attrParams['default_value']) { + } elseif ($withDefaultValue && null !== $attrParams['default_value'] && empty($rowData['_store'])) { $resultAttrs[$attrCode] = $attrParams['default_value']; } } @@ -605,7 +611,8 @@ protected function getProductEntityLinkField() } /** - * Clean cached values + * Clean cached values. + * * @since 100.2.0 */ public function __destruct() diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Uploader.php b/app/code/Magento/CatalogImportExport/Model/Import/Uploader.php index 2ac9f2e5a4992..081934dfdfb14 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Uploader.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Uploader.php @@ -108,7 +108,7 @@ class Uploader extends \Magento\MediaStorage\Model\File\Uploader * @param \Magento\MediaStorage\Model\File\Validator\NotProtectedExtension $validator * @param \Magento\Framework\Filesystem $filesystem * @param \Magento\Framework\Filesystem\File\ReadFactory $readFactory - * @param null $filePath + * @param string|null $filePath * @param \Magento\Framework\App\Filesystem\DirectoryResolver|null $directoryResolver * @throws \Magento\Framework\Exception\LocalizedException */ @@ -157,29 +157,48 @@ public function init() * @param string $fileName * @param bool $renameFileOff * @return array + * @throws \Magento\Framework\Exception\LocalizedException */ public function move($fileName, $renameFileOff = false) { if ($renameFileOff) { $this->setAllowRenameFiles(false); } + + if ($this->getTmpDir()) { + $filePath = $this->getTmpDir() . '/'; + } else { + $filePath = ''; + } + if (preg_match('/\bhttps?:\/\//i', $fileName, $matches)) { $url = str_replace($matches[0], '', $fileName); + $driver = $matches[0] === $this->httpScheme ? DriverPool::HTTP : DriverPool::HTTPS; + $read = $this->_readFactory->create($url, $driver); + + //only use filename (for URI with query parameters) + $parsedUrlPath = parse_url($url, PHP_URL_PATH); + if ($parsedUrlPath) { + $urlPathValues = explode('/', $parsedUrlPath); + if (!empty($urlPathValues)) { + $fileName = end($urlPathValues); + } + } - if ($matches[0] === $this->httpScheme) { - $read = $this->_readFactory->create($url, DriverPool::HTTP); - } else { - $read = $this->_readFactory->create($url, DriverPool::HTTPS); + $fileExtension = pathinfo($fileName, PATHINFO_EXTENSION); + if ($fileExtension && !$this->checkAllowedExtension($fileExtension)) { + throw new \Magento\Framework\Exception\LocalizedException(__('Disallowed file type.')); } $fileName = preg_replace('/[^a-z0-9\._-]+/i', '', $fileName); + $relativePath = $this->_directory->getRelativePath($filePath . $fileName); $this->_directory->writeFile( - $this->_directory->getRelativePath($this->getTmpDir() . '/' . $fileName), + $relativePath, $read->readAll() ); } - $filePath = $this->_directory->getRelativePath($this->getTmpDir() . '/' . $fileName); + $filePath = $this->_directory->getRelativePath($filePath . $fileName); $this->_setUploadFile($filePath); $destDir = $this->_directory->getAbsolutePath($this->getDestDir()); $result = $this->save($destDir); diff --git a/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/UploaderTest.php b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/UploaderTest.php index eceea9947811c..aa21f6a392b47 100644 --- a/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/UploaderTest.php +++ b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/UploaderTest.php @@ -56,6 +56,9 @@ class UploaderTest extends \PHPUnit\Framework\TestCase */ protected $uploader; + /** + * @inheritdoc + */ protected function setUp() { $this->coreFileStorageDb = $this->getMockBuilder(\Magento\MediaStorage\Helper\File\Storage\Database::class) @@ -79,7 +82,7 @@ protected function setUp() ->setMethods(['create']) ->getMock(); - $this->directoryMock = $this->getMockBuilder(\Magento\Framework\Filesystem\Directory\Writer::class) + $this->directoryMock = $this->getMockBuilder(\Magento\Framework\Filesystem\Directory\Write::class) ->setMethods(['writeFile', 'getRelativePath', 'isWritable', 'isReadable', 'getAbsolutePath']) ->disableOriginalConstructor() ->getMock(); @@ -109,23 +112,32 @@ protected function setUp() null, $this->directoryResolver ]) - ->setMethods(['_setUploadFile', 'save', 'getTmpDir']) + ->setMethods(['_setUploadFile', 'save', 'getTmpDir', 'checkAllowedExtension']) ->getMock(); } /** * @dataProvider moveFileUrlDataProvider + * @param string $fileUrl + * @param string $expectedHost + * @param string $expectedFileName + * @param int $checkAllowedExtension + * @return void */ - public function testMoveFileUrl($fileUrl, $expectedHost, $expectedFileName) - { + public function testMoveFileUrl( + string $fileUrl, + string $expectedHost, + string $expectedFileName, + int $checkAllowedExtension + ) { $destDir = 'var/dest/dir'; - $expectedRelativeFilePath = $this->uploader->getTmpDir() . '/' . $expectedFileName; + $expectedRelativeFilePath = $expectedFileName; $this->directoryMock->expects($this->once())->method('isWritable')->with($destDir)->willReturn(true); $this->directoryMock->expects($this->any())->method('getRelativePath')->with($expectedRelativeFilePath); $this->directoryMock->expects($this->once())->method('getAbsolutePath')->with($destDir) ->willReturn($destDir . '/' . $expectedFileName); // Check writeFile() method invoking. - $this->directoryMock->expects($this->any())->method('writeFile')->will($this->returnValue($expectedFileName)); + $this->directoryMock->expects($this->any())->method('writeFile')->willReturn($expectedFileName); // Create adjusted reader which does not validate path. $readMock = $this->getMockBuilder(\Magento\Framework\Filesystem\File\Read::class) @@ -133,17 +145,20 @@ public function testMoveFileUrl($fileUrl, $expectedHost, $expectedFileName) ->setMethods(['readAll']) ->getMock(); // Check readAll() method invoking. - $readMock->expects($this->once())->method('readAll')->will($this->returnValue(null)); + $readMock->expects($this->once())->method('readAll')->willReturn(null); // Check create() method invoking with expected argument. $this->readFactory->expects($this->once()) ->method('create') ->will($this->returnValue($readMock))->with($expectedHost); //Check invoking of getTmpDir(), _setUploadFile(), save() methods. - $this->uploader->expects($this->any())->method('getTmpDir')->will($this->returnValue('')); - $this->uploader->expects($this->once())->method('_setUploadFile')->will($this->returnSelf()); + $this->uploader->expects($this->any())->method('getTmpDir')->willReturn(''); + $this->uploader->expects($this->once())->method('_setUploadFile')->willReturnSelf(); $this->uploader->expects($this->once())->method('save')->with($destDir . '/' . $expectedFileName) ->willReturn(['name' => $expectedFileName, 'path' => 'absPath']); + $this->uploader->expects($this->exactly($checkAllowedExtension)) + ->method('checkAllowedExtension') + ->willReturn(true); $this->uploader->setDestDir($destDir); $result = $this->uploader->move($fileUrl); @@ -155,14 +170,14 @@ public function testMoveFileName() { $destDir = 'var/dest/dir'; $fileName = 'test_uploader_file'; - $expectedRelativeFilePath = $this->uploader->getTmpDir() . '/' . $fileName; + $expectedRelativeFilePath = $fileName; $this->directoryMock->expects($this->once())->method('isWritable')->with($destDir)->willReturn(true); $this->directoryMock->expects($this->any())->method('getRelativePath')->with($expectedRelativeFilePath); $this->directoryMock->expects($this->once())->method('getAbsolutePath')->with($destDir) ->willReturn($destDir . '/' . $fileName); //Check invoking of getTmpDir(), _setUploadFile(), save() methods. - $this->uploader->expects($this->once())->method('getTmpDir')->will($this->returnValue('')); - $this->uploader->expects($this->once())->method('_setUploadFile')->will($this->returnSelf()); + $this->uploader->expects($this->once())->method('getTmpDir')->willReturn(''); + $this->uploader->expects($this->once())->method('_setUploadFile')->willReturnSelf(); $this->uploader->expects($this->once())->method('save')->with($destDir . '/' . $fileName) ->willReturn(['name' => $fileName]); @@ -239,12 +254,38 @@ public function moveFileUrlDataProvider() [ '$fileUrl' => 'http://test_uploader_file', '$expectedHost' => 'test_uploader_file', - '$expectedFileName' => 'httptest_uploader_file', + '$expectedFileName' => 'test_uploader_file', + '$checkAllowedExtension' => 0, ], [ '$fileUrl' => 'https://!:^&`;file', '$expectedHost' => '!:^&`;file', - '$expectedFileName' => 'httpsfile', + '$expectedFileName' => 'file', + '$checkAllowedExtension' => 0, + ], + [ + '$fileUrl' => 'https://www.google.com/image.jpg', + '$expectedHost' => 'www.google.com/image.jpg', + '$expectedFileName' => 'image.jpg', + '$checkAllowedExtension' => 1, + ], + [ + '$fileUrl' => 'https://www.google.com/image.jpg?param=1', + '$expectedHost' => 'www.google.com/image.jpg?param=1', + '$expectedFileName' => 'image.jpg', + '$checkAllowedExtension' => 1, + ], + [ + '$fileUrl' => 'https://www.google.com/image.jpg?param=1¶m=2', + '$expectedHost' => 'www.google.com/image.jpg?param=1¶m=2', + '$expectedFileName' => 'image.jpg', + '$checkAllowedExtension' => 1, + ], + [ + '$fileUrl' => 'http://www.google.com/image.jpg?param=1¶m=2', + '$expectedHost' => 'www.google.com/image.jpg?param=1¶m=2', + '$expectedFileName' => 'image.jpg', + '$checkAllowedExtension' => 1, ], ]; } @@ -253,8 +294,9 @@ public function moveFileUrlDataProvider() * @dataProvider validatePathDataProvider * * @param bool $pathIsValid + * @return void */ - public function testSetTmpDir($pathIsValid) + public function testSetTmpDir(bool $pathIsValid) { $path = 'path'; $absolutePath = 'absolute_path'; @@ -272,11 +314,11 @@ public function testSetTmpDir($pathIsValid) * * @return array */ - public function validatePathDataProvider() + public function validatePathDataProvider(): array { return [ [true], - [false] + [false], ]; } } diff --git a/app/code/Magento/CatalogImportExport/composer.json b/app/code/Magento/CatalogImportExport/composer.json index 39b05acc4e3b6..60115e63403cf 100644 --- a/app/code/Magento/CatalogImportExport/composer.json +++ b/app/code/Magento/CatalogImportExport/composer.json @@ -16,7 +16,7 @@ "ext-ctype": "*" }, "type": "magento2-module", - "version": "100.2.5", + "version": "100.2.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogInventory/Block/Stockqty/AbstractStockqty.php b/app/code/Magento/CatalogInventory/Block/Stockqty/AbstractStockqty.php index 568fa600ec52d..6614b418da920 100644 --- a/app/code/Magento/CatalogInventory/Block/Stockqty/AbstractStockqty.php +++ b/app/code/Magento/CatalogInventory/Block/Stockqty/AbstractStockqty.php @@ -131,7 +131,8 @@ public function getPlaceholderId() */ public function isMsgVisible() { - return $this->getStockQty() > 0 && $this->getStockQtyLeft() <= $this->getThresholdQty(); + return $this->getStockQty() > 0 && $this->getStockQtyLeft() > 0 + && $this->getStockQtyLeft() <= $this->getThresholdQty(); } /** diff --git a/app/code/Magento/CatalogInventory/Model/Stock/StockItemRepository.php b/app/code/Magento/CatalogInventory/Model/Stock/StockItemRepository.php index 0d0d42009315e..ee613ac1db018 100644 --- a/app/code/Magento/CatalogInventory/Model/Stock/StockItemRepository.php +++ b/app/code/Magento/CatalogInventory/Model/Stock/StockItemRepository.php @@ -161,7 +161,10 @@ public function save(StockItemInterface $stockItem) $typeId = $product->getTypeId() ?: $product->getTypeInstance()->getTypeId(); $isQty = $this->stockConfiguration->isQty($typeId); if ($isQty) { - $this->changeIsInStockIfNecessary($stockItem); + $isInStock = $this->stockStateProvider->verifyStock($stockItem); + if ($stockItem->getManageStock() && !$isInStock) { + $stockItem->setIsInStock(false)->setStockStatusChangedAutomaticallyFlag(true); + } // if qty is below notify qty, update the low stock date to today date otherwise set null $stockItem->setLowStockDate(null); if ($this->stockStateProvider->verifyNotification($stockItem)) { @@ -257,29 +260,4 @@ private function getStockRegistryStorage() } return $this->stockRegistryStorage; } - - /** - * Change is_in_stock value if necessary. - * - * @param StockItemInterface $stockItem - * - * @return void - */ - private function changeIsInStockIfNecessary(StockItemInterface $stockItem) - { - $isInStock = $this->stockStateProvider->verifyStock($stockItem); - if ($stockItem->getManageStock() && !$isInStock) { - $stockItem->setIsInStock(false)->setStockStatusChangedAutomaticallyFlag(true); - } - - if ($stockItem->getManageStock() - && $isInStock - && !$stockItem->getIsInStock() - && $stockItem->getQty() > 0 - && $stockItem->getOrigData(\Magento\CatalogInventory\Api\Data\StockItemInterface::QTY) <= 0 - && $stockItem->getOrigData(\Magento\CatalogInventory\Api\Data\StockItemInterface::QTY) !== null - ) { - $stockItem->setIsInStock(true)->setStockStatusChangedAutomaticallyFlag(true); - } - } } diff --git a/app/code/Magento/CatalogInventory/Model/StockManagement.php b/app/code/Magento/CatalogInventory/Model/StockManagement.php index 107645a45a390..ed8fcef5dea03 100644 --- a/app/code/Magento/CatalogInventory/Model/StockManagement.php +++ b/app/code/Magento/CatalogInventory/Model/StockManagement.php @@ -83,6 +83,7 @@ public function __construct( /** * Subtract product qtys from stock. + * * Return array of items that require full save. * * @param string[] $items @@ -139,17 +140,25 @@ public function registerProductsSale($items, $websiteId = null) } /** - * @param string[] $items - * @param int $websiteId - * @return bool + * @inheritdoc */ public function revertProductsSale($items, $websiteId = null) { //if (!$websiteId) { $websiteId = $this->stockConfiguration->getDefaultScopeId(); //} - $this->qtyCounter->correctItemsQty($items, $websiteId, '+'); - return true; + $revertItems = []; + foreach ($items as $productId => $qty) { + $stockItem = $this->stockRegistryProvider->getStockItem($productId, $websiteId); + $canSubtractQty = $stockItem->getItemId() && $this->canSubtractQty($stockItem); + if (!$canSubtractQty || !$this->stockConfiguration->isQty($stockItem->getTypeId())) { + continue; + } + $revertItems[$productId] = $qty; + } + $this->qtyCounter->correctItemsQty($revertItems, $websiteId, '+'); + + return $revertItems; } /** @@ -193,6 +202,8 @@ protected function getProductType($productId) } /** + * Get stock resource. + * * @return ResourceStock */ protected function getResource() diff --git a/app/code/Magento/CatalogInventory/Model/StockRegistry.php b/app/code/Magento/CatalogInventory/Model/StockRegistry.php index d688132fdb916..30b08ee4b8e7f 100644 --- a/app/code/Magento/CatalogInventory/Model/StockRegistry.php +++ b/app/code/Magento/CatalogInventory/Model/StockRegistry.php @@ -171,6 +171,16 @@ public function updateStockItemBySku($productSku, \Magento\CatalogInventory\Api\ $productId = $this->resolveProductId($productSku); $websiteId = $stockItem->getWebsiteId() ?: null; $origStockItem = $this->getStockItem($productId, $websiteId); + + if ($stockItem->getManageStock() + && !$stockItem->getIsInStock() + && $stockItem->getQty() > 0 + && $stockItem->getOrigData(\Magento\CatalogInventory\Api\Data\StockItemInterface::QTY) <= 0 + && $stockItem->getOrigData(\Magento\CatalogInventory\Api\Data\StockItemInterface::QTY) !== null + ) { + $stockItem->setIsInStock(true)->setStockStatusChangedAutomaticallyFlag(true); + } + $data = $stockItem->getData(); if ($origStockItem->getItemId()) { unset($data['item_id']); diff --git a/app/code/Magento/CatalogInventory/Observer/CancelOrderItemObserver.php b/app/code/Magento/CatalogInventory/Observer/CancelOrderItemObserver.php index 1e99794d68a40..098e254d785a5 100644 --- a/app/code/Magento/CatalogInventory/Observer/CancelOrderItemObserver.php +++ b/app/code/Magento/CatalogInventory/Observer/CancelOrderItemObserver.php @@ -6,15 +6,21 @@ namespace Magento\CatalogInventory\Observer; -use Magento\Framework\Event\ObserverInterface; use Magento\CatalogInventory\Api\StockManagementInterface; +use Magento\CatalogInventory\Model\Configuration; use Magento\Framework\Event\Observer as EventObserver; +use Magento\Framework\Event\ObserverInterface; /** * Catalog inventory module observer */ class CancelOrderItemObserver implements ObserverInterface { + /** + * @var \Magento\CatalogInventory\Model\Configuration + */ + protected $configuration; + /** * @var StockManagementInterface */ @@ -26,13 +32,16 @@ class CancelOrderItemObserver implements ObserverInterface protected $priceIndexer; /** + * @param Configuration $configuration * @param StockManagementInterface $stockManagement * @param \Magento\Catalog\Model\Indexer\Product\Price\Processor $priceIndexer */ public function __construct( + Configuration $configuration, StockManagementInterface $stockManagement, \Magento\Catalog\Model\Indexer\Product\Price\Processor $priceIndexer ) { + $this->configuration = $configuration; $this->stockManagement = $stockManagement; $this->priceIndexer = $priceIndexer; } @@ -49,7 +58,8 @@ public function execute(EventObserver $observer) $item = $observer->getEvent()->getItem(); $children = $item->getChildrenItems(); $qty = $item->getQtyOrdered() - max($item->getQtyShipped(), $item->getQtyInvoiced()) - $item->getQtyCanceled(); - if ($item->getId() && $item->getProductId() && empty($children) && $qty) { + if ($item->getId() && $item->getProductId() && empty($children) && $qty && $this->configuration + ->getCanBackInStock()) { $this->stockManagement->backItemQty($item->getProductId(), $qty, $item->getStore()->getWebsiteId()); } $this->priceIndexer->reindexRow($item->getProductId()); diff --git a/app/code/Magento/CatalogInventory/Observer/RevertQuoteInventoryObserver.php b/app/code/Magento/CatalogInventory/Observer/RevertQuoteInventoryObserver.php index 93a50cc9a7a4d..ab21f32b3f62c 100644 --- a/app/code/Magento/CatalogInventory/Observer/RevertQuoteInventoryObserver.php +++ b/app/code/Magento/CatalogInventory/Observer/RevertQuoteInventoryObserver.php @@ -64,8 +64,8 @@ public function execute(EventObserver $observer) { $quote = $observer->getEvent()->getQuote(); $items = $this->productQty->getProductQty($quote->getAllItems()); - $this->stockManagement->revertProductsSale($items, $quote->getStore()->getWebsiteId()); - $productIds = array_keys($items); + $revertedItems = $this->stockManagement->revertProductsSale($items, $quote->getStore()->getWebsiteId()); + $productIds = array_keys($revertedItems); if (!empty($productIds)) { $this->stockIndexerProcessor->reindexList($productIds); $this->priceIndexer->reindexList($productIds); diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Data/ProductStockOptionsData.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Data/ProductStockOptionsData.xml new file mode 100644 index 0000000000000..4ff43f4177401 --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Mftf/Data/ProductStockOptionsData.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <!-- Change Maximum Qty Allowed in Shopping Cart config --> + <entity name="ProductStockOptions" type="catalog_inventory_product_stock_options"> + <requiredEntity type="max_qty_to_cart">MaxQtyAllowInCartChange</requiredEntity> + </entity> + <entity name="MaxQtyAllowInCartChange" type="max_qty_to_cart"> + <data key="value">0</data> + </entity> + <!-- Maximum Qty Allowed in Shopping Cart to default config --> + <entity name="DefaultProductStockOptions" type="catalog_inventory_product_stock_options"> + <requiredEntity type="max_qty_to_cart">MaxQtyAllowInCartDefault</requiredEntity> + </entity> + <entity name="MaxQtyAllowInCartDefault" type="max_qty_to_cart"> + <data key="value">10000</data> + </entity> +</entities> diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Metadata/product_stock_options-meta.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Metadata/product_stock_options-meta.xml new file mode 100644 index 0000000000000..71b1ebd9806ca --- /dev/null +++ b/app/code/Magento/CatalogInventory/Test/Mftf/Metadata/product_stock_options-meta.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<operations xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataOperation.xsd"> + <operation name="CatalogInventoryProductStockSetup" dataType="catalog_inventory_product_stock_options" type="create" auth="adminFormKey" url="/admin/system_config/save/section/cataloginventory/" successRegex="/messages-message-success/" method="POST"> + <object key="groups" dataType="catalog_inventory_product_stock_options"> + <object key="item_options" dataType="catalog_inventory_product_stock_options"> + <object key="fields" dataType="catalog_inventory_product_stock_options"> + <object key="max_sale_qty" dataType="max_qty_to_cart"> + <field key="value">string</field> + </object> + </object> + </object> + </object> + </operation> +</operations> diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/StockItemRepositoryTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/StockItemRepositoryTest.php index 6b1770ff7d403..293874bb32b9f 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/StockItemRepositoryTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/Stock/StockItemRepositoryTest.php @@ -276,7 +276,7 @@ public function testSave() ->method('verifyStock') ->with($this->stockItemMock) ->willReturn(false); - $this->stockItemMock->expects($this->exactly(2))->method('getManageStock')->willReturn(true); + $this->stockItemMock->expects($this->once())->method('getManageStock')->willReturn(true); $this->stockItemMock->expects($this->once())->method('setIsInStock')->with(false)->willReturnSelf(); $this->stockItemMock->expects($this->once()) ->method('setStockStatusChangedAutomaticallyFlag') diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/StockRegistryTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/StockRegistryTest.php index c04bd1e9e4402..03e60a9d39d5e 100644 --- a/app/code/Magento/CatalogInventory/Test/Unit/Model/StockRegistryTest.php +++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/StockRegistryTest.php @@ -5,6 +5,8 @@ */ namespace Magento\CatalogInventory\Test\Unit\Model; +use PHPUnit_Framework_MockObject_MockObject as MockObject; + /** * Class StockRegistryTest */ @@ -16,37 +18,188 @@ class StockRegistryTest extends \PHPUnit\Framework\TestCase protected $model; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var \Magento\CatalogInventory\Api\StockItemCriteriaInterface|MockObject */ protected $criteria; + /** + * @var \Magento\CatalogInventory\Api\StockItemCriteriaInterfaceFactory|MockObject + */ + private $criteriaFactory; + + /** + * @var \Magento\CatalogInventory\Api\Data\StockItemInterface|MockObject + */ + private $stockItemMock; + + /** + * @var \Magento\CatalogInventory\Api\StockConfigurationInterface|MockObject + */ + private $stockConfigurationMock; + + /** + * @var \Magento\CatalogInventory\Api\StockItemRepositoryInterface|MockObject + */ + private $stockItemRepositoryMock; + + /** + * @var \Magento\CatalogInventory\Model\Spi\StockRegistryProviderInterface|MockObject + */ + private $stockRegistryProviderMock; + + /** + * @var \Magento\Catalog\Model\ProductFactory|MockObject + */ + private $productFactoryMock; + protected function setUp() { $this->criteria = $this->getMockBuilder(\Magento\CatalogInventory\Api\StockItemCriteriaInterface::class) ->disableOriginalConstructor() ->getMock(); - $criteriaFactory = $this->getMockBuilder(\Magento\CatalogInventory\Api\StockItemCriteriaInterfaceFactory::class) - ->setMethods(['create']) + $this->criteriaFactory = $this->getMockBuilder( + \Magento\CatalogInventory\Api\StockItemCriteriaInterfaceFactory::class + )->setMethods(['create']) ->disableOriginalConstructor() ->getMock(); - $criteriaFactory->expects($this->once())->method('create')->willReturn($this->criteria); + + $this->stockItemMock = $this->getMockBuilder(\Magento\CatalogInventory\Api\Data\StockItemInterface::class) + ->disableOriginalConstructor() + ->setMethods([ + 'getWebsiteId', + 'getData', + 'addData', + 'getManageStock', + 'getIsInStock', + 'getQty', + 'getOrigData', + 'setIsInStock', + 'setStockStatusChangedAutomaticallyFlag', + ]) + ->getMockForAbstractClass(); + + $this->stockConfigurationMock = $this->createMock( + \Magento\CatalogInventory\Api\StockConfigurationInterface::class + ); + $this->stockRegistryProviderMock = $this->createMock( + \Magento\CatalogInventory\Model\Spi\StockRegistryProviderInterface::class + ); + $this->stockItemRepositoryMock = $this->createMock( + \Magento\CatalogInventory\Api\StockItemRepositoryInterface::class + ); + $this->productFactoryMock = $this->createPartialMock(\Magento\Catalog\Model\ProductFactory::class, ['create']); $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); $this->model = $objectManager->getObject( \Magento\CatalogInventory\Model\StockRegistry::class, [ - 'criteriaFactory' => $criteriaFactory + 'stockConfiguration' => $this->stockConfigurationMock, + 'stockRegistryProvider' => $this->stockRegistryProviderMock, + 'stockItemRepository' => $this->stockItemRepositoryMock, + 'criteriaFactory' => $this->criteriaFactory, + 'productFactory' => $this->productFactoryMock, ] ); } public function testGetLowStockItems() { + $this->criteriaFactory->expects($this->once())->method('create')->willReturn($this->criteria); $this->criteria->expects($this->once())->method('setLimit')->with(1, 0); $this->criteria->expects($this->once())->method('setScopeFilter')->with(1); $this->criteria->expects($this->once())->method('setQtyFilter')->with('<='); $this->criteria->expects($this->once())->method('addField')->with('qty'); $this->model->getLowStockItems(1, 100); } + + /** + * @return void + */ + public function testUpdateStockItemBySku() + { + $manageStock = 1; + $isInStock = 0; + $qty = 10; + $origQty = 0; + + $this->stockItemMock->expects($this->once())->method('getManageStock')->willReturn($manageStock); + $this->stockItemMock->expects($this->once())->method('getIsInStock')->willReturn($isInStock); + $this->stockItemMock->expects($this->once())->method('getQty')->willReturn($qty); + $this->stockItemMock->expects($this->exactly(2)) + ->method('getOrigData') + ->with(\Magento\CatalogInventory\Api\Data\StockItemInterface::QTY) + ->willReturn($origQty); + + $this->stockItemMock->expects($this->once())->method('setIsInStock')->with(true)->willReturnSelf(); + $this->stockItemMock->expects($this->once()) + ->method('setStockStatusChangedAutomaticallyFlag') + ->with(true) + ->willReturnSelf(); + + $this->configureAndCallUpdateStockItemBySku(); + } + + /** + * @return void + */ + public function testUpdateStockItemBySkuWithoutUpdateStockStatus() + { + $manageStock = 0; + + $this->stockItemMock->expects($this->once())->method('getManageStock')->willReturn($manageStock); + $this->stockItemMock->expects($this->never())->method('getIsInStock'); + $this->stockItemMock->expects($this->never())->method('getQty'); + $this->stockItemMock->expects($this->never()) + ->method('getOrigData') + ->with(\Magento\CatalogInventory\Api\Data\StockItemInterface::QTY); + + $this->stockItemMock->expects($this->never())->method('setIsInStock')->with(true); + $this->stockItemMock->expects($this->never())->method('setStockStatusChangedAutomaticallyFlag')->with(true); + + $this->configureAndCallUpdateStockItemBySku(); + } + + /** + * @return void + */ + private function configureAndCallUpdateStockItemBySku() + { + $productId = 1; + $productSku = 'Simple'; + $websiteId = 0; + $scopeId = 1; + $data = ['item_id' => 1, 'is_in_stock' => 1]; + + /** @var \Magento\CatalogInventory\Api\Data\StockItemInterface|MockObject $origStockItemMock */ + $origStockItemMock = $this->getMockBuilder(\Magento\CatalogInventory\Api\Data\StockItemInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getItemId', 'addData', 'setProductId']) + ->getMockForAbstractClass(); + + /** @var \Magento\Catalog\Model\Product|MockObject $productMock */ + $productMock = $this->createPartialMock(\Magento\Catalog\Model\Product::class, ['getIdBySku']); + $this->productFactoryMock->expects($this->once())->method('create')->willReturn($productMock); + $productMock->expects($this->once())->method('getIdBySku')->with($productSku)->willReturn($productId); + + $this->stockItemMock->expects($this->once())->method('getWebsiteId')->willReturn($websiteId); + $this->stockItemMock->expects($this->once())->method('getData')->willReturn($data); + + $origStockItemMock->expects($this->exactly(2))->method('getItemId')->willReturn(null); + $origStockItemMock->expects($this->once())->method('addData')->with($data)->willReturnSelf(); + $origStockItemMock->expects($this->once())->method('setProductId')->with($productId)->willReturnSelf(); + + $this->stockConfigurationMock->expects($this->once())->method('getDefaultScopeId')->willReturn($scopeId); + $this->stockRegistryProviderMock->expects($this->once()) + ->method('getStockItem') + ->with($productId, $scopeId) + ->willReturn($origStockItemMock); + + $this->stockItemRepositoryMock->expects($this->once()) + ->method('save') + ->with($origStockItemMock) + ->willReturn($origStockItemMock); + + $this->model->updateStockItemBySku($productSku, $this->stockItemMock); + } } diff --git a/app/code/Magento/CatalogInventory/composer.json b/app/code/Magento/CatalogInventory/composer.json index 41fd9db15f15a..1c34d360cf9f1 100644 --- a/app/code/Magento/CatalogInventory/composer.json +++ b/app/code/Magento/CatalogInventory/composer.json @@ -14,7 +14,7 @@ "magento/module-sales": "101.0.*" }, "type": "magento2-module", - "version": "100.2.5", + "version": "100.2.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogInventory/etc/di.xml b/app/code/Magento/CatalogInventory/etc/di.xml index 7cddb4f8f0b54..535e91ba30f52 100644 --- a/app/code/Magento/CatalogInventory/etc/di.xml +++ b/app/code/Magento/CatalogInventory/etc/di.xml @@ -111,7 +111,7 @@ <argument name="batchSizeManagement" xsi:type="object">Magento\CatalogInventory\Model\Indexer\Stock\BatchSizeManagement</argument> </arguments> </type> - <type name="\Magento\Framework\Data\CollectionModifier"> + <type name="Magento\Framework\Data\CollectionModifier"> <arguments> <argument name="conditions" xsi:type="array"> <item name="stockStatusCondition" xsi:type="object">Magento\CatalogInventory\Model\ProductCollectionStockCondition</item> diff --git a/app/code/Magento/CatalogRule/Model/Indexer/AbstractIndexer.php b/app/code/Magento/CatalogRule/Model/Indexer/AbstractIndexer.php index 5d93e6f216866..6b7c12dfdf463 100644 --- a/app/code/Magento/CatalogRule/Model/Indexer/AbstractIndexer.php +++ b/app/code/Magento/CatalogRule/Model/Indexer/AbstractIndexer.php @@ -10,6 +10,9 @@ use Magento\Framework\DataObject\IdentityInterface; use Magento\Framework\Indexer\CacheContext; +/** + * Abstract class for CatalogRule indexers. + */ abstract class AbstractIndexer implements IndexerActionInterface, MviewActionInterface, IdentityInterface { /** @@ -66,7 +69,6 @@ public function executeFull() { $this->indexBuilder->reindexFull(); $this->_eventManager->dispatch('clean_cache_by_tags', ['object' => $this]); - //TODO: remove after fix fpc. MAGETWO-50668 $this->getCacheManager()->clean($this->getIdentities()); } @@ -137,8 +139,9 @@ public function executeRow($id) abstract protected function doExecuteRow($id); /** - * @return \Magento\Framework\App\CacheInterface|mixed + * Get cache manager * + * @return \Magento\Framework\App\CacheInterface|mixed * @deprecated 100.0.7 */ private function getCacheManager() diff --git a/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/CatalogPriceRuleActionGroup.xml b/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/CatalogPriceRuleActionGroup.xml index 72e082392059e..51c87831d1f71 100644 --- a/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/CatalogPriceRuleActionGroup.xml +++ b/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/CatalogPriceRuleActionGroup.xml @@ -68,4 +68,30 @@ <fillField selector="{{AdminCatalogPriceRuleConditionsSection.targetInput('1', '1')}}" userInput="{{productSku}}" after="clickEllipsis" stepKey="fillProductSku"/> <click selector="{{AdminCatalogPriceRuleConditionsSection.applyButton('1', '1')}}" after="fillProductSku" stepKey="clickApply"/> </actionGroup> + <!--Add Catalog Rule Condition With Category--> + <actionGroup name="newCatalogPriceRuleByUIWithConditionIsCategory" extends="CreateCatalogPriceRule"> + <arguments> + <argument name="categoryId" type="string"/> + </arguments> + <click selector="{{AdminCatalogPriceRuleSection.conditionsTab}}" after="discardSubsequentRules" stepKey="openConditionsTab"/> + <click selector="{{AdminCatalogPriceRuleConditionsSection.newCondition}}" after="openConditionsTab" stepKey="addNewCondition"/> + <selectOption selector="{{AdminCatalogPriceRuleConditionsSection.conditionSelect('1')}}" userInput="Magento\CatalogRule\Model\Rule\Condition\Product|category_ids" after="addNewCondition" stepKey="selectTypeCondition"/> + <click selector="{{AdminCatalogPriceRuleConditionsSection.targetEllipsis('1')}}" after="selectTypeCondition" stepKey="clickEllipsis"/> + <fillField selector="{{AdminCatalogPriceRuleConditionsSection.targetInput('1', '1')}}" userInput="{{categoryId}}" after="clickEllipsis" stepKey="fillCategoryId"/> + <click selector="{{AdminCatalogPriceRuleConditionsSection.applyButton('1', '1')}}" after="fillCategoryId" stepKey="clickApply"/> + </actionGroup> + + <!-- Open rule for Edit --> + <actionGroup name="OpenCatalogPriceRule"> + <arguments> + <argument name="ruleName" type="string" defaultValue="CustomCatalogRule.name"/> + </arguments> + + <amOnPage url="{{AdminCatalogPriceRuleGridPage.url}}" stepKey="goToAdminCatalogPriceRuleGridPage"/> + <conditionalClick selector="{{AdminDataGridHeaderSection.clearFilters}}" dependentSelector="{{AdminDataGridHeaderSection.clearFilters}}" visible="true" stepKey="clearExistingFilters"/> + <fillField selector="{{AdminCatalogPriceRuleGridSection.filterByRuleName}}" userInput="{{ruleName}}" stepKey="filterByRuleName"/> + <click selector="{{AdminDataGridHeaderSection.applyFilters}}" stepKey="clickSearch"/> + <click selector="{{AdminGridTableSection.row('1')}}" stepKey="clickEdit"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/CatalogRule/Test/Mftf/Test/StorefrontCatalogPriceRuleAndCustomerGroupMembershipArePersistedUnderLongTermCookieTest.xml b/app/code/Magento/CatalogRule/Test/Mftf/Test/StorefrontCatalogPriceRuleAndCustomerGroupMembershipArePersistedUnderLongTermCookieTest.xml new file mode 100644 index 0000000000000..94413788bdc21 --- /dev/null +++ b/app/code/Magento/CatalogRule/Test/Mftf/Test/StorefrontCatalogPriceRuleAndCustomerGroupMembershipArePersistedUnderLongTermCookieTest.xml @@ -0,0 +1,82 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontCatalogPriceRuleAndCustomerGroupMembershipArePersistedUnderLongTermCookieTest"> + <annotations> + <features value="Persistent"/> + <stories value="Check the price"/> + <title value="Verify that Catalog Price Rule and Customer Group Membership are persisted under long-term cookie"/> + <description value="Verify that Catalog Price Rule and Customer Group Membership are persisted under long-term cookie"/> + <severity value="CRITICAL"/> + <testCaseId value="MAGETWO-76169"/> + <group value="persistent"/> + </annotations> + <before> + <createData entity="PersistentConfigEnabled" stepKey="enablePersistent"/> + <createData entity="PersistentLogoutClearDisabled" stepKey="persistentLogoutClearDisable"/> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <createData entity="_defaultProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + <field key="price">50</field> + </createData> + <createData entity="Simple_US_Customer" stepKey="createCustomer"> + <field key="group_id">1</field> + </createData> + + <actionGroup ref="LoginAsAdmin" stepKey="login"/> + <!--Create Catalog Rule--> + <actionGroup ref="newCatalogPriceRuleByUIWithConditionIsCategory" stepKey="createCatalogPriceRule"> + <argument name="catalogRule" value="CustomCatalogRule"/> + <argument name="categoryId" value="$$createCategory.id$$"/> + </actionGroup> + <click selector="{{AdminCatalogPriceRuleGridSection.applyRulesButton}}" stepKey="clickApplyRules"/> + + <magentoCLI command="indexer:reindex" stepKey="reindex"/> + <magentoCLI command="cache:flush" stepKey="flushCache"/> + </before> + <after> + <!-- Delete the rule --> + <actionGroup ref="RemoveCatalogPriceRule" stepKey="deletePriceRule"> + <argument name="ruleName" value="CustomCatalogRule.name" /> + </actionGroup> + <actionGroup ref="logout" stepKey="logout"/> + <createData entity="PersistentConfigDefault" stepKey="setDefaultPersistentState"/> + <createData entity="PersistentLogoutClearEnabled" stepKey="persistentLogoutClearEnabled"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + </after> + + <!--Go to category and check price--> + <amOnPage url="{{StorefrontCategoryPage.url($$createCategory.name$$)}}" stepKey="onStorefrontCategoryPage"/> + <see selector="{{StorefrontCategoryProductSection.ProductPriceByName($$createProduct.name$$)}}" userInput="$$createProduct.price$$" stepKey="checkPriceSimpleProduct"/> + + <!--Login to storfront from customer and check price--> + <actionGroup ref="CustomerLoginOnStorefront" stepKey="logInFromCustomer"> + <argument name="customer" value="$$createCustomer$$"/> + </actionGroup> + <amOnPage url="{{StorefrontCategoryPage.url($$createCategory.name$$)}}" stepKey="onStorefrontCategoryPage2"/> + <see userInput="Welcome, $$createCustomer.firstname$$ $$createCustomer.lastname$$!" selector="{{StorefrontPanelHeaderSection.welcomeMessage}}" stepKey="homeCheckWelcome"/> + <see selector="{{StorefrontCategoryProductSection.ProductPriceByName($$createProduct.name$$)}}" userInput="45.00" stepKey="checkPriceSimpleProduct2"/> + + <!--Click *Sign Out* and check the price of the Simple Product--> + <actionGroup ref="CustomerLogoutStorefrontActionGroup" stepKey="storefrontSignOut"/> + <amOnPage url="{{StorefrontCategoryPage.url($$createCategory.name$$)}}" stepKey="onStorefrontCategoryPage3"/> + <see userInput="Welcome, $$createCustomer.firstname$$ $$createCustomer.lastname$$!" selector="{{StorefrontPanelHeaderSection.welcomeMessage}}" stepKey="homeCheckWelcome2"/> + <seeElement selector="{{StorefrontPanelHeaderSection.notYouLink}}" stepKey="checkLinkNotYou"/> + <see selector="{{StorefrontCategoryProductSection.ProductPriceByName($$createProduct.name$$)}}" userInput="45.00" stepKey="checkPriceSimpleProduct3"/> + + <!--Click the *Not you?* link and check the price for Simple Product--> + <click selector="{{StorefrontPanelHeaderSection.notYouLink}}" stepKey="clickNext"/> + <amOnPage url="{{StorefrontCategoryPage.url($$createCategory.name$$)}}" stepKey="onStorefrontCategoryPage4"/> + <see userInput="Default welcome msg!" selector="{{StorefrontPanelHeaderSection.welcomeMessage}}" stepKey="homeCheckWelcome3"/> + <see selector="{{StorefrontCategoryProductSection.ProductPriceByName($$createProduct.name$$)}}" userInput="$$createProduct.price$$" stepKey="checkPriceSimpleProduct4"/> + </test> +</tests> diff --git a/app/code/Magento/CatalogRule/composer.json b/app/code/Magento/CatalogRule/composer.json index 4067d7044b158..cdcd7629c6040 100644 --- a/app/code/Magento/CatalogRule/composer.json +++ b/app/code/Magento/CatalogRule/composer.json @@ -17,7 +17,7 @@ "magento/module-catalog-rule-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "101.0.5", + "version": "101.0.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogRuleConfigurable/composer.json b/app/code/Magento/CatalogRuleConfigurable/composer.json index 93a937ffcc45e..16d5f2955d5d2 100644 --- a/app/code/Magento/CatalogRuleConfigurable/composer.json +++ b/app/code/Magento/CatalogRuleConfigurable/composer.json @@ -13,7 +13,7 @@ "magento/module-catalog-rule": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProvider.php b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProvider.php index 286b6427c8132..7a94dcb78cfd5 100644 --- a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProvider.php +++ b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/DataProvider.php @@ -12,6 +12,8 @@ use Magento\Store\Model\Store; /** + * Catalog search full text search data provider. + * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @SuppressWarnings(PHPMD.TooManyFields) * @api @@ -571,8 +573,8 @@ public function prepareProductIndex($indexData, $productData, $storeId) } } foreach ($indexData as $entityId => $attributeData) { - foreach ($attributeData as $attributeId => $attributeValue) { - $value = $this->getAttributeValue($attributeId, $attributeValue, $storeId); + foreach ($attributeData as $attributeId => $attributeValues) { + $value = $this->getAttributeValue($attributeId, $attributeValues, $storeId); if (!empty($value)) { if (isset($index[$attributeId])) { $index[$attributeId][$entityId] = $value; @@ -603,16 +605,16 @@ public function prepareProductIndex($indexData, $productData, $storeId) * Retrieve attribute source value for search * * @param int $attributeId - * @param mixed $valueId + * @param mixed $valueIds * @param int $storeId * @return string */ - private function getAttributeValue($attributeId, $valueId, $storeId) + private function getAttributeValue($attributeId, $valueIds, $storeId) { $attribute = $this->getSearchableAttribute($attributeId); - $value = $this->engine->processAttributeValue($attribute, $valueId); + $value = $this->engine->processAttributeValue($attribute, $valueIds); if (false !== $value) { - $optionValue = $this->getAttributeOptionValue($attributeId, $valueId, $storeId); + $optionValue = $this->getAttributeOptionValue($attributeId, $valueIds, $storeId); if (null === $optionValue) { $value = preg_replace('/\s+/iu', ' ', trim(strip_tags($value))); } else { @@ -627,13 +629,15 @@ private function getAttributeValue($attributeId, $valueId, $storeId) * Get attribute option value * * @param int $attributeId - * @param int $valueId + * @param int|string $valueIds * @param int $storeId * @return null|string */ - private function getAttributeOptionValue($attributeId, $valueId, $storeId) + private function getAttributeOptionValue($attributeId, $valueIds, $storeId) { $optionKey = $attributeId . '-' . $storeId; + $attributeValueIds = explode(',', $valueIds); + $attributeOptionValue = ''; if (!array_key_exists($optionKey, $this->attributeOptions) ) { $attribute = $this->getSearchableAttribute($attributeId); @@ -649,6 +653,12 @@ private function getAttributeOptionValue($attributeId, $valueId, $storeId) } } - return $this->attributeOptions[$optionKey][$valueId] ?? null; + foreach ($attributeValueIds as $attributeValueId) { + if (isset($this->attributeOptions[$optionKey][$attributeValueId])) { + $attributeOptionValue .= $this->attributeOptions[$optionKey][$attributeValueId] . ' '; + } + } + + return empty($attributeOptionValue) ? null : trim($attributeOptionValue); } } diff --git a/app/code/Magento/CatalogSearch/Model/Layer/Filter/Attribute.php b/app/code/Magento/CatalogSearch/Model/Layer/Filter/Attribute.php index 7aac6e98fc044..7b239d84bf962 100644 --- a/app/code/Magento/CatalogSearch/Model/Layer/Filter/Attribute.php +++ b/app/code/Magento/CatalogSearch/Model/Layer/Filter/Attribute.php @@ -91,12 +91,10 @@ protected function _getItemsData() return $this->itemDataBuilder->build(); } - $productSize = $productCollection->getSize(); - $options = $attribute->getFrontend() ->getSelectOptions(); foreach ($options as $option) { - $this->buildOptionData($option, $isAttributeFilterable, $optionsFacetedData, $productSize); + $this->buildOptionData($option, $isAttributeFilterable, $optionsFacetedData); } return $this->itemDataBuilder->build(); @@ -108,17 +106,16 @@ protected function _getItemsData() * @param array $option * @param boolean $isAttributeFilterable * @param array $optionsFacetedData - * @param int $productSize * @return void */ - private function buildOptionData($option, $isAttributeFilterable, $optionsFacetedData, $productSize) + private function buildOptionData($option, $isAttributeFilterable, $optionsFacetedData) { $value = $this->getOptionValue($option); if ($value === false) { return; } $count = $this->getOptionCount($value, $optionsFacetedData); - if ($isAttributeFilterable && (!$this->isOptionReducesResults($count, $productSize) || $count === 0)) { + if ($isAttributeFilterable && $count === 0) { return; } diff --git a/app/code/Magento/CatalogSearch/Model/ResourceModel/Engine.php b/app/code/Magento/CatalogSearch/Model/ResourceModel/Engine.php index fac8c4d2a47f6..831780631c124 100644 --- a/app/code/Magento/CatalogSearch/Model/ResourceModel/Engine.php +++ b/app/code/Magento/CatalogSearch/Model/ResourceModel/Engine.php @@ -107,7 +107,10 @@ public function processAttributeValue($attribute, $value) && in_array($attribute->getFrontendInput(), ['text', 'textarea']) ) { $result = $value; - } elseif ($this->isTermFilterableAttribute($attribute)) { + } elseif ($this->isTermFilterableAttribute($attribute) + || ($attribute->getIsSearchable() + && in_array($attribute->getFrontendInput(), ['select', 'multiselect'])) + ) { $result = ''; } @@ -115,7 +118,8 @@ public function processAttributeValue($attribute, $value) } /** - * Prepare index array as a string glued by separator + * Prepare index array as a string glued by separator. + * * Support 2 level array gluing * * @param array $index diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/AttributeTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/AttributeTest.php index abc0fdd1069fe..69e2c33d02d1a 100644 --- a/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/AttributeTest.php +++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/AttributeTest.php @@ -321,10 +321,6 @@ public function testGetItemsWithoutApply() ->method('build') ->will($this->returnValue($builtData)); - $this->fulltextCollection->expects($this->once()) - ->method('getSize') - ->will($this->returnValue(50)); - $expectedFilterItems = [ $this->createFilterItem(0, $builtData[0]['label'], $builtData[0]['value'], $builtData[0]['count']), $this->createFilterItem(1, $builtData[1]['label'], $builtData[1]['value'], $builtData[1]['count']), @@ -383,9 +379,6 @@ public function testGetItemsOnlyWithResults() $this->fulltextCollection->expects($this->once()) ->method('getFacetedData') ->willReturn($facetedData); - $this->fulltextCollection->expects($this->once()) - ->method('getSize') - ->will($this->returnValue(50)); $this->itemDataBuilder->expects($this->once()) ->method('addItemData') diff --git a/app/code/Magento/CatalogSearch/composer.json b/app/code/Magento/CatalogSearch/composer.json index 2a72af9cf96a5..0b935c71c17d0 100644 --- a/app/code/Magento/CatalogSearch/composer.json +++ b/app/code/Magento/CatalogSearch/composer.json @@ -20,7 +20,7 @@ "magento/module-config": "101.0.*" }, "type": "magento2-module", - "version": "100.2.5", + "version": "100.2.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogSearch/etc/indexer.xml b/app/code/Magento/CatalogSearch/etc/indexer.xml index 9726f5372311d..a0b9ca10afccb 100644 --- a/app/code/Magento/CatalogSearch/etc/indexer.xml +++ b/app/code/Magento/CatalogSearch/etc/indexer.xml @@ -9,8 +9,6 @@ <indexer id="catalogsearch_fulltext" view_id="catalogsearch_fulltext" class="Magento\CatalogSearch\Model\Indexer\Fulltext"> <title translate="true">Catalog Search Rebuild Catalog product fulltext search index - - diff --git a/app/code/Magento/CatalogUrlRewrite/Model/Category/ChildrenUrlRewriteGenerator.php b/app/code/Magento/CatalogUrlRewrite/Model/Category/ChildrenUrlRewriteGenerator.php index 6aa33f37cd31f..beed1e18582c1 100644 --- a/app/code/Magento/CatalogUrlRewrite/Model/Category/ChildrenUrlRewriteGenerator.php +++ b/app/code/Magento/CatalogUrlRewrite/Model/Category/ChildrenUrlRewriteGenerator.php @@ -5,12 +5,16 @@ */ namespace Magento\CatalogUrlRewrite\Model\Category; +use Magento\Catalog\Api\CategoryRepositoryInterface; use Magento\Catalog\Model\Category; -use Magento\CatalogUrlRewrite\Model\CategoryUrlRewriteGeneratorFactory; use Magento\CatalogUrlRewrite\Model\CategoryUrlRewriteGenerator; -use Magento\UrlRewrite\Model\MergeDataProviderFactory; +use Magento\CatalogUrlRewrite\Model\CategoryUrlRewriteGeneratorFactory; use Magento\Framework\App\ObjectManager; +use Magento\UrlRewrite\Model\MergeDataProviderFactory; +/** + * Model for generate url rewrites for children categories. + */ class ChildrenUrlRewriteGenerator { /** @@ -28,15 +32,22 @@ class ChildrenUrlRewriteGenerator */ private $mergeDataProviderPrototype; + /** + * @var CategoryRepositoryInterface + */ + private $categoryRepository; + /** * @param \Magento\CatalogUrlRewrite\Model\Category\ChildrenCategoriesProvider $childrenCategoriesProvider * @param \Magento\CatalogUrlRewrite\Model\CategoryUrlRewriteGeneratorFactory $categoryUrlRewriteGeneratorFactory * @param \Magento\UrlRewrite\Model\MergeDataProviderFactory|null $mergeDataProviderFactory + * @param CategoryRepositoryInterface|null $categoryRepository */ public function __construct( ChildrenCategoriesProvider $childrenCategoriesProvider, CategoryUrlRewriteGeneratorFactory $categoryUrlRewriteGeneratorFactory, - MergeDataProviderFactory $mergeDataProviderFactory = null + MergeDataProviderFactory $mergeDataProviderFactory = null, + CategoryRepositoryInterface $categoryRepository = null ) { $this->childrenCategoriesProvider = $childrenCategoriesProvider; $this->categoryUrlRewriteGeneratorFactory = $categoryUrlRewriteGeneratorFactory; @@ -44,6 +55,8 @@ public function __construct( $mergeDataProviderFactory = ObjectManager::getInstance()->get(MergeDataProviderFactory::class); } $this->mergeDataProviderPrototype = $mergeDataProviderFactory->create(); + $this->categoryRepository = $categoryRepository + ?: ObjectManager::getInstance()->get(CategoryRepositoryInterface::class); } /** @@ -57,14 +70,18 @@ public function __construct( public function generate($storeId, Category $category, $rootCategoryId = null) { $mergeDataProvider = clone $this->mergeDataProviderPrototype; - foreach ($this->childrenCategoriesProvider->getChildren($category, true) as $childCategory) { - $childCategory->setStoreId($storeId); - $childCategory->setData('save_rewrites_history', $category->getData('save_rewrites_history')); - /** @var CategoryUrlRewriteGenerator $categoryUrlRewriteGenerator */ - $categoryUrlRewriteGenerator = $this->categoryUrlRewriteGeneratorFactory->create(); - $mergeDataProvider->merge( - $categoryUrlRewriteGenerator->generate($childCategory, false, $rootCategoryId) - ); + $childrenIds = $this->childrenCategoriesProvider->getChildrenIds($category, true); + if ($childrenIds) { + foreach ($childrenIds as $childId) { + /** @var Category $childCategory */ + $childCategory = $this->categoryRepository->get($childId, $storeId); + $childCategory->setData('save_rewrites_history', $category->getData('save_rewrites_history')); + /** @var CategoryUrlRewriteGenerator $categoryUrlRewriteGenerator */ + $categoryUrlRewriteGenerator = $this->categoryUrlRewriteGeneratorFactory->create(); + $mergeDataProvider->merge( + $categoryUrlRewriteGenerator->generate($childCategory, false, $rootCategoryId) + ); + } } return $mergeDataProvider->getData(); diff --git a/app/code/Magento/CatalogUrlRewrite/Model/CategoryUrlPathGenerator.php b/app/code/Magento/CatalogUrlRewrite/Model/CategoryUrlPathGenerator.php index ee20b0e934b5d..2961dc4358970 100644 --- a/app/code/Magento/CatalogUrlRewrite/Model/CategoryUrlPathGenerator.php +++ b/app/code/Magento/CatalogUrlRewrite/Model/CategoryUrlPathGenerator.php @@ -8,6 +8,9 @@ use Magento\Catalog\Api\CategoryRepositoryInterface; use Magento\Catalog\Model\Category; +/** + * Class for generation category url_path. + */ class CategoryUrlPathGenerator { /** @@ -58,12 +61,13 @@ public function __construct( } /** - * Build category URL path + * Build category URL path. * * @param \Magento\Catalog\Api\Data\CategoryInterface|\Magento\Framework\Model\AbstractModel $category + * @param null|\Magento\Catalog\Api\Data\CategoryInterface|\Magento\Framework\Model\AbstractModel $parentCategory * @return string */ - public function getUrlPath($category) + public function getUrlPath($category, $parentCategory = null) { if (in_array($category->getParentId(), [Category::ROOT_CATEGORY_ID, Category::TREE_ROOT_ID])) { return ''; @@ -77,15 +81,18 @@ public function getUrlPath($category) return $category->getUrlPath(); } if ($this->isNeedToGenerateUrlPathForParent($category)) { - $parentPath = $this->getUrlPath( - $this->categoryRepository->get($category->getParentId(), $category->getStoreId()) - ); + $parentCategory = $parentCategory ?? + $this->categoryRepository->get($category->getParentId(), $category->getStoreId()); + $parentPath = $this->getUrlPath($parentCategory); $path = $parentPath === '' ? $path : $parentPath . '/' . $path; } + return $path; } /** + * Define whether we should generate URL path for parent. + * * @param \Magento\Catalog\Model\Category $category * @return bool */ diff --git a/app/code/Magento/CatalogUrlRewrite/Observer/AfterImportDataObserver.php b/app/code/Magento/CatalogUrlRewrite/Observer/AfterImportDataObserver.php index 022a78be00197..9aaa384776855 100644 --- a/app/code/Magento/CatalogUrlRewrite/Observer/AfterImportDataObserver.php +++ b/app/code/Magento/CatalogUrlRewrite/Observer/AfterImportDataObserver.php @@ -135,6 +135,7 @@ class AfterImportDataObserver implements ObserverInterface 'url_path', 'name', 'visibility', + 'save_rewrites_history' ]; /** @@ -199,6 +200,7 @@ public function __construct( /** * Action after data import. + * * Save new url rewrites and remove old if exist. * * @param Observer $observer @@ -267,6 +269,8 @@ protected function _populateForUrlGeneration($rowData) } /** + * Add store id to product data. + * * @param \Magento\Catalog\Model\Product $product * @param array $rowData * @return void @@ -436,6 +440,8 @@ protected function currentUrlRewritesRegenerate() } /** + * Generate url-rewrite for outogenerated url-rewirte. + * * @param UrlRewrite $url * @param Category $category * @return array @@ -470,6 +476,8 @@ protected function generateForAutogenerated($url, $category) } /** + * Generate url-rewrite for custom url-rewirte. + * * @param UrlRewrite $url * @param Category $category * @return array @@ -503,6 +511,8 @@ protected function generateForCustom($url, $category) } /** + * Retrieve category from url metadata. + * * @param UrlRewrite $url * @return Category|null|bool */ @@ -517,6 +527,8 @@ protected function retrieveCategoryFromMetadata($url) } /** + * Check, category suited for url-rewrite generation. + * * @param \Magento\Catalog\Model\Category $category * @param int $storeId * @return bool diff --git a/app/code/Magento/CatalogUrlRewrite/Observer/CategoryProcessUrlRewriteSavingObserver.php b/app/code/Magento/CatalogUrlRewrite/Observer/CategoryProcessUrlRewriteSavingObserver.php index 5130b43333d47..745c302d619a1 100644 --- a/app/code/Magento/CatalogUrlRewrite/Observer/CategoryProcessUrlRewriteSavingObserver.php +++ b/app/code/Magento/CatalogUrlRewrite/Observer/CategoryProcessUrlRewriteSavingObserver.php @@ -100,16 +100,19 @@ public function execute(\Magento\Framework\Event\Observer $observer) } $mapsGenerated = false; - if ($category->dataHasChangedFor('url_key') - || $category->dataHasChangedFor('is_anchor') - || $category->getChangedProductIds() - ) { + if ($this->isCategoryHasChanged($category)) { if ($category->dataHasChangedFor('url_key')) { $categoryUrlRewriteResult = $this->categoryUrlRewriteGenerator->generate($category); $this->urlRewriteBunchReplacer->doBunchReplace($categoryUrlRewriteResult); } - $productUrlRewriteResult = $this->urlRewriteHandler->generateProductUrlRewrites($category); - $this->urlRewriteBunchReplacer->doBunchReplace($productUrlRewriteResult); + if ($this->isChangedOnlyProduct($category)) { + $productUrlRewriteResult = + $this->urlRewriteHandler->updateProductUrlRewritesForChangedProduct($category); + $this->urlRewriteBunchReplacer->doBunchReplace($productUrlRewriteResult); + } else { + $productUrlRewriteResult = $this->urlRewriteHandler->generateProductUrlRewrites($category); + $this->urlRewriteBunchReplacer->doBunchReplace($productUrlRewriteResult); + } $mapsGenerated = true; } @@ -120,8 +123,42 @@ public function execute(\Magento\Framework\Event\Observer $observer) } /** - * in case store_id is not set for category then we can assume that it was passed through product import. - * store group must have only one root category, so receiving category's path and checking if one of it parts + * Check is category changed changed. + * + * @param Category $category + * @return bool + */ + private function isCategoryHasChanged(Category $category): bool + { + if ($category->dataHasChangedFor('url_key') + || $category->dataHasChangedFor('is_anchor') + || !empty($category->getChangedProductIds())) { + return true; + } + + return false; + } + + /** + * Check is only product changed. + * + * @param Category $category + * @return bool + */ + private function isChangedOnlyProduct(Category $category): bool + { + if (!empty($category->getChangedProductIds()) + && !$category->dataHasChangedFor('is_anchor') + && !$category->dataHasChangedFor('url_key')) { + return true; + } + + return false; + } + + /** + * In case store_id is not set for category then we can assume that it was passed through product import. + * Store group must have only one root category, so receiving category's path and checking if one of it parts * is the root category for store group, we can set default_store_id value from it to category. * it prevents urls duplication for different stores * ("Default Category/category/sub" and "Default Category2/category/sub") diff --git a/app/code/Magento/CatalogUrlRewrite/Observer/CategoryUrlPathAutogeneratorObserver.php b/app/code/Magento/CatalogUrlRewrite/Observer/CategoryUrlPathAutogeneratorObserver.php index eb54f0427c11a..b49777e08bda9 100644 --- a/app/code/Magento/CatalogUrlRewrite/Observer/CategoryUrlPathAutogeneratorObserver.php +++ b/app/code/Magento/CatalogUrlRewrite/Observer/CategoryUrlPathAutogeneratorObserver.php @@ -5,14 +5,17 @@ */ namespace Magento\CatalogUrlRewrite\Observer; +use Magento\Catalog\Api\CategoryRepositoryInterface; use Magento\Catalog\Model\Category; +use Magento\CatalogUrlRewrite\Model\Category\ChildrenCategoriesProvider; use Magento\CatalogUrlRewrite\Model\CategoryUrlPathGenerator; use Magento\CatalogUrlRewrite\Service\V1\StoreViewService; -use Magento\Framework\Event\Observer; -use Magento\CatalogUrlRewrite\Model\Category\ChildrenCategoriesProvider; use Magento\Framework\Event\ObserverInterface; use Magento\Store\Model\Store; +/** + * Class observer to initiate generation category url_path. + */ class CategoryUrlPathAutogeneratorObserver implements ObserverInterface { /** @@ -30,22 +33,32 @@ class CategoryUrlPathAutogeneratorObserver implements ObserverInterface */ protected $storeViewService; + /** + * @var CategoryRepositoryInterface + */ + private $categoryRepository; + /** * @param CategoryUrlPathGenerator $categoryUrlPathGenerator * @param ChildrenCategoriesProvider $childrenCategoriesProvider * @param \Magento\CatalogUrlRewrite\Service\V1\StoreViewService $storeViewService + * @param CategoryRepositoryInterface $categoryRepository */ public function __construct( CategoryUrlPathGenerator $categoryUrlPathGenerator, ChildrenCategoriesProvider $childrenCategoriesProvider, - StoreViewService $storeViewService + StoreViewService $storeViewService, + CategoryRepositoryInterface $categoryRepository ) { $this->categoryUrlPathGenerator = $categoryUrlPathGenerator; $this->childrenCategoriesProvider = $childrenCategoriesProvider; $this->storeViewService = $storeViewService; + $this->categoryRepository = $categoryRepository; } /** + * Generate Category Url Path. + * * @param \Magento\Framework\Event\Observer $observer * @return void * @throws \Magento\Framework\Exception\LocalizedException @@ -57,45 +70,68 @@ public function execute(\Magento\Framework\Event\Observer $observer) $useDefaultAttribute = !$category->isObjectNew() && !empty($category->getData('use_default')['url_key']); if ($category->getUrlKey() !== false && !$useDefaultAttribute) { $resultUrlKey = $this->categoryUrlPathGenerator->getUrlKey($category); - if (empty($resultUrlKey)) { - throw new \Magento\Framework\Exception\LocalizedException(__('Invalid URL key')); - } - $category->setUrlKey($resultUrlKey) - ->setUrlPath($this->categoryUrlPathGenerator->getUrlPath($category)); - if (!$category->isObjectNew()) { - $category->getResource()->saveAttribute($category, 'url_path'); - if ($category->dataHasChangedFor('url_path')) { - $this->updateUrlPathForChildren($category); - } + $this->updateUrlKey($category, $resultUrlKey); + } else if ($useDefaultAttribute) { + $resultUrlKey = $category->formatUrlKey($category->getOrigData('name')); + $this->updateUrlKey($category, $resultUrlKey); + $category->setUrlKey(null)->setUrlPath(null); + } + } + + /** + * Update Url Key. + * + * @param Category $category + * @param string $urlKey + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function updateUrlKey(Category $category, string $urlKey) + { + if (empty($urlKey)) { + throw new \Magento\Framework\Exception\LocalizedException(__('Invalid URL key')); + } + $category->setUrlKey($urlKey) + ->setUrlPath($this->categoryUrlPathGenerator->getUrlPath($category)); + if (!$category->isObjectNew()) { + $category->getResource()->saveAttribute($category, 'url_path'); + if ($category->dataHasChangedFor('url_path')) { + $this->updateUrlPathForChildren($category); } } } /** + * Update URL path for children. + * * @param Category $category * @return void */ protected function updateUrlPathForChildren(Category $category) { - $children = $this->childrenCategoriesProvider->getChildren($category, true); - if ($this->isGlobalScope($category->getStoreId())) { - foreach ($children as $child) { + $childrenIds = $this->childrenCategoriesProvider->getChildrenIds($category, true); + foreach ($childrenIds as $childId) { foreach ($category->getStoreIds() as $storeId) { if ($this->storeViewService->doesEntityHaveOverriddenUrlPathForStore( $storeId, - $child->getId(), + $childId, Category::ENTITY )) { - $child->setStoreId($storeId); + $child = $this->categoryRepository->get($childId, $storeId); $this->updateUrlPathForCategory($child); } } } } else { + $children = $this->childrenCategoriesProvider->getChildren($category, true); foreach ($children as $child) { + /** @var Category $child */ $child->setStoreId($category->getStoreId()); - $this->updateUrlPathForCategory($child); + if ($child->getParentId() === $category->getId()) { + $this->updateUrlPathForCategory($child, $category); + } else { + $this->updateUrlPathForCategory($child); + } } } } @@ -112,13 +148,16 @@ protected function isGlobalScope($storeId) } /** + * Update URL path for category. + * * @param Category $category + * @param Category|null $parentCategory * @return void */ - protected function updateUrlPathForCategory(Category $category) + protected function updateUrlPathForCategory(Category $category, Category $parentCategory = null) { $category->unsUrlPath(); - $category->setUrlPath($this->categoryUrlPathGenerator->getUrlPath($category)); + $category->setUrlPath($this->categoryUrlPathGenerator->getUrlPath($category, $parentCategory)); $category->getResource()->saveAttribute($category, 'url_path'); } } diff --git a/app/code/Magento/CatalogUrlRewrite/Observer/UrlRewriteHandler.php b/app/code/Magento/CatalogUrlRewrite/Observer/UrlRewriteHandler.php index 18360dedf0693..b4a35f323e1bc 100644 --- a/app/code/Magento/CatalogUrlRewrite/Observer/UrlRewriteHandler.php +++ b/app/code/Magento/CatalogUrlRewrite/Observer/UrlRewriteHandler.php @@ -24,6 +24,8 @@ use Magento\UrlRewrite\Service\V1\Data\UrlRewrite; /** + * Class for management url rewrites. + * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class UrlRewriteHandler @@ -125,7 +127,7 @@ public function generateProductUrlRewrites(Category $category): array { $mergeDataProvider = clone $this->mergeDataProviderPrototype; $this->isSkippedProduct[$category->getEntityId()] = []; - $saveRewriteHistory = $category->getData('save_rewrites_history'); + $saveRewriteHistory = (bool)$category->getData('save_rewrites_history'); $storeId = (int)$category->getStoreId(); if ($category->getChangedProductIds()) { @@ -156,6 +158,30 @@ public function generateProductUrlRewrites(Category $category): array } /** + * Update product url rewrites for changed product. + * + * @param Category $category + * @return array + */ + public function updateProductUrlRewritesForChangedProduct(Category $category): array + { + $mergeDataProvider = clone $this->mergeDataProviderPrototype; + $this->isSkippedProduct[$category->getEntityId()] = []; + $saveRewriteHistory = (bool)$category->getData('save_rewrites_history'); + $storeIds = $this->getCategoryStoreIds($category); + + if ($category->getChangedProductIds()) { + foreach ($storeIds as $storeId) { + $this->generateChangedProductUrls($mergeDataProvider, $category, (int)$storeId, $saveRewriteHistory); + } + } + + return $mergeDataProvider->getData(); + } + + /** + * Delete category rewrites for children. + * * @param Category $category * @return void */ @@ -184,6 +210,8 @@ public function deleteCategoryRewritesForChildren(Category $category) } /** + * Get category products url rewrites. + * * @param Category $category * @param int $storeId * @param bool $saveRewriteHistory @@ -230,15 +258,15 @@ private function getCategoryProductsUrlRewrites( * * @param MergeDataProvider $mergeDataProvider * @param Category $category - * @param Product $product * @param int $storeId - * @param $saveRewriteHistory + * @param bool $saveRewriteHistory + * @return void */ private function generateChangedProductUrls( MergeDataProvider $mergeDataProvider, Category $category, int $storeId, - $saveRewriteHistory + bool $saveRewriteHistory ) { $this->isSkippedProduct[$category->getEntityId()] = $category->getAffectedProductIds(); diff --git a/app/code/Magento/CatalogUrlRewrite/Test/Mftf/Test/RewriteStoreLevelUrlKeyOfChildCategoryTest.xml b/app/code/Magento/CatalogUrlRewrite/Test/Mftf/Test/RewriteStoreLevelUrlKeyOfChildCategoryTest.xml new file mode 100644 index 0000000000000..ce3f98ee0058a --- /dev/null +++ b/app/code/Magento/CatalogUrlRewrite/Test/Mftf/Test/RewriteStoreLevelUrlKeyOfChildCategoryTest.xml @@ -0,0 +1,66 @@ + + + + + + + <stories value="MAGETWO-89619: #13513: Magento ignore store-level url_key of child category in URL rewrite process for global scope"/> + <description value="Rewriting Store-level URL key of child category"/> + <features value="CatalogUrlRewrite"/> + <severity value="MAJOR"/> + <testCaseId value="MAGETWO-96558"/> + <group value="catalog_url_rewrite"/> + </annotations> + + <before> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <actionGroup ref="AdminCreateStoreViewActionGroup" stepKey="createStoreView" /> + + <createData entity="_defaultCategory" stepKey="defaultCategory"/> + <createData entity="SubCategoryWithParent" stepKey="subCategory"> + <requiredEntity createDataKey="defaultCategory"/> + </createData> + </before> + + <after> + <actionGroup ref="AdminDeleteStoreViewActionGroup" stepKey="deleteStoreView"/> + <actionGroup ref="logout" stepKey="logout"/> + + <deleteData createDataKey="subCategory" stepKey="deleteSubCategory"/> + <deleteData createDataKey="defaultCategory" stepKey="deleteCategory"/> + </after> + + <actionGroup ref="AdminNavigateToCategoryInTree" stepKey="navigateToCreatedSubCategory"> + <argument name="category" value="$$subCategory$$"/> + </actionGroup> + + <actionGroup ref="AdminSwitchStoreViewActionGroup" stepKey="switchToCustomStoreView"/> + + <actionGroup ref="ChangeSeoUrlKeyForSubCategory" stepKey="changeSeoUrlKeyForSubCategory"> + <argument name="value" value="{{BagsCategory.url_key}}"/> + </actionGroup> + + <actionGroup ref="AdminNavigateToCategoryInTree" stepKey="navigateToCreatedDefaultCategory"> + <argument name="category" value="$$defaultCategory$$"/> + </actionGroup> + + <actionGroup ref="ChangeSeoUrlKey" stepKey="changeSeoUrlKeyForDefaultCategory"> + <argument name="value" value="{{GearCategory.url_key}}"/> + </actionGroup> + + <amOnPage url="{{StorefrontHomePage.url}}" stepKey="goToStorefrontPage"/> + + <actionGroup ref="StorefrontSwitchStoreViewActionGroup" stepKey="storefrontSwitchStoreView"/> + + <actionGroup ref="StorefrontGoToSubCategoryPage" stepKey="goToSubCategoryPage"> + <argument name="parentCategory" value="$$defaultCategory$$"/> + <argument name="subCategory" value="$$subCategory$$"/> + <argument name="urlPath" value="{{GearCategory.url_key}}/{{BagsCategory.url_key}}"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/Category/ChildrenUrlRewriteGeneratorTest.php b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/Category/ChildrenUrlRewriteGeneratorTest.php index 3f641256b1259..f832293706567 100644 --- a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/Category/ChildrenUrlRewriteGeneratorTest.php +++ b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/Category/ChildrenUrlRewriteGeneratorTest.php @@ -31,6 +31,12 @@ class ChildrenUrlRewriteGeneratorTest extends \PHPUnit\Framework\TestCase /** @var \PHPUnit_Framework_MockObject_MockObject */ private $serializerMock; + /** @var \PHPUnit_Framework_MockObject_MockObject */ + private $categoryRepository; + + /** + * @inheritdoc + */ protected function setUp() { $this->serializerMock = $this->getMockBuilder(Json::class) @@ -47,6 +53,9 @@ protected function setUp() $this->categoryUrlRewriteGenerator = $this->getMockBuilder( \Magento\CatalogUrlRewrite\Model\CategoryUrlRewriteGenerator::class )->disableOriginalConstructor()->getMock(); + $this->categoryRepository = $this->getMockBuilder( + \Magento\Catalog\Model\CategoryRepository::class + )->disableOriginalConstructor()->getMock(); $mergeDataProviderFactory = $this->createPartialMock( \Magento\UrlRewrite\Model\MergeDataProviderFactory::class, ['create'] @@ -59,14 +68,15 @@ protected function setUp() [ 'childrenCategoriesProvider' => $this->childrenCategoriesProvider, 'categoryUrlRewriteGeneratorFactory' => $this->categoryUrlRewriteGeneratorFactory, - 'mergeDataProviderFactory' => $mergeDataProviderFactory + 'mergeDataProviderFactory' => $mergeDataProviderFactory, + 'categoryRepository' => $this->categoryRepository, ] ); } public function testNoChildrenCategories() { - $this->childrenCategoriesProvider->expects($this->once())->method('getChildren')->with($this->category, true) + $this->childrenCategoriesProvider->expects($this->once())->method('getChildrenIds')->with($this->category, true) ->will($this->returnValue([])); $this->assertEquals([], $this->childrenUrlRewriteGenerator->generate('store_id', $this->category)); @@ -76,14 +86,16 @@ public function testGenerate() { $storeId = 'store_id'; $saveRewritesHistory = 'flag'; + $childId = 2; $childCategory = $this->getMockBuilder(\Magento\Catalog\Model\Category::class) ->disableOriginalConstructor()->getMock(); - $childCategory->expects($this->once())->method('setStoreId')->with($storeId); $childCategory->expects($this->once())->method('setData') ->with('save_rewrites_history', $saveRewritesHistory); - $this->childrenCategoriesProvider->expects($this->once())->method('getChildren')->with($this->category, true) - ->will($this->returnValue([$childCategory])); + $this->childrenCategoriesProvider->expects($this->once())->method('getChildrenIds')->with($this->category, true) + ->willReturn([$childId]); + $this->categoryRepository->expects($this->once())->method('get') + ->with($childId, $storeId)->willReturn($childCategory); $this->category->expects($this->any())->method('getData')->with('save_rewrites_history') ->will($this->returnValue($saveRewritesHistory)); $this->categoryUrlRewriteGeneratorFactory->expects($this->once())->method('create') diff --git a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/CategoryUrlPathGeneratorTest.php b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/CategoryUrlPathGeneratorTest.php index 7297d150a8e6f..e804fb9d08e54 100644 --- a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/CategoryUrlPathGeneratorTest.php +++ b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Model/CategoryUrlPathGeneratorTest.php @@ -157,6 +157,61 @@ public function testGetUrlPathWithParent( $this->assertEquals($result, $this->categoryUrlPathGenerator->getUrlPath($this->category)); } + /** + * @return array + */ + public function getUrlPathWithParentCategoryDataProvider(): array + { + $requireGenerationLevel = CategoryUrlPathGenerator::MINIMAL_CATEGORY_LEVEL_FOR_PROCESSING; + $noGenerationLevel = CategoryUrlPathGenerator::MINIMAL_CATEGORY_LEVEL_FOR_PROCESSING - 1; + return [ + [13, 'url-key', false, $requireGenerationLevel, 10, 'parent-path', 'parent-path/url-key'], + [13, 'url-key', false, $requireGenerationLevel, Category::TREE_ROOT_ID, null, 'url-key'], + [13, 'url-key', true, $noGenerationLevel, Category::TREE_ROOT_ID, null, 'url-key'], + ]; + } + + /** + * Test receiving Url Path when parent category is presented. + * + * @param int $parentId + * @param string $urlKey + * @param bool $isCategoryNew + * @param bool $level + * @param int $parentCategoryParentId + * @param null|string $parentUrlPath + * @param string $result + * @dataProvider getUrlPathWithParentCategoryDataProvider + */ + public function testGetUrlPathWithParentCategory( + int $parentId, + string $urlKey, + bool $isCategoryNew, + bool $level, + int $parentCategoryParentId, + $parentUrlPath, + string $result + ) { + $urlPath = null; + $this->category->expects($this->any())->method('getParentId')->willReturn($parentId); + $this->category->expects($this->any())->method('getLevel')->willReturn($level); + $this->category->expects($this->any())->method('getUrlPath')->willReturn($urlPath); + $this->category->expects($this->any())->method('getUrlKey')->willReturn($urlKey); + $this->category->expects($this->any())->method('isObjectNew')->willReturn($isCategoryNew); + + $methods = ['getUrlPath', 'getParentId']; + $parentCategoryMock = $this->createPartialMock(\Magento\Catalog\Model\Category::class, $methods); + $parentCategoryMock->expects($this->any())->method('getParentId')->willReturn($parentCategoryParentId); + $parentCategoryMock->expects($this->any())->method('getUrlPath')->willReturn($parentUrlPath); + + $this->categoryRepository->expects($this->any()) + ->method('get') + ->with($parentCategoryParentId) + ->willReturn($parentCategoryMock); + + $this->assertEquals($result, $this->categoryUrlPathGenerator->getUrlPath($this->category, $parentCategoryMock)); + } + /** * @return array */ diff --git a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/CategoryProcessUrlRewriteSavingObserverTest.php b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/CategoryProcessUrlRewriteSavingObserverTest.php new file mode 100644 index 0000000000000..634dae5643c02 --- /dev/null +++ b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/CategoryProcessUrlRewriteSavingObserverTest.php @@ -0,0 +1,129 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\CatalogUrlRewrite\Test\Unit\Observer; + +use Magento\Catalog\Model\Category; +use Magento\CatalogUrlRewrite\Model\CategoryUrlRewriteGenerator; +use Magento\CatalogUrlRewrite\Model\Map\DatabaseMapPool; +use Magento\CatalogUrlRewrite\Model\Map\DataCategoryUrlRewriteDatabaseMap; +use Magento\CatalogUrlRewrite\Model\Map\DataProductUrlRewriteDatabaseMap; +use Magento\CatalogUrlRewrite\Model\UrlRewriteBunchReplacer; +use Magento\CatalogUrlRewrite\Observer\CategoryProcessUrlRewriteSavingObserver; +use Magento\CatalogUrlRewrite\Observer\UrlRewriteHandler; +use Magento\Framework\Event; +use Magento\Framework\Event\Observer; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Store\Model\ResourceModel\Group\CollectionFactory; + +/** + * Tests Magento\CatalogUrlRewrite\Observer\CategoryProcessUrlRewriteSavingObserver. + */ +class CategoryProcessUrlRewriteSavingObserverTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var CategoryProcessUrlRewriteSavingObserver + */ + private $observer; + + /** + * @var CategoryUrlRewriteGenerator|\PHPUnit_Framework_MockObject_MockObject + */ + private $categoryUrlRewriteGeneratorMock; + + /** + * @var UrlRewriteHandler|\PHPUnit_Framework_MockObject_MockObject + */ + private $urlRewriteHandlerMock; + + /** + * @var UrlRewriteBunchReplacer|\PHPUnit_Framework_MockObject_MockObject $urlRewriteMock + */ + private $urlRewriteBunchReplacerMock; + + /** + * @var DatabaseMapPool|\PHPUnit_Framework_MockObject_MockObject + */ + private $databaseMapPoolMock; + + /** + * @inheritdoc + */ + protected function setUp() + { + $objectManager = new ObjectManager($this); + $this->categoryUrlRewriteGeneratorMock = $this->createMock(CategoryUrlRewriteGenerator::class); + $this->urlRewriteHandlerMock = $this->createMock(UrlRewriteHandler::class); + $this->urlRewriteBunchReplacerMock = $this->createMock(UrlRewriteBunchReplacer::class); + $this->databaseMapPoolMock = $this->createMock(DatabaseMapPool::class); + /** @var CollectionFactory|\PHPUnit_Framework_MockObject_MockObject $storeGroupFactoryMock */ + $storeGroupCollectionFactoryMock = $this->createMock(CollectionFactory::class); + + $this->observer = $objectManager->getObject( + CategoryProcessUrlRewriteSavingObserver::class, + [ + 'categoryUrlRewriteGenerator' => $this->categoryUrlRewriteGeneratorMock, + 'urlRewriteHandler' => $this->urlRewriteHandlerMock, + 'urlRewriteBunchReplacer' => $this->urlRewriteBunchReplacerMock, + 'databaseMapPool' => $this->databaseMapPoolMock, + 'dataUrlRewriteClassNames' => [ + DataCategoryUrlRewriteDatabaseMap::class, + DataProductUrlRewriteDatabaseMap::class + ], + 'storeGroupFactory' => $storeGroupCollectionFactoryMock, + ] + ); + } + + /** + * Covers case when only associated products are changed for category. + * + * @return void + */ + public function testExecuteCategoryOnlyProductHasChanged() + { + $productId = 120; + $productRewrites = ['product-url-rewrite']; + + /** @var Observer|\PHPUnit_Framework_MockObject_MockObject $observerMock */ + $observerMock = $this->createMock(Observer::class); + /** @var Event|\PHPUnit_Framework_MockObject_MockObject $eventMock */ + $eventMock = $this->createMock(Event::class); + /** @var Category|\PHPUnit_Framework_MockObject_MockObject $categoryMock */ + $categoryMock = $this->createPartialMock( + Category::class, + [ + 'hasData', + 'dataHasChangedFor', + 'getChangedProductIds', + ] + ); + + $categoryMock->expects($this->once())->method('hasData')->with('store_id')->willReturn(true); + $categoryMock->expects($this->exactly(2))->method('getChangedProductIds')->willReturn([$productId]); + $categoryMock->expects($this->any())->method('dataHasChangedFor') + ->willReturnMap( + [ + ['url_key', false], + ['is_anchor', false], + ] + ); + $eventMock->expects($this->once())->method('getData')->with('category')->willReturn($categoryMock); + $observerMock->expects($this->once())->method('getEvent')->willReturn($eventMock); + + $this->urlRewriteHandlerMock->expects($this->once()) + ->method('updateProductUrlRewritesForChangedProduct') + ->with($categoryMock) + ->willReturn($productRewrites); + + $this->urlRewriteBunchReplacerMock->expects($this->once()) + ->method('doBunchReplace') + ->with($productRewrites, 10000); + + $this->observer->execute($observerMock); + } +} diff --git a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/CategoryUrlPathAutogeneratorObserverTest.php b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/CategoryUrlPathAutogeneratorObserverTest.php index 1b4d1e08aa208..8ea5dc1ba621b 100644 --- a/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/CategoryUrlPathAutogeneratorObserverTest.php +++ b/app/code/Magento/CatalogUrlRewrite/Test/Unit/Observer/CategoryUrlPathAutogeneratorObserverTest.php @@ -49,7 +49,9 @@ protected function setUp() 'getResource', 'getUrlKey', 'getStoreId', - 'getData' + 'getData', + 'getOrigData', + 'formatUrlKey', ]); $this->category->expects($this->any())->method('getResource')->willReturn($this->categoryResource); $this->observer->expects($this->any())->method('getEvent')->willReturnSelf(); @@ -109,7 +111,35 @@ public function testExecuteWithException() $this->categoryUrlPathGenerator->expects($this->once()) ->method('getUrlKey') ->with($this->category) - ->willReturn(null); + ->willReturn(''); + $this->categoryUrlPathAutogeneratorObserver->execute($this->observer); + } + + /** + * Test execute method when option use default url key is true. + */ + public function testExecuteWithUseDefault() + { + $categoryName = 'test'; + $categoryData = ['url_key' => 1]; + $this->category->expects($this->once())->method('getUrlKey')->willReturn($categoryName); + $this->category->expects($this->atLeastOnce()) + ->method('getData') + ->with('use_default') + ->willReturn($categoryData); + $this->category->expects($this->once())->method('getOrigData')->with('name')->willReturn('some_name'); + $this->category->expects($this->once())->method('formatUrlKey')->with('some_name')->willReturn('url_key'); + $this->category->expects($this->any())->method('setUrlKey') + ->willReturnMap([ + ['url_key', $this->category], + [null, $this->category], + ]); + $this->category->expects($this->any())->method('setUrlPath') + ->willReturnMap([ + ['url_path', $this->category], + [null, $this->category], + ]); + $this->categoryUrlPathAutogeneratorObserver->execute($this->observer); } diff --git a/app/code/Magento/CatalogUrlRewrite/composer.json b/app/code/Magento/CatalogUrlRewrite/composer.json index f6d3f2102940c..2f34ee54f36e1 100644 --- a/app/code/Magento/CatalogUrlRewrite/composer.json +++ b/app/code/Magento/CatalogUrlRewrite/composer.json @@ -17,7 +17,7 @@ "magento/module-webapi": "*" }, "type": "magento2-module", - "version": "100.2.5", + "version": "100.2.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CatalogWidget/Test/Mftf/ActionGroup/AdminCreateBlockWithWidgetActionGroup.xml b/app/code/Magento/CatalogWidget/Test/Mftf/ActionGroup/AdminCreateBlockWithWidgetActionGroup.xml new file mode 100644 index 0000000000000..3631e56fcdcf0 --- /dev/null +++ b/app/code/Magento/CatalogWidget/Test/Mftf/ActionGroup/AdminCreateBlockWithWidgetActionGroup.xml @@ -0,0 +1,45 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminCreateCmsBlockWithCatalogProductListWidget"> + <arguments> + <argument name="conditionAttribute" type="string"/> + <argument name="conditionOperator" type="string"/> + <argument name="conditionValue" type="string"/> + </arguments> + <fillField selector="{{AdminCmsBlockContentSection.content}}" userInput="" stepKey="makeContentFieldEmpty"/> + + <click selector="{{AdminCmsBlockContentSection.insertWidgetButton}}" stepKey="clickInsertWidgetButton"/> + <waitForElementVisible selector="{{AdminNewWidgetSection.widgetTypeDropDown}}" time="10" stepKey="waitForInsertWidgetFrame"/> + + <selectOption selector="{{AdminNewWidgetSection.widgetTypeDropDown}}" userInput="Catalog Products List" stepKey="selectCatalogProductListOption"/> + <waitForElementVisible selector="{{AdminNewWidgetSection.addNewCondition}}" stepKey="waitForConditionsElementBecomeAvailable"/> + + <click selector="{{AdminNewWidgetSection.addNewCondition}}" stepKey="clickToAddCondition"/> + <waitForElementVisible selector="{{AdminNewWidgetSection.selectCondition}}" stepKey="waitForSelectBoxOpened"/> + + <selectOption selector="{{AdminNewWidgetSection.selectCondition}}" userInput="{{conditionAttribute}}" stepKey="selectConditionsSelectBox"/> + <waitForElementVisible selector="{{AdminNewWidgetSection.ruleParameter}}" stepKey="seeConditionsAdded"/> + + <click selector="{{AdminNewWidgetSection.conditionOperator}}" stepKey="clickToConditionIs"/> + <selectOption selector="{{AdminNewWidgetSection.conditionOperatorSelect('1')}}" userInput="{{conditionOperator}}" stepKey="selectOperator"/> + + <click selector="{{AdminNewWidgetSection.ruleParameter}}" stepKey="clickAddConditionItem"/> + <waitForElementVisible selector="{{AdminNewWidgetSection.setRuleParameter}}" stepKey="waitForConditionFieldOpened"/> + + <fillField selector="{{AdminNewWidgetSection.setRuleParameter}}" userInput="{{conditionValue}}" stepKey="setConditionValue"/> + <click selector="{{AdminNewWidgetSection.insertWidget}}" stepKey="clickInsertWidget"/> + + <waitForElementVisible selector="{{AdminMainActionsSection.save}}" stepKey="waitForInsertWidgetSaved"/> + <click selector="{{AdminMainActionsSection.save}}" stepKey="clickSaveButton"/> + <see userInput="You saved the block." stepKey="seeSavedBlockMsgOnForm"/> + <conditionalClick selector="{{AdminDataGridHeaderSection.clearFilters}}" dependentSelector="{{AdminDataGridHeaderSection.clearFilters}}" visible="true" stepKey="clearExistingOrderFilters"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/CatalogWidget/Test/Mftf/Test/AdminCatalogProductListWidgetOperatorsTest.xml b/app/code/Magento/CatalogWidget/Test/Mftf/Test/AdminCatalogProductListWidgetOperatorsTest.xml new file mode 100644 index 0000000000000..95cc73ae92d4e --- /dev/null +++ b/app/code/Magento/CatalogWidget/Test/Mftf/Test/AdminCatalogProductListWidgetOperatorsTest.xml @@ -0,0 +1,145 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminCatalogProductListWidgetOperatorsTest"> + <annotations> + <features value="CatalogWidget"/> + <stories value="MAGETWO-90930: Problems with operator more/less in the 'catalog Products List' widget"/> + <title value="Checking operator more/less in the 'catalog Products List' widget"/> + <description value="Check 'less than', 'equals or greater than', 'equals or less than' operators"/> + <severity value="MAJOR"/> + <testCaseId value="MAGETWO-96479"/> + <group value="catalogWidget"/> + <group value="WYSIWYGDisabled"/> + </annotations> + + <before> + <createData entity="SimpleSubCategory" stepKey="createSimpleCategory"/> + <createData entity="SimpleProduct" stepKey="createFirstProduct"> + <requiredEntity createDataKey="createSimpleCategory"/> + <field key="price">10</field> + </createData> + <createData entity="SimpleProduct" stepKey="createSecondProduct"> + <requiredEntity createDataKey="createSimpleCategory"/> + <field key="price">50</field> + </createData> + <createData entity="SimpleProduct" stepKey="createThirdProduct"> + <requiredEntity createDataKey="createSimpleCategory"/> + <field key="price">100</field> + </createData> + <createData entity="DefaultCmsBlock" stepKey="createPreReqBlock"/> + <!--User log in on back-end as admin--> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + </before> + <after> + <deleteData createDataKey="createPreReqBlock" stepKey="deletePreReqBlock" /> + <deleteData createDataKey="createSimpleCategory" stepKey="deleteSimpleCategory"/> + <deleteData createDataKey="createFirstProduct" stepKey="deleteFirstProduct"/> + <deleteData createDataKey="createSecondProduct" stepKey="deleteSecondProduct"/> + <deleteData createDataKey="createThirdProduct" stepKey="deleteThirdProduct"/> + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <!--Open block with widget--> + <actionGroup ref="NavigateToCreatedCMSBlockPage" stepKey="navigateToCreatedCMSBlockPage1"> + <argument name="cmsBlock" value="$$createPreReqBlock$$"/> + </actionGroup> + + <actionGroup ref="AdminCreateCmsBlockWithCatalogProductListWidget" stepKey="adminCreateBlockWithWidget"> + <argument name="conditionAttribute" value="Price"/> + <argument name="conditionOperator" value="greater than"/> + <argument name="conditionValue" value="20"/> + </actionGroup> + + <!--Go to Catalog > Categories (choose category where created products)--> + <amOnPage url="{{AdminCategoryPage.url}}" stepKey="onCategoryIndexPage"/> + <click selector="{{AdminCategorySidebarTreeSection.expandAll}}" stepKey="clickExpandAll"/> + <waitForElementVisible selector="{{AdminCategorySidebarTreeSection.categoryInTree(SimpleSubCategory.name)}}" stepKey="waitForCategoryVisible"/> + <click selector="{{AdminCategorySidebarTreeSection.categoryInTree(SimpleSubCategory.name)}}" stepKey="clickCategoryLink"/> + + <!--Categories > Content > Add CMS Block: name saved block--> + <waitForElementVisible selector="{{AdminCategoryContentSection.sectionHeader}}" stepKey="waitForContentSection"/> + <conditionalClick selector="{{AdminCategoryContentSection.sectionHeader}}" dependentSelector="{{AdminCategoryContentSection.addCMSBlock}}" visible="false" stepKey="openContentSection"/> + + <selectOption selector="{{AdminCategoryContentSection.addCMSBlock}}" userInput="{{DefaultCmsBlock.title}}" stepKey="selectSavedBlock"/> + + <!--Display Settings > Display Mode: Static block only--> + <waitForElementVisible selector="{{AdminCategoryDisplaySettingsSection.settingsHeader}}" stepKey="waitForDisplaySettingsSection"/> + <conditionalClick selector="{{AdminCategoryDisplaySettingsSection.settingsHeader}}" dependentSelector="{{AdminCategoryDisplaySettingsSection.displayMode}}" visible="false" stepKey="openDisplaySettingsSection"/> + <selectOption userInput="Static block only" selector="{{AdminCategoryDisplaySettingsSection.displayMode}}" stepKey="selectStaticBlockOnlyOption"/> + <click selector="{{AdminMainActionsSection.save}}" stepKey="saveCategoryWithProducts"/> + <see userInput="You saved the category." stepKey="seeSuccessMessage"/> + + <!--Go to Storefront > category--> + <amOnPage url="{{StorefrontCategoryPage.url($$createSimpleCategory.name$$)}}" stepKey="goToStorefrontCategoryPage1"/> + + <!--Check operators Greater than--> + <dontSeeElement selector="{{StorefrontWidgetsSection.checkElementStorefrontByPrice('10')}}" stepKey="dontSeeElementByPrice10"/> + <seeElement selector="{{StorefrontWidgetsSection.checkElementStorefrontByPrice('50')}}" stepKey="seeElementByPrice50"/> + <seeElement selector="{{StorefrontWidgetsSection.checkElementStorefrontByPrice('100')}}" stepKey="seeElementByPrice100"/> + + <!--Open block with widget.--> + <actionGroup ref="NavigateToCreatedCMSBlockPage" stepKey="navigateToCreatedCMSBlockPage2"> + <argument name="cmsBlock" value="$$createPreReqBlock$$"/> + </actionGroup> + + <actionGroup ref="AdminCreateCmsBlockWithCatalogProductListWidget" stepKey="adminCreateBlockWithWidgetLessThan"> + <argument name="conditionAttribute" value="Price"/> + <argument name="conditionOperator" value="less than"/> + <argument name="conditionValue" value="20"/> + </actionGroup> + + <!--Go to Storefront > category--> + <amOnPage url="{{StorefrontCategoryPage.url($$createSimpleCategory.name$$)}}" stepKey="goToStorefrontCategoryPage2"/> + + <!--Check operators Greater than--> + <seeElement selector="{{StorefrontWidgetsSection.checkElementStorefrontByPrice('10')}}" stepKey="seeElementByPrice10"/> + <dontSeeElement selector="{{StorefrontWidgetsSection.checkElementStorefrontByPrice('50')}}" stepKey="dontSeeElementByPrice50"/> + <dontSeeElement selector="{{StorefrontWidgetsSection.checkElementStorefrontByPrice('100')}}" stepKey="dontSeeElementByPrice100"/> + + <!--Open block with widget--> + <actionGroup ref="NavigateToCreatedCMSBlockPage" stepKey="navigateToCreatedCMSBlockPage3"> + <argument name="cmsBlock" value="$$createPreReqBlock$$"/> + </actionGroup> + + <actionGroup ref="AdminCreateCmsBlockWithCatalogProductListWidget" stepKey="adminCreateBlockWithWidgetEqualsOrGreaterThan"> + <argument name="conditionAttribute" value="Price"/> + <argument name="conditionOperator" value="equals or greater than"/> + <argument name="conditionValue" value="50"/> + </actionGroup> + + <!--Go to Storefront > category--> + <amOnPage url="{{StorefrontCategoryPage.url($$createSimpleCategory.name$$)}}" stepKey="goToStorefrontCategoryPage3"/> + + <!--Check operators Greater than--> + <dontSeeElement selector="{{StorefrontWidgetsSection.checkElementStorefrontByPrice('10')}}" stepKey="dontSeeElementByPrice10a"/> + <seeElement selector="{{StorefrontWidgetsSection.checkElementStorefrontByPrice('50')}}" stepKey="seeElementByPrice50a"/> + <seeElement selector="{{StorefrontWidgetsSection.checkElementStorefrontByPrice('100')}}" stepKey="seeElementByPrice100a"/> + + <!--Open block with widget--> + <actionGroup ref="NavigateToCreatedCMSBlockPage" stepKey="navigateToCreatedCMSBlockPage4"> + <argument name="cmsBlock" value="$$createPreReqBlock$$"/> + </actionGroup> + + <actionGroup ref="AdminCreateCmsBlockWithCatalogProductListWidget" stepKey="adminCreateBlockWithWidgetEqualsOrLessThan"> + <argument name="conditionAttribute" value="Price"/> + <argument name="conditionOperator" value="equals or less than"/> + <argument name="conditionValue" value="50"/> + </actionGroup> + + <!--Go to Storefront > category--> + <amOnPage url="{{StorefrontCategoryPage.url($$createSimpleCategory.name$$)}}" stepKey="goToStorefrontCategoryPage4"/> + + <!--Check operators Greater than--> + <seeElement selector="{{StorefrontWidgetsSection.checkElementStorefrontByPrice('10')}}" stepKey="seeElementByPrice10b"/> + <seeElement selector="{{StorefrontWidgetsSection.checkElementStorefrontByPrice('50')}}" stepKey="seeElementByPrice50b"/> + <dontSeeElement selector="{{StorefrontWidgetsSection.checkElementStorefrontByPrice('100')}}" stepKey="dontSeeElementByPrice100b"/> + </test> +</tests> diff --git a/app/code/Magento/CatalogWidget/composer.json b/app/code/Magento/CatalogWidget/composer.json index 496651a6bfa17..7bc1240c43276 100644 --- a/app/code/Magento/CatalogWidget/composer.json +++ b/app/code/Magento/CatalogWidget/composer.json @@ -14,7 +14,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Checkout/Api/Data/PaymentDetailsInterface.php b/app/code/Magento/Checkout/Api/Data/PaymentDetailsInterface.php index 45e885d0dbd46..589a94243efa5 100644 --- a/app/code/Magento/Checkout/Api/Data/PaymentDetailsInterface.php +++ b/app/code/Magento/Checkout/Api/Data/PaymentDetailsInterface.php @@ -5,11 +5,13 @@ */ namespace Magento\Checkout\Api\Data; +use Magento\Framework\Api\ExtensibleDataInterface; + /** * Interface PaymentDetailsInterface * @api */ -interface PaymentDetailsInterface +interface PaymentDetailsInterface extends ExtensibleDataInterface { /**#@+ * Constants defined for keys of array, makes typos less likely diff --git a/app/code/Magento/Checkout/Block/Checkout/AttributeMerger.php b/app/code/Magento/Checkout/Block/Checkout/AttributeMerger.php index de996bed02439..bf0884a8c83ea 100644 --- a/app/code/Magento/Checkout/Block/Checkout/AttributeMerger.php +++ b/app/code/Magento/Checkout/Block/Checkout/AttributeMerger.php @@ -6,10 +6,14 @@ namespace Magento\Checkout\Block\Checkout; use Magento\Customer\Api\CustomerRepositoryInterface as CustomerRepository; +use Magento\Customer\Api\Data\CustomerInterface; use Magento\Customer\Helper\Address as AddressHelper; use Magento\Customer\Model\Session; use Magento\Directory\Helper\Data as DirectoryHelper; +/** + * Fields attribute merger. + */ class AttributeMerger { /** @@ -46,6 +50,7 @@ class AttributeMerger 'alpha' => 'validate-alpha', 'numeric' => 'validate-number', 'alphanumeric' => 'validate-alphanum', + 'alphanum-with-spaces' => 'validate-alphanum-with-spaces', 'url' => 'validate-url', 'email' => 'email2', 'length' => 'validate-length', @@ -67,7 +72,7 @@ class AttributeMerger private $customerRepository; /** - * @var \Magento\Customer\Api\Data\CustomerInterface + * @var CustomerInterface */ private $customer; @@ -309,6 +314,8 @@ protected function getMultilineFieldConfig($attributeCode, array $attributeConfi } /** + * Returns default attribute value. + * * @param string $attributeCode * @return null|string */ @@ -346,7 +353,9 @@ protected function getDefaultValue($attributeCode) } /** - * @return \Magento\Customer\Api\Data\CustomerInterface|null + * Returns logged customer. + * + * @return CustomerInterface|null */ protected function getCustomer() { diff --git a/app/code/Magento/Checkout/Block/Onepage.php b/app/code/Magento/Checkout/Block/Onepage.php index ca6b045ddbb5d..e01d5835b4cf0 100644 --- a/app/code/Magento/Checkout/Block/Onepage.php +++ b/app/code/Magento/Checkout/Block/Onepage.php @@ -38,7 +38,7 @@ class Onepage extends \Magento\Framework\View\Element\Template protected $layoutProcessors; /** - * @var \Magento\Framework\Serialize\Serializer\Json + * @var \Magento\Framework\Serialize\SerializerInterface */ private $serializer; @@ -48,8 +48,9 @@ class Onepage extends \Magento\Framework\View\Element\Template * @param \Magento\Checkout\Model\CompositeConfigProvider $configProvider * @param array $layoutProcessors * @param array $data - * @param \Magento\Framework\Serialize\Serializer\Json|null $serializer - * @throws \RuntimeException + * @param \Magento\Framework\Serialize\Serializer\Json $serializer + * @param \Magento\Framework\Serialize\SerializerInterface $serializerInterface + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function __construct( \Magento\Framework\View\Element\Template\Context $context, @@ -57,7 +58,8 @@ public function __construct( \Magento\Checkout\Model\CompositeConfigProvider $configProvider, array $layoutProcessors = [], array $data = [], - \Magento\Framework\Serialize\Serializer\Json $serializer = null + \Magento\Framework\Serialize\Serializer\Json $serializer = null, + \Magento\Framework\Serialize\SerializerInterface $serializerInterface = null ) { parent::__construct($context, $data); $this->formKey = $formKey; @@ -65,12 +67,12 @@ public function __construct( $this->jsLayout = isset($data['jsLayout']) && is_array($data['jsLayout']) ? $data['jsLayout'] : []; $this->configProvider = $configProvider; $this->layoutProcessors = $layoutProcessors; - $this->serializer = $serializer ?: \Magento\Framework\App\ObjectManager::getInstance() - ->get(\Magento\Framework\Serialize\Serializer\Json::class); + $this->serializer = $serializerInterface ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(\Magento\Framework\Serialize\Serializer\JsonHexTag::class); } /** - * @return string + * @inheritdoc */ public function getJsLayout() { @@ -78,7 +80,7 @@ public function getJsLayout() $this->jsLayout = $processor->process($this->jsLayout); } - return json_encode($this->jsLayout, JSON_HEX_TAG); + return $this->serializer->serialize($this->jsLayout); } /** @@ -115,11 +117,13 @@ public function getBaseUrl() } /** + * Retrieve serialized checkout config. + * * @return bool|string * @since 100.2.0 */ public function getSerializedCheckoutConfig() { - return json_encode($this->getCheckoutConfig(), JSON_HEX_TAG); + return $this->serializer->serialize($this->getCheckoutConfig()); } } diff --git a/app/code/Magento/Checkout/Controller/Sidebar/UpdateItemQty.php b/app/code/Magento/Checkout/Controller/Sidebar/UpdateItemQty.php index e9a968681bf3d..3ca511cce6f4e 100644 --- a/app/code/Magento/Checkout/Controller/Sidebar/UpdateItemQty.php +++ b/app/code/Magento/Checkout/Controller/Sidebar/UpdateItemQty.php @@ -50,12 +50,12 @@ public function __construct( } /** - * @return $this + * @inheritdoc */ public function execute() { $itemId = (int)$this->getRequest()->getParam('item_id'); - $itemQty = (int)$this->getRequest()->getParam('item_qty'); + $itemQty = $this->getRequest()->getParam('item_qty'); try { $this->sidebar->checkQuoteItem($itemId); diff --git a/app/code/Magento/Checkout/CustomerData/Cart.php b/app/code/Magento/Checkout/CustomerData/Cart.php index ddb077462ef10..01e91d75c02d9 100644 --- a/app/code/Magento/Checkout/CustomerData/Cart.php +++ b/app/code/Magento/Checkout/CustomerData/Cart.php @@ -82,7 +82,7 @@ public function __construct( } /** - * {@inheritdoc} + * @inheritdoc */ public function getSectionData() { @@ -158,11 +158,10 @@ protected function getRecentItems() : $item->getProduct(); $products = $this->catalogUrl->getRewriteByProductStore([$product->getId() => $item->getStoreId()]); - if (!isset($products[$product->getId()])) { - continue; + if (isset($products[$product->getId()])) { + $urlDataObject = new \Magento\Framework\DataObject($products[$product->getId()]); + $item->getProduct()->setUrlDataObject($urlDataObject); } - $urlDataObject = new \Magento\Framework\DataObject($products[$product->getId()]); - $item->getProduct()->setUrlDataObject($urlDataObject); } $items[] = $this->itemPoolInterface->getItemData($item); } diff --git a/app/code/Magento/Checkout/Model/DefaultConfigProvider.php b/app/code/Magento/Checkout/Model/DefaultConfigProvider.php index 04cabf3db89c4..85065599341c1 100644 --- a/app/code/Magento/Checkout/Model/DefaultConfigProvider.php +++ b/app/code/Magento/Checkout/Model/DefaultConfigProvider.php @@ -14,6 +14,7 @@ use Magento\Customer\Model\Session as CustomerSession; use Magento\Customer\Model\Url as CustomerUrlManager; use Magento\Eav\Api\AttributeOptionManagementInterface; +use Magento\Framework\Api\CustomAttributesDataInterface; use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\Http\Context as HttpContext; use Magento\Framework\App\ObjectManager; @@ -22,9 +23,11 @@ use Magento\Framework\UrlInterface; use Magento\Quote\Api\CartItemRepositoryInterface as QuoteItemRepository; use Magento\Quote\Api\CartTotalRepositoryInterface; +use Magento\Quote\Api\Data\AddressInterface; use Magento\Quote\Api\ShippingMethodManagementInterface as ShippingMethodManager; use Magento\Quote\Model\QuoteIdMaskFactory; use Magento\Store\Model\ScopeInterface; +use Magento\Ui\Component\Form\Element\Multiline; /** * Default Config Provider. @@ -268,17 +271,33 @@ public function __construct( } /** - * @inheritdoc + * Return configuration array. + * + * @return array|mixed + * @throws \Magento\Framework\Exception\LocalizedException */ public function getConfig() { - $quoteId = $this->checkoutSession->getQuote()->getId(); + $quote = $this->checkoutSession->getQuote(); + $quoteId = $quote->getId(); + $email = $quote->getShippingAddress()->getEmail(); + $quoteItemData = $this->getQuoteItemData(); $output['formKey'] = $this->formKey->getFormKey(); $output['customerData'] = $this->getCustomerData(); $output['quoteData'] = $this->getQuoteData(); - $output['quoteItemData'] = $this->getQuoteItemData(); + $output['quoteItemData'] = $quoteItemData; + $output['quoteMessages'] = $this->getQuoteItemsMessages($quoteItemData); $output['isCustomerLoggedIn'] = $this->isCustomerLoggedIn(); $output['selectedShippingMethod'] = $this->getSelectedShippingMethod(); + if ($email && !$this->isCustomerLoggedIn()) { + $shippingAddressFromData = $this->getAddressFromData($quote->getShippingAddress()); + $billingAddressFromData = $this->getAddressFromData($quote->getBillingAddress()); + $output['shippingAddressFromData'] = $shippingAddressFromData; + if ($shippingAddressFromData != $billingAddressFromData) { + $output['billingAddressFromData'] = $billingAddressFromData; + } + $output['validatedEmailValue'] = $email; + } $output['storeCode'] = $this->getStoreCode(); $output['isGuestCheckoutAllowed'] = $this->isGuestCheckoutAllowed(); $output['registerUrl'] = $this->getRegisterUrl(); @@ -289,14 +308,15 @@ public function getConfig() $output['staticBaseUrl'] = $this->getStaticBaseUrl(); $output['priceFormat'] = $this->localeFormat->getPriceFormat( null, - $this->checkoutSession->getQuote()->getQuoteCurrencyCode() + $quote->getQuoteCurrencyCode() ); $output['basePriceFormat'] = $this->localeFormat->getPriceFormat( null, - $this->checkoutSession->getQuote()->getBaseCurrencyCode() + $quote->getBaseCurrencyCode() ); $output['postCodes'] = $this->postCodesConfig->getPostCodes(); $output['imageData'] = $this->imageProvider->getImages($quoteId); + $output['totalsData'] = $this->getTotalsData(); $output['shippingPolicy'] = [ 'isEnabled' => $this->scopeConfig->isSetFlag( @@ -431,6 +451,7 @@ private function getQuoteItemData() $quoteItem->getProduct(), 'product_thumbnail_image' )->getUrl(); + $quoteItemData[$index]['message'] = $quoteItem->getMessage(); } } return $quoteItemData; @@ -525,7 +546,40 @@ private function getSelectedShippingMethod() } /** - * Retrieve store code. + * Create address data appropriate to fill checkout address form. + * + * @param AddressInterface $address + * @return array + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function getAddressFromData(AddressInterface $address): array + { + $addressData = []; + $attributesMetadata = $this->addressMetadata->getAllAttributesMetadata(); + foreach ($attributesMetadata as $attributeMetadata) { + if (!$attributeMetadata->isVisible()) { + continue; + } + $attributeCode = $attributeMetadata->getAttributeCode(); + $attributeData = $address->getData($attributeCode); + if ($attributeData) { + if ($attributeMetadata->getFrontendInput() === Multiline::NAME) { + $attributeData = is_array($attributeData) ? $attributeData : explode("\n", $attributeData); + $attributeData = (object)$attributeData; + } + if ($attributeMetadata->isUserDefined()) { + $addressData[CustomAttributesDataInterface::CUSTOM_ATTRIBUTES][$attributeCode] = $attributeData; + continue; + } + $addressData[$attributeCode] = $attributeData; + } + } + + return $addressData; + } + + /** + * Retrieve store code * * @return string * @codeCoverageIgnore @@ -709,4 +763,22 @@ private function getAttributeLabels(array $customAttribute, string $customAttrib return $attributeOptionLabels; } + + /** + * Get notification messages for the quote items + * + * @param array $quoteItemData + * @return array + */ + private function getQuoteItemsMessages(array $quoteItemData): array + { + $quoteItemsMessages = []; + if ($quoteItemData) { + foreach ($quoteItemData as $item) { + $quoteItemsMessages[$item['item_id']] = $item['message']; + } + } + + return $quoteItemsMessages; + } } diff --git a/app/code/Magento/Checkout/Model/ShippingInformationManagement.php b/app/code/Magento/Checkout/Model/ShippingInformationManagement.php index d8142d033f78c..d6fe3ece473df 100644 --- a/app/code/Magento/Checkout/Model/ShippingInformationManagement.php +++ b/app/code/Magento/Checkout/Model/ShippingInformationManagement.php @@ -98,8 +98,8 @@ class ShippingInformationManagement implements \Magento\Checkout\Api\ShippingInf * @param \Magento\Customer\Api\AddressRepositoryInterface $addressRepository * @param \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig * @param \Magento\Quote\Model\Quote\TotalsCollector $totalsCollector - * @param CartExtensionFactory|null $cartExtensionFactory, - * @param ShippingAssignmentFactory|null $shippingAssignmentFactory, + * @param CartExtensionFactory|null $cartExtensionFactory + * @param ShippingAssignmentFactory|null $shippingAssignmentFactory * @param ShippingFactory|null $shippingFactory * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ @@ -150,6 +150,10 @@ public function saveAddressInformation( $address->setCustomerAddressId(null); } + if ($billingAddress && !$billingAddress->getCustomerAddressId()) { + $billingAddress->setCustomerAddressId(null); + } + if (!$address->getCountryId()) { throw new StateException(__('Shipping address is not set')); } @@ -203,6 +207,8 @@ protected function validateQuote(\Magento\Quote\Model\Quote $quote) } /** + * Prepare shipping assignment. + * * @param CartInterface $quote * @param AddressInterface $address * @param string $method diff --git a/app/code/Magento/Checkout/Observer/SalesQuoteSaveAfterObserver.php b/app/code/Magento/Checkout/Observer/SalesQuoteSaveAfterObserver.php index d926e33d54113..6bc7965ff5e34 100644 --- a/app/code/Magento/Checkout/Observer/SalesQuoteSaveAfterObserver.php +++ b/app/code/Magento/Checkout/Observer/SalesQuoteSaveAfterObserver.php @@ -7,6 +7,9 @@ use Magento\Framework\Event\ObserverInterface; +/** + * Class SalesQuoteSaveAfterObserver + */ class SalesQuoteSaveAfterObserver implements ObserverInterface { /** @@ -24,15 +27,18 @@ public function __construct(\Magento\Checkout\Model\Session $checkoutSession) } /** + * Assign quote to session + * * @param \Magento\Framework\Event\Observer $observer * @return void */ public function execute(\Magento\Framework\Event\Observer $observer) { + /* @var \Magento\Quote\Model\Quote $quote */ $quote = $observer->getEvent()->getQuote(); - /* @var $quote \Magento\Quote\Model\Quote */ + if ($quote->getIsCheckoutCart()) { - $this->checkoutSession->getQuoteId($quote->getId()); + $this->checkoutSession->setQuoteId($quote->getId()); } } } diff --git a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/AdminCheckoutActionGroup.xml b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/AdminCheckoutActionGroup.xml new file mode 100644 index 0000000000000..b8686f93a4039 --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/AdminCheckoutActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/actionGroupSchema.xsd"> + <!-- Checkout select Check/Money billing method --> + <actionGroup name="AdminCheckoutSelectCheckMoneyOrderBillingMethodActionGroup"> + <waitForLoadingMaskToDisappear stepKey="waitForLoadingMask"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + <conditionalClick selector="{{AdminCheckoutPaymentSection.checkBillingMethodByName('Check / Money order')}}" dependentSelector="{{AdminCheckoutPaymentSection.checkBillingMethodByName('Check / Money order')}}" visible="true" stepKey="selectCheckmoBillingMethod"/> + <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskAfterBillingMethodSelection"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/CheckoutActionGroup.xml b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/CheckoutActionGroup.xml index aa168899ddb02..f79d59028c468 100644 --- a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/CheckoutActionGroup.xml +++ b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/CheckoutActionGroup.xml @@ -11,8 +11,24 @@ <!-- Checkout select Check/Money Order payment --> <actionGroup name="CheckoutSelectCheckMoneyOrderPaymentActionGroup"> <waitForLoadingMaskToDisappear stepKey="waitForLoadingMask"/> - <waitForElement selector="{{CheckoutPaymentSection.paymentSectionTitle}}" time="30" stepKey="waitForPaymentSectionLoaded"/> - <conditionalClick selector="{{CheckoutPaymentSection.checkMoneyOrderPayment}}" dependentSelector="{{CheckoutPaymentSection.checkMoneyOrderPayment}}" visible="true" stepKey="clickCheckMoneyOrderPayment"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + <conditionalClick selector="{{PaymentMethodSection.checkPaymentMethodByName('Check / Money order')}}" dependentSelector="{{PaymentMethodSection.checkPaymentMethodByName('Check / Money order')}}" visible="true" stepKey="selectCheckmoPaymentMethod"/> + <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskAfterPaymentMethodSelection"/> + </actionGroup> + + <!-- Checkout select Flat Rate shipping method --> + <actionGroup name="CheckoutSelectFlatRateShippingMethodActionGroup"> + <conditionalClick selector="{{CheckoutShippingMethodsSection.checkShippingMethodByName('Flat Rate')}}" dependentSelector="{{CheckoutShippingMethodsSection.checkShippingMethodByName('Flat Rate')}}" visible="true" stepKey="selectFlatRateShippingMethod"/> + <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskForNextButton"/> + </actionGroup> + + <!-- Go to checkout from cart --> + <actionGroup name="GoToCheckoutFromCartActionGroup"> + <waitForElementNotVisible selector="{{StorefrontMinicartSection.emptyCart}}" stepKey="waitUpdateQuantity" /> + <click selector="{{StorefrontMinicartSection.showCart}}" stepKey="clickCart"/> + <waitForLoadingMaskToDisappear stepKey="waitForLoadingMask"/> + <seeInCurrentUrl url="{{CheckoutCartPage.url}}" stepKey="assertCheckoutCartUrl"/> + <click selector="{{StorefrontCheckoutCartSummarySection.proceedToCheckout}}" stepKey="goToCheckout"/> </actionGroup> <!-- Checkout place order --> @@ -30,8 +46,8 @@ <!-- Logged in user checkout filling shipping section --> <actionGroup name="LoggedInUserCheckoutFillingShippingSectionActionGroup"> <arguments> - <argument name="customerVar"/> - <argument name="customerAddressVar"/> + <argument name="customerVar" defaultValue="CustomerEntityOne"/> + <argument name="customerAddressVar" defaultValue="CustomerAddressSimple"/> </arguments> <waitForElementVisible selector="{{CheckoutShippingSection.firstName}}" stepKey="waitForFirstNameFieldAppears" time="30"/> <fillField selector="{{CheckoutShippingSection.firstName}}" userInput="{{customerVar.firstname}}" stepKey="enterFirstName"/> @@ -68,9 +84,26 @@ <see selector="{{CheckoutSuccessMainSection.successTitle}}" userInput="Thank you for your purchase!" stepKey="waitForLoadSuccessPage"/> </actionGroup> - <!-- Checkout select Flat Rate shipping method --> - <actionGroup name="CheckoutSelectFlatRateShippingMethodActionGroup"> - <conditionalClick selector="{{CheckoutShippingMethodsSection.checkShippingMethodByName('Flat Rate')}}" dependentSelector="{{CheckoutShippingMethodsSection.checkShippingMethodByName('Flat Rate')}}" visible="true" stepKey="selectFlatRateShippingMethod"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskForNextButton"/> + <!-- Place order with logged the user --> + <actionGroup name="PlaceOrderWithLoggedUserActionGroup"> + <arguments> + <!--First available shipping method will be selected if value is not passed for shippingMethod--> + <argument name="shippingMethod" defaultValue="" type="string"/> + <!--First available payment method will be selected if value is not passed for paymentMethod--> + <argument name="paymentMethod" defaultValue="" type="string"/> + </arguments> + <waitForElementVisible selector="{{StorefrontCheckoutCartSummarySection.proceedToCheckout}}" stepKey="waitProceedToCheckout"/> + <click selector="{{StorefrontCheckoutCartSummarySection.proceedToCheckout}}" stepKey="clickProceedToCheckout"/> + <click selector="{{CheckoutShippingMethodsSection.checkShippingMethodByName('shippingMethod')}}" stepKey="selectShippingMethod"/> + <waitForElement selector="{{CheckoutShippingSection.next}}" stepKey="waitForNextButton"/> + <click selector="{{CheckoutShippingSection.next}}" stepKey="clickNext"/> + <waitForPageLoad stepKey="waitForPaymentPageLoad"/> + <waitForElement selector="{{CheckoutPaymentSection.paymentSectionTitle}}" stepKey="waitForPaymentSectionLoaded"/> + <seeInCurrentUrl url="{{CheckoutPage.url}}/#payment" stepKey="assertCheckoutPaymentUrl"/> + <conditionalClick selector="{{CheckoutPaymentSection.checkPaymentMethodByName('paymentMethod')}}" dependentSelector="{{CheckoutPaymentSection.checkPaymentMethodByName('paymentMethod')}}" + visible="true" stepKey="checkPaymentMethodIfExist"/> + <waitForElementVisible selector="{{CheckoutPaymentSection.placeOrder}}" stepKey="waitForPlaceOrderButton"/> + <click selector="{{CheckoutPaymentSection.placeOrder}}" stepKey="clickPlaceOrder"/> + <see selector="{{CheckoutSuccessMainSection.successTitle}}" userInput="Thank you for your purchase!" stepKey="waitForLoadSuccessPage"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/GoToCheckoutFromMinicartActionGroup.xml b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/GoToCheckoutFromMinicartActionGroup.xml index 5d91be6517097..f03be61cccd3a 100644 --- a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/GoToCheckoutFromMinicartActionGroup.xml +++ b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/GoToCheckoutFromMinicartActionGroup.xml @@ -7,12 +7,13 @@ --> <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/actionGroupSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <!-- Go to checkout from minicart --> <actionGroup name="GoToCheckoutFromMinicartActionGroup"> <waitForElement selector="{{StorefrontMinicartSection.showCart}}" stepKey="waitMiniCartSectionShow" /> <click selector="{{StorefrontMinicartSection.showCart}}" stepKey="clickCart"/> - <click selector="{{StorefrontMinicartSection.goToCheckout}}" stepKey="goToCheckout"/> + <waitForElementVisible selector="{{StorefrontMinicartSection.goToCheckout}}" time="30" stepKey="waitForGoToCheckoutButtonVisible"/> + <click selector="{{StorefrontMinicartSection.goToCheckout}}" stepKey="clickGoToCheckoutButton"/> <waitForPageLoad stepKey="waitForPageLoad"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/GuestCheckoutFillingShippingSectionActionGroup.xml b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/GuestCheckoutFillingShippingSectionActionGroup.xml index 8ff84e7a436e5..19aeeef8e2bc0 100644 --- a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/GuestCheckoutFillingShippingSectionActionGroup.xml +++ b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/GuestCheckoutFillingShippingSectionActionGroup.xml @@ -32,4 +32,13 @@ <seeInCurrentUrl url="{{CheckoutPage.url}}/#payment" stepKey="assertCheckoutPaymentUrl"/> <waitForLoadingMaskToDisappear stepKey="waitForLoadingMask1"/> </actionGroup> + + <actionGroup name="GuestCheckoutFillingShippingSectionWithoutPaymentsActionGroup" extends="GuestCheckoutFillingShippingSectionActionGroup"> + <waitForElement selector="{{CheckoutPaymentSection.isPaymentSection}}" time="30" stepKey="waitForPaymentSectionLoaded"/> + </actionGroup> + + <actionGroup name="GuestCheckoutFillingShippingSectionWithoutRegionActionGroup" extends="GuestCheckoutFillingShippingSectionActionGroup"> + <selectOption selector="{{CheckoutShippingSection.country}}" userInput="{{customerAddressVar.country}}" after="enterPostcode" stepKey="selectCountry"/> + <remove keyForRemoval="selectRegion"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/LoggedInUserCheckoutAddNewAddressInShippingSectionActionGroup.xml b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/LoggedInUserCheckoutAddNewAddressInShippingSectionActionGroup.xml index 722e6f1ee49ab..9c4d16ff500a0 100644 --- a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/LoggedInUserCheckoutAddNewAddressInShippingSectionActionGroup.xml +++ b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/LoggedInUserCheckoutAddNewAddressInShippingSectionActionGroup.xml @@ -7,7 +7,7 @@ --> <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/actionGroupSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <!-- Logged in user checkout add new adress shipping section --> <actionGroup name="LoggedInUserCheckoutAddNewAddressInShippingSectionActionGroup"> <arguments> @@ -21,6 +21,14 @@ <fillField selector="{{CheckoutShippingSection.addTelephone}}" userInput="{{customerAddressVar.telephone}}" stepKey="enterTelephone"/> <click selector="{{CheckoutShippingSection.addSaveButton}}" stepKey="clickSaveAdressAdd"/> <waitForPageLoad stepKey="waitPageLoad"/> - <see stepKey="seeRegionSelected" selector="{{CheckoutShippingSection.selectedShippingAddress}}" userInput="{{customerAddressVar.state}}"/> + <see selector="{{CheckoutShippingSection.selectedShippingAddress}}" userInput="{{customerAddressVar.state}}" stepKey="seeRegionSelected"/> + </actionGroup> + + <actionGroup name="LoggedInUserCheckoutAddNewShippingSectionWithoutRegionActionGroup" + extends="LoggedInUserCheckoutAddNewAddressInShippingSectionActionGroup"> + <remove keyForRemoval="selectRegion"/> + <remove keyForRemoval="seeRegionSelected"/> + <selectOption selector="{{CheckoutShippingSection.addCountry}}" userInput="{{customerAddressVar.country}}" after="enterPostcode" stepKey="enterCountry"/> + <see selector="{{CheckoutShippingSection.selectedShippingAddress}}" userInput="{{customerAddressVar.city}}" after="waitPageLoad" stepKey="seeCitySelected"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontMiniCartActionGroup.xml b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontMiniCartActionGroup.xml new file mode 100644 index 0000000000000..7a5c5e1d15872 --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/StorefrontMiniCartActionGroup.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="clickViewAndEditCartFromMiniCart"> + <conditionalClick selector="{{StorefrontMinicartSection.showCart}}" dependentSelector="{{StorefrontMinicartSection.miniCartOpened}}" visible="false" stepKey="openMiniCart"/> + <waitForElementVisible selector="{{StorefrontMinicartSection.viewAndEditCart}}" stepKey="waitForViewAndEditCartVisible"/> + <click selector="{{StorefrontMinicartSection.viewAndEditCart}}" stepKey="viewAndEditCart"/> + <seeInCurrentUrl url="checkout/cart" stepKey="seeInCurrentUrl"/> + </actionGroup> + <actionGroup name="assertOneProductNameInMiniCart"> + <arguments> + <argument name="productName"/> + </arguments> + <conditionalClick selector="{{StorefrontMinicartSection.showCart}}" dependentSelector="{{StorefrontMinicartSection.miniCartOpened}}" visible="false" stepKey="openMiniCart"/> + <waitForElementVisible selector="{{StorefrontMinicartSection.viewAndEditCart}}" stepKey="waitForViewAndEditCartVisible"/> + <see selector="{{StorefrontMinicartSection.miniCartItemsText}}" userInput="{{productName}}" stepKey="seeInMiniCart"/> + </actionGroup> + + <!--Remove an item from the cart using minicart--> + <actionGroup name="removeProductFromMiniCart"> + <arguments> + <argument name="productName" type="string"/> + </arguments> + <conditionalClick selector="{{StorefrontMinicartSection.showCart}}" dependentSelector="{{StorefrontMinicartSection.miniCartOpened}}" visible="false" stepKey="openMiniCart"/> + <waitForElementVisible selector="{{StorefrontMinicartSection.viewAndEditCart}}" stepKey="waitForMiniCartOpen"/> + <click selector="{{StorefrontMinicartSection.deleteMiniCartItemByName(productName)}}" stepKey="clickDelete"/> + <waitForElementVisible selector="{{StoreFrontRemoveItemModalSection.message}}" stepKey="waitForConfirmationModal"/> + <see selector="{{StoreFrontRemoveItemModalSection.message}}" userInput="Are you sure you would like to remove this item from the shopping cart?" stepKey="seeDeleteConfirmationMessage"/> + <click selector="{{StoreFrontRemoveItemModalSection.ok}}" stepKey="confirmDelete"/> + <waitForPageLoad stepKey="waitForDeleteToFinish"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Checkout/Test/Mftf/Section/AdminCheckoutPaymentSection.xml b/app/code/Magento/Checkout/Test/Mftf/Section/AdminCheckoutPaymentSection.xml new file mode 100644 index 0000000000000..de866fbb50c80 --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/Section/AdminCheckoutPaymentSection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + <section name="AdminCheckoutPaymentSection"> + <element name="checkBillingMethodByName" type="radio" selector="//div[@id='order-billing_method']//dl[@class='admin__payment-methods']//dt//label[contains(., '{{methodName}}')]/..//input" parameterized="true"/> + </section> +</sections> diff --git a/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutCartProductSection.xml b/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutCartProductSection.xml index 2e0b91d50c3ff..f0b58f4263899 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutCartProductSection.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutCartProductSection.xml @@ -32,5 +32,8 @@ parameterized="true"/> <element name="removeItem" type="button" selector="//table[@id='shopping-cart-table']//tbody//tr[contains(@class,'item-actions')]//a[contains(@class,'action-delete')]"/> + <element name="productPriceByName" type="text" + selector="//table[@id='shopping-cart-table']//tbody//tr[//a/text()='{{var1}}']//ancestor::td[contains(@class,'price')]//span[@class='price']" + parameterized="true"/> </section> </sections> diff --git a/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutPaymentSection.xml b/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutPaymentSection.xml index c73001cfe6832..ae8f9aa5f2aa7 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutPaymentSection.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutPaymentSection.xml @@ -24,7 +24,7 @@ <element name="guestTelephone" type="input" selector=".billing-address-form input[name*='telephone']"/> <element name="cartItems" type="text" selector=".minicart-items"/> <element name="billingAddress" type="text" selector="div.billing-address-details"/> - <element name="placeOrder" type="button" selector="button.action.primary.checkout" timeout="30"/> + <element name="placeOrder" type="button" selector=".payment-method._active button.action.primary.checkout" timeout="30"/> <element name="productOptionsByProductItemPrice" type="text" selector="//div[@class='product-item-inner']//div[@class='subtotal']//span[@class='price'][contains(.,'{{price}}')]//ancestor::div[@class='product-item-details']//div[@class='product options']" parameterized="true"/> <element name="productOptionsActiveByProductItemPrice" type="text" selector="//div[@class='subtotal']//span[@class='price'][contains(.,'{{price}}')]//ancestor::div[@class='product-item-details']//div[@class='product options active']" parameterized="true"/> <element name="productItemPriceByName" type="text" selector="//div[@class='product-item-details'][contains(., '{{ProductName}}')]//span[@class='price']" parameterized="true"/> @@ -39,5 +39,10 @@ <element name="newAddressSelect" type="select" selector=".payment-method._active select[name*='billing_address_id']"/> <element name="goToShipping" type="button" selector="#checkout>ul>li.opc-progress-bar-item._complete>span"/> <element name="orderSummarySubtotal" type="text" selector=".totals.sub span" /> + <element name="billingAddressSameAsShipping" type="checkbox" selector=".payment-method._active [name='billing-address-same-as-shipping']"/> + <element name="orderSummaryTotal" type="text" selector="tr.grand.totals span.price" /> + <element name="checkPaymentMethodByName" type="radio" selector="//div[@id='checkout-payment-method-load']//div[contains(., '{{paymentName}}')]/..//input[@type='radio']" parameterized="true"/> + <element name="orderSummaryShippingTotal" type="text" selector=".totals.shipping.excl span.price"/> + <element name="noPaymentMethods" type="text" selector=".no-quotes-block"/> </section> </sections> diff --git a/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutShippingSection.xml b/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutShippingSection.xml index 2a7437c44eccf..76c9e9f674e6a 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutShippingSection.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Section/CheckoutShippingSection.xml @@ -19,7 +19,7 @@ <element name="postcode" type="input" selector="input[name=postcode]"/> <element name="country" type="select" selector="select[name=country_id]"/> <element name="telephone" type="input" selector="input[name=telephone]"/> - <element name="firstShippingMethod" type="radio" selector="#checkout-shipping-method-load input[type='radio']"/> + <element name="firstShippingMethod" type="radio" selector="#checkout-shipping-method-load input[type='radio']" timeout="30"/> <element name="selectedShippingAddress" type="text" selector=".shipping-address-item.selected-item"/> <element name="newAddressButton" type="button" selector="#checkout-step-shipping button"/> <element name="next" type="button" selector="[data-role='opc-continue']"/> @@ -30,7 +30,7 @@ <element name="addState" type="select" selector="#shipping-new-address-form select[name='region_id']"/> <element name="addPostcode" type="input" selector="#shipping-new-address-form input[name='postcode']"/> <element name="addTelephone" type="input" selector="#shipping-new-address-form input[name='telephone']"/> - <element name="addcCountry" type="select" selector="#shipping-new-address-form select[name='country_id']"/> + <element name="addCountry" type="select" selector="#shipping-new-address-form select[name='country_id']"/> <element name="addSaveButton" type="button" selector=".action.primary.action-save-address"/> <element name="editActiveAddress" type="button" selector="//div[@class='shipping-address-item selected-item']//span[text()='Edit']" timeout="30"/> </section> diff --git a/app/code/Magento/Checkout/Test/Mftf/Section/PaymentMethodSection.xml b/app/code/Magento/Checkout/Test/Mftf/Section/PaymentMethodSection.xml index 57f150a1db3e5..05681a5068190 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Section/PaymentMethodSection.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Section/PaymentMethodSection.xml @@ -10,5 +10,6 @@ xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> <section name="PaymentMethodSection"> <element name="billingAddress" type="text" selector=".checkout-billing-address"/> + <element name="checkPaymentMethodByName" type="radio" selector="//div[@id='checkout-payment-method-load']//div[@class='payment-method']//label//span[contains(., '{{methodName}}')]/../..//input" parameterized="true"/> </section> </sections> diff --git a/app/code/Magento/Checkout/Test/Mftf/Section/StorefrontCheckoutCartSummarySection.xml b/app/code/Magento/Checkout/Test/Mftf/Section/StorefrontCheckoutCartSummarySection.xml index a7c5fb484b2bf..7c68ecf543874 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Section/StorefrontCheckoutCartSummarySection.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Section/StorefrontCheckoutCartSummarySection.xml @@ -21,5 +21,7 @@ <element name="shippingHeading" type="button" selector="#block-shipping-heading"/> <element name="blockSummary" type="button" selector="#block-summary"/> <element name="discountAmount" type="text" selector="td[data-th='Discount']"/> + <element name="totalsElementByPosition" type="text" selector=".data.table.totals > tbody tr:nth-of-type({{value}}) > th" parameterized="true"/> + <element name="tableTotals" type="text" selector="#cart-totals .data.table.totals"/> </section> </sections> diff --git a/app/code/Magento/Checkout/Test/Mftf/Section/StorefrontMinicartSection.xml b/app/code/Magento/Checkout/Test/Mftf/Section/StorefrontMinicartSection.xml index baf098c855eab..17ea090ad14d0 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Section/StorefrontMinicartSection.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Section/StorefrontMinicartSection.xml @@ -22,5 +22,9 @@ <element name="viewAndEditCart" type="button" selector=".action.viewcart" timeout="30"/> <element name="miniCartItemsText" type="text" selector=".minicart-items"/> <element name="miniCartSubtotalField" type="text" selector=".block-minicart .amount span.price"/> + <element name="itemQuantity" type="input" selector="//a[text()='{{productName}}']/../..//input[contains(@class,'cart-item-qty')]" parameterized="true"/> + <element name="itemQuantityUpdate" type="button" selector="//a[text()='{{productName}}']/../..//span[text()='Update']" parameterized="true"/> + <element name="emptyCart" type="text" selector=".counter.qty.empty"/> + <element name="minicartContent" type="block" selector="#minicart-content-wrapper"/> </section> </sections> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/CheckNotVisibleProductInMinicartTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/CheckNotVisibleProductInMinicartTest.xml new file mode 100644 index 0000000000000..4b4ca1935fd78 --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/Test/CheckNotVisibleProductInMinicartTest.xml @@ -0,0 +1,70 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="CheckNotVisibleProductInMinicartTest"> + <annotations> + <features value="Checkout"/> + <stories value="MAGETWO-96422: Hidden Products are absent in Storefront Mini-Cart" /> + <title value="Not visible individually product in mini-shopping cart."/> + <description value="To be sure that product in mini-shopping cart remains visible after admin makes it not visible individually"/> + <severity value="MAJOR"/> + <group value="checkout"/> + </annotations> + + <!--Create simple product1 and simple product2--> + <createData entity="SimpleTwo" stepKey="createSimpleProduct1"/> + <createData entity="SimpleTwo" stepKey="createSimpleProduct2"/> + + <!--Go to simple product1 page--> + <amOnPage url="$$createSimpleProduct1.custom_attributes[url_key]$$.html" stepKey="navigateToSimpleProductPage1"/> + <waitForPageLoad stepKey="waitForCatalogPageLoad"/> + + <!--Add simple product1 to Shopping Cart--> + <actionGroup ref="addToCartFromStorefrontProductPage" stepKey="addToCartFromStorefrontProductPage1"> + <argument name="productName" value="$$createSimpleProduct1.name$$"/> + </actionGroup> + + <!--Check simple product1 in minicart--> + <comment userInput="Check simple product 1 in minicart" stepKey="commentCheckSimpleProduct1InMinicart" after="addToCartFromStorefrontProductPage1"/> + <actionGroup ref="assertOneProductNameInMiniCart" stepKey="assertProduct1NameInMiniCart"> + <argument name="productName" value="$$createSimpleProduct1.name$$"/> + </actionGroup> + + <!--Make simple product1 not visible individually--> + <updateData entity="SetProductVisibilityHidden" createDataKey="createSimpleProduct1" stepKey="updateSimpleProduct1"> + <requiredEntity createDataKey="createSimpleProduct1"/> + </updateData> + + <!--Go to simple product2 page--> + <amOnPage url="$$createSimpleProduct2.custom_attributes[url_key]$$.html" stepKey="navigateToSimpleProductPage2"/> + <waitForPageLoad stepKey="waitForCatalogPageLoad2"/> + + <!--Add simple product2 to Shopping Cart for updating cart items--> + <actionGroup ref="addToCartFromStorefrontProductPage" stepKey="addToCartFromStorefrontProductPage2"> + <argument name="productName" value="$$createSimpleProduct2.name$$"/> + </actionGroup> + + <!--Check simple product1 in minicart--> + <comment userInput="Check hidden simple product 1 in minicart" stepKey="commentCheckHiddenSimpleProduct1InMinicart" after="addToCartFromStorefrontProductPage2"/> + <actionGroup ref="assertOneProductNameInMiniCart" stepKey="assertHiddenProduct1NameInMiniCart"> + <argument name="productName" value="$$createSimpleProduct1.name$$"/> + </actionGroup> + + <!--Check simple product2 in minicart--> + <comment userInput="Check hidden simple product 2 in minicart" stepKey="commentCheckSimpleProduct2InMinicart" after="addToCartFromStorefrontProductPage2"/> + <actionGroup ref="assertOneProductNameInMiniCart" stepKey="assertProduct2NameInMiniCart"> + <argument name="productName" value="$$createSimpleProduct2.name$$"/> + </actionGroup> + + <!--Delete simple product1 and simple product2--> + <deleteData createDataKey="createSimpleProduct1" stepKey="deleteProduct1"/> + <deleteData createDataKey="createSimpleProduct2" stepKey="deleteProduct2"/> + </test> +</tests> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutTotalsSortOrderInCartTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutTotalsSortOrderInCartTest.xml new file mode 100644 index 0000000000000..11aae024d2e0a --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCheckoutTotalsSortOrderInCartTest.xml @@ -0,0 +1,46 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontCheckoutTotalsSortOrderInCartTest"> + <annotations> + <title value="Checkout Totals Sort Order configuration and displaying in cart"/> + <stories value="MAGETWO-89397: Wrong Checkout Totals Sort Order in cart"/> + <description value="Checkout Totals Sort Order configuration and displaying in cart"/> + <features value="Checkout"/> + <severity value="AVERAGE"/> + <testCaseId value="MAGETWO-96635"/> + <group value="checkout"/> + </annotations> + + <before> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <createData entity="SimpleProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="SaleRule50PercentDiscountNoCoupon" stepKey="createCartRule"/> + <createData entity="CheckoutShippingTotalsSortOrder" stepKey="setConfigShippingTotalsSortOrder"/> + </before> + + <after> + <deleteData createDataKey="createCartRule" stepKey="deleteCartRule"/> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <createData entity="DefaultTotalsSortOrder" stepKey="setDefaultTotalsSortOrder"/> + </after> + + <actionGroup ref="AddSimpleProductToCart" stepKey="addProductToCart"> + <argument name="product" value="$$createProduct$$"/> + </actionGroup> + + <actionGroup ref="VerifyDiscountAmount" stepKey="verifyDiscountAmount"> + <argument name="expectedDiscount" value="-$100"/> + </actionGroup> + + <see userInput="Shipping (Flat Rate - Fixed)" selector="{{StorefrontCheckoutCartSummarySection.totalsElementByPosition('3')}}" stepKey="assertElementPosition"/> + </test> +</tests> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCustomerCheckoutTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCustomerCheckoutTest.xml index 7af9c646db1e8..374ce8dbb425c 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCustomerCheckoutTest.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCustomerCheckoutTest.xml @@ -62,7 +62,7 @@ <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin1"/> <amOnPage stepKey="s67" url="{{AdminOrdersPage.url}}"/> - <waitForPageLoad stepKey="s75"/> + <actionGroup ref="clearFiltersAdminDataGrid" stepKey="clearGridFilter"/> <fillField stepKey="s77" selector="{{OrdersGridSection.search}}" userInput="{$s53}" /> <waitForPageLoad stepKey="s78"/> @@ -77,6 +77,7 @@ <see stepKey="s95" selector="{{OrderDetailsInformationSection.itemsOrdered}}" userInput="$$simpleproduct1.name$$" /> <amOnPage stepKey="s96" url="{{AdminCustomerPage.url}}"/> <waitForPageLoad stepKey="s97"/> + <waitForElementVisible selector="{{AdminCustomerFiltersSection.filtersButton}}" time="30" stepKey="waitFiltersButton"/> <click stepKey="s98" selector="{{AdminCustomerFiltersSection.filtersButton}}"/> <fillField stepKey="s99" selector="{{AdminCustomerFiltersSection.emailInput}}" userInput="$$simpleuscustomer.email$$"/> <click stepKey="s100" selector="{{AdminCustomerFiltersSection.apply}}"/> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCustomerCheckoutTestWithRestrictedCountriesForPaymentTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCustomerCheckoutTestWithRestrictedCountriesForPaymentTest.xml new file mode 100644 index 0000000000000..a4583fb7fa50c --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontCustomerCheckoutTestWithRestrictedCountriesForPaymentTest.xml @@ -0,0 +1,59 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontCustomerCheckoutTestWithRestrictedCountriesForPaymentTest" + extends="StorefrontGuestCheckoutTestWithRestrictedCountriesForPaymentTest"> + <annotations> + <stories value="Checkout via the Storefront"/> + <title value="Checkout via Customer Checkout with restricted countries for payment"/> + <description value="Should be able to place an order as a Customer with restricted countries for payment."/> + <testCaseId value="MC-10831"/> + </annotations> + <before> + <createData entity="Simple_US_Customer" stepKey="createSimpleUsCustomer"/> + </before> + <after> + <actionGroup ref="CustomerLogoutStorefrontActionGroup" stepKey="customerLogoutStorefront"/> + <deleteData createDataKey="createSimpleUsCustomer" stepKey="deleteCustomer"/> + </after> + + <remove keyForRemoval="guestCheckoutFillingShippingSection"/> + <remove keyForRemoval="guestCheckoutFillingShippingSectionUK"/> + <remove keyForRemoval="guestPlaceOrder"/> + + <!-- Login as Customer --> + <actionGroup ref="CustomerLoginOnStorefront" before="goToProductPage" stepKey="customerLogin"> + <argument name="customer" value="$$createSimpleUsCustomer$$" /> + </actionGroup> + + <!-- Select address and go to payments page--> + <see selector="{{CheckoutShippingSection.selectedShippingAddress}}" userInput="{{US_Address_TX.state}}" after="shippingStepIsOpened" stepKey="seeRegion" /> + <waitForElement selector="{{CheckoutShippingMethodsSection.next}}" time="30" after="seeRegion" stepKey="waitNextButton"/> + <actionGroup ref="CheckoutSelectFlatRateShippingMethodActionGroup" after="waitNextButton" stepKey="selectShippingMethod"/> + <click selector="{{CheckoutShippingMethodsSection.next}}" after="selectShippingMethod" stepKey="clickNextButton" /> + <waitForPageLoad after="clickNextButton" stepKey="waitBillingForm"/> + <seeElement selector="{{CheckoutPaymentSection.isPaymentSection}}" after="waitBillingForm" stepKey="checkoutPaymentStepIsOpened"/> + + <!-- Fill UK Address and verify that payment available and checkout successful --> + <click selector="{{CheckoutShippingSection.newAdress}}" after="shippingStepIsOpened1" stepKey="fillNewAddress" /> + <actionGroup ref="LoggedInUserCheckoutAddNewShippingSectionWithoutRegionActionGroup" after="fillNewAddress" stepKey="customerCheckoutFillingShippingSectionUK"> + <argument name="customerVar" value="$$createSimpleUsCustomer$$" /> + <argument name="customerAddressVar" value="UK_Default_Address" /> + </actionGroup> + <waitForElement selector="{{CheckoutShippingMethodsSection.next}}" time="30" after="customerCheckoutFillingShippingSectionUK" stepKey="waitNextButton1"/> + <actionGroup ref="CheckoutSelectFlatRateShippingMethodActionGroup" after="waitNextButton1" stepKey="selectShippingMethod1"/> + <click selector="{{CheckoutShippingMethodsSection.next}}" after="selectShippingMethod1" stepKey="clickNextButton1" /> + + <actionGroup ref="CheckoutPlaceOrderActionGroup" after="selectCheckMoneyOrderPayment" stepKey="customerPlaceorder"> + <argument name="orderNumberMessage" value="CONST.successCheckoutOrderNumberMessage" /> + <argument name="emailYouMessage" value="CONST.successCheckoutEmailYouMessage" /> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontGuestCheckoutDataPersistTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontGuestCheckoutDataPersistTest.xml new file mode 100644 index 0000000000000..bebb7be6c9b15 --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontGuestCheckoutDataPersistTest.xml @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontGuestCheckoutDataPersistTest"> + <annotations> + <features value="Checkout"/> + <stories value="MAGETWO-95067: Checkout data (shipping address etc) not persistant after cart update"/> + <title value="Check that checkout data persist after cart update"/> + <description value="Checkout data should be persist after updating cart"/> + <severity value="CRITICAL"/> + <testCaseId value="MAGETWO-96670"/> + <group value="checkout"/> + </annotations> + <before> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <createData entity="_defaultProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + </before> + <after> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + </after> + + <!-- Add simple product to cart --> + <actionGroup ref="AddSimpleProductToCart" stepKey="addProductToCart"> + <argument name="product" value="$$createProduct$$"/> + </actionGroup> + <!-- Navigate to checkout --> + <actionGroup ref="GoToCheckoutFromMinicartActionGroup" stepKey="goToCheckoutFromMinicart"/> + <!-- Fill shipping address --> + <actionGroup ref="GuestCheckoutFillingShippingSectionActionGroup" stepKey="guestCheckoutFillingShipping"> + <argument name="shippingMethod" value="Flat Rate"/> + </actionGroup> + <!-- Add simple product to cart --> + <actionGroup ref="AddSimpleProductToCart" stepKey="addProductToCart1"> + <argument name="product" value="$$createProduct$$"/> + </actionGroup> + <!-- Navigate to checkout --> + <actionGroup ref="GoToCheckoutFromMinicartActionGroup" stepKey="goToCheckoutFromMinicart1"/> + <seeInField selector="{{GuestCheckoutShippingSection.email}}" userInput="{{CustomerEntityOne.email}}" stepKey="assertGuestEmail"/> + <seeInField selector="{{GuestCheckoutShippingSection.firstName}}" userInput="{{CustomerEntityOne.firstname}}" stepKey="assertGuestFirstName"/> + <seeInField selector="{{GuestCheckoutShippingSection.lastName}}" userInput="{{CustomerEntityOne.lastname}}" stepKey="assertGuestLastName"/> + <seeInField selector="{{GuestCheckoutShippingSection.street}}" userInput="{{CustomerAddressSimple.street[0]}}" stepKey="assertGuestStreet"/> + <seeInField selector="{{GuestCheckoutShippingSection.city}}" userInput="{{CustomerAddressSimple.city}}" stepKey="assertGuestCity"/> + <seeInField selector="{{GuestCheckoutShippingSection.region}}" userInput="{{CustomerAddressSimple.state}}" stepKey="assertGuestRegion"/> + <seeInField selector="{{GuestCheckoutShippingSection.postcode}}" userInput="{{CustomerAddressSimple.postcode}}" stepKey="assertGuestPostcode"/> + <seeInField selector="{{GuestCheckoutShippingSection.telephone}}" userInput="{{CustomerAddressSimple.telephone}}" stepKey="assertGuestTelephone"/> + </test> +</tests> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontGuestCheckoutTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontGuestCheckoutTest.xml index dff957457da95..c5269ca5d0b56 100644 --- a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontGuestCheckoutTest.xml +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontGuestCheckoutTest.xml @@ -25,7 +25,10 @@ </createData> </before> <after> - <amOnPage url="admin/admin/auth/logout/" stepKey="amOnLogoutPage"/> + <!--Clear filter in orders grid--> + <actionGroup ref="AdminOrdersGridClearFiltersActionGroup" stepKey="ordersGridClearFilters"/> + <actionGroup ref="logout" stepKey="logoutAdminUserAfterTest"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> </after> @@ -50,6 +53,7 @@ <click selector="{{GuestCheckoutShippingSection.firstShippingMethod}}" stepKey="selectFirstShippingMethod"/> <waitForElement selector="{{GuestCheckoutShippingSection.next}}" time="30" stepKey="waitForNextButton"/> <click selector="{{GuestCheckoutShippingSection.next}}" stepKey="clickNext"/> + <actionGroup ref="CheckoutSelectCheckMoneyOrderPaymentActionGroup" stepKey="checkPaymentMethod"/> <waitForElement selector="{{GuestCheckoutPaymentSection.placeOrder}}" time="30" stepKey="waitForPlaceOrderButton"/> <conditionalClick selector="{{GuestCheckoutPaymentSection.cartItemsArea}}" dependentSelector="{{GuestCheckoutPaymentSection.cartItemsAreaActive}}" visible="false" stepKey="exposeMiniCart"/> <see selector="{{GuestCheckoutPaymentSection.cartItems}}" userInput="{{_defaultProduct.name}}" stepKey="seeProductInCart"/> @@ -61,13 +65,13 @@ <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin1"/> - <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPage"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskToDisappearOnOrdersPage"/> - <actionGroup ref="clearFiltersAdminDataGrid" stepKey="clearGridFilter"/> - <fillField selector="{{OrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="fillOrderNum"/> - <click selector="{{OrdersGridSection.submitSearch}}" stepKey="submitSearchOrderNum"/> + <!--Navigate to orders index page and filter orders--> + <actionGroup ref="filterOrderGridById" stepKey="filterOrderGridById"> + <argument name="orderId" value="$grabOrderNumber"/> + </actionGroup> <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskToDisappearOnSearch"/> <click selector="{{OrdersGridSection.firstRow}}" stepKey="clickOrderRow"/> + <waitForPageLoad stepKey="waitForOrderPageLoad"/> <see selector="{{OrderDetailsInformationSection.orderStatus}}" userInput="Pending" stepKey="seeAdminOrderStatus"/> <see selector="{{OrderDetailsInformationSection.accountInformation}}" userInput="Guest" stepKey="seeAdminOrderGuest"/> <see selector="{{OrderDetailsInformationSection.accountInformation}}" userInput="{{CustomerEntityOne.email}}" stepKey="seeAdminOrderEmail"/> @@ -75,4 +79,23 @@ <see selector="{{OrderDetailsInformationSection.shippingAddress}}" userInput="{{CustomerAddressSimple.street[0]}}" stepKey="seeAdminOrderShippingAddress"/> <see selector="{{OrderDetailsInformationSection.itemsOrdered}}" userInput="{{_defaultProduct.name}}" stepKey="seeAdminOrderProduct"/> </test> + <test name="StorefrontGuestCheckoutWithSidebarDisabledTest" extends="StorefrontGuestCheckoutTest"> + <annotations> + <features value="Checkout"/> + <stories value="Checkout via Guest Checkout"/> + <title value="Guest Checkout when Cart sidebar disabled"/> + <description value="Should be able to place an order as a Guest when Cart sidebar is disabled"/> + <severity value="CRITICAL"/> + <testCaseId value="MAGETWO-97155"/> + <group value="checkout"/> + </annotations> + <before> + <magentoCLI command="config:set checkout/sidebar/display 0" stepKey="disableSidebar" /> + </before> + <after> + <magentoCLI command="config:set checkout/sidebar/display 1" stepKey="enableSidebar" /> + </after> + <remove keyForRemoval="addProductNavigateToCheckout" /> + <actionGroup ref="GoToCheckoutFromCartActionGroup" stepKey="guestGoToCheckoutFromCart" after="seeCartQuantity" /> + </test> </tests> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontGuestCheckoutTestWithRestrictedCountriesForPaymentTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontGuestCheckoutTestWithRestrictedCountriesForPaymentTest.xml new file mode 100644 index 0000000000000..3d1dc2cf66689 --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontGuestCheckoutTestWithRestrictedCountriesForPaymentTest.xml @@ -0,0 +1,70 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontGuestCheckoutTestWithRestrictedCountriesForPaymentTest"> + <annotations> + <features value="Checkout"/> + <stories value="Checkout via Guest Checkout"/> + <title value="Checkout via Guest Checkout with restricted countries for payment"/> + <description value="Should be able to place an order as a Guest with restricted countries for payment."/> + <severity value="MAJOR"/> + <testCaseId value="MC-8243"/> + <group value="checkout"/> + </annotations> + <before> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <createData entity="ApiSimpleProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <magentoCLI command="config:set payment/checkmo/allowspecific" arguments="1" stepKey="setAllowSpecificCountiesValue" /> + <magentoCLI command="config:set payment/checkmo/specificcountry" arguments="GB" stepKey="setSpecificCountryValue" /> + </before> + <after> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <magentoCLI command="config:set payment/checkmo/allowspecific 0" stepKey="unsetAllowSpecificCountiesValue"/> + <magentoCLI command="config:set payment/checkmo/specificcountry ''" stepKey="unsetSpecificCountryValue" /> + </after> + + <!-- Add product to cart --> + <amOnPage url="{{StorefrontProductPage.url($$createProduct.sku$$)}}" stepKey="goToProductPage"/> + <actionGroup ref="addToCartFromStorefrontProductPage" stepKey="addProductToCart"> + <argument name="productName" value="$$createProduct.name$$"/> + </actionGroup> + <!-- Go to checkout page --> + <actionGroup ref="GoToCheckoutFromMinicartActionGroup" stepKey="goToCheckoutFromMinicart" /> + + <!-- Fill US Address and verify that no payment available --> + <seeElement selector="{{CheckoutShippingSection.isShippingStep}}" stepKey="shippingStepIsOpened"/> + <actionGroup ref="GuestCheckoutFillingShippingSectionWithoutPaymentsActionGroup" stepKey="guestCheckoutFillingShippingSection"> + <argument name="customerVar" value="Simple_US_Customer"/> + <argument name="customerAddressVar" value="US_Address_TX"/> + <argument name="shippingMethod" value="Flat Rate" type="string"/> + </actionGroup> + + <waitForElementVisible selector="{{CheckoutPaymentSection.noPaymentMethods}}" stepKey="waitMessage"/> + <see userInput="No Payment method available." stepKey="checkMessage"/> + + <!-- Fill UK Address and verify that payment available and checkout successful --> + <click selector="{{CheckoutHeaderSection.shippingMethodStep}}" stepKey="goToShipping" /> + <waitForElementVisible selector="{{CheckoutShippingSection.isShippingStep}}" stepKey="shippingStepIsOpened1"/> + + <actionGroup ref="GuestCheckoutFillingShippingSectionWithoutRegionActionGroup" stepKey="guestCheckoutFillingShippingSectionUK"> + <argument name="customerVar" value="Simple_US_Customer" /> + <argument name="customerAddressVar" value="UK_Default_Address" /> + <argument name="shippingMethod" value="Flat Rate" type="string"/> + </actionGroup> + <actionGroup ref="CheckoutSelectCheckMoneyOrderPaymentActionGroup" stepKey="selectCheckMoneyOrderPayment" /> + <actionGroup ref="CheckoutPlaceOrderActionGroup" stepKey="guestPlaceOrder"> + <argument name="orderNumberMessage" value="CONST.successGuestCheckoutOrderNumberMessage" /> + <argument name="emailYouMessage" value="CONST.successCheckoutEmailYouMessage" /> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontOnePageCheckoutDataWhenChangeQtyTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontOnePageCheckoutDataWhenChangeQtyTest.xml new file mode 100644 index 0000000000000..093435b8c8f26 --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontOnePageCheckoutDataWhenChangeQtyTest.xml @@ -0,0 +1,92 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontOnePageCheckoutDataWhenChangeQtyTest"> + <annotations> + <features value="Checkout"/> + <stories value="Checkout via Guest Checkout"/> + <title value="One page Checkout Customer data when changing Product Qty"/> + <description value="One page Checkout Customer data when changing Product Qty"/> + <severity value="MAJOR"/> + <testCaseId value="MC-13609"/> + <useCaseId value="MAGETWO-96711"/> + <group value="checkout"/> + </annotations> + <before> + <!--Create a product--> + <createData entity="SimpleProduct3" stepKey="createProduct"/> + </before> + <after> + <!--Delete created data--> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + </after> + + <!--Add product to cart and checkout--> + <amOnPage url="{{StorefrontProductPage.url($$createProduct.name$$)}}" stepKey="amOnSimpleProductPage"/> + <actionGroup ref="addToCartFromStorefrontProductPage" stepKey="addToCartFromStorefrontProductPage"> + <argument name="productName" value="$createProduct.name$$"/> + </actionGroup> + <actionGroup ref="GoToCheckoutFromMinicartActionGroup" stepKey="goToCheckoutFromMinicart"/> + <fillField selector="{{CheckoutShippingSection.email}}" userInput="{{CustomerEntityOne.email}}" stepKey="enterEmail"/> + <fillField selector="{{CheckoutShippingSection.firstName}}" userInput="{{CustomerEntityOne.firstname}}" stepKey="enterFirstName"/> + <fillField selector="{{CheckoutShippingSection.lastName}}" userInput="{{CustomerEntityOne.lastname}}" stepKey="enterLastName"/> + <fillField selector="{{CheckoutShippingSection.street}}" userInput="{{CustomerAddressSimple.street[0]}}" stepKey="enterStreet"/> + <fillField selector="{{CheckoutShippingSection.city}}" userInput="{{CustomerAddressSimple.city}}" stepKey="enterCity"/> + <selectOption selector="{{CheckoutShippingSection.region}}" userInput="{{CustomerAddressSimple.state}}" stepKey="selectRegion"/> + <fillField selector="{{CheckoutShippingSection.postcode}}" userInput="{{CustomerAddressSimple.postcode}}" stepKey="enterPostcode"/> + <fillField selector="{{CheckoutShippingSection.telephone}}" userInput="{{CustomerAddressSimple.telephone}}" stepKey="enterTelephone"/> + <waitForLoadingMaskToDisappear stepKey="waitForLoadingMask"/> + + <!--Grab customer data to check it--> + <grabValueFrom selector="{{CheckoutShippingSection.email}}" stepKey="grabEmail"/> + <grabValueFrom selector="{{CheckoutShippingSection.firstName}}" stepKey="grabFirstName"/> + <grabValueFrom selector="{{CheckoutShippingSection.lastName}}" stepKey="grabLastName"/> + <grabValueFrom selector="{{CheckoutShippingSection.street}}" stepKey="grabStreet"/> + <grabValueFrom selector="{{CheckoutShippingSection.city}}" stepKey="grabCity"/> + <grabTextFrom selector="{{CheckoutShippingSection.region}}" stepKey="grabRegion"/> + <grabValueFrom selector="{{CheckoutShippingSection.postcode}}" stepKey="grabPostcode"/> + <grabValueFrom selector="{{CheckoutShippingSection.telephone}}" stepKey="grabTelephone"/> + + <!--Select shipping method and finalize checkout--> + <actionGroup ref="CheckoutSelectFlatRateShippingMethodActionGroup" stepKey="selectFlatRateShippingMethod"/> + <waitForElement selector="{{CheckoutShippingSection.next}}" time="30" stepKey="waitForNextButton"/> + <click selector="{{CheckoutShippingSection.next}}" stepKey="clickNext"/> + <waitForElement selector="{{CheckoutPaymentSection.paymentSectionTitle}}" time="30" stepKey="waitForPaymentSectionLoaded"/> + <seeInCurrentUrl url="{{CheckoutPage.url}}/#payment" stepKey="assertCheckoutPaymentUrl"/> + + <!--Go to cart page, update qty and proceed to checkout--> + <amOnPage url="{{CheckoutCartPage.url}}" stepKey="goToCartPage"/> + <see userInput="Shopping Cart" stepKey="seeCartPageIsOpened"/> + <fillField selector="{{CheckoutCartProductSection.productQuantityByName($$createProduct.name$$)}}" userInput="2" stepKey="updateProductQty"/> + <click selector="{{CheckoutCartProductSection.updateShoppingCartButton}}" stepKey="clickUpdateShoppingCart"/> + <grabValueFrom selector="{{CheckoutCartProductSection.productQuantityByName($$createProduct.name$$)}}" stepKey="grabQty"/> + <assertEquals expected="2" actual="$grabQty" stepKey="assertQty"/> + <click selector="{{StorefrontCheckoutCartSummarySection.proceedToCheckout}}" stepKey="clickProceedToCheckout"/> + + <!--Check that form is filled with customer data--> + <grabValueFrom selector="{{CheckoutShippingSection.email}}" stepKey="grabEmail1"/> + <grabValueFrom selector="{{CheckoutShippingSection.firstName}}" stepKey="grabFirstName1"/> + <grabValueFrom selector="{{CheckoutShippingSection.lastName}}" stepKey="grabLastName1"/> + <grabValueFrom selector="{{CheckoutShippingSection.street}}" stepKey="grabStreet1"/> + <grabValueFrom selector="{{CheckoutShippingSection.city}}" stepKey="grabCity1"/> + <grabTextFrom selector="{{CheckoutShippingSection.region}}" stepKey="grabRegion1"/> + <grabValueFrom selector="{{CheckoutShippingSection.postcode}}" stepKey="grabPostcode1"/> + <grabValueFrom selector="{{CheckoutShippingSection.telephone}}" stepKey="grabTelephone1"/> + + <assertEquals expected="$grabEmail" actual="$grabEmail1" stepKey="assertEmail"/> + <assertEquals expected="$grabFirstName" actual="$grabFirstName1" stepKey="assertFirstName"/> + <assertEquals expected="$grabLastName" actual="$grabLastName1" stepKey="assertLastName"/> + <assertEquals expected="$grabStreet" actual="$grabStreet1" stepKey="assertStreet"/> + <assertEquals expected="$grabCity" actual="$grabCity1" stepKey="assertCity"/> + <assertEquals expected="$grabRegion" actual="$grabRegion1" stepKey="assertRegion"/> + <assertEquals expected="$grabPostcode" actual="$grabPostcode1" stepKey="assertPostcode"/> + <assertEquals expected="$grabTelephone" actual="$grabTelephone1" stepKey="assertTelephone"/> + </test> +</tests> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontShoppingCartCheckCustomerDefaultShippingAddressForVirtualQuoteTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontShoppingCartCheckCustomerDefaultShippingAddressForVirtualQuoteTest.xml new file mode 100644 index 0000000000000..7750ce0e1686a --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontShoppingCartCheckCustomerDefaultShippingAddressForVirtualQuoteTest.xml @@ -0,0 +1,54 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontShoppingCartCheckCustomerDefaultShippingAddressForVirtualQuoteTest"> + <annotations> + <features value="Checkout"/> + <stories value="Checkout via the Storefront"/> + <title value="Estimator in Shopping cart must be pre-filled by Customer default shipping address for virtual quote"/> + <description value="Estimator in Shopping cart must be pre-filled by Customer default shipping address for virtual quote"/> + <severity value="CRITICAL"/> + <testCaseId value="MAGETWO-78596"/> + <group value="checkout"/> + </annotations> + <before> + <createData entity="VirtualProduct" stepKey="createVirtualProduct"/> + <createData entity="Customer_With_Different_Default_Billing_Shipping_Addresses" stepKey="createCustomer"/> + </before> + <after> + <actionGroup ref="CustomerLogoutStorefrontActionGroup" stepKey="customerLogout"/> + <deleteData createDataKey="createVirtualProduct" stepKey="deleteVirtualProduct"/> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + </after> + <!-- Steps --> + <!-- Step 1: Go to Storefront as Customer --> + <actionGroup ref="CustomerLoginOnStorefront" stepKey="customerLogin"> + <argument name="customer" value="$$createCustomer$$" /> + </actionGroup> + <!-- Step 2: Add virtual product to cart --> + <amOnPage url="{{StorefrontProductPage.url($$createVirtualProduct.custom_attributes[url_key]$)}}" stepKey="amOnPage"/> + <actionGroup ref="StorefrontAddProductToCartActionGroup" stepKey="addProductToCart"> + <argument name="product" value="$$createVirtualProduct$$"/> + <argument name="productCount" value="1"/> + </actionGroup> + <!-- Step 3: Go to Shopping Cart --> + <actionGroup ref="StorefrontViewAndEditCartFromMiniCartActionGroup" stepKey="goToShoppingcart"/> + <!-- Step 4: Open Estimate Tax section --> + <click selector="{{StorefrontCheckoutCartSummarySection.estimateShippingAndTax}}" stepKey="openEstimateTaxSection"/> + <seeOptionIsSelected selector="{{StorefrontCheckoutCartSummarySection.country}}" userInput="{{US_Address_CA.country}}" stepKey="checkCountry"/> + <seeOptionIsSelected selector="{{StorefrontCheckoutCartSummarySection.region}}" userInput="{{US_Address_CA.state}}" stepKey="checkState"/> + <scrollTo selector="{{StorefrontCheckoutCartSummarySection.postcode}}" stepKey="scrollToPostCodeField"/> + <grabValueFrom selector="{{StorefrontCheckoutCartSummarySection.postcode}}" stepKey="grabTextPostCode"/> + <assertEquals message="Customer postcode is invalid" stepKey="checkCustomerPostcode"> + <expectedResult type="string">{{US_Address_CA.postcode}}</expectedResult> + <actualResult type="variable">grabTextPostCode</actualResult> + </assertEquals> + </test> +</tests> diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontUpdateQtyInShoppingCartAfterUpdateInMinicartTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontUpdateQtyInShoppingCartAfterUpdateInMinicartTest.xml new file mode 100644 index 0000000000000..b0a2c0bfb7e13 --- /dev/null +++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontUpdateQtyInShoppingCartAfterUpdateInMinicartTest.xml @@ -0,0 +1,61 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontUpdateQtyInShoppingCartAfterUpdateInMinicartTest"> + <annotations> + <features value="Checkout"/> + <stories value="Checkout via Guest Checkout"/> + <title value="Check updating shopping cart while updating items from minicart"/> + <description value="Check updating shopping cart while updating items from minicart"/> + <severity value="AVERAGE"/> + <testCaseId value="MC-13626"/> + <group value="checkout"/> + </annotations> + <before> + <!--Create category--> + <createData entity="SimpleSubCategory" stepKey="createCategory"/> + <!--Create product--> + <createData entity="SimpleProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + </before> + <after> + <!--Delete product--> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <!--Delete category--> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + </after> + + <!--Open Product Page--> + <amOnPage url="{{StorefrontProductPage.url($$createProduct.name$$)}}" stepKey="openProductPage"/> + <!--Add product to cart--> + <actionGroup ref="addToCartFromStorefrontProductPage" stepKey="addProductToCart"> + <argument name="productName" value="$$createProduct.name$$"/> + </actionGroup> + + <!--Go to Shopping cart--> + <actionGroup ref="StorefrontOpenCartFromMinicartActionGroup" stepKey="openShoppingCart"/> + <!--Check quantity in Shopping cart--> + <grabValueFrom selector="{{CheckoutCartProductSection.productQuantityByName($$createProduct.name$$)}}" stepKey="grabQtyFromShoppingCart"/> + <assertEquals expected="1" actual="$grabQtyFromShoppingCart" stepKey="assertQtyInShoppingCart"/> + + <!--Open minicart--> + <click selector="{{StorefrontMinicartSection.showCart}}" stepKey="openMiniCart"/> + <waitForElementVisible selector="{{StorefrontMinicartSection.itemQuantity($$createProduct.name$$)}}" stepKey="waitForItemQuantity"/> + <pressKey selector="{{StorefrontMinicartSection.itemQuantity($$createProduct.name$$)}}" parameterArray="[\Facebook\WebDriver\WebDriverKeys::BACKSPACE]" stepKey="clearQtyField"/> + <fillField selector="{{StorefrontMinicartSection.itemQuantity($$createProduct.name$$)}}" userInput="5" stepKey="fillQtyField"/> + <waitForElementVisible selector="{{StorefrontMinicartSection.itemQuantityUpdate($$createProduct.name$$)}}" stepKey="waitForUpdateButton"/> + <click selector="{{StorefrontMinicartSection.itemQuantityUpdate($$createProduct.name$$)}}" stepKey="clickUpdateButton"/> + <waitForAjaxLoad stepKey="waitForAjaxLoad"/> + <!--Check quantity in shopping cart after updating--> + <grabValueFrom selector="{{CheckoutCartProductSection.productQuantityByName($$createProduct.name$$)}}" stepKey="grabQtyFromShoppingCart1"/> + <assertEquals expected="5" actual="$grabQtyFromShoppingCart1" stepKey="assertQtyInShoppingCart1"/> + </test> +</tests> diff --git a/app/code/Magento/Checkout/Test/Unit/Block/Checkout/AttributeMergerTest.php b/app/code/Magento/Checkout/Test/Unit/Block/Checkout/AttributeMergerTest.php new file mode 100644 index 0000000000000..bff2243f30d03 --- /dev/null +++ b/app/code/Magento/Checkout/Test/Unit/Block/Checkout/AttributeMergerTest.php @@ -0,0 +1,122 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Checkout\Test\Unit\Block\Checkout; + +use Magento\Customer\Api\CustomerRepositoryInterface as CustomerRepository; +use Magento\Customer\Helper\Address as AddressHelper; +use Magento\Customer\Model\Session as CustomerSession; +use Magento\Directory\Helper\Data as DirectoryHelper; +use Magento\Checkout\Block\Checkout\AttributeMerger; + +class AttributeMergerTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var CustomerRepository + */ + private $customerRepositoryMock; + + /** + * @var CustomerSession + */ + private $customerSessionMock; + + /** + * @var AddressHelper + */ + private $addressHelperMock; + + /** + * @var DirectoryHelper + */ + private $directoryHelperMock; + + /** + * @var AttributeMerger + */ + private $attributeMerger; + + /** + * @inheritdoc + */ + protected function setUp() + { + + $this->customerRepositoryMock = $this->createMock(CustomerRepository::class); + $this->customerSessionMock = $this->createMock(CustomerSession::class); + $this->addressHelperMock = $this->createMock(AddressHelper::class); + $this->directoryHelperMock = $this->createMock(DirectoryHelper::class); + + $this->attributeMerger = new AttributeMerger( + $this->addressHelperMock, + $this->customerSessionMock, + $this->customerRepositoryMock, + $this->directoryHelperMock + ); + } + + /** + * Tests of element attributes merging. + * + * @param string $validationRule + * @param string $expectedValidation + * @return void + * @dataProvider validationRulesDataProvider + */ + public function testMerge($validationRule, $expectedValidation) + { + $elements = [ + 'field' => [ + 'visible' => true, + 'formElement' => 'input', + 'label' => __('City'), + 'value' => null, + 'sortOrder' => 1, + 'validation' => [ + 'input_validation' => $validationRule, + ], + ] + ]; + + $actualResult = $this->attributeMerger->merge( + $elements, + 'provider', + 'dataScope', + [ + 'field' => + [ + 'validation' => ['length' => true], + ], + ] + ); + + $expectedResult = [ + $expectedValidation => true, + 'length' => true, + ]; + + $this->assertEquals($expectedResult, $actualResult['field']['validation']); + } + + /** + * Provides possible validation types. + * + * @return array + */ + public function validationRulesDataProvider(): array + { + return [ + ['alpha', 'validate-alpha'], + ['numeric', 'validate-number'], + ['alphanumeric', 'validate-alphanum'], + ['alphanum-with-spaces', 'validate-alphanum-with-spaces'], + ['url', 'validate-url'], + ['email', 'email2'], + ['length', 'validate-length'], + ]; + } +} diff --git a/app/code/Magento/Checkout/Test/Unit/Block/OnepageTest.php b/app/code/Magento/Checkout/Test/Unit/Block/OnepageTest.php index 54f77c95148ac..b54339aa2c1d8 100644 --- a/app/code/Magento/Checkout/Test/Unit/Block/OnepageTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Block/OnepageTest.php @@ -35,7 +35,7 @@ class OnepageTest extends \PHPUnit\Framework\TestCase /** * @var \PHPUnit_Framework_MockObject_MockObject */ - private $serializer; + private $serializerMock; protected function setUp() { @@ -49,7 +49,7 @@ protected function setUp() \Magento\Checkout\Block\Checkout\LayoutProcessorInterface::class ); - $this->serializer = $this->createMock(\Magento\Framework\Serialize\Serializer\Json::class); + $this->serializerMock = $this->createMock(\Magento\Framework\Serialize\Serializer\JsonHexTag::class); $this->model = new \Magento\Checkout\Block\Onepage( $contextMock, @@ -57,7 +57,8 @@ protected function setUp() $this->configProviderMock, [$this->layoutProcessorMock], [], - $this->serializer + $this->serializerMock, + $this->serializerMock ); } @@ -93,6 +94,7 @@ public function testGetJsLayout() $processedLayout = ['layout' => ['processed' => true]]; $jsonLayout = '{"layout":{"processed":true}}'; $this->layoutProcessorMock->expects($this->once())->method('process')->with([])->willReturn($processedLayout); + $this->serializerMock->expects($this->once())->method('serialize')->willReturn($jsonLayout); $this->assertEquals($jsonLayout, $this->model->getJsLayout()); } @@ -101,6 +103,7 @@ public function testGetSerializedCheckoutConfig() { $checkoutConfig = ['checkout', 'config']; $this->configProviderMock->expects($this->once())->method('getConfig')->willReturn($checkoutConfig); + $this->serializerMock->expects($this->once())->method('serialize')->willReturn(json_encode($checkoutConfig)); $this->assertEquals(json_encode($checkoutConfig), $this->model->getSerializedCheckoutConfig()); } diff --git a/app/code/Magento/Checkout/Test/Unit/Controller/Sidebar/UpdateItemQtyTest.php b/app/code/Magento/Checkout/Test/Unit/Controller/Sidebar/UpdateItemQtyTest.php index e2a00c6872542..269536baa9c59 100644 --- a/app/code/Magento/Checkout/Test/Unit/Controller/Sidebar/UpdateItemQtyTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Controller/Sidebar/UpdateItemQtyTest.php @@ -5,40 +5,46 @@ */ namespace Magento\Checkout\Test\Unit\Controller\Sidebar; +use Magento\Checkout\Controller\Sidebar\UpdateItemQty; +use Magento\Checkout\Model\Sidebar; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\App\ResponseInterface; use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Json\Helper\Data; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; +use Psr\Log\LoggerInterface; class UpdateItemQtyTest extends \PHPUnit\Framework\TestCase { - /** @var \Magento\Checkout\Controller\Sidebar\UpdateItemQty */ - protected $updateItemQty; + /** @var UpdateItemQty */ + private $updateItemQty; /** @var ObjectManagerHelper */ - protected $objectManagerHelper; + private $objectManagerHelper; - /** @var \Magento\Checkout\Model\Sidebar|\PHPUnit_Framework_MockObject_MockObject */ - protected $sidebarMock; + /** @var Sidebar|\PHPUnit_Framework_MockObject_MockObject */ + private $sidebarMock; - /** @var \Psr\Log\LoggerInterface|\PHPUnit_Framework_MockObject_MockObject */ - protected $loggerMock; + /** @var LoggerInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $loggerMock; - /** @var \Magento\Framework\Json\Helper\Data|\PHPUnit_Framework_MockObject_MockObject */ - protected $jsonHelperMock; + /** @var Data|\PHPUnit_Framework_MockObject_MockObject */ + private $jsonHelperMock; - /** @var \Magento\Framework\App\RequestInterface|\PHPUnit_Framework_MockObject_MockObject */ - protected $requestMock; + /** @var RequestInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $requestMock; - /** @var \Magento\Framework\App\ResponseInterface|\PHPUnit_Framework_MockObject_MockObject */ - protected $responseMock; + /** @var ResponseInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $responseMock; protected function setUp() { - $this->sidebarMock = $this->createMock(\Magento\Checkout\Model\Sidebar::class); - $this->loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class); - $this->jsonHelperMock = $this->createMock(\Magento\Framework\Json\Helper\Data::class); - $this->requestMock = $this->createMock(\Magento\Framework\App\RequestInterface::class); + $this->sidebarMock = $this->createMock(Sidebar::class); + $this->loggerMock = $this->createMock(LoggerInterface::class); + $this->jsonHelperMock = $this->createMock(Data::class); + $this->requestMock = $this->createMock(RequestInterface::class); $this->responseMock = $this->getMockForAbstractClass( - \Magento\Framework\App\ResponseInterface::class, + ResponseInterface::class, [], '', false, @@ -49,7 +55,7 @@ protected function setUp() $this->objectManagerHelper = new ObjectManagerHelper($this); $this->updateItemQty = $this->objectManagerHelper->getObject( - \Magento\Checkout\Controller\Sidebar\UpdateItemQty::class, + UpdateItemQty::class, [ 'sidebar' => $this->sidebarMock, 'logger' => $this->loggerMock, @@ -60,6 +66,9 @@ protected function setUp() ); } + /** + * Tests execute action. + */ public function testExecute() { $this->requestMock->expects($this->at(0)) @@ -113,6 +122,9 @@ public function testExecute() $this->assertEquals('json represented', $this->updateItemQty->execute()); } + /** + * Tests with localized exception. + */ public function testExecuteWithLocalizedException() { $this->requestMock->expects($this->at(0)) @@ -157,6 +169,9 @@ public function testExecuteWithLocalizedException() $this->assertEquals('json represented', $this->updateItemQty->execute()); } + /** + * Tests with exception. + */ public function testExecuteWithException() { $this->requestMock->expects($this->at(0)) @@ -207,4 +222,31 @@ public function testExecuteWithException() $this->assertEquals('json represented', $this->updateItemQty->execute()); } + + /** + * Tests execute with float item quantity. + */ + public function testExecuteWithFloatItemQty() + { + $itemId = '1'; + $floatItemQty = '2.2'; + + $this->requestMock->expects($this->at(0)) + ->method('getParam') + ->with('item_id', null) + ->willReturn($itemId); + $this->requestMock->expects($this->at(1)) + ->method('getParam') + ->with('item_qty', null) + ->willReturn($floatItemQty); + + $this->sidebarMock->expects($this->once()) + ->method('checkQuoteItem') + ->with($itemId); + $this->sidebarMock->expects($this->once()) + ->method('updateQuoteItem') + ->with($itemId, $floatItemQty); + + $this->updateItemQty->execute(); + } } diff --git a/app/code/Magento/Checkout/Test/Unit/Observer/SalesQuoteSaveAfterObserverTest.php b/app/code/Magento/Checkout/Test/Unit/Observer/SalesQuoteSaveAfterObserverTest.php index 6070bb5d424c1..dabaf173d90b3 100644 --- a/app/code/Magento/Checkout/Test/Unit/Observer/SalesQuoteSaveAfterObserverTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Observer/SalesQuoteSaveAfterObserverTest.php @@ -30,13 +30,14 @@ protected function setUp() public function testSalesQuoteSaveAfter() { + $quoteId = 7; $observer = $this->createMock(\Magento\Framework\Event\Observer::class); $observer->expects($this->once())->method('getEvent')->will( $this->returnValue(new \Magento\Framework\DataObject( - ['quote' => new \Magento\Framework\DataObject(['is_checkout_cart' => 1, 'id' => 7])] + ['quote' => new \Magento\Framework\DataObject(['is_checkout_cart' => 1, 'id' => $quoteId])] )) ); - $this->checkoutSession->expects($this->once())->method('getQuoteId')->with(7); + $this->checkoutSession->expects($this->once())->method('setQuoteId')->with($quoteId); $this->object->execute($observer); } diff --git a/app/code/Magento/Checkout/composer.json b/app/code/Magento/Checkout/composer.json index 32cb6c53864db..0fbfaffa7f22a 100644 --- a/app/code/Magento/Checkout/composer.json +++ b/app/code/Magento/Checkout/composer.json @@ -26,7 +26,7 @@ "magento/module-cookie": "100.2.*" }, "type": "magento2-module", - "version": "100.2.6", + "version": "100.2.7", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Checkout/etc/frontend/di.xml b/app/code/Magento/Checkout/etc/frontend/di.xml index d80f88786c87b..00bcd2a27005a 100644 --- a/app/code/Magento/Checkout/etc/frontend/di.xml +++ b/app/code/Magento/Checkout/etc/frontend/di.xml @@ -59,6 +59,7 @@ <item name="totalsSortOrder" xsi:type="object">Magento\Checkout\Block\Checkout\TotalsProcessor</item> <item name="directoryData" xsi:type="object">Magento\Checkout\Block\Checkout\DirectoryDataProcessor</item> </argument> + <argument name="serializer" xsi:type="object">Magento\Framework\Serialize\Serializer\JsonHexTag</argument> </arguments> </type> <type name="Magento\Checkout\Block\Cart\Totals"> diff --git a/app/code/Magento/Checkout/etc/frontend/sections.xml b/app/code/Magento/Checkout/etc/frontend/sections.xml index 35733a6119a25..90c2878f501cf 100644 --- a/app/code/Magento/Checkout/etc/frontend/sections.xml +++ b/app/code/Magento/Checkout/etc/frontend/sections.xml @@ -46,7 +46,6 @@ </action> <action name="rest/*/V1/guest-carts/*/payment-information"> <section name="cart"/> - <section name="checkout-data"/> </action> <action name="rest/*/V1/guest-carts/*/selected-payment-method"> <section name="cart"/> diff --git a/app/code/Magento/Checkout/i18n/en_US.csv b/app/code/Magento/Checkout/i18n/en_US.csv index 2dcb611c1fe60..bacfe169c1bff 100644 --- a/app/code/Magento/Checkout/i18n/en_US.csv +++ b/app/code/Magento/Checkout/i18n/en_US.csv @@ -181,3 +181,4 @@ Payment,Payment "Item in Cart","Item in Cart" "Items in Cart","Items in Cart" "Close","Close" +"You added %1 to your <a href=""%2"">shopping cart</a>.","You added %1 to your <a href=""%2"">shopping cart</a>." diff --git a/app/code/Magento/Checkout/view/adminhtml/email/failed_payment.html b/app/code/Magento/Checkout/view/adminhtml/email/failed_payment.html index fb55f9b601dc9..03ad7d9e8d848 100644 --- a/app/code/Magento/Checkout/view/adminhtml/email/failed_payment.html +++ b/app/code/Magento/Checkout/view/adminhtml/email/failed_payment.html @@ -23,43 +23,43 @@ <h1>{{trans "Payment Transaction Failed"}}</h1> <ul> <li> - <b>{{trans "Reason"}}</b><br /> + <strong>{{trans "Reason"}}</strong><br /> {{var reason}} </li> <li> - <b>{{trans "Checkout Type"}}</b><br /> + <strong>{{trans "Checkout Type"}}</strong><br /> {{var checkoutType}} </li> <li> - <b>{{trans "Customer:"}}</b><br /> + <strong>{{trans "Customer:"}}</strong><br /> <a href="mailto:{{var customerEmail}}">{{var customer}}</a> <{{var customerEmail}}> </li> <li> - <b>{{trans "Items"}}</b><br /> + <strong>{{trans "Items"}}</strong><br /> {{var items|raw}} </li> <li> - <b>{{trans "Total:"}}</b><br /> + <strong>{{trans "Total:"}}</strong><br /> {{var total}} </li> <li> - <b>{{trans "Billing Address:"}}</b><br /> + <strong>{{trans "Billing Address:"}}</strong><br /> {{var billingAddress.format('html')|raw}} </li> <li> - <b>{{trans "Shipping Address:"}}</b><br /> + <strong>{{trans "Shipping Address:"}}</strong><br /> {{var shippingAddress.format('html')|raw}} </li> <li> - <b>{{trans "Shipping Method:"}}</b><br /> + <strong>{{trans "Shipping Method:"}}</strong><br /> {{var shippingMethod}} </li> <li> - <b>{{trans "Payment Method:"}}</b><br /> + <strong>{{trans "Payment Method:"}}</strong><br /> {{var paymentMethod}} </li> <li> - <b>{{trans "Date & Time:"}}</b><br /> + <strong>{{trans "Date & Time:"}}</strong><br /> {{var dateAndTime}} </li> </ul> diff --git a/app/code/Magento/Checkout/view/frontend/layout/checkout_index_index.xml b/app/code/Magento/Checkout/view/frontend/layout/checkout_index_index.xml index 6c562f0b9027b..642fb5664ddc0 100644 --- a/app/code/Magento/Checkout/view/frontend/layout/checkout_index_index.xml +++ b/app/code/Magento/Checkout/view/frontend/layout/checkout_index_index.xml @@ -404,6 +404,10 @@ <item name="component" xsi:type="string">Magento_Checkout/js/view/summary/item/details/subtotal</item> <item name="displayArea" xsi:type="string">after_details</item> </item> + <item name="message" xsi:type="array"> + <item name="component" xsi:type="string">Magento_Checkout/js/view/summary/item/details/message</item> + <item name="displayArea" xsi:type="string">item_message</item> + </item> </item> </item> </item> diff --git a/app/code/Magento/Checkout/view/frontend/templates/cart/minicart.phtml b/app/code/Magento/Checkout/view/frontend/templates/cart/minicart.phtml index e6d0260cf2305..20be9cd010c64 100644 --- a/app/code/Magento/Checkout/view/frontend/templates/cart/minicart.phtml +++ b/app/code/Magento/Checkout/view/frontend/templates/cart/minicart.phtml @@ -41,6 +41,14 @@ </div> <?= $block->getChildHtml('minicart.addons') ?> </div> + <?php else: ?> + <script> + require(['jquery'], function ($) { + $('a.action.showcart').click(function() { + $(document.body).trigger('processStart'); + }); + }); + </script> <?php endif ?> <script> window.checkout = <?= /* @escapeNotVerified */ $block->getSerializedConfig() ?>; diff --git a/app/code/Magento/Checkout/view/frontend/web/js/checkout-data.js b/app/code/Magento/Checkout/view/frontend/web/js/checkout-data.js index 22b37b2da0b2f..1858ce946fb07 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/checkout-data.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/checkout-data.js @@ -10,7 +10,8 @@ */ define([ 'jquery', - 'Magento_Customer/js/customer-data' + 'Magento_Customer/js/customer-data', + 'jquery/jquery-storageapi' ], function ($, storage) { 'use strict'; @@ -23,6 +24,22 @@ define([ storage.set(cacheKey, data); }, + /** + * @return {*} + */ + initData = function () { + return { + 'selectedShippingAddress': null, //Selected shipping address pulled from persistence storage + 'shippingAddressFromData': null, //Shipping address pulled from persistence storage + 'newCustomerShippingAddress': null, //Shipping address pulled from persistence storage for customer + 'selectedShippingRate': null, //Shipping rate pulled from persistence storage + 'selectedPaymentMethod': null, //Payment method pulled from persistence storage + 'selectedBillingAddress': null, //Selected billing address pulled from persistence storage + 'billingAddressFromData': null, //Billing address pulled from persistence storage + 'newCustomerBillingAddress': null //Billing address pulled from persistence storage for new customer + }; + }, + /** * @return {*} */ @@ -30,17 +47,12 @@ define([ var data = storage.get(cacheKey)(); if ($.isEmptyObject(data)) { - data = { - 'selectedShippingAddress': null, //Selected shipping address pulled from persistence storage - 'shippingAddressFromData': null, //Shipping address pulled from persistence storage - 'newCustomerShippingAddress': null, //Shipping address pulled from persistence storage for customer - 'selectedShippingRate': null, //Shipping rate pulled from persistence storage - 'selectedPaymentMethod': null, //Payment method pulled from persistence storage - 'selectedBillingAddress': null, //Selected billing address pulled from persistence storage - 'billingAddressFromData': null, //Billing address pulled from persistence storage - 'newCustomerBillingAddress': null //Billing address pulled from persistence storage for new customer - }; - saveData(data); + data = $.initNamespaceStorage('mage-cache-storage').localStorage.get(cacheKey); + + if ($.isEmptyObject(data)) { + data = initData(); + saveData(data); + } } return data; diff --git a/app/code/Magento/Checkout/view/frontend/web/js/model/checkout-data-resolver.js b/app/code/Magento/Checkout/view/frontend/web/js/model/checkout-data-resolver.js index 28e04699f8daf..bf152f68e25e5 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/model/checkout-data-resolver.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/model/checkout-data-resolver.js @@ -67,7 +67,15 @@ define([ * Resolve shipping address. Used local storage */ resolveShippingAddress: function () { - var newCustomerShippingAddress = checkoutData.getNewCustomerShippingAddress(); + var newCustomerShippingAddress; + + if (!checkoutData.getShippingAddressFromData() && + window.checkoutConfig.shippingAddressFromData + ) { + checkoutData.setShippingAddressFromData(window.checkoutConfig.shippingAddressFromData); + } + + newCustomerShippingAddress = checkoutData.getNewCustomerShippingAddress(); if (newCustomerShippingAddress) { createShippingAddress(newCustomerShippingAddress); @@ -196,8 +204,17 @@ define([ * Resolve billing address. Used local storage */ resolveBillingAddress: function () { - var selectedBillingAddress = checkoutData.getSelectedBillingAddress(), - newCustomerBillingAddressData = checkoutData.getNewCustomerBillingAddress(); + var selectedBillingAddress, + newCustomerBillingAddressData; + + if (!checkoutData.getBillingAddressFromData() && + window.checkoutConfig.billingAddressFromData + ) { + checkoutData.setBillingAddressFromData(window.checkoutConfig.billingAddressFromData); + } + + selectedBillingAddress = checkoutData.getSelectedBillingAddress(); + newCustomerBillingAddressData = checkoutData.getNewCustomerBillingAddress(); if (selectedBillingAddress) { if (selectedBillingAddress == 'new-customer-address' && newCustomerBillingAddressData) { //eslint-disable-line diff --git a/app/code/Magento/Checkout/view/frontend/web/js/model/place-order.js b/app/code/Magento/Checkout/view/frontend/web/js/model/place-order.js index c3c5b9d68cec0..c07878fcaea92 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/model/place-order.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/model/place-order.js @@ -9,9 +9,10 @@ define( [ 'mage/storage', 'Magento_Checkout/js/model/error-processor', - 'Magento_Checkout/js/model/full-screen-loader' + 'Magento_Checkout/js/model/full-screen-loader', + 'Magento_Customer/js/customer-data' ], - function (storage, errorProcessor, fullScreenLoader) { + function (storage, errorProcessor, fullScreenLoader, customerData) { 'use strict'; return function (serviceUrl, payload, messageContainer) { @@ -23,6 +24,23 @@ define( function (response) { errorProcessor.process(response, messageContainer); } + ).success( + function (response) { + var clearData = { + 'selectedShippingAddress': null, + 'shippingAddressFromData': null, + 'newCustomerShippingAddress': null, + 'selectedShippingRate': null, + 'selectedPaymentMethod': null, + 'selectedBillingAddress': null, + 'billingAddressFromData': null, + 'newCustomerBillingAddress': null + }; + + if (response.responseType !== 'error') { + customerData.set('checkout-data', clearData); + } + } ).always( function () { fullScreenLoader.stopLoader(); diff --git a/app/code/Magento/Checkout/view/frontend/web/js/model/postcode-validator.js b/app/code/Magento/Checkout/view/frontend/web/js/model/postcode-validator.js index a95471d90dab8..0a5334a42c7e5 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/model/postcode-validator.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/model/postcode-validator.js @@ -14,11 +14,13 @@ define([ /** * @param {*} postCode * @param {*} countryId + * @param {Array} postCodesPatterns * @return {Boolean} */ - validate: function (postCode, countryId) { - var patterns = window.checkoutConfig.postCodes[countryId], - pattern, regex; + validate: function (postCode, countryId, postCodesPatterns) { + var pattern, regex, + patterns = postCodesPatterns ? postCodesPatterns[countryId] : + window.checkoutConfig.postCodes[countryId]; this.validatedPostCodeExample = []; diff --git a/app/code/Magento/Checkout/view/frontend/web/js/model/shipping-rates-validator.js b/app/code/Magento/Checkout/view/frontend/web/js/model/shipping-rates-validator.js index fde88ebadb393..8b07c02e4d380 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/model/shipping-rates-validator.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/model/shipping-rates-validator.js @@ -42,6 +42,7 @@ define([ return { validateAddressTimeout: 0, + validateZipCodeTimeout: 0, validateDelay: 2000, /** @@ -133,16 +134,20 @@ define([ }); } else { element.on('value', function () { + clearTimeout(self.validateZipCodeTimeout); + self.validateZipCodeTimeout = setTimeout(function () { + if (element.index === postcodeElementName) { + self.postcodeValidation(element); + } else { + $.each(postcodeElements, function (index, elem) { + self.postcodeValidation(elem); + }); + } + }, delay); + if (!formPopUpState.isVisible()) { clearTimeout(self.validateAddressTimeout); self.validateAddressTimeout = setTimeout(function () { - if (element.index === postcodeElementName) { - self.postcodeValidation(element); - } else { - $.each(postcodeElements, function (index, elem) { - self.postcodeValidation(elem); - }); - } self.validateFields(); }, delay); } diff --git a/app/code/Magento/Checkout/view/frontend/web/js/sidebar.js b/app/code/Magento/Checkout/view/frontend/web/js/sidebar.js index dde1ad72ba15e..e66c66006246c 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/sidebar.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/sidebar.js @@ -25,6 +25,7 @@ define([ } }, scrollHeight: 0, + shoppingCartUrl: window.checkout.shoppingCartUrl, /** * Create sidebar. @@ -227,6 +228,10 @@ define([ if (!_.isUndefined(productData)) { $(document).trigger('ajax:updateCartItemQty'); + + if (window.location.href === this.shoppingCartUrl) { + window.location.reload(false); + } } this._hideItemButton(elem); }, diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/form/element/email.js b/app/code/Magento/Checkout/view/frontend/web/js/view/form/element/email.js index 90ad07da0ae37..a195037394085 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/view/form/element/email.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/form/element/email.js @@ -17,7 +17,16 @@ define([ ], function ($, Component, ko, customer, checkEmailAvailability, loginAction, quote, checkoutData, fullScreenLoader) { 'use strict'; - var validatedEmail = checkoutData.getValidatedEmailValue(); + var validatedEmail; + + if (!checkoutData.getValidatedEmailValue() && + window.checkoutConfig.validatedEmailValue + ) { + checkoutData.setInputFieldEmailValue(window.checkoutConfig.validatedEmailValue); + checkoutData.setValidatedEmailValue(window.checkoutConfig.validatedEmailValue); + } + + validatedEmail = checkoutData.getValidatedEmailValue(); if (validatedEmail && !customer.isLoggedIn()) { quote.guestEmail = validatedEmail; @@ -33,6 +42,9 @@ define([ listens: { email: 'emailHasChanged', emailFocused: 'validateEmail' + }, + ignoreTmpls: { + email: true } }, checkDelay: 2000, diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/shipping.js b/app/code/Magento/Checkout/view/frontend/web/js/view/shipping.js index df50a5ae94ae9..b4997f9664c81 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/view/shipping.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/shipping.js @@ -247,6 +247,7 @@ define([ */ setShippingInformation: function () { if (this.validateShippingInformation()) { + quote.billingAddress(null); checkoutDataResolver.resolveBillingAddress(); setShippingInformationAction().done( function () { diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/summary/item/details/message.js b/app/code/Magento/Checkout/view/frontend/web/js/view/summary/item/details/message.js new file mode 100644 index 0000000000000..ed41fd26c47ec --- /dev/null +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/summary/item/details/message.js @@ -0,0 +1,30 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define(['uiComponent'], function (Component) { + 'use strict'; + + var quoteMessages = window.checkoutConfig.quoteMessages; + + return Component.extend({ + defaults: { + template: 'Magento_Checkout/summary/item/details/message' + }, + displayArea: 'item_message', + quoteMessages: quoteMessages, + + /** + * @param {Object} item + * @return {null} + */ + getMessage: function (item) { + if (this.quoteMessages[item['item_id']]) { + return this.quoteMessages[item['item_id']]; + } + + return null; + } + }); +}); diff --git a/app/code/Magento/Checkout/view/frontend/web/template/minicart/content.html b/app/code/Magento/Checkout/view/frontend/web/template/minicart/content.html index 2daca51a2f5da..fb128a891aea2 100644 --- a/app/code/Magento/Checkout/view/frontend/web/template/minicart/content.html +++ b/app/code/Magento/Checkout/view/frontend/web/template/minicart/content.html @@ -97,7 +97,7 @@ </div> </div> - <div id="minicart-widgets" class="minicart-widgets"> + <div id="minicart-widgets" class="minicart-widgets" if="getRegion('promotion').length"> <each args="getRegion('promotion')" render=""/> </div> </div> diff --git a/app/code/Magento/Checkout/view/frontend/web/template/summary/item/details.html b/app/code/Magento/Checkout/view/frontend/web/template/summary/item/details.html index dd59bd78416c6..2491ee12d263c 100644 --- a/app/code/Magento/Checkout/view/frontend/web/template/summary/item/details.html +++ b/app/code/Magento/Checkout/view/frontend/web/template/summary/item/details.html @@ -43,3 +43,6 @@ </div> <!-- /ko --> </div> +<!-- ko foreach: getRegion('item_message') --> + <!-- ko template: getTemplate() --><!-- /ko --> +<!-- /ko --> diff --git a/app/code/Magento/Checkout/view/frontend/web/template/summary/item/details/message.html b/app/code/Magento/Checkout/view/frontend/web/template/summary/item/details/message.html new file mode 100644 index 0000000000000..ea8f58cccd595 --- /dev/null +++ b/app/code/Magento/Checkout/view/frontend/web/template/summary/item/details/message.html @@ -0,0 +1,9 @@ +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<div class="cart item message notice" if="getMessage($parents[1])"> + <div data-bind="text: getMessage($parents[1])"></div> +</div> diff --git a/app/code/Magento/CheckoutAgreements/composer.json b/app/code/Magento/CheckoutAgreements/composer.json index a53558981e2f8..c6c2102600974 100644 --- a/app/code/Magento/CheckoutAgreements/composer.json +++ b/app/code/Magento/CheckoutAgreements/composer.json @@ -10,7 +10,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFiles.php b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFiles.php index 890c9bf5eae52..f7970b0a93ca9 100644 --- a/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFiles.php +++ b/app/code/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFiles.php @@ -79,7 +79,7 @@ public function execute() $filesystem = $this->_objectManager->get(\Magento\Framework\Filesystem::class); $dir = $filesystem->getDirectoryRead(DirectoryList::MEDIA); $filePath = $path . '/' . \Magento\Framework\File\Uploader::getCorrectFileName($file); - if ($dir->isFile($dir->getRelativePath($filePath))) { + if ($dir->isFile($dir->getRelativePath($filePath)) && !preg_match('/^\.htaccess$/', $file)) { $this->getStorage()->deleteFile($filePath); } } diff --git a/app/code/Magento/Cms/Model/Page/Source/PageLayout.php b/app/code/Magento/Cms/Model/Page/Source/PageLayout.php index fb759348759b2..23a452c0fe58c 100644 --- a/app/code/Magento/Cms/Model/Page/Source/PageLayout.php +++ b/app/code/Magento/Cms/Model/Page/Source/PageLayout.php @@ -20,6 +20,7 @@ class PageLayout implements OptionSourceInterface /** * @var array + * @deprecated since the cache is now handled by \Magento\Theme\Model\PageLayout\Config\Builder::$configFiles */ protected $options; @@ -34,16 +35,10 @@ public function __construct(BuilderInterface $pageLayoutBuilder) } /** - * Get options - * - * @return array + * @inheritdoc */ public function toOptionArray() { - if ($this->options !== null) { - return $this->options; - } - $configOptions = $this->pageLayoutBuilder->getPageLayoutsConfig()->getOptions(); $options = []; foreach ($configOptions as $key => $value) { @@ -54,6 +49,6 @@ public function toOptionArray() } $this->options = $options; - return $this->options; + return $options; } } diff --git a/app/code/Magento/Cms/Test/Mftf/ActionGroup/NavigateToCMSPagesActionGroup.xml b/app/code/Magento/Cms/Test/Mftf/ActionGroup/NavigateToCMSPagesActionGroup.xml new file mode 100644 index 0000000000000..6a2012b551407 --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/ActionGroup/NavigateToCMSPagesActionGroup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="NavigateToCreatedCMSBlockPage"> + <arguments> + <argument name="cmsBlock"/> + </arguments> + <amOnPage url="{{AdminCmsBlockGridPage.url}}" stepKey="navigateToCMSBlocksGrid"/> + <conditionalClick selector="{{AdminDataGridHeaderSection.clearFilters}}" dependentSelector="{{AdminDataGridHeaderSection.clearFilters}}" visible="true" stepKey="clickToResetFilters"/> + <click selector="{{AdminDataGridHeaderSection.filters}}" stepKey="clickOnFilters"/> + <fillField userInput="{{cmsBlock.identifier}}" selector="{{AdminDataGridHeaderSection.filterFieldInput('identifier')}}" stepKey="fillFilterAttributeCodeField"/> + <click selector="{{AdminDataGridHeaderSection.applyFilters}}" stepKey="clickApplyFiltersButton"/> + <click selector="{{AdminDataGridTableSection.rowActionSelect}}" stepKey="clickSelectCreatedCMSBlock" /> + <click selector="{{AdminDataGridTableSection.rowEditAction}}" stepKey="navigateToCreatedCMSBlock" /> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Cms/Test/Mftf/Data/CmsBlockData.xml b/app/code/Magento/Cms/Test/Mftf/Data/CmsBlockData.xml new file mode 100644 index 0000000000000..c8f71253dc6bd --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/Data/CmsBlockData.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <entity name="DefaultCmsBlock" type="cms_block"> + <data key="title">Default Block</data> + <data key="identifier" unique="suffix" >block</data> + <data key="content">Here is a block test. Yeah!</data> + <data key="active">true</data> + </entity> +</entities> diff --git a/app/code/Magento/Cms/Test/Mftf/Metadata/cms_block-meta.xml b/app/code/Magento/Cms/Test/Mftf/Metadata/cms_block-meta.xml new file mode 100644 index 0000000000000..bab2be6a36155 --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/Metadata/cms_block-meta.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<operations xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataOperation.xsd"> + <operation name="CreateCmsBlock" dataType="cms_block" type="create" auth="adminOauth" url="/V1/cmsBlock" method="POST"> + <contentType>application/json</contentType> + <object key="block" dataType="cms_block"> + <field key="title">string</field> + <field key="identifier">string</field> + <field key="content">string</field> + <field key="active">true</field> + </object> + </operation> + + <operation name="DeleteCmsBlock" dataType="cms_block" type="delete" auth="adminOauth" url="/V1/cmsBlock/{id}" method="DELETE"> + <contentType>application/json</contentType> + </operation> +</operations> diff --git a/app/code/Magento/Cms/Test/Mftf/Page/AdminCmsBlockEditPage.xml b/app/code/Magento/Cms/Test/Mftf/Page/AdminCmsBlockEditPage.xml new file mode 100644 index 0000000000000..e3584427f4767 --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/Page/AdminCmsBlockEditPage.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminCmsBlockEditPage" url="/cms/block/edit/id/{{var1}}" area="admin" module="Magento_Cms" parameterized="true"> + <section name="AdminCmsBlockContentSection" /> + <section name="AdminMediaGallerySection" /> + </page> +</pages> diff --git a/app/code/Magento/Cms/Test/Mftf/Page/AdminCmsBlockGridPage.xml b/app/code/Magento/Cms/Test/Mftf/Page/AdminCmsBlockGridPage.xml new file mode 100644 index 0000000000000..2cabe182714dc --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/Page/AdminCmsBlockGridPage.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminCmsBlockGridPage" url="/cms/block/" area="admin" module="Magento_Cms"> + </page> +</pages> diff --git a/app/code/Magento/Cms/Test/Mftf/Page/StorefrontHomePage.xml b/app/code/Magento/Cms/Test/Mftf/Page/StorefrontHomePage.xml index fe1719a640070..5468d08bd4e0b 100644 --- a/app/code/Magento/Cms/Test/Mftf/Page/StorefrontHomePage.xml +++ b/app/code/Magento/Cms/Test/Mftf/Page/StorefrontHomePage.xml @@ -7,9 +7,11 @@ --> <pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/PageObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> <page name="StorefrontHomePage" url="/" module="Magento_Cms" area="storefront"> <section name="StorefrontHeaderSection"/> <section name="StorefrontQuickSearchSection"/> + <section name="StorefrontHeaderCurrencySwitcherSection"/> + <section name="StorefrontCmsPageSection"/> </page> </pages> diff --git a/app/code/Magento/Cms/Test/Mftf/Section/AdminCmsBlockContentSection.xml b/app/code/Magento/Cms/Test/Mftf/Section/AdminCmsBlockContentSection.xml new file mode 100644 index 0000000000000..9614f13f9e3d3 --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/Section/AdminCmsBlockContentSection.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminCmsBlockContentSection"> + <element name="content" type="textarea" selector="#cms_block_form_content"/> + <element name="insertWidgetButton" type="button" selector=".scalable.action-add-widget.plugin"/> + </section> +</sections> diff --git a/app/code/Magento/Cms/Test/Mftf/Section/AdminMediaGallerySection.xml b/app/code/Magento/Cms/Test/Mftf/Section/AdminMediaGallerySection.xml new file mode 100644 index 0000000000000..9d08bc708aef9 --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/Section/AdminMediaGallerySection.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminMediaGallerySection"> + <element name="imageSelected" type="text" selector="//small[text()='{{imageName}}']/parent::*[@class='filecnt selected']" parameterized="true"/> + <element name="uploadImage" type="file" selector="input.fileupload" /> + <element name="insertFile" type="text" selector="#insert_files"/> + <element name="imageBlockByName" type="block" selector="//div[@data-row='file'][contains(., '{{imageName}}')]" parameterized="true"/> + </section> +</sections> diff --git a/app/code/Magento/Cms/Test/Mftf/Section/CmsNewPagePageBasicFieldsSection.xml b/app/code/Magento/Cms/Test/Mftf/Section/CmsNewPagePageBasicFieldsSection.xml index cf07d1003b7c2..1d9e58e870cfb 100644 --- a/app/code/Magento/Cms/Test/Mftf/Section/CmsNewPagePageBasicFieldsSection.xml +++ b/app/code/Magento/Cms/Test/Mftf/Section/CmsNewPagePageBasicFieldsSection.xml @@ -7,8 +7,9 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="CmsNewPagePageBasicFieldsSection"> <element name="pageTitle" type="input" selector="input[name=title]"/> + <element name="requiredFieldIndicator" type="text" selector=" return window.getComputedStyle(document.querySelector('._required[data-index=title]>.admin__field-label span'), ':after').getPropertyValue('content');"/> </section> </sections> diff --git a/app/code/Magento/Cms/Test/Mftf/Section/CmsPagesPageActionsSection.xml b/app/code/Magento/Cms/Test/Mftf/Section/CmsPagesPageActionsSection.xml index 63069c2ccf264..370e7af15d4b2 100644 --- a/app/code/Magento/Cms/Test/Mftf/Section/CmsPagesPageActionsSection.xml +++ b/app/code/Magento/Cms/Test/Mftf/Section/CmsPagesPageActionsSection.xml @@ -7,7 +7,7 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="CmsPagesPageActionsSection"> <element name="addNewPage" type="button" selector="#add" timeout="30"/> <element name="select" type="button" selector="//div[text()='{{var1}}']/parent::td//following-sibling::td[@class='data-grid-actions-cell']//button[text()='Select']" parameterized="true"/> diff --git a/app/code/Magento/Cms/Test/Mftf/Section/StorefrontCmsPageSection.xml b/app/code/Magento/Cms/Test/Mftf/Section/StorefrontCmsPageSection.xml new file mode 100644 index 0000000000000..2a2a80098b92e --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/Section/StorefrontCmsPageSection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontCmsPageSection"> + <element name="imageSource" type="text" selector="img[src*='{{imageName}}']" parameterized="true"/> + </section> +</sections> diff --git a/app/code/Magento/Cms/Test/Mftf/Test/AdminRestrictedUserOnlyAccessCmsBlockTest.xml b/app/code/Magento/Cms/Test/Mftf/Test/AdminRestrictedUserOnlyAccessCmsBlockTest.xml new file mode 100644 index 0000000000000..d0ed330779676 --- /dev/null +++ b/app/code/Magento/Cms/Test/Mftf/Test/AdminRestrictedUserOnlyAccessCmsBlockTest.xml @@ -0,0 +1,59 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminRestrictedUserOnlyAccessCmsBlockTest"> + <annotations> + <features value="Cms"/> + <stories value="Check access for restricted admin user"/> + <title value="Check: restricted admin with access only to CMS Block"/> + <description value="Check that the system shows information only in Blocks"/> + <severity value="MAJOR"/> + <testCaseId value="MC-13814"/> + <useCaseId value="MAGETWO-88612"/> + <group value="Cms"/> + </annotations> + <before> + <createData entity="restrictedWebUser" stepKey="createRestrictedAdmin"/> + <actionGroup ref="LoginToAdminActionGroup" stepKey="loginToBackend"/> + <actionGroup ref="AdminCreateUserRoleActionGroup" stepKey="createRestrictedAdminRole"> + <argument name="roleName" value="{{RoleTest.roleName}}"/> + <argument name="resourceAccess" value="Custom"/> + <argument name="resource" value="Magento_Cms::block"/> + </actionGroup> + <actionGroup ref="AdminAssignUserRoleActionGroup" stepKey="assignAdminRole"> + <argument name="user_restricted" value="$$createRestrictedAdmin$$"/> + <argument name="roleName" value="{{RoleTest.roleName}}"/> + </actionGroup> + <actionGroup ref="logout" stepKey="logOut"/> + </before> + <after> + <actionGroup ref="LoginActionGroup" stepKey="loginAsAdminWithAllAccess"/> + <actionGroup ref="AdminDeleteUserRoleActionGroup" stepKey="deleteRestrictedRole"> + <argument name="roleName" value="{{RoleTest.roleName}}"/> + </actionGroup> + <actionGroup ref="AdminDeleteUserActionGroup" stepKey="deleteRestrictedUser"> + <argument name="user_restricted" value="$$createRestrictedAdmin$$"/> + </actionGroup> + <!--Log Out--> + <actionGroup ref="logout" stepKey="logOut"/> + </after> + + <!--login as restricted user--> + <actionGroup ref="AdminLoginAsAnyUser" stepKey="logAsNewUser"> + <argument name="login" value="$$createRestrictedAdmin.username$$"/> + <argument name="password" value="$$createRestrictedAdmin.password$$"/> + </actionGroup> + + <!--Verify that The system shows information included in "Blocks"--> + <see userInput="Blocks" stepKey="seeBlocksPage"/> + <seeInCurrentUrl url="{{AdminCmsBlockGridPage.url}}" stepKey="assertUrl"/> + <!--Log Out--> + <actionGroup ref="logout" stepKey="logOut"/> + </test> +</tests> diff --git a/app/code/Magento/Cms/Test/Unit/Ui/Component/Listing/DataProviderTest.php b/app/code/Magento/Cms/Test/Unit/Ui/Component/Listing/DataProviderTest.php index 54e0e17ab7ad6..ec9cb86c6c9dc 100644 --- a/app/code/Magento/Cms/Test/Unit/Ui/Component/Listing/DataProviderTest.php +++ b/app/code/Magento/Cms/Test/Unit/Ui/Component/Listing/DataProviderTest.php @@ -118,11 +118,12 @@ public function testPrepareMetadata() 'config' => [ 'editorConfig' => [ 'enabled' => false - ] - ] - ] - ] - ] + ], + 'componentType' => \Magento\Ui\Component\Container::NAME, + ], + ], + ], + ], ]; $this->assertEquals( diff --git a/app/code/Magento/Cms/Ui/Component/DataProvider.php b/app/code/Magento/Cms/Ui/Component/DataProvider.php index 5fc9c5a896037..a0f68f8dde05a 100644 --- a/app/code/Magento/Cms/Ui/Component/DataProvider.php +++ b/app/code/Magento/Cms/Ui/Component/DataProvider.php @@ -13,6 +13,9 @@ use Magento\Framework\AuthorizationInterface; use Magento\Framework\View\Element\UiComponent\DataProvider\Reporting; +/** + * DataProvider for cms ui. + */ class DataProvider extends \Magento\Framework\View\Element\UiComponent\DataProvider\DataProvider { /** @@ -67,6 +70,8 @@ public function __construct( } /** + * Get authorization info. + * * @deprecated 101.0.7 * @return AuthorizationInterface|mixed */ @@ -95,11 +100,12 @@ public function prepareMetadata() 'config' => [ 'editorConfig' => [ 'enabled' => false - ] - ] - ] - ] - ] + ], + 'componentType' => \Magento\Ui\Component\Container::NAME, + ], + ], + ], + ], ]; } diff --git a/app/code/Magento/Cms/composer.json b/app/code/Magento/Cms/composer.json index 64e97e0a38e18..3f425e91b89e2 100644 --- a/app/code/Magento/Cms/composer.json +++ b/app/code/Magento/Cms/composer.json @@ -18,7 +18,7 @@ "magento/module-cms-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "102.0.6", + "version": "102.0.7", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Cms/view/adminhtml/templates/browser/content/files.phtml b/app/code/Magento/Cms/view/adminhtml/templates/browser/content/files.phtml index a4c570f9d65a1..f19450cb09c66 100644 --- a/app/code/Magento/Cms/view/adminhtml/templates/browser/content/files.phtml +++ b/app/code/Magento/Cms/view/adminhtml/templates/browser/content/files.phtml @@ -15,7 +15,7 @@ $_height = $block->getImagesHeight(); <?php if ($block->getFilesCount() > 0): ?> <?php foreach ($block->getFiles() as $file): ?> <div data-row="file" class="filecnt" id="<?= $block->escapeHtmlAttr($block->getFileId($file)) ?>"> - <p class="nm" style="height:<?= $block->escapeHtmlAttr($_height) ?>px;width:<?= $block->escapeHtmlAttr($_width) ?>px;"> + <p class="nm" style="height:<?= $block->escapeHtmlAttr($_height) ?>px;"> <?php if ($block->getFileThumbUrl($file)):?> <img src="<?= $block->escapeHtmlAttr($block->getFileThumbUrl($file)) ?>" alt="<?= $block->escapeHtmlAttr($block->getFileName($file)) ?>"/> <?php endif; ?> diff --git a/app/code/Magento/CmsUrlRewrite/composer.json b/app/code/Magento/CmsUrlRewrite/composer.json index 414dcdf54921d..abd8ad9fe2916 100644 --- a/app/code/Magento/CmsUrlRewrite/composer.json +++ b/app/code/Magento/CmsUrlRewrite/composer.json @@ -9,7 +9,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Config/App/Config/Type/System.php b/app/code/Magento/Config/App/Config/Type/System.php index 83c61f90f789a..c07872a630830 100644 --- a/app/code/Magento/Config/App/Config/Type/System.php +++ b/app/code/Magento/Config/App/Config/Type/System.php @@ -11,6 +11,7 @@ use Magento\Framework\App\Config\Spi\PreProcessorInterface; use Magento\Framework\App\ObjectManager; use Magento\Config\App\Config\Type\System\Reader; +use Magento\Framework\Lock\LockManagerInterface; use Magento\Framework\Serialize\Serializer\Sensitive as SensitiveSerializer; use Magento\Framework\Serialize\Serializer\SensitiveFactory as SensitiveSerializerFactory; use Magento\Framework\App\ScopeInterface; @@ -24,12 +25,43 @@ * * @api * @since 100.1.2 + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class System implements ConfigTypeInterface { + /** + * Config cache tag. + */ const CACHE_TAG = 'config_scopes'; + + /** + * System config type. + */ const CONFIG_TYPE = 'system'; + /** + * @var string + */ + private static $lockName = 'SYSTEM_CONFIG'; + + /** + * Timeout between retrieves to load the configuration from the cache. + * + * Value of the variable in microseconds. + * + * @var int + */ + private static $delayTimeout = 50000; + + /** + * Lifetime of the lock for write in cache. + * + * Value of the variable in seconds. + * + * @var int + */ + private static $lockTimeout = 8; + /** * @var array */ @@ -71,6 +103,11 @@ class System implements ConfigTypeInterface */ private $availableDataScopes; + /** + * @var LockManagerInterface + */ + private $locker; + /** * @param ConfigSourceInterface $source * @param PostProcessorInterface $postProcessor @@ -82,7 +119,7 @@ class System implements ConfigTypeInterface * @param string $configType * @param Reader $reader * @param SensitiveSerializerFactory|null $sensitiveFactory - * + * @param LockManagerInterface|null $locker * @SuppressWarnings(PHPMD.ExcessiveParameterList) * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ @@ -96,7 +133,8 @@ public function __construct( $cachingNestedLevel = 1, $configType = self::CONFIG_TYPE, Reader $reader = null, - SensitiveSerializerFactory $sensitiveFactory = null + SensitiveSerializerFactory $sensitiveFactory = null, + LockManagerInterface $locker = null ) { $this->postProcessor = $postProcessor; $this->cache = $cache; @@ -110,6 +148,7 @@ public function __construct( $this->serializer = $sensitiveFactory->create( ['serializer' => $serializer] ); + $this->locker = $locker ?: ObjectManager::getInstance()->get(LockManagerInterface::class); } /** @@ -153,7 +192,7 @@ private function getWithParts($path) if (count($pathParts) === 1 && $pathParts[0] !== ScopeInterface::SCOPE_DEFAULT) { if (!isset($this->data[$pathParts[0]])) { - $data = $this->readData(); + $data = $this->loadAllData(); $this->data = array_replace_recursive($data, $this->data); } @@ -186,21 +225,60 @@ private function getWithParts($path) } /** - * Load configuration data for all scopes + * Make lock on data load. * + * @param callable $dataLoader + * @param bool $flush * @return array */ - private function loadAllData() + private function lockedLoadData(callable $dataLoader, bool $flush = false): array { - $cachedData = $this->cache->load($this->configType); + $cachedData = $dataLoader(); //optimistic read - if ($cachedData === false) { - $data = $this->readData(); - } else { - $data = $this->serializer->unserialize($cachedData); + while ($cachedData === false && $this->locker->isLocked(self::$lockName)) { + usleep(self::$delayTimeout); + $cachedData = $dataLoader(); } - return $data; + while ($cachedData === false) { + try { + if ($this->locker->lock(self::$lockName, self::$lockTimeout)) { + if (!$flush) { + $data = $this->readData(); + $this->cacheData($data); + $cachedData = $data; + } else { + $this->cache->clean(\Zend_Cache::CLEANING_MODE_MATCHING_TAG, [self::CACHE_TAG]); + $cachedData = []; + } + } + } finally { + $this->locker->unlock(self::$lockName); + } + + if ($cachedData === false) { + usleep(self::$delayTimeout); + $cachedData = $dataLoader(); + } + } + + return $cachedData; + } + + /** + * Load configuration data for all scopes + * + * @return array + */ + private function loadAllData() + { + return $this->lockedLoadData(function () { + $cachedData = $this->cache->load($this->configType); + if ($cachedData === false) { + return $cachedData; + } + return $this->serializer->unserialize($cachedData); + }); } /** @@ -211,16 +289,13 @@ private function loadAllData() */ private function loadDefaultScopeData($scopeType) { - $cachedData = $this->cache->load($this->configType . '_' . $scopeType); - - if ($cachedData === false) { - $data = $this->readData(); - $this->cacheData($data); - } else { - $data = [$scopeType => $this->serializer->unserialize($cachedData)]; - } - - return $data; + return $this->lockedLoadData(function () use ($scopeType) { + $cachedData = $this->cache->load($this->configType . '_' . $scopeType); + if ($cachedData === false) { + return $cachedData; + } + return [$scopeType => $this->serializer->unserialize($cachedData)]; + }); } /** @@ -232,25 +307,22 @@ private function loadDefaultScopeData($scopeType) */ private function loadScopeData($scopeType, $scopeId) { - $cachedData = $this->cache->load($this->configType . '_' . $scopeType . '_' . $scopeId); - - if ($cachedData === false) { - if ($this->availableDataScopes === null) { - $cachedScopeData = $this->cache->load($this->configType . '_scopes'); - if ($cachedScopeData !== false) { - $this->availableDataScopes = $this->serializer->unserialize($cachedScopeData); + return $this->lockedLoadData(function () use ($scopeType, $scopeId) { + $cachedData = $this->cache->load($this->configType . '_' . $scopeType . '_' . $scopeId); + if ($cachedData === false) { + if ($this->availableDataScopes === null) { + $cachedScopeData = $this->cache->load($this->configType . '_scopes'); + if ($cachedScopeData !== false) { + $this->availableDataScopes = $this->serializer->unserialize($cachedScopeData); + } } + if (is_array($this->availableDataScopes) && !isset($this->availableDataScopes[$scopeType][$scopeId])) { + return [$scopeType => [$scopeId => []]]; + } + return false; } - if (is_array($this->availableDataScopes) && !isset($this->availableDataScopes[$scopeType][$scopeId])) { - return [$scopeType => [$scopeId => []]]; - } - $data = $this->readData(); - $this->cacheData($data); - } else { - $data = [$scopeType => [$scopeId => $this->serializer->unserialize($cachedData)]]; - } - - return $data; + return [$scopeType => [$scopeId => $this->serializer->unserialize($cachedData)]]; + }); } /** @@ -340,6 +412,11 @@ private function readData(): array public function clean() { $this->data = []; - $this->cache->clean(\Zend_Cache::CLEANING_MODE_MATCHING_TAG, [self::CACHE_TAG]); + $this->lockedLoadData( + function () { + return false; + }, + true + ); } } diff --git a/app/code/Magento/Config/Block/System/Config/Form.php b/app/code/Magento/Config/Block/System/Config/Form.php index 81e39a83296d7..a05151153daa2 100644 --- a/app/code/Magento/Config/Block/System/Config/Form.php +++ b/app/code/Magento/Config/Block/System/Config/Form.php @@ -143,13 +143,15 @@ public function __construct( \Magento\Config\Model\Config\Structure $configStructure, \Magento\Config\Block\System\Config\Form\Fieldset\Factory $fieldsetFactory, \Magento\Config\Block\System\Config\Form\Field\Factory $fieldFactory, - array $data = [] + array $data = [], + SettingChecker $settingChecker = null ) { parent::__construct($context, $registry, $formFactory, $data); $this->_configFactory = $configFactory; $this->_configStructure = $configStructure; $this->_fieldsetFactory = $fieldsetFactory; $this->_fieldFactory = $fieldFactory; + $this->settingChecker = $settingChecker ?: ObjectManager::getInstance()->get(SettingChecker::class); $this->_scopeLabels = [ self::SCOPE_DEFAULT => __('[GLOBAL]'), @@ -158,18 +160,6 @@ public function __construct( ]; } - /** - * @deprecated 100.1.2 - * @return SettingChecker - */ - private function getSettingChecker() - { - if ($this->settingChecker === null) { - $this->settingChecker = ObjectManager::getInstance()->get(SettingChecker::class); - } - return $this->settingChecker; - } - /** * Initialize objects required to render config form * @@ -366,9 +356,8 @@ protected function _initElement( $sharedClass = $this->_getSharedCssClass($field); $requiresClass = $this->_getRequiresCssClass($field, $fieldPrefix); + $isReadOnly = $this->isReadOnly($field, $path); - $isReadOnly = $this->getElementVisibility()->isDisabled($field->getPath()) - ?: $this->getSettingChecker()->isReadOnly($path, $this->getScope(), $this->getStringScopeCode()); $formField = $fieldset->addField( $elementId, $field->getType(), @@ -417,7 +406,7 @@ private function getFieldData(\Magento\Config\Model\Config\Structure\Element\Fie { $data = $this->getAppConfigDataValue($path); - $placeholderValue = $this->getSettingChecker()->getPlaceholderValue( + $placeholderValue = $this->settingChecker->getPlaceholderValue( $path, $this->getScope(), $this->getStringScopeCode() @@ -718,6 +707,7 @@ protected function _getAdditionalElementTypes() * * @TODO delete this methods when {^see above^} is done * @return string + * @SuppressWarnings(PHPMD.RequestAwareBlockMethod) */ public function getSectionCode() { @@ -729,6 +719,7 @@ public function getSectionCode() * * @TODO delete this methods when {^see above^} is done * @return string + * @SuppressWarnings(PHPMD.RequestAwareBlockMethod) */ public function getWebsiteCode() { @@ -740,6 +731,7 @@ public function getWebsiteCode() * * @TODO delete this methods when {^see above^} is done * @return string + * @SuppressWarnings(PHPMD.RequestAwareBlockMethod) */ public function getStoreCode() { @@ -797,6 +789,26 @@ private function getAppConfig() return $this->appConfig; } + /** + * Check Path is Readonly + * + * @param \Magento\Config\Model\Config\Structure\Element\Field $field + * @param string $path + * @return boolean + */ + private function isReadOnly(\Magento\Config\Model\Config\Structure\Element\Field $field, $path) + { + $isReadOnly = $this->settingChecker->isReadOnly( + $path, + ScopeConfigInterface::SCOPE_TYPE_DEFAULT + ); + if (!$isReadOnly) { + $isReadOnly = $this->getElementVisibility()->isDisabled($field->getPath()) + ?: $this->settingChecker->isReadOnly($path, $this->getScope(), $this->getStringScopeCode()); + } + return $isReadOnly; + } + /** * Retrieve deployment config data value by path * diff --git a/app/code/Magento/Config/Console/Command/ConfigSet/DefaultProcessor.php b/app/code/Magento/Config/Console/Command/ConfigSet/DefaultProcessor.php index d7d513bfad423..86ae1f96749df 100644 --- a/app/code/Magento/Config/Console/Command/ConfigSet/DefaultProcessor.php +++ b/app/code/Magento/Config/Console/Command/ConfigSet/DefaultProcessor.php @@ -7,17 +7,19 @@ use Magento\Config\App\Config\Type\System; use Magento\Config\Console\Command\ConfigSetCommand; +use Magento\Config\Model\Config\Factory as ConfigFactory; use Magento\Framework\App\Config\ConfigPathResolver; use Magento\Framework\App\DeploymentConfig; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\CouldNotSaveException; use Magento\Config\Model\PreparedValueFactory; -use Magento\Framework\App\Config\Value; /** * Processes default flow of config:set command. + * * This processor saves the value of configuration into database. * - * {@inheritdoc} + * @inheritdoc * @api * @since 100.2.0 */ @@ -44,26 +46,36 @@ class DefaultProcessor implements ConfigSetProcessorInterface */ private $preparedValueFactory; + /** + * @var ConfigFactory + */ + private $configFactory; + /** * @param PreparedValueFactory $preparedValueFactory The factory for prepared value * @param DeploymentConfig $deploymentConfig The deployment configuration reader * @param ConfigPathResolver $configPathResolver The resolver for configuration paths according to source type + * @param ConfigFactory|null $configFactory */ public function __construct( PreparedValueFactory $preparedValueFactory, DeploymentConfig $deploymentConfig, - ConfigPathResolver $configPathResolver + ConfigPathResolver $configPathResolver, + ConfigFactory $configFactory = null ) { $this->preparedValueFactory = $preparedValueFactory; $this->deploymentConfig = $deploymentConfig; $this->configPathResolver = $configPathResolver; + + $this->configFactory = $configFactory ?? ObjectManager::getInstance()->get(ConfigFactory::class); } /** * Processes database flow of config:set command. + * * Requires installed application. * - * {@inheritdoc} + * @inheritdoc * @since 100.2.0 */ public function process($path, $value, $scope, $scopeCode) @@ -78,12 +90,12 @@ public function process($path, $value, $scope, $scopeCode) } try { - /** @var Value $backendModel */ - $backendModel = $this->preparedValueFactory->create($path, $value, $scope, $scopeCode); - if ($backendModel instanceof Value) { - $resourceModel = $backendModel->getResource(); - $resourceModel->save($backendModel); - } + $config = $this->configFactory->create([ + 'scope' => $scope, + 'scope_code' => $scopeCode, + ]); + $config->setDataByPath($path, $value); + $config->save(); } catch (\Exception $exception) { throw new CouldNotSaveException(__('%1', $exception->getMessage()), $exception); } diff --git a/app/code/Magento/Config/Model/Config.php b/app/code/Magento/Config/Model/Config.php index 0472c5daa276f..b1074e92cc949 100644 --- a/app/code/Magento/Config/Model/Config.php +++ b/app/code/Magento/Config/Model/Config.php @@ -9,15 +9,32 @@ use Magento\Config\Model\Config\Structure\Element\Group; use Magento\Config\Model\Config\Structure\Element\Field; use Magento\Framework\App\ObjectManager; +use Magento\Framework\App\ScopeInterface; +use Magento\Framework\App\ScopeResolverPool; +use Magento\Store\Model\ScopeInterface as StoreScopeInterface; +use Magento\Store\Model\ScopeTypeNormalizer; /** * Backend config model + * * Used to save configuration * * @author Magento Core Team <core@magentocommerce.com> * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @api * @since 100.0.2 + * @method string getSection() + * @method void setSection(string $section) + * @method string getWebsite() + * @method void setWebsite(string $website) + * @method string getStore() + * @method void setStore(string $store) + * @method string getScope() + * @method void setScope(string $scope) + * @method int getScopeId() + * @method void setScopeId(int $scopeId) + * @method string getScopeCode() + * @method void setScopeCode(string $scopeCode) */ class Config extends \Magento\Framework\DataObject { @@ -87,6 +104,16 @@ class Config extends \Magento\Framework\DataObject */ private $settingChecker; + /** + * @var ScopeResolverPool + */ + private $scopeResolverPool; + + /** + * @var ScopeTypeNormalizer + */ + private $scopeTypeNormalizer; + /** * @param \Magento\Framework\App\Config\ReinitableConfigInterface $config * @param \Magento\Framework\Event\ManagerInterface $eventManager @@ -97,6 +124,9 @@ class Config extends \Magento\Framework\DataObject * @param \Magento\Store\Model\StoreManagerInterface $storeManager * @param Config\Reader\Source\Deployed\SettingChecker|null $settingChecker * @param array $data + * @param ScopeResolverPool|null $scopeResolverPool + * @param ScopeTypeNormalizer|null $scopeTypeNormalizer + * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( \Magento\Framework\App\Config\ReinitableConfigInterface $config, @@ -107,7 +137,9 @@ public function __construct( \Magento\Framework\App\Config\ValueFactory $configValueFactory, \Magento\Store\Model\StoreManagerInterface $storeManager, SettingChecker $settingChecker = null, - array $data = [] + array $data = [], + ScopeResolverPool $scopeResolverPool = null, + ScopeTypeNormalizer $scopeTypeNormalizer = null ) { parent::__construct($data); $this->_eventManager = $eventManager; @@ -117,11 +149,17 @@ public function __construct( $this->_configLoader = $configLoader; $this->_configValueFactory = $configValueFactory; $this->_storeManager = $storeManager; - $this->settingChecker = $settingChecker ?: ObjectManager::getInstance()->get(SettingChecker::class); + $this->settingChecker = $settingChecker + ?? ObjectManager::getInstance()->get(SettingChecker::class); + $this->scopeResolverPool = $scopeResolverPool + ?? ObjectManager::getInstance()->get(ScopeResolverPool::class); + $this->scopeTypeNormalizer = $scopeTypeNormalizer + ?? ObjectManager::getInstance()->get(ScopeTypeNormalizer::class); } /** * Save config section + * * Require set: section, website, store and groups * * @throws \Exception @@ -504,8 +542,8 @@ public function setDataByPath($path, $value) } /** - * Get scope name and scopeId - * @todo refactor to scope resolver + * Set scope data + * * @return void */ private function initScope() @@ -513,31 +551,66 @@ private function initScope() if ($this->getSection() === null) { $this->setSection(''); } + + $scope = $this->retrieveScope(); + $this->setScope($this->scopeTypeNormalizer->normalize($scope->getScopeType())); + $this->setScopeCode($scope->getCode()); + $this->setScopeId($scope->getId()); + if ($this->getWebsite() === null) { - $this->setWebsite(''); + $this->setWebsite(StoreScopeInterface::SCOPE_WEBSITES === $this->getScope() ? $scope->getId() : ''); } if ($this->getStore() === null) { - $this->setStore(''); + $this->setStore(StoreScopeInterface::SCOPE_STORES === $this->getScope() ? $scope->getId() : ''); } + } - if ($this->getStore()) { - $scope = 'stores'; - $store = $this->_storeManager->getStore($this->getStore()); - $scopeId = (int)$store->getId(); - $scopeCode = $store->getCode(); - } elseif ($this->getWebsite()) { - $scope = 'websites'; - $website = $this->_storeManager->getWebsite($this->getWebsite()); - $scopeId = (int)$website->getId(); - $scopeCode = $website->getCode(); + /** + * Retrieve scope from initial data + * + * @return ScopeInterface + */ + private function retrieveScope(): ScopeInterface + { + $scopeType = $this->getScope(); + if (!$scopeType) { + switch (true) { + case $this->getStore(): + $scopeType = StoreScopeInterface::SCOPE_STORES; + $scopeIdentifier = $this->getStore(); + break; + case $this->getWebsite(): + $scopeType = StoreScopeInterface::SCOPE_WEBSITES; + $scopeIdentifier = $this->getWebsite(); + break; + default: + $scopeType = ScopeInterface::SCOPE_DEFAULT; + $scopeIdentifier = null; + break; + } } else { - $scope = 'default'; - $scopeId = 0; - $scopeCode = ''; + switch (true) { + case $this->getScopeId() !== null: + $scopeIdentifier = $this->getScopeId(); + break; + case $this->getScopeCode() !== null: + $scopeIdentifier = $this->getScopeCode(); + break; + case $this->getStore() !== null: + $scopeIdentifier = $this->getStore(); + break; + case $this->getWebsite() !== null: + $scopeIdentifier = $this->getWebsite(); + break; + default: + $scopeIdentifier = null; + break; + } } - $this->setScope($scope); - $this->setScopeId($scopeId); - $this->setScopeCode($scopeCode); + $scope = $this->scopeResolverPool->get($scopeType) + ->getScope($scopeIdentifier); + + return $scope; } /** diff --git a/app/code/Magento/Config/Model/Config/Backend/Currency/AbstractCurrency.php b/app/code/Magento/Config/Model/Config/Backend/Currency/AbstractCurrency.php index 4ae66bfd9692b..25303093ace5d 100644 --- a/app/code/Magento/Config/Model/Config/Backend/Currency/AbstractCurrency.php +++ b/app/code/Magento/Config/Model/Config/Backend/Currency/AbstractCurrency.php @@ -14,6 +14,8 @@ namespace Magento\Config\Model\Config\Backend\Currency; /** + * Base currency class + * * @api * @since 100.0.2 */ @@ -26,18 +28,19 @@ abstract class AbstractCurrency extends \Magento\Framework\App\Config\Value */ protected function _getAllowedCurrencies() { - if (!$this->isFormData() || $this->getData('groups/options/fields/allow/inherit')) { - return explode( + $allowValue = $this->getData('groups/options/fields/allow/value'); + $allowedCurrencies = $allowValue === null || $this->getData('groups/options/fields/allow/inherit') + ? explode( ',', (string)$this->_config->getValue( \Magento\Directory\Model\Currency::XML_PATH_CURRENCY_ALLOW, $this->getScope(), $this->getScopeId() ) - ); - } + ) + : (array) $allowValue; - return (array)$this->getData('groups/options/fields/allow/value'); + return $allowedCurrencies; } /** diff --git a/app/code/Magento/Config/Setup/ConfigOptionsList.php b/app/code/Magento/Config/Setup/ConfigOptionsList.php new file mode 100644 index 0000000000000..45e3987d282f1 --- /dev/null +++ b/app/code/Magento/Config/Setup/ConfigOptionsList.php @@ -0,0 +1,88 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Config\Setup; + +use Magento\Framework\App\DeploymentConfig; +use Magento\Framework\Config\Data\ConfigDataFactory; +use Magento\Framework\Config\File\ConfigFilePool; +use Magento\Framework\Setup\ConfigOptionsListInterface; +use Magento\Framework\Setup\Option\SelectConfigOption; + +/** + * Deployment configuration options required for the Config module. + */ +class ConfigOptionsList implements ConfigOptionsListInterface +{ + /** + * Input key for the option. + */ + const INPUT_KEY_DEBUG_LOGGING = 'enable-debug-logging'; + + /** + * Path to the value in the deployment config. + */ + const CONFIG_PATH_DEBUG_LOGGING = 'dev/debug/debug_logging'; + + /** + * @var ConfigDataFactory + */ + private $configDataFactory; + + /** + * @param ConfigDataFactory $configDataFactory + */ + public function __construct(ConfigDataFactory $configDataFactory) + { + $this->configDataFactory = $configDataFactory; + } + + /** + * @inheritdoc + */ + public function getOptions() + { + return [ + new SelectConfigOption( + self::INPUT_KEY_DEBUG_LOGGING, + SelectConfigOption::FRONTEND_WIZARD_RADIO, + [true, false, 1, 0], + self::CONFIG_PATH_DEBUG_LOGGING, + 'Enable debug logging' + ) + ]; + } + + /** + * @inheritdoc + */ + public function createConfig(array $options, DeploymentConfig $deploymentConfig) + { + $config = []; + if (isset($options[self::INPUT_KEY_DEBUG_LOGGING])) { + $configData = $this->configDataFactory->create(ConfigFilePool::APP_ENV); + if ($options[self::INPUT_KEY_DEBUG_LOGGING] === 'true' + || $options[self::INPUT_KEY_DEBUG_LOGGING] === '1') { + $value = 1; + } else { + $value = 0; + } + $configData->set(self::CONFIG_PATH_DEBUG_LOGGING, $value); + $config[] = $configData; + } + + return $config; + } + + /** + * @inheritdoc + */ + public function validate(array $options, DeploymentConfig $deploymentConfig) + { + return []; + } +} diff --git a/app/code/Magento/Config/Test/Mftf/Data/WebUrlOptionsConfigData.xml b/app/code/Magento/Config/Test/Mftf/Data/WebUrlOptionsConfigData.xml new file mode 100644 index 0000000000000..5010e383ba55c --- /dev/null +++ b/app/code/Magento/Config/Test/Mftf/Data/WebUrlOptionsConfigData.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <entity name="DefaultWebUrlOptionsConfig" type="web_url_use_store"> + <requiredEntity type="url_use_store_value">DefaultConfigWebUrlOptions</requiredEntity> + </entity> + <entity name="DefaultConfigWebUrlOptions" type="url_use_store_value"> + <data key="value">0</data> + </entity> + + <entity name="EnableWebUrlOptionsConfig" type="web_url_use_store"> + <requiredEntity type="url_use_store_value">WebUrlOptionsYes</requiredEntity> + </entity> + <entity name="WebUrlOptionsYes" type="url_use_store_value"> + <data key="value">1</data> + </entity> +</entities> \ No newline at end of file diff --git a/app/code/Magento/Config/Test/Mftf/Metadata/web_url_options_config-meta.xml b/app/code/Magento/Config/Test/Mftf/Metadata/web_url_options_config-meta.xml new file mode 100644 index 0000000000000..58809f8b41e28 --- /dev/null +++ b/app/code/Magento/Config/Test/Mftf/Metadata/web_url_options_config-meta.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<operations xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataOperation.xsd"> + <operation name="WebUrlOptionsConfig" dataType="web_url_use_store" type="create" auth="adminFormKey" url="/admin/system_config/save/section/web/" + method="POST" successRegex="/messages-message-success/" returnRegex=""> + <object key="groups" dataType="web_url_use_store"> + <object key="url" dataType="web_url_use_store"> + <object key="fields" dataType="web_url_use_store"> + <object key="use_store" dataType="url_use_store_value"> + <field key="value">string</field> + </object> + </object> + </object> + </object> + </operation> +</operations> \ No newline at end of file diff --git a/app/code/Magento/Config/Test/Mftf/Section/AdminSalesConfigSection.xml b/app/code/Magento/Config/Test/Mftf/Section/AdminSalesConfigSection.xml index c58e77d200bfb..62b5cdcf9ceec 100644 --- a/app/code/Magento/Config/Test/Mftf/Section/AdminSalesConfigSection.xml +++ b/app/code/Magento/Config/Test/Mftf/Section/AdminSalesConfigSection.xml @@ -7,7 +7,7 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminSalesConfigSection"> <element name="enableMAPUseSystemValue" type="checkbox" selector="#sales_msrp_enabled_inherit"/> <element name="enableMAPSelect" type="select" selector="#sales_msrp_enabled"/> diff --git a/app/code/Magento/Config/Test/Unit/Block/System/Config/FormTest.php b/app/code/Magento/Config/Test/Unit/Block/System/Config/FormTest.php index 83b7bd5fda42e..528d141306cce 100644 --- a/app/code/Magento/Config/Test/Unit/Block/System/Config/FormTest.php +++ b/app/code/Magento/Config/Test/Unit/Block/System/Config/FormTest.php @@ -103,6 +103,9 @@ protected function setUp() $this->_fieldsetFactoryMock = $this->createMock(\Magento\Config\Block\System\Config\Form\Fieldset\Factory::class); $this->_fieldFactoryMock = $this->createMock(\Magento\Config\Block\System\Config\Form\Field\Factory::class); $this->_coreConfigMock = $this->createMock(\Magento\Framework\App\Config\ScopeConfigInterface::class); + $settingCheckerMock = $this->getMockBuilder(SettingChecker::class) + ->disableOriginalConstructor() + ->getMock(); $this->_backendConfigMock = $this->createMock(\Magento\Config\Model\Config::class); @@ -150,6 +153,7 @@ protected function setUp() 'fieldsetFactory' => $this->_fieldsetFactoryMock, 'fieldFactory' => $this->_fieldFactoryMock, 'context' => $context, + 'settingChecker' => $settingCheckerMock, ]; $objectArguments = $helper->getConstructArguments(\Magento\Config\Block\System\Config\Form::class, $data); @@ -529,7 +533,7 @@ public function testInitFields( $elementVisibilityMock = $this->getMockBuilder(ElementVisibilityInterface::class) ->getMockForAbstractClass(); - $elementVisibilityMock->expects($this->once()) + $elementVisibilityMock->expects($this->any()) ->method('isDisabled') ->willReturn($isDisabled); diff --git a/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/DefaultProcessorTest.php b/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/DefaultProcessorTest.php index 984e0fe842687..3fb7d9ad21cd4 100644 --- a/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/DefaultProcessorTest.php +++ b/app/code/Magento/Config/Test/Unit/Console/Command/ConfigSet/DefaultProcessorTest.php @@ -7,13 +7,14 @@ use Magento\Config\App\Config\Type\System; use Magento\Config\Console\Command\ConfigSet\DefaultProcessor; +use Magento\Config\Model\Config; +use Magento\Config\Model\Config\Factory as ConfigFactory; use Magento\Framework\App\Config\ConfigPathResolver; use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\DeploymentConfig; use Magento\Store\Model\ScopeInterface; use Magento\Config\Model\PreparedValueFactory; use Magento\Framework\App\Config\Value; -use Magento\Framework\App\Config\ValueInterface; use Magento\Framework\Model\ResourceModel\Db\AbstractDb; use PHPUnit_Framework_MockObject_MockObject as Mock; @@ -55,17 +56,18 @@ class DefaultProcessorTest extends \PHPUnit\Framework\TestCase */ private $resourceModelMock; + /** + * @var ConfigFactory|Mock + */ + private $configFactory; + /** * @inheritdoc */ protected function setUp() { - $this->deploymentConfigMock = $this->getMockBuilder(DeploymentConfig::class) - ->disableOriginalConstructor() - ->getMock(); - $this->configPathResolverMock = $this->getMockBuilder(ConfigPathResolver::class) - ->disableOriginalConstructor() - ->getMock(); + $this->deploymentConfigMock = $this->createMock(DeploymentConfig::class); + $this->configPathResolverMock = $this->createMock(ConfigPathResolver::class); $this->resourceModelMock = $this->getMockBuilder(AbstractDb::class) ->disableOriginalConstructor() ->setMethods(['save']) @@ -74,14 +76,14 @@ protected function setUp() ->disableOriginalConstructor() ->setMethods(['getResource']) ->getMock(); - $this->preparedValueFactoryMock = $this->getMockBuilder(PreparedValueFactory::class) - ->disableOriginalConstructor() - ->getMock(); + $this->preparedValueFactoryMock = $this->createMock(PreparedValueFactory::class); + $this->configFactory = $this->createMock(ConfigFactory::class); $this->model = new DefaultProcessor( $this->preparedValueFactoryMock, $this->deploymentConfigMock, - $this->configPathResolverMock + $this->configPathResolverMock, + $this->configFactory ); } @@ -98,15 +100,14 @@ public function testProcess($path, $value, $scope, $scopeCode) { $this->configMockForProcessTest($path, $scope, $scopeCode); - $this->preparedValueFactoryMock->expects($this->once()) - ->method('create') - ->willReturn($this->valueMock); - $this->valueMock->expects($this->once()) - ->method('getResource') - ->willReturn($this->resourceModelMock); - $this->resourceModelMock->expects($this->once()) + $config = $this->createMock(Config::class); + $this->configFactory->method('create') + ->with(['scope' => $scope, 'scope_code' => $scopeCode]) + ->willReturn($config); + $config->method('setDataByPath') + ->with($path, $value); + $config->expects($this->once()) ->method('save') - ->with($this->valueMock) ->willReturnSelf(); $this->model->process($path, $value, $scope, $scopeCode); @@ -124,28 +125,6 @@ public function processDataProvider() ]; } - public function testProcessWithWrongValueInstance() - { - $path = 'test/test/test'; - $scope = ScopeConfigInterface::SCOPE_TYPE_DEFAULT; - $scopeCode = null; - $value = 'value'; - $valueInterfaceMock = $this->getMockBuilder(ValueInterface::class) - ->getMockForAbstractClass(); - - $this->configMockForProcessTest($path, $scope, $scopeCode); - - $this->preparedValueFactoryMock->expects($this->once()) - ->method('create') - ->willReturn($valueInterfaceMock); - $this->valueMock->expects($this->never()) - ->method('getResource'); - $this->resourceModelMock->expects($this->never()) - ->method('save'); - - $this->model->process($path, $value, $scope, $scopeCode); - } - /** * @param string $path * @param string $scope @@ -185,6 +164,9 @@ public function testProcessLockedValue() ->method('resolve') ->willReturn('system/default/test/test/test'); + $this->configFactory->expects($this->never()) + ->method('create'); + $this->model->process($path, $value, ScopeConfigInterface::SCOPE_TYPE_DEFAULT, null); } } diff --git a/app/code/Magento/Config/Test/Unit/Model/ConfigTest.php b/app/code/Magento/Config/Test/Unit/Model/ConfigTest.php index fcc1ff8b9c70c..bb772f51c0dac 100644 --- a/app/code/Magento/Config/Test/Unit/Model/ConfigTest.php +++ b/app/code/Magento/Config/Test/Unit/Model/ConfigTest.php @@ -5,138 +5,183 @@ */ namespace Magento\Config\Test\Unit\Model; +use PHPUnit_Framework_MockObject_MockObject as MockObject; +use Magento\Config\Model\Config; +use Magento\Framework\App\Config\ReinitableConfigInterface; +use Magento\Framework\Event\ManagerInterface; +use Magento\Config\Model\Config\Structure\Reader; +use Magento\Framework\DB\TransactionFactory; +use Magento\Config\Model\Config\Loader; +use Magento\Framework\App\Config\ValueFactory; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Config\Model\Config\Structure; +use Magento\Config\Model\Config\Reader\Source\Deployed\SettingChecker; +use Magento\Framework\App\ScopeResolverPool; +use Magento\Framework\App\ScopeResolverInterface; +use Magento\Framework\App\ScopeInterface; +use Magento\Store\Model\ScopeTypeNormalizer; +use Magento\Framework\DB\Transaction; +use Magento\Framework\App\Config\Value; +use Magento\Store\Model\Website; +use Magento\Config\Model\Config\Structure\Element\Group; +use Magento\Config\Model\Config\Structure\Element\Field; + /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class ConfigTest extends \PHPUnit\Framework\TestCase { /** - * @var \Magento\Config\Model\Config + * @var Config + */ + private $model; + + /** + * @var ManagerInterface|MockObject + */ + private $eventManagerMock; + + /** + * @var Reader|MockObject + */ + private $structureReaderMock; + + /** + * @var TransactionFactory|MockObject */ - protected $_model; + private $transFactoryMock; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var ReinitableConfigInterface|MockObject */ - protected $_eventManagerMock; + private $appConfigMock; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var Loader|MockObject */ - protected $_structureReaderMock; + private $configLoaderMock; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var ValueFactory|MockObject */ - protected $_transFactoryMock; + private $dataFactoryMock; /** - * @var \Magento\Framework\App\Config\ReinitableConfigInterface|\PHPUnit_Framework_MockObject_MockObject + * @var StoreManagerInterface|MockObject */ - protected $_appConfigMock; + private $storeManager; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var Structure|MockObject */ - protected $_applicationMock; + private $configStructure; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var SettingChecker|MockObject */ - protected $_configLoaderMock; + private $settingsChecker; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var ScopeResolverPool|MockObject */ - protected $_dataFactoryMock; + private $scopeResolverPool; /** - * @var \Magento\Store\Model\StoreManagerInterface + * @var ScopeResolverInterface|MockObject */ - protected $_storeManager; + private $scopeResolver; /** - * @var \Magento\Config\Model\Config\Structure + * @var ScopeInterface|MockObject */ - protected $_configStructure; + private $scope; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var ScopeTypeNormalizer|MockObject */ - private $_settingsChecker; + private $scopeTypeNormalizer; protected function setUp() { - $this->_eventManagerMock = $this->createMock(\Magento\Framework\Event\ManagerInterface::class); - $this->_structureReaderMock = $this->createPartialMock( - \Magento\Config\Model\Config\Structure\Reader::class, + $this->eventManagerMock = $this->createMock(ManagerInterface::class); + $this->structureReaderMock = $this->createPartialMock( + Reader::class, ['getConfiguration'] ); - $this->_configStructure = $this->createMock(\Magento\Config\Model\Config\Structure::class); + $this->configStructure = $this->createMock(Structure::class); - $this->_structureReaderMock->expects( - $this->any() - )->method( - 'getConfiguration' - )->will( - $this->returnValue($this->_configStructure) - ); + $this->structureReaderMock->method('getConfiguration') + ->willReturn($this->configStructure); - $this->_transFactoryMock = $this->createPartialMock( - \Magento\Framework\DB\TransactionFactory::class, + $this->transFactoryMock = $this->createPartialMock( + TransactionFactory::class, ['create', 'addObject'] ); - $this->_appConfigMock = $this->createMock(\Magento\Framework\App\Config\ReinitableConfigInterface::class); - $this->_configLoaderMock = $this->createPartialMock( - \Magento\Config\Model\Config\Loader::class, + $this->appConfigMock = $this->createMock(ReinitableConfigInterface::class); + $this->configLoaderMock = $this->createPartialMock( + Loader::class, ['getConfigByPath'] ); - $this->_dataFactoryMock = $this->createMock(\Magento\Framework\App\Config\ValueFactory::class); - - $this->_storeManager = $this->getMockForAbstractClass(\Magento\Store\Model\StoreManagerInterface::class); - - $this->_settingsChecker = $this - ->createMock(\Magento\Config\Model\Config\Reader\Source\Deployed\SettingChecker::class); - - $this->_model = new \Magento\Config\Model\Config( - $this->_appConfigMock, - $this->_eventManagerMock, - $this->_configStructure, - $this->_transFactoryMock, - $this->_configLoaderMock, - $this->_dataFactoryMock, - $this->_storeManager, - $this->_settingsChecker + $this->dataFactoryMock = $this->createMock(ValueFactory::class); + + $this->storeManager = $this->createMock(StoreManagerInterface::class); + + $this->settingsChecker = $this->createMock(SettingChecker::class); + + $this->scopeResolverPool = $this->createMock(ScopeResolverPool::class); + $this->scopeResolver = $this->createMock(ScopeResolverInterface::class); + $this->scopeResolverPool->method('get') + ->willReturn($this->scopeResolver); + $this->scope = $this->createMock(ScopeInterface::class); + $this->scopeResolver->method('getScope') + ->willReturn($this->scope); + + $this->scopeTypeNormalizer = $this->createMock(ScopeTypeNormalizer::class); + + $this->model = new Config( + $this->appConfigMock, + $this->eventManagerMock, + $this->configStructure, + $this->transFactoryMock, + $this->configLoaderMock, + $this->dataFactoryMock, + $this->storeManager, + $this->settingsChecker, + [], + $this->scopeResolverPool, + $this->scopeTypeNormalizer ); } public function testSaveDoesNotDoAnythingIfGroupsAreNotPassed() { - $this->_configLoaderMock->expects($this->never())->method('getConfigByPath'); - $this->_model->save(); + $this->configLoaderMock->expects($this->never())->method('getConfigByPath'); + $this->model->save(); } public function testSaveEmptiesNonSetArguments() { - $this->_structureReaderMock->expects($this->never())->method('getConfiguration'); - $this->assertNull($this->_model->getSection()); - $this->assertNull($this->_model->getWebsite()); - $this->assertNull($this->_model->getStore()); - $this->_model->save(); - $this->assertSame('', $this->_model->getSection()); - $this->assertSame('', $this->_model->getWebsite()); - $this->assertSame('', $this->_model->getStore()); + $this->structureReaderMock->expects($this->never())->method('getConfiguration'); + $this->assertNull($this->model->getSection()); + $this->assertNull($this->model->getWebsite()); + $this->assertNull($this->model->getStore()); + $this->model->save(); + $this->assertSame('', $this->model->getSection()); + $this->assertSame('', $this->model->getWebsite()); + $this->assertSame('', $this->model->getStore()); } public function testSaveToCheckAdminSystemConfigChangedSectionEvent() { - $transactionMock = $this->createMock(\Magento\Framework\DB\Transaction::class); + $transactionMock = $this->createMock(Transaction::class); - $this->_transFactoryMock->expects($this->any())->method('create')->will($this->returnValue($transactionMock)); + $this->transFactoryMock->method('create') + ->willReturn($transactionMock); - $this->_configLoaderMock->expects($this->any())->method('getConfigByPath')->will($this->returnValue([])); + $this->configLoaderMock->method('getConfigByPath') + ->willReturn([]); - $this->_eventManagerMock->expects( + $this->eventManagerMock->expects( $this->at(0) )->method( 'dispatch' @@ -145,7 +190,7 @@ public function testSaveToCheckAdminSystemConfigChangedSectionEvent() $this->arrayHasKey('website') ); - $this->_eventManagerMock->expects( + $this->eventManagerMock->expects( $this->at(0) )->method( 'dispatch' @@ -154,123 +199,147 @@ public function testSaveToCheckAdminSystemConfigChangedSectionEvent() $this->arrayHasKey('store') ); - $this->_model->setGroups(['1' => ['data']]); - $this->_model->save(); + $this->model->setGroups(['1' => ['data']]); + $this->model->save(); } public function testDoNotSaveReadOnlyFields() { - $transactionMock = $this->createMock(\Magento\Framework\DB\Transaction::class); - $this->_transFactoryMock->expects($this->any())->method('create')->will($this->returnValue($transactionMock)); + $transactionMock = $this->createMock(Transaction::class); + $this->transFactoryMock->method('create') + ->willReturn($transactionMock); - $this->_settingsChecker->expects($this->any())->method('isReadOnly')->will($this->returnValue(true)); - $this->_configLoaderMock->expects($this->any())->method('getConfigByPath')->will($this->returnValue([])); + $this->settingsChecker->method('isReadOnly') + ->willReturn(true); + $this->configLoaderMock->method('getConfigByPath') + ->willReturn([]); - $this->_model->setGroups(['1' => ['fields' => ['key' => ['data']]]]); - $this->_model->setSection('section'); + $this->model->setGroups(['1' => ['fields' => ['key' => ['data']]]]); + $this->model->setSection('section'); - $group = $this->createMock(\Magento\Config\Model\Config\Structure\Element\Group::class); - $group->method('getPath')->willReturn('section/1'); + $group = $this->createMock(Group::class); + $group->method('getPath') + ->willReturn('section/1'); - $field = $this->createMock(\Magento\Config\Model\Config\Structure\Element\Field::class); - $field->method('getGroupPath')->willReturn('section/1'); - $field->method('getId')->willReturn('key'); + $field = $this->createMock(Field::class); + $field->method('getGroupPath') + ->willReturn('section/1'); + $field->method('getId') + ->willReturn('key'); - $this->_configStructure->expects($this->at(0)) + $this->configStructure->expects($this->at(0)) ->method('getElement') ->with('section/1') - ->will($this->returnValue($group)); - $this->_configStructure->expects($this->at(1)) + ->willReturn($group); + $this->configStructure->expects($this->at(1)) ->method('getElement') ->with('section/1') - ->will($this->returnValue($group)); - $this->_configStructure->expects($this->at(2)) + ->willReturn($group); + $this->configStructure->expects($this->at(2)) ->method('getElement') ->with('section/1/key') - ->will($this->returnValue($field)); + ->willReturn($field); $backendModel = $this->createPartialMock( - \Magento\Framework\App\Config\Value::class, + Value::class, ['addData'] ); - $this->_dataFactoryMock->expects($this->any())->method('create')->will($this->returnValue($backendModel)); + $this->dataFactoryMock->method('create') + ->willReturn($backendModel); - $this->_transFactoryMock->expects($this->never())->method('addObject'); - $backendModel->expects($this->never())->method('addData'); + $this->transFactoryMock->expects($this->never()) + ->method('addObject'); + $backendModel->expects($this->never()) + ->method('addData'); - $this->_model->save(); + $this->model->save(); } public function testSaveToCheckScopeDataSet() { - $transactionMock = $this->createMock(\Magento\Framework\DB\Transaction::class); - $this->_transFactoryMock->expects($this->any())->method('create')->will($this->returnValue($transactionMock)); + $transactionMock = $this->createMock(Transaction::class); + $this->transFactoryMock->method('create') + ->willReturn($transactionMock); - $this->_configLoaderMock->expects($this->any())->method('getConfigByPath')->will($this->returnValue([])); + $this->configLoaderMock->method('getConfigByPath') + ->willReturn([]); - $this->_eventManagerMock->expects($this->at(0)) + $this->eventManagerMock->expects($this->at(0)) ->method('dispatch') ->with( $this->equalTo('admin_system_config_changed_section_section'), $this->arrayHasKey('website') ); - $this->_eventManagerMock->expects($this->at(0)) + $this->eventManagerMock->expects($this->at(0)) ->method('dispatch') ->with( $this->equalTo('admin_system_config_changed_section_section'), $this->arrayHasKey('store') ); - $group = $this->createMock(\Magento\Config\Model\Config\Structure\Element\Group::class); + $group = $this->createMock(Group::class); $group->method('getPath')->willReturn('section/1'); - $field = $this->createMock(\Magento\Config\Model\Config\Structure\Element\Field::class); + $field = $this->createMock(Field::class); $field->method('getGroupPath')->willReturn('section/1'); $field->method('getId')->willReturn('key'); - $this->_configStructure->expects($this->at(0)) + $this->configStructure->expects($this->at(0)) ->method('getElement') ->with('section/1') - ->will($this->returnValue($group)); - $this->_configStructure->expects($this->at(1)) + ->willReturn($group); + $this->configStructure->expects($this->at(1)) ->method('getElement') ->with('section/1') - ->will($this->returnValue($group)); - $this->_configStructure->expects($this->at(2)) + ->willReturn($group); + $this->configStructure->expects($this->at(2)) ->method('getElement') ->with('section/1/key') - ->will($this->returnValue($field)); - $this->_configStructure->expects($this->at(3)) + ->willReturn($field); + $this->configStructure->expects($this->at(3)) ->method('getElement') ->with('section/1') - ->will($this->returnValue($group)); - $this->_configStructure->expects($this->at(4)) + ->willReturn($group); + $this->configStructure->expects($this->at(4)) ->method('getElement') ->with('section/1/key') - ->will($this->returnValue($field)); - - $website = $this->createMock(\Magento\Store\Model\Website::class); - $website->expects($this->any())->method('getCode')->will($this->returnValue('website_code')); - $this->_storeManager->expects($this->any())->method('getWebsite')->will($this->returnValue($website)); - $this->_storeManager->expects($this->any())->method('getWebsites')->will($this->returnValue([$website])); - $this->_storeManager->expects($this->any())->method('isSingleStoreMode')->will($this->returnValue(true)); - - $this->_model->setWebsite('website'); - $this->_model->setSection('section'); - $this->_model->setGroups(['1' => ['fields' => ['key' => ['data']]]]); + ->willReturn($field); + + $this->scopeResolver->method('getScope') + ->with('1') + ->willReturn($this->scope); + $this->scope->expects($this->atLeastOnce()) + ->method('getScopeType') + ->willReturn('website'); + $this->scope->expects($this->atLeastOnce()) + ->method('getId') + ->willReturn(1); + $this->scope->expects($this->atLeastOnce()) + ->method('getCode') + ->willReturn('website_code'); + $this->scopeTypeNormalizer->expects($this->atLeastOnce()) + ->method('normalize') + ->with('website') + ->willReturn('websites'); + $website = $this->createMock(Website::class); + $this->storeManager->method('getWebsites')->willReturn([$website]); + $this->storeManager->method('isSingleStoreMode')->willReturn(true); + + $this->model->setWebsite('1'); + $this->model->setSection('section'); + $this->model->setGroups(['1' => ['fields' => ['key' => ['data']]]]); $backendModel = $this->createPartialMock( - \Magento\Framework\App\Config\Value::class, + Value::class, ['setPath', 'addData', '__sleep', '__wakeup'] ); - $backendModel->expects($this->once()) - ->method('addData') + $backendModel->method('addData') ->with([ 'field' => 'key', 'groups' => [1 => ['fields' => ['key' => ['data']]]], 'group_id' => null, 'scope' => 'websites', - 'scope_id' => 0, + 'scope_id' => 1, 'scope_code' => 'website_code', 'field_config' => null, 'fieldset_data' => ['key' => null], @@ -278,18 +347,19 @@ public function testSaveToCheckScopeDataSet() $backendModel->expects($this->once()) ->method('setPath') ->with('section/1/key') - ->will($this->returnValue($backendModel)); + ->willReturn($backendModel); - $this->_dataFactoryMock->expects($this->any())->method('create')->will($this->returnValue($backendModel)); + $this->dataFactoryMock->method('create') + ->willReturn($backendModel); - $this->_model->save(); + $this->model->save(); } public function testSetDataByPath() { $value = 'value'; $path = '<section>/<group>/<field>'; - $this->_model->setDataByPath($path, $value); + $this->model->setDataByPath($path, $value); $expected = [ 'section' => '<section>', 'groups' => [ @@ -300,7 +370,7 @@ public function testSetDataByPath() ], ], ]; - $this->assertSame($expected, $this->_model->getData()); + $this->assertSame($expected, $this->model->getData()); } /** @@ -309,7 +379,7 @@ public function testSetDataByPath() */ public function testSetDataByPathEmpty() { - $this->_model->setDataByPath('', 'value'); + $this->model->setDataByPath('', 'value'); } /** @@ -324,7 +394,7 @@ public function testSetDataByPathWrongDepth($path, $expectedException) $this->expectException('\UnexpectedValueException'); $this->expectExceptionMessage($expectedException); $value = 'value'; - $this->_model->setDataByPath($path, $value); + $this->model->setDataByPath($path, $value); } /** diff --git a/app/code/Magento/Config/composer.json b/app/code/Magento/Config/composer.json index e43c9b0382e25..793d423280414 100644 --- a/app/code/Magento/Config/composer.json +++ b/app/code/Magento/Config/composer.json @@ -13,7 +13,7 @@ "magento/module-deploy": "100.2.*" }, "type": "magento2-module", - "version": "101.0.6", + "version": "101.0.7", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Config/etc/di.xml b/app/code/Magento/Config/etc/di.xml index a5dd18097fb47..87a0e666d2d7b 100644 --- a/app/code/Magento/Config/etc/di.xml +++ b/app/code/Magento/Config/etc/di.xml @@ -77,6 +77,11 @@ </argument> </arguments> </type> + <type name="Magento\Framework\Lock\Backend\Cache"> + <arguments> + <argument name="cache" xsi:type="object">Magento\Framework\App\Cache\Type\Config</argument> + </arguments> + </type> <type name="Magento\Config\App\Config\Type\System"> <arguments> <argument name="source" xsi:type="object">systemConfigSourceAggregatedProxy</argument> @@ -85,6 +90,7 @@ <argument name="preProcessor" xsi:type="object">Magento\Framework\App\Config\PreProcessorComposite</argument> <argument name="serializer" xsi:type="object">Magento\Framework\Serialize\Serializer\Serialize</argument> <argument name="reader" xsi:type="object">Magento\Config\App\Config\Type\System\Reader\Proxy</argument> + <argument name="locker" xsi:type="object">Magento\Framework\Lock\Backend\Cache</argument> </arguments> </type> <type name="Magento\Config\App\Config\Type\System\Reader"> diff --git a/app/code/Magento/Config/etc/module.xml b/app/code/Magento/Config/etc/module.xml index cdf31ab7a5d19..b7df33554af90 100644 --- a/app/code/Magento/Config/etc/module.xml +++ b/app/code/Magento/Config/etc/module.xml @@ -6,7 +6,7 @@ */ --> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> - <module name="Magento_Config" setup_version="2.0.0"> + <module name="Magento_Config" setup_version="2.1.0"> <sequence> <module name="Magento_Store"/> </sequence> diff --git a/app/code/Magento/ConfigurableImportExport/Model/Export/RowCustomizer.php b/app/code/Magento/ConfigurableImportExport/Model/Export/RowCustomizer.php index 58462b873d8b1..7146108f61fe1 100644 --- a/app/code/Magento/ConfigurableImportExport/Model/Export/RowCustomizer.php +++ b/app/code/Magento/ConfigurableImportExport/Model/Export/RowCustomizer.php @@ -3,14 +3,21 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\ConfigurableImportExport\Model\Export; -use Magento\CatalogImportExport\Model\Export\RowCustomizerInterface; use Magento\Catalog\Model\ResourceModel\Product\Collection as ProductCollection; -use Magento\ConfigurableProduct\Model\Product\Type\Configurable as ConfigurableProductType; +use Magento\CatalogImportExport\Model\Export\RowCustomizerInterface; use Magento\CatalogImportExport\Model\Import\Product as ImportProduct; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable as ConfigurableProductType; use Magento\ImportExport\Model\Import; +use Magento\Store\Model\Store; +use Magento\Store\Model\StoreManagerInterface; +/** + * Customizes output during export + */ class RowCustomizer implements RowCustomizerInterface { /** @@ -36,6 +43,19 @@ class RowCustomizer implements RowCustomizerInterface self::CONFIGURABLE_VARIATIONS_LABELS_COLUMN ]; + /** + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * @param StoreManagerInterface $storeManager + */ + public function __construct(StoreManagerInterface $storeManager) + { + $this->storeManager = $storeManager; + } + /** * Prepare configurable data for export * @@ -49,6 +69,9 @@ public function prepareData($collection, $productIds) $productCollection->addAttributeToFilter('entity_id', ['in' => $productIds]) ->addAttributeToFilter('type_id', ['eq' => ConfigurableProductType::TYPE_CODE]); + // set global scope during export + $this->storeManager->setCurrentStore(Store::DEFAULT_STORE_ID); + while ($product = $productCollection->fetchItem()) { $productAttributesOptions = $product->getTypeInstance()->getConfigurableOptions($product); $this->configurableData[$product->getId()] = []; diff --git a/app/code/Magento/ConfigurableImportExport/composer.json b/app/code/Magento/ConfigurableImportExport/composer.json index b2070fe7ebc44..b3d8af9f419d2 100644 --- a/app/code/Magento/ConfigurableImportExport/composer.json +++ b/app/code/Magento/ConfigurableImportExport/composer.json @@ -8,10 +8,11 @@ "magento/module-eav": "101.0.*", "magento/module-import-export": "100.2.*", "magento/module-configurable-product": "100.2.*", - "magento/framework": "101.0.*" + "magento/framework": "101.0.*", + "magento/module-store": "100.2.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/ConfigurableProduct/Block/Cart/Item/Renderer/Configurable.php b/app/code/Magento/ConfigurableProduct/Block/Cart/Item/Renderer/Configurable.php index 4c7c5df736112..b128458665d73 100644 --- a/app/code/Magento/ConfigurableProduct/Block/Cart/Item/Renderer/Configurable.php +++ b/app/code/Magento/ConfigurableProduct/Block/Cart/Item/Renderer/Configurable.php @@ -70,4 +70,12 @@ public function getIdentities() } return $identities; } + + /** + * @inheritdoc + */ + public function getProductPriceHtml(\Magento\Catalog\Model\Product $product) + { + return parent::getProductPriceHtml($this->getChildProduct()); + } } diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php index 81e2e99bfe93a..b7bbf7aa1871c 100644 --- a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php @@ -12,6 +12,11 @@ use Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\Query\BaseFinalPrice; use Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\IndexTableStructureFactory; use Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\IndexTableStructure; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Store\Model\ScopeInterface; +use Magento\Framework\App\ObjectManager; +use Magento\CatalogInventory\Model\Stock; +use Magento\CatalogInventory\Model\Configuration; /** * Configurable Products Price Indexer Resource model @@ -65,6 +70,11 @@ class Configurable implements DimensionalIndexerInterface */ private $basePriceModifier; + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + /** * @param BaseFinalPrice $baseFinalPrice * @param IndexTableStructureFactory $indexTableStructureFactory @@ -74,6 +84,7 @@ class Configurable implements DimensionalIndexerInterface * @param BasePriceModifier $basePriceModifier * @param bool $fullReindexAction * @param string $connectionName + * @param ScopeConfigInterface $scopeConfig */ public function __construct( BaseFinalPrice $baseFinalPrice, @@ -83,7 +94,8 @@ public function __construct( \Magento\Framework\App\ResourceConnection $resource, BasePriceModifier $basePriceModifier, $fullReindexAction = false, - $connectionName = 'indexer' + $connectionName = 'indexer', + ScopeConfigInterface $scopeConfig = null ) { $this->baseFinalPrice = $baseFinalPrice; $this->indexTableStructureFactory = $indexTableStructureFactory; @@ -93,10 +105,11 @@ public function __construct( $this->resource = $resource; $this->fullReindexAction = $fullReindexAction; $this->basePriceModifier = $basePriceModifier; + $this->scopeConfig = $scopeConfig ?: ObjectManager::getInstance()->get(ScopeConfigInterface::class); } /** - * {@inheritdoc} + * @inheritdoc * * @throws \Exception */ @@ -184,7 +197,19 @@ private function fillTemporaryOptionsTable(string $temporaryOptionsTableName, ar ['le' => $this->getTable('catalog_product_entity')], 'le.' . $linkField . ' = l.parent_id', [] - )->columns( + ); + + // Does not make sense to extend query if out of stock products won't appear in tables for indexing + if ($this->isConfigShowOutOfStock()) { + $select->join( + ['si' => $this->getTable('cataloginventory_stock_item')], + 'si.product_id = l.product_id', + [] + ); + $select->where('si.is_in_stock = ?', Stock::STOCK_IN_STOCK); + } + + $select->columns( [ 'le.entity_id', 'customer_group_id', @@ -250,7 +275,7 @@ private function getMainTable($dimensions) /** * Get connection * - * return \Magento\Framework\DB\Adapter\AdapterInterface + * @return \Magento\Framework\DB\Adapter\AdapterInterface * @throws \DomainException */ private function getConnection(): \Magento\Framework\DB\Adapter\AdapterInterface @@ -272,4 +297,17 @@ private function getTable($tableName) { return $this->resource->getTableName($tableName, $this->connectionName); } + + /** + * Is flag Show Out Of Stock setted + * + * @return bool + */ + private function isConfigShowOutOfStock(): bool + { + return $this->scopeConfig->isSetFlag( + Configuration::XML_PATH_SHOW_OUT_OF_STOCK, + ScopeInterface::SCOPE_STORE + ); + } } diff --git a/app/code/Magento/ConfigurableProduct/Plugin/Catalog/Model/Product/Pricing/Renderer/SalableResolver.php b/app/code/Magento/ConfigurableProduct/Plugin/Catalog/Model/Product/Pricing/Renderer/SalableResolver.php index 8a7e846c0e9f1..8c80a56a649e7 100644 --- a/app/code/Magento/ConfigurableProduct/Plugin/Catalog/Model/Product/Pricing/Renderer/SalableResolver.php +++ b/app/code/Magento/ConfigurableProduct/Plugin/Catalog/Model/Product/Pricing/Renderer/SalableResolver.php @@ -5,7 +5,7 @@ */ namespace Magento\ConfigurableProduct\Plugin\Catalog\Model\Product\Pricing\Renderer; -use Magento\ConfigurableProduct\Pricing\Price\LowestPriceOptionsProviderInterface; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable as TypeConfigurable; /** * A plugin for a salable resolver. @@ -13,17 +13,16 @@ class SalableResolver { /** - * @var LowestPriceOptionsProviderInterface + * @var TypeConfigurable */ - private $lowestPriceOptionsProvider; + private $typeConfigurable; /** - * @param LowestPriceOptionsProviderInterface $lowestPriceOptionsProvider + * @param TypeConfigurable $typeConfigurable */ - public function __construct( - LowestPriceOptionsProviderInterface $lowestPriceOptionsProvider - ) { - $this->lowestPriceOptionsProvider = $lowestPriceOptionsProvider; + public function __construct(TypeConfigurable $typeConfigurable) + { + $this->typeConfigurable = $typeConfigurable; } /** @@ -33,9 +32,7 @@ public function __construct( * @param \Magento\Catalog\Model\Product\Pricing\Renderer\SalableResolver $subject * @param bool $result * @param \Magento\Framework\Pricing\SaleableInterface $salableItem - * * @return bool - * * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function afterIsSalable( @@ -43,8 +40,8 @@ public function afterIsSalable( $result, \Magento\Framework\Pricing\SaleableInterface $salableItem ) { - if ($salableItem->getTypeId() == 'configurable' && $result) { - $result = $salableItem->isSalable(); + if ($salableItem->getTypeId() === TypeConfigurable::TYPE_CODE && $result) { + $result = $this->typeConfigurable->isSalable($salableItem); } return $result; diff --git a/app/code/Magento/ConfigurableProduct/Pricing/Render/TierPriceBox.php b/app/code/Magento/ConfigurableProduct/Pricing/Render/TierPriceBox.php index 611523a60b06d..816de36b16f96 100644 --- a/app/code/Magento/ConfigurableProduct/Pricing/Render/TierPriceBox.php +++ b/app/code/Magento/ConfigurableProduct/Pricing/Render/TierPriceBox.php @@ -5,6 +5,8 @@ */ namespace Magento\ConfigurableProduct\Pricing\Render; +use Magento\Catalog\Pricing\Price\TierPrice; + /** * Responsible for displaying tier price box on configurable product page. * @@ -17,9 +19,28 @@ class TierPriceBox extends FinalPriceBox */ public function toHtml() { - // Hide tier price block in case of MSRP. - if (!$this->isMsrpPriceApplicable()) { + // Hide tier price block in case of MSRP or in case when no options with tier price. + if (!$this->isMsrpPriceApplicable() && $this->isTierPriceApplicable()) { return parent::toHtml(); } } + + /** + * Check if at least one of simple products has tier price. + * + * @return bool + */ + private function isTierPriceApplicable(): bool + { + $product = $this->getSaleableItem(); + foreach ($product->getTypeInstance()->getUsedProducts($product) as $simpleProduct) { + if ($simpleProduct->isSalable() + && !empty($simpleProduct->getPriceInfo()->getPrice(TierPrice::PRICE_CODE)->getTierPriceList()) + ) { + return true; + } + } + + return false; + } } diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminCreateApiConfigurableProductActionGroup.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminCreateApiConfigurableProductActionGroup.xml index d918649ed4914..161441736f940 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminCreateApiConfigurableProductActionGroup.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminCreateApiConfigurableProductActionGroup.xml @@ -69,4 +69,17 @@ <requiredEntity createDataKey="getConfigAttributeOption1"/> </createData> </actionGroup> + + <!-- Create the configurable product, children are not visible individually --> + <actionGroup name="AdminCreateApiConfigurableProductWithHiddenChildActionGroup" extends="AdminCreateApiConfigurableProductActionGroup"> + <!-- Create the 2 children that will be a part of the configurable product --> + <createData entity="ApiSimpleOneHidden" stepKey="createConfigChildProduct1"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + <requiredEntity createDataKey="getConfigAttributeOption1"/> + </createData> + <createData entity="ApiSimpleTwoHidden" stepKey="createConfigChildProduct2"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + <requiredEntity createDataKey="getConfigAttributeOption2"/> + </createData> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminCreateConfigurableProductActionGroup.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminCreateConfigurableProductActionGroup.xml index a01a9e14b97b6..e16ee43978a1e 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminCreateConfigurableProductActionGroup.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminCreateConfigurableProductActionGroup.xml @@ -62,6 +62,32 @@ <!-- Save the product --> <click selector="{{AdminProductFormActionSection.saveArrow}}" stepKey="openSaveDropDown"/> <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="clickOnSave"/> + <conditionalClick selector="{{AdminChooseAffectedAttributeSetPopup.confirm}}" dependentSelector="{{AdminChooseAffectedAttributeSetPopup.confirm}}" visible="true" stepKey="clickConfirm"/> <see selector="{{AdminProductMessagesSection.successMessage}}" userInput="You saved the product." stepKey="assertSuccess"/> </actionGroup> + + <actionGroup name="AdminGenerateProductConfigurations"> + <arguments> + <argument name="attributeCode" type="string"/> + <argument name="qty" type="string"/> + <argument name="price" type="string"/> + </arguments> + <conditionalClick selector="{{AdminDataGridHeaderSection.clearFilters}}" dependentSelector="{{AdminDataGridHeaderSection.clearFilters}}" visible="true" stepKey="clearExistingFilters"/> + <click selector="{{AdminDataGridHeaderSection.filters}}" stepKey="clickOnFilters"/> + <fillField selector="{{AdminProductAttributeGridSection.attributeCodeFilterInput}}" userInput="{{attributeCode}}" stepKey="fillFilterAttributeCodeField"/> + <click selector="{{AdminDataGridHeaderSection.applyFilters}}" stepKey="clickApplyFiltersButton"/> + <click selector="{{AdminDataGridTableSection.rowCheckbox('1')}}" stepKey="clickOnFirstCheckbox"/> + <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="clickNextStep"/> + <waitForElementVisible selector="{{AdminCreateProductConfigurationsPanelSection.selectAllByAttribute(attributeCode)}}" stepKey="waitForNextPageOpened"/> + <click selector="{{AdminCreateProductConfigurationsPanelSection.selectAllByAttribute(attributeCode)}}" stepKey="clickSelectAll"/> + <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="clickNextStep1"/> + <waitForElementVisible selector="{{AdminCreateProductConfigurationsPanel.applySinglePriceToAllSkus}}" stepKey="waitForNextPageOpened1"/> + <click selector="{{AdminCreateProductConfigurationsPanel.applySinglePriceToAllSkus}}" stepKey="clickOnApplySinglePriceToAllSkus"/> + <fillField selector="{{AdminCreateProductConfigurationsPanel.singlePrice}}" userInput="{{price}}" stepKey="enterAttributePrice"/> + <click selector="{{AdminCreateProductConfigurationsPanel.applySingleQuantityToEachSkus}}" stepKey="clickOnApplySingleQuantityToEachSku"/> + <fillField selector="{{AdminCreateProductConfigurationsPanel.quantity}}" userInput="{{qty}}" stepKey="enterAttributeQuantity"/> + <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="clickNextStep2"/> + <waitForElementVisible selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="waitForNextPageOpened2"/> + <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="generateProducts"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/AdminProductFormConfigurationsSection.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/AdminProductFormConfigurationsSection.xml index 5ae70488d164d..c774dad1ed607 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/AdminProductFormConfigurationsSection.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/AdminProductFormConfigurationsSection.xml @@ -7,7 +7,7 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminProductFormConfigurationsSection"> <element name="sectionHeader" type="text" selector=".admin__collapsible-block-wrapper[data-index='configurable']"/> <element name="createConfigurations" type="button" selector="button[data-index='create_configurable_products_button']" timeout="30"/> @@ -22,5 +22,7 @@ <element name="removeProductBtn" type="button" selector="//a[text()='Remove Product']"/> <element name="disableProductBtn" type="button" selector="//a[text()='Disable Product']"/> <element name="enableProductBtn" type="button" selector="//a[text()='Enable Product']"/> + <element name="configurableMatrixSku" type="input" selector="input[name='configurable-matrix[{{index}}][sku]']" parameterized="true"/> + <element name="skuValidationMessage" type="text" selector="input[name='configurable-matrix[{{index}}][sku]'] + label" parameterized="true"/> </section> </sections> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/StorefrontProductInfoMainSection.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/StorefrontProductInfoMainSection.xml index be0c2b05e48ba..4f2320666efbc 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/StorefrontProductInfoMainSection.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/StorefrontProductInfoMainSection.xml @@ -14,5 +14,6 @@ <element name="stockIndication" type="block" selector=".stock" /> <element name="productAttributeOptionsSelectButton" type="select" selector="#product-options-wrapper .super-attribute-select"/> <element name="optionByAttributeId" type="input" selector="#attribute{{var1}}" parameterized="true"/> + <element name="productPriceBox" type="block" selector=".price-box"/> </section> </sections> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminCheckValidatorConfigurableProductTest.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminCheckValidatorConfigurableProductTest.xml new file mode 100644 index 0000000000000..03f4d8461cebb --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminCheckValidatorConfigurableProductTest.xml @@ -0,0 +1,116 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminCheckValidatorConfigurableProductTest"> + <annotations> + <features value="ConfigurableProduct"/> + <stories value="Create a Configurable Product via the Admin"/> + <title value="Check that validator works correctly when creating Configurations for Configurable Products"/> + <description value="Verify validator works correctly for Configurable Products"/> + <severity value="MAJOR"/> + <testCaseId value="MC-13719"/> + <group value="configurableProduct"/> + </annotations> + + <before> + <!--Login as admin--> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <!--Create Category--> + <createData entity="ApiCategory" stepKey="createCategory"/> + <!--Create Configurable product--> + <createData entity="ApiConfigurableProduct" stepKey="createConfigProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + </before> + + <after> + <!--Delete created data--> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <actionGroup ref="deleteProductUsingProductGrid" stepKey="deleteProduct"> + <argument name="product" value="ApiConfigurableProduct"/> + <argument name="productCount" value="2"/> + </actionGroup> + + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="openAdminProductPage"/> + <actionGroup ref="clearFiltersAdminDataGrid" stepKey="resetProductsFilter" /> + + <!-- Remove attribute --> + <actionGroup ref="deleteProductAttribute" stepKey="deleteAttribute"> + <argument name="ProductAttribute" value="productAttributeWithDropdownTwoOptions"/> + </actionGroup> + <amOnPage url="{{AdminProductAttributeGridPage.url}}" stepKey="goToProductAttributesGridPage"/> + <actionGroup ref="clearFiltersAdminDataGrid" stepKey="resetAttributesFilter" /> + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <!-- Find the product that we just created using the product grid --> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="visitAdminProductPage"/> + <actionGroup ref="filterProductGridBySku" stepKey="findCreatedProduct"> + <argument name="product" value="ApiConfigurableProduct"/> + </actionGroup> + <click selector="{{AdminProductGridSection.firstRow}}" stepKey="clickOnProduct"/> + <waitForPageLoad stepKey="waitForProductPageLoad"/> + + <!-- Create configurations for product we created earlier --> + <click selector="{{AdminProductFormConfigurationsSection.createConfigurations}}" stepKey="clickCreateConfigurations"/> + + <!--Create new attribute--> + <waitForElementVisible selector="{{AdminCreateProductConfigurationsPanel.createNewAttribute}}" stepKey="waitForNewAttributePageOpened"/> + <click selector="{{AdminCreateProductConfigurationsPanel.createNewAttribute}}" stepKey="clickCreateNewAttribute"/> + <switchToIFrame selector="{{AdminNewAttributePanelSection.newAttributeIFrame}}" stepKey="enterAttributePanelIFrame"/> + <waitForElementVisible selector="{{AdminNewAttributePanel.defaultLabel}}" time="30" stepKey="waitForIframeLoad"/> + <fillField selector="{{AdminNewAttributePanel.defaultLabel}}" userInput="{{productAttributeWithDropdownTwoOptions.attribute_code}}" stepKey="fillDefaultLabel"/> + <selectOption selector="{{AdminNewAttributePanelSection.inputType}}" userInput="{{colorProductAttribute.input_type}}" stepKey="selectAttributeInputType"/> + <!--Add option to attribute--> + <click selector="{{AdminNewAttributePanelSection.addOption}}" stepKey="clickAddOption"/> + <waitForElementVisible selector="{{AdminNewAttributePanelSection.isDefault('1')}}" time="30" stepKey="waitForOptionRow"/> + <fillField selector="{{AdminNewAttributePanelSection.optionAdminValue('0')}}" userInput="ThisIsLongNameNameLengthMoreThanSixtyFourThisIsLongNameNameLength" stepKey="fillAdminLabel"/> + <fillField selector="{{AdminNewAttributePanelSection.optionDefaultStoreValue('0')}}" userInput="{{colorProductAttribute1.name}}" stepKey="fillDefaultLabel1"/> + + <!--Save attribute--> + <click selector="{{AdminNewAttributePanelSection.saveAttribute}}" stepKey="clickOnNewAttributePanel"/> + <waitForPageLoad stepKey="waitForSaveAttribute"/> + <switchToIFrame stepKey="switchOutOfIFrame"/> + + <!-- Generate products --> + <actionGroup ref="AdminGenerateProductConfigurations" stepKey="generateProducts"> + <argument name="attributeCode" value="{{productAttributeWithDropdownTwoOptions.attribute_code}}"/> + <argument name="qty" value="100"/> + <argument name="price" value="10"/> + </actionGroup> + <waitForElementVisible selector="{{AdminProductFormActionSection.saveButton}}" stepKey="waitForSaveButtonVisible"/> + <scrollToTopOfPage stepKey="scrollToTop"/> + <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="saveProduct"/> + <waitForElementVisible selector="{{AdminChooseAffectedAttributeSetPopup.confirm}}" stepKey="waitForPopUpVisible"/> + <click selector="{{AdminChooseAffectedAttributeSetPopup.confirm}}" stepKey="clickOnConfirmInPopup"/> + <dontSeeElement selector="{{AdminMessagesSection.success}}" stepKey="dontSeeSaveProductMessage"/> + + <!--Close modal window--> + <click selector="{{AdminChooseAffectedAttributeSetPopup.closePopUp}}" stepKey="clickOnClosePopup"/> + <waitForElementNotVisible selector="{{AdminChooseAffectedAttributeSetPopup.closePopUp}}" stepKey="waitForDialogClosed"/> + + <!--See that validation message is shown under the fields--> + <scrollTo selector="{{AdminProductFormConfigurationsSection.currentVariationsSkuCells}}" stepKey="scrollTConfigurationTab"/> + <see userInput="Please enter less or equal than 64 symbols." selector="{{AdminProductFormConfigurationsSection.skuValidationMessage('0')}}" stepKey="seeValidationMessage"/> + + <!--Edit "SKU" with valid quantity--> + <fillField selector="{{AdminProductFormConfigurationsSection.configurableMatrixSku('0')}}" userInput="{{ApiConfigurableProduct.sku}}-thisIsShortName" stepKey="fillValidValue"/> + + <!--Click on "Save"--> + <waitForElementVisible selector="{{AdminProductFormActionSection.saveButton}}" stepKey="waitForSaveBtnVisible"/> + <scrollToTopOfPage stepKey="scrollToTop1"/> + <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="saveProductAgain"/> + + <!--Click on "Confirm". Product is saved, success message appears --> + <waitForElementVisible selector="{{AdminChooseAffectedAttributeSetPopup.confirm}}" stepKey="waitPopUpVisible"/> + <click selector="{{AdminChooseAffectedAttributeSetPopup.confirm}}" stepKey="clickOnConfirmPopup"/> + <see selector="{{AdminMessagesSection.success}}" userInput="You saved the product." stepKey="seeSaveProductMessage"/> + </test> +</tests> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminCheckingProductQtyAfterOrderCancelTest.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminCheckingProductQtyAfterOrderCancelTest.xml new file mode 100644 index 0000000000000..af463c9042357 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminCheckingProductQtyAfterOrderCancelTest.xml @@ -0,0 +1,135 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminCheckingProductQtyAfterOrderCancelTest"> + <annotations> + <features value="ConfigurableProduct"/> + <stories value="Product quantity after order cancel"/> + <title value="Products quantity return after order cancel"/> + <description value="Checking product quantity after the order cancel"/> + <severity value="CRITICAL"/> + <testCaseId value="MC-13790"/> + <group value="configurableProduct"/> + </annotations> + <before> + <!--Create category--> + <createData entity="ApiCategory" stepKey="createCategory"/> + <!--Create configurable product and add it to the category--> + <createData entity="ApiConfigurableProduct" stepKey="createConfigProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <!--Create attribute--> + <createData entity="productAttributeWithDropdownTwoOptions" stepKey="createConfigProductAttribute"/> + <createData entity="productAttributeOption1" stepKey="createConfigProductAttributeOption1"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + <!--Add the attribute to default attribute set--> + <createData entity="AddToDefaultSet" stepKey="createConfigAddToAttributeSet"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + <!--Get the option of the attribute--> + <getData entity="ProductAttributeOptionGetter" index="1" stepKey="getConfigAttributeOption"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </getData> + <!--Create simple product and give it the attribute with option--> + <createData entity="ApiSimpleWithQty100" stepKey="createConfigChildProduct"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + <requiredEntity createDataKey="getConfigAttributeOption"/> + </createData> + <!--Create configurable product--> + <createData entity="ConfigurableProductTwoOptions" stepKey="createConfigProductOption"> + <requiredEntity createDataKey="createConfigProduct"/> + <requiredEntity createDataKey="createConfigProductAttribute"/> + <requiredEntity createDataKey="getConfigAttributeOption"/> + </createData> + <!--Add simple product to the configurable product--> + <createData entity="ConfigurableProductAddChild" stepKey="createConfigProductAddChild"> + <requiredEntity createDataKey="createConfigProduct"/> + <requiredEntity createDataKey="createConfigChildProduct"/> + </createData> + <!--Create customer--> + <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + <!--Login--> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + </before> + <after> + <!--Clear grid filters--> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="goToOrderGridPage"/> + <actionGroup ref="clearFiltersAdminDataGrid" stepKey="clearGridFilter"/> + <!--Delete entities--> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <deleteData createDataKey="createConfigProduct" stepKey="deleteConfigProduct"/> + <deleteData createDataKey="createConfigChildProduct" stepKey="deleteConfigChildProduct"/> + <deleteData createDataKey="createConfigProductAttribute" stepKey="deleteConfigProductAttribute"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <!--Logout--> + <actionGroup ref="logout" stepKey="logoutFromAdmin"/> + <actionGroup ref="CustomerLogoutStorefrontActionGroup" stepKey="logoutFromStorefront"/> + </after> + + <!--Go to Storefront as Customer--> + <actionGroup ref="CustomerLoginOnStorefront" stepKey="customerLogin"> + <argument name="customer" value="$$createCustomer$$" /> + </actionGroup> + + <!--Go to the configurable product page on Storefront--> + <amOnPage url="{{StorefrontProductPage.url($$createConfigProduct.sku$$)}}" stepKey="goToProductPage"/> + <!--Select option--> + <selectOption selector="{{StorefrontProductInfoMainSection.productAttributeOptionsSelectButton}}" userInput="$$getConfigAttributeOption.label$$" stepKey="selectOption"/> + <!--Add product to the Shopping cart--> + <actionGroup ref="StorefrontAddProductToCartQuantityActionGroup" stepKey="addProductToCart"> + <argument name="productName" value="$createConfigProduct.name$"/> + <argument name="quantity" value="4"/> + </actionGroup> + + <!--Open Shopping cart--> + <actionGroup ref="StorefrontOpenCartFromMinicartActionGroup" stepKey="openShoppingCartFromMinicart"/> + <!--Place order--> + <actionGroup ref="PlaceOrderWithLoggedUserActionGroup" stepKey="placeOrder"> + <argument name="shippingMethod" value="Flat Rate"/> + <argument name="paymentMethod" value="Check / Money order"/> + </actionGroup> + <grabTextFrom selector="{{CheckoutSuccessMainSection.orderNumber22}}" stepKey="grabOrderNumber"/> + + <!--Open order--> + <actionGroup ref="OpenOrderById" stepKey="openOrderById"> + <argument name="orderId" value="{$grabOrderNumber}"/> + </actionGroup> + + <!--Start create invoice--> + <actionGroup ref="StartCreateInvoiceFromOrderPage" stepKey="startCreateInvoice"/> + <!--Create partial invoice--> + <actionGroup ref="CreatePartialInvoice" stepKey="createPartialInvoice"> + <argument name="productSku" value="$createConfigChildProduct.sku$"/> + <argument name="qtyToInvoice" value="1"/> + </actionGroup> + <!--Submit Invoice--> + <actionGroup ref="SubmitInvoice" stepKey="submitInvoice"/> + <!--Create Shipment--> + <actionGroup ref="StartCreateShipmentFromOrderPage" stepKey="startCreateShipment"/> + <fillField selector="{{AdminShipmentItemsSection.itemQtyToShip('1')}}" userInput="1" stepKey="changeItemQtyToShip"/> + <actionGroup ref="SubmitShipment" stepKey="submitShipment"/> + + <!--Cancel order--> + <actionGroup ref="CancelProcessingOrder" stepKey="cancelOrder"/> + <!--Check quantities in "Items Ordered" table--> + <see selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" userInput="Invoiced 1" stepKey="seeInvoicedQuantity"/> + <see selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" userInput="Shipped 1" stepKey="seeShippedQuantity"/> + <see selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" userInput="Canceled 3" stepKey="seeCanceledQuantity"/> + + <!--Go to catalog products page on Admin--> + <amOnPage url="{{AdminCatalogProductPage.url}}" stepKey="goToCatalogProductPage"/> + <actionGroup ref="filterProductGridBySku" stepKey="filterProductGrid"> + <argument name="product" value="$$createConfigChildProduct$$"/> + </actionGroup> + + <!--Check quantity of configurable child product--> + <see selector="{{AdminProductGridSection.productGridCell('1', 'Quantity')}}" userInput="99" stepKey="seeProductSkuInGrid"/> + </test> +</tests> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminTierPriceNotAvailableForProductOptionsWithoutTierPriceTest.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminTierPriceNotAvailableForProductOptionsWithoutTierPriceTest.xml new file mode 100644 index 0000000000000..5daf699294155 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminTierPriceNotAvailableForProductOptionsWithoutTierPriceTest.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminTierPriceNotAvailableForProductOptionsWithoutTierPriceTest"> + <annotations> + <features value="ConfigurableProduct"/> + <stories value="View configurable product details on storefront"/> + <title value="Check that 'trie price' block not available for simple product from options without 'trie price'"/> + <description value="Check that 'trie price' block not available for simple product from options without 'trie price'"/> + <severity value="MAJOR"/> + <testCaseId value="MC-13789"/> + <useCaseId value="MAGETWO-96457"/> + <group value="catalog"/> + </annotations> + <before> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <!--Create Configurable product--> + <actionGroup ref="AdminCreateApiConfigurableProductActionGroup" stepKey="createConfigurableProduct"/> + </before> + <after> + <!--Delete created data--> + <deleteData createDataKey="createConfigProductCreateConfigurableProduct" stepKey="deleteConfigProduct"/> + <deleteData createDataKey="createConfigChildProduct1CreateConfigurableProduct" stepKey="deleteConfigChildProduct1"/> + <deleteData createDataKey="createConfigChildProduct2CreateConfigurableProduct" stepKey="deleteConfigChildProduct2"/> + <deleteData createDataKey="createConfigProductAttributeCreateConfigurableProduct" stepKey="deleteConfigProductAttribute"/> + + <actionGroup ref="logout" stepKey="logoutOfAdmin"/> + </after> + + <!--Go to storefront product page an check price box css--> + <amOnPage url="{{StorefrontProductPage.url($$createConfigProductCreateConfigurableProduct.custom_attributes[url_key]$$)}}" stepKey="navigateToSimpleProductPage"/> + <selectOption selector="{{StorefrontProductInfoMainSection.productAttributeOptionsSelectButton}}" userInput="$$getConfigAttributeOption1CreateConfigurableProduct.value$$" stepKey="selectOption"/> + <grabAttributeFrom selector="{{StorefrontProductInfoMainSection.productPriceBox}}" userInput="class" stepKey="grabGrabPriceClass"/> + <assertContains actual="$grabGrabPriceClass" expected="price-box price-final_price" expectedType="string" stepKey="assertEquals"/> + </test> +</tests> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/StorefrontConfigurableProductWithFileCustomOptionTest.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/StorefrontConfigurableProductWithFileCustomOptionTest.xml index a1e3e0b83e722..36615d3af6b7b 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/StorefrontConfigurableProductWithFileCustomOptionTest.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/StorefrontConfigurableProductWithFileCustomOptionTest.xml @@ -60,7 +60,6 @@ <!--Try invalid file--> <attachFile selector="{{StorefrontProductInfoMainSection.addLinkFileUploadFile(ProductOptionFile.title)}}" userInput="lorem_ipsum.docx" stepKey="attachInvalidFile"/> <click selector="{{StorefrontProductPageSection.addToCartBtn}}" stepKey="addToCartInvalidFile"/> - <waitForPageLoad time="30" stepKey="waitForAddToCartWithError"/> <waitForElementVisible selector="{{StorefrontProductPageSection.alertMessage}}" stepKey="waitForErrorMessageInvalidFile"/> <see selector="{{StorefrontProductPageSection.messagesBlock}}" userInput="The file 'lorem_ipsum.docx' for '{{ProductOptionFile.title}}' has an invalid extension." stepKey="seeMessageInvalidFile"/> <!--Option remains selected--> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/StorefrontSortingByPriceForConfigurableWithCatalogRuleAppliedTest.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/StorefrontSortingByPriceForConfigurableWithCatalogRuleAppliedTest.xml index d07ff3ec4d656..c8ca2ced0d60f 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/StorefrontSortingByPriceForConfigurableWithCatalogRuleAppliedTest.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/StorefrontSortingByPriceForConfigurableWithCatalogRuleAppliedTest.xml @@ -16,9 +16,6 @@ <severity value="CRITICAL"/> <testCaseId value="MAGETWO-76081"/> <group value="configurable_product"/> - <skip> - <issueId value="MAGETWO-96271"/> - </skip> </annotations> <before> <createData entity="ApiCategory" stepKey="createCategory"/> diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Block/Cart/Item/Renderer/ConfigurableTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Block/Cart/Item/Renderer/ConfigurableTest.php index f324494f5918a..698a7ac9f2693 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Unit/Block/Cart/Item/Renderer/ConfigurableTest.php +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Block/Cart/Item/Renderer/ConfigurableTest.php @@ -8,6 +8,9 @@ use Magento\Catalog\Model\Config\Source\Product\Thumbnail as ThumbnailSource; use Magento\ConfigurableProduct\Block\Cart\Item\Renderer\Configurable as Renderer; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class ConfigurableTest extends \PHPUnit\Framework\TestCase { /** @@ -30,6 +33,16 @@ class ConfigurableTest extends \PHPUnit\Framework\TestCase */ private $productConfigMock; + /** + * @var \Magento\Backend\Block\Template\Context|\PHPUnit_Framework_MockObject_MockObject + */ + private $contextMock; + + /** + * @var \Magento\Framework\View\Layout|PHPUnit_Framework_MockObject_MockObject + */ + private $layoutMock; + /** * @var Renderer */ @@ -39,6 +52,10 @@ protected function setUp() { parent::setUp(); $objectManagerHelper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->contextMock = $this->createPartialMock(\Magento\Backend\Block\Template\Context::class, ['getLayout']); + $this->layoutMock = $this->createPartialMock(\Magento\Framework\View\Layout::class, ['getBlock']); + $this->contextMock->expects($this->once())->method('getLayout')->willReturn($this->layoutMock); + $this->configManager = $this->createMock(\Magento\Framework\View\ConfigInterface::class); $this->imageHelper = $this->createPartialMock( \Magento\Catalog\Helper\Image::class, @@ -53,6 +70,7 @@ protected function setUp() 'imageHelper' => $this->imageHelper, 'scopeConfig' => $this->scopeConfig, 'productConfig' => $this->productConfigMock, + 'context' => $this->contextMock, ] ); } @@ -75,4 +93,42 @@ public function testGetIdentities() $this->renderer->setItem($item); $this->assertEquals(array_merge($productTags, $productTags), $this->renderer->getIdentities()); } + + /** + * Product price renderer test. + * + * @return void + */ + public function testGetProductPriceHtml() + { + $priceHtml = 'some price html'; + $productMock = $this->createMock(\Magento\Catalog\Model\Product::class); + + $item = $this->createMock(\Magento\Quote\Model\Quote\Item::class); + $item->expects($this->atLeastOnce())->method('getProduct')->willReturn($productMock); + + $priceRenderMock = $this->getMockBuilder(\Magento\Framework\Pricing\Render::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->layoutMock->expects($this->once()) + ->method('getBlock') + ->with('product.price.render.default') + ->willReturn($priceRenderMock); + + $priceRenderMock->expects($this->once()) + ->method('render') + ->with( + \Magento\Catalog\Pricing\Price\ConfiguredPriceInterface::CONFIGURED_PRICE_CODE, + $productMock, + [ + 'include_container' => true, + 'display_minimal_price' => true, + 'zone' => \Magento\Framework\Pricing\Render::ZONE_ITEM_LIST, + ] + )->willReturn($priceHtml); + + $this->renderer->setItem($item); + $this->assertEquals($priceHtml, $this->renderer->getProductPriceHtml($productMock)); + } } diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Plugin/Catalog/Model/Product/Pricing/Renderer/SalableResolverTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Plugin/Catalog/Model/Product/Pricing/Renderer/SalableResolverTest.php new file mode 100644 index 0000000000000..71bd6fd83c24d --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Plugin/Catalog/Model/Product/Pricing/Renderer/SalableResolverTest.php @@ -0,0 +1,87 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ConfigurableProduct\Test\Unit\Plugin\Catalog\Model\Product\Pricing\Renderer; + +use Magento\Catalog\Model\Product\Pricing\Renderer\SalableResolver; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable as TypeConfigurable; +use Magento\ConfigurableProduct\Plugin\Catalog\Model\Product\Pricing\Renderer\SalableResolver as SalableResolverPlugin; +use Magento\Framework\Pricing\SaleableInterface; +use PHPUnit_Framework_MockObject_MockObject as MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Class SalableResolverTest + */ +class SalableResolverTest extends TestCase +{ + /** + * @var TypeConfigurable|MockObject + */ + private $typeConfigurable; + + /** + * @var SalableResolverPlugin + */ + private $salableResolver; + + protected function setUp() + { + $this->typeConfigurable = $this->createMock(TypeConfigurable::class); + $this->salableResolver = new SalableResolverPlugin($this->typeConfigurable); + } + + /** + * @param SaleableInterface|MockObject $salableItem + * @param bool $isSalable + * @param bool $typeIsSalable + * @param bool $expectedResult + * @return void + * @dataProvider afterIsSalableDataProvider + */ + public function testAfterIsSalable($salableItem, bool $isSalable, bool $typeIsSalable, bool $expectedResult) + { + $salableResolver = $this->createMock(SalableResolver::class); + + $this->typeConfigurable->method('isSalable') + ->willReturn($typeIsSalable); + + $result = $this->salableResolver->afterIsSalable($salableResolver, $isSalable, $salableItem); + $this->assertEquals($expectedResult, $result); + } + + /** + * Data provider for testAfterIsSalable + * + * @return array + */ + public function afterIsSalableDataProvider(): array + { + $simpleSalableItem = $this->createMock(SaleableInterface::class); + $simpleSalableItem->method('getTypeId') + ->willReturn('simple'); + + $configurableSalableItem = $this->createMock(SaleableInterface::class); + $configurableSalableItem->method('getTypeId') + ->willReturn('configurable'); + + return [ + [ + $simpleSalableItem, + true, + false, + true, + ], + [ + $configurableSalableItem, + true, + false, + false, + ], + ]; + } +} diff --git a/app/code/Magento/ConfigurableProduct/composer.json b/app/code/Magento/ConfigurableProduct/composer.json index eae9dac046b23..74e611af2cbbc 100644 --- a/app/code/Magento/ConfigurableProduct/composer.json +++ b/app/code/Magento/ConfigurableProduct/composer.json @@ -25,7 +25,7 @@ "magento/module-product-links-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "100.2.6", + "version": "100.2.7", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/components/price-configurable.js b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/components/price-configurable.js index 28e775b984b05..6bbab77a3a0ab 100644 --- a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/components/price-configurable.js +++ b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/components/price-configurable.js @@ -53,6 +53,8 @@ define([ if (isConfigurable) { this.disable(); this.clear(); + } else { + this.enable(); } } }); diff --git a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/variations.js b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/variations.js index e2e0faec3b805..1d251f8ecc333 100644 --- a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/variations.js +++ b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/variations.js @@ -383,7 +383,11 @@ define([ * Chose action for the form save button */ saveFormHandler: function () { - this.serializeData(); + this.formElement().validate(); + + if (this.formElement().source.get('params.invalid') === false) { + this.serializeData(); + } if (this.checkForNewAttributes()) { this.formSaveParams = arguments; diff --git a/app/code/Magento/ConfigurableProduct/view/base/templates/product/price/tier_price.phtml b/app/code/Magento/ConfigurableProduct/view/base/templates/product/price/tier_price.phtml index 18f96cfaaf398..325ee1d5d79b3 100644 --- a/app/code/Magento/ConfigurableProduct/view/base/templates/product/price/tier_price.phtml +++ b/app/code/Magento/ConfigurableProduct/view/base/templates/product/price/tier_price.phtml @@ -15,10 +15,13 @@ + '</span>' + '</span>'; %> <li class="item"> - <%= $t('Buy %1 for %2 each and').replace('%1', item.qty).replace('%2', priceStr) %> - <strong class="benefit"> - <%= $t('save') %><span class="percent tier-<%= key %>"> <%= item.percentage %></span>% - </strong> + <%= '<?= $block->escapeHtml(__('Buy %1 for %2 each and', '%1', '%2')) ?>' + .replace('%1', item.qty) + .replace('%2', priceStr) %> + <strong class="benefit"> + <?= $block->escapeHtml(__('save')) ?><span + class="percent tier-<%= key %>"> <%= item.percentage %></span>% + </strong> </li> <% }); %> </ul> diff --git a/app/code/Magento/ConfigurableProduct/view/frontend/web/js/configurable.js b/app/code/Magento/ConfigurableProduct/view/frontend/web/js/configurable.js index 78974877dd90d..1df84d27a5c30 100644 --- a/app/code/Magento/ConfigurableProduct/view/frontend/web/js/configurable.js +++ b/app/code/Magento/ConfigurableProduct/view/frontend/web/js/configurable.js @@ -291,6 +291,8 @@ define([ images = this.options.spConfig.images[this.simpleProduct]; if (images) { + images = this._sortImages(images); + if (this.options.gallerySwitchStrategy === 'prepend') { images = images.concat(initialImages); } @@ -309,7 +311,17 @@ define([ $(this.options.mediaGallerySelector).AddFotoramaVideoEvents(); } - galleryObject.first(); + }, + + /** + * Sorting images array + * + * @private + */ + _sortImages: function (images) { + return _.sortBy(images, function (image) { + return image.position; + }); }, /** @@ -364,7 +376,8 @@ define([ basePrice = parseFloat(this.options.spConfig.prices.basePrice.amount), optionFinalPrice, optionPriceDiff, - optionPrices = this.options.spConfig.optionPrices; + optionPrices = this.options.spConfig.optionPrices, + allowedProductMinPrice; this._clearSelect(element); element.options[0] = new Option('', ''); @@ -395,8 +408,8 @@ define([ if (typeof allowedProducts[0] !== 'undefined' && typeof optionPrices[allowedProducts[0]] !== 'undefined') { - - optionFinalPrice = parseFloat(optionPrices[allowedProducts[0]].finalPrice.amount); + allowedProductMinPrice = this._getAllowedProductWithMinPrice(allowedProducts); + optionFinalPrice = parseFloat(optionPrices[allowedProductMinPrice].finalPrice.amount); optionPriceDiff = optionFinalPrice - basePrice; if (optionPriceDiff !== 0) { @@ -477,36 +490,27 @@ define([ _getPrices: function () { var prices = {}, elements = _.toArray(this.options.settings), - hasProductPrice = false, - optionPriceDiff = 0, - allowedProduct, optionPrices, basePrice, optionFinalPrice; + allowedProduct; _.each(elements, function (element) { var selected = element.options[element.selectedIndex], config = selected && selected.config, priceValue = {}; - if (config && config.allowedProducts.length === 1 && !hasProductPrice) { - prices = {}; + if (config && config.allowedProducts.length === 1) { priceValue = this._calculatePrice(config); - hasProductPrice = true; } else if (element.value) { allowedProduct = this._getAllowedProductWithMinPrice(config.allowedProducts); - optionPrices = this.options.spConfig.optionPrices; - basePrice = parseFloat(this.options.spConfig.prices.basePrice.amount); - - if (!_.isEmpty(allowedProduct)) { - optionFinalPrice = parseFloat(optionPrices[allowedProduct].finalPrice.amount); - optionPriceDiff = optionFinalPrice - basePrice; - } - - if (optionPriceDiff !== 0) { - prices = {}; - priceValue = this._calculatePriceDifference(allowedProduct); - } + priceValue = this._calculatePrice({ + 'allowedProducts': [ + allowedProduct + ] + }); } - prices[element.attributeId] = priceValue; + if (!_.isEmpty(priceValue)) { + prices.prices = priceValue; + } }, this); return prices; @@ -527,40 +531,15 @@ define([ _.each(allowedProducts, function (allowedProduct) { optionFinalPrice = parseFloat(optionPrices[allowedProduct].finalPrice.amount); - if (_.isEmpty(product)) { + if (_.isEmpty(product) || optionFinalPrice < optionMinPrice) { optionMinPrice = optionFinalPrice; product = allowedProduct; } - - if (optionFinalPrice < optionMinPrice) { - product = allowedProduct; - } }, this); return product; }, - /** - * Calculate price difference for allowed product - * - * @param {*} allowedProduct - Product - * @returns {*} - * @private - */ - _calculatePriceDifference: function (allowedProduct) { - var displayPrices = $(this.options.priceHolderSelector).priceBox('option').prices, - newPrices = this.options.spConfig.optionPrices[allowedProduct]; - - _.each(displayPrices, function (price, code) { - - if (newPrices[code]) { - displayPrices[code].amount = newPrices[code].amount - displayPrices[code].amount; - } - }); - - return displayPrices; - }, - /** * Returns prices for configured products * diff --git a/app/code/Magento/ConfigurableProductSales/composer.json b/app/code/Magento/ConfigurableProductSales/composer.json index ca6638924b8c6..5baa9bef17e11 100644 --- a/app/code/Magento/ConfigurableProductSales/composer.json +++ b/app/code/Magento/ConfigurableProductSales/composer.json @@ -12,7 +12,7 @@ "magento/module-configurable-product": "100.2.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Contact/composer.json b/app/code/Magento/Contact/composer.json index 49ac8ec40dbc4..72c8005c53432 100644 --- a/app/code/Magento/Contact/composer.json +++ b/app/code/Magento/Contact/composer.json @@ -10,7 +10,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Contact/view/frontend/email/submitted_form.html b/app/code/Magento/Contact/view/frontend/email/submitted_form.html index 1bce6159c586a..17146257aeff1 100644 --- a/app/code/Magento/Contact/view/frontend/email/submitted_form.html +++ b/app/code/Magento/Contact/view/frontend/email/submitted_form.html @@ -16,19 +16,19 @@ <table class="message-details"> <tr> - <td><b>{{trans "Name"}}</b></td> + <td><strong>{{trans "Name"}}</strong></td> <td>{{var data.name}}</td> </tr> <tr> - <td><b>{{trans "Email"}}</b></td> + <td><strong>{{trans "Email"}}</strong></td> <td>{{var data.email}}</td> </tr> <tr> - <td><b>{{trans "Phone"}}</b></td> + <td><strong>{{trans "Phone"}}</strong></td> <td>{{var data.telephone}}</td> </tr> </table> -<p><b>{{trans "Message"}}</b></p> +<p><strong>{{trans "Message"}}</strong></p> <p>{{var data.comment}}</p> {{template config_path="design/email/footer_template"}} diff --git a/app/code/Magento/Contact/view/frontend/web/css/source/_module.less b/app/code/Magento/Contact/view/frontend/web/css/source/_module.less new file mode 100644 index 0000000000000..0aaec05aa2afe --- /dev/null +++ b/app/code/Magento/Contact/view/frontend/web/css/source/_module.less @@ -0,0 +1,42 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. +*/ + +& when (@media-common = true) { + .contact-index-index { + .column:not(.sidebar-main) { + .form.contact { + float: none; + width: 50%; + } + } + + .column:not(.sidebar-additional) { + .form.contact { + float: none; + width: 50%; + } + } + } +} + +// Mobile +.media-width(@extremum, @break) when (@extremum = 'max') and (@break = @screen__m) { + .contact-index-index { + .column:not(.sidebar-main) { + .form.contact { + float: none; + width: 100%; + } + } + + .column:not(.sidebar-additional) { + .form.contact { + float: none; + width: 100%; + } + } + } +} + diff --git a/app/code/Magento/Cookie/composer.json b/app/code/Magento/Cookie/composer.json index cc04b0ccd5d7d..ba4427e9681b5 100644 --- a/app/code/Magento/Cookie/composer.json +++ b/app/code/Magento/Cookie/composer.json @@ -10,7 +10,7 @@ "magento/module-backend": "100.2.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Cron/composer.json b/app/code/Magento/Cron/composer.json index a8c124dc57207..ddbe7df2c0a2e 100644 --- a/app/code/Magento/Cron/composer.json +++ b/app/code/Magento/Cron/composer.json @@ -10,7 +10,7 @@ "magento/module-config": "101.0.*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CurrencySymbol/composer.json b/app/code/Magento/CurrencySymbol/composer.json index da76425436038..021fe7ae9bc56 100644 --- a/app/code/Magento/CurrencySymbol/composer.json +++ b/app/code/Magento/CurrencySymbol/composer.json @@ -11,7 +11,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CurrencySymbol/view/adminhtml/templates/system/currency/rate/matrix.phtml b/app/code/Magento/CurrencySymbol/view/adminhtml/templates/system/currency/rate/matrix.phtml index 8e0abcb319764..8a16eb71e0853 100644 --- a/app/code/Magento/CurrencySymbol/view/adminhtml/templates/system/currency/rate/matrix.phtml +++ b/app/code/Magento/CurrencySymbol/view/adminhtml/templates/system/currency/rate/matrix.phtml @@ -45,7 +45,7 @@ $_rates = ($_newRates) ? $_newRates : $_oldRates; class="admin__control-text" <?= ($_currencyCode == $_rate) ? ' disabled' : '' ?> /> <?php if (isset($_newRates) && $_currencyCode != $_rate && isset($_oldRates[$_currencyCode][$_rate])): ?> - <div class="admin__field-note"><?= /* @escapeNotVerified */ __('Old rate:') ?> <b><?= /* @escapeNotVerified */ $_oldRates[$_currencyCode][$_rate] ?></b></div> + <div class="admin__field-note"><?= /* @escapeNotVerified */ __('Old rate:') ?> <strong><?= /* @escapeNotVerified */ $_oldRates[$_currencyCode][$_rate] ?></strong></div> <?php endif; ?> </td> <?php else: ?> @@ -56,7 +56,7 @@ $_rates = ($_newRates) ? $_newRates : $_oldRates; class="admin__control-text" <?= ($_currencyCode == $_rate) ? ' disabled' : '' ?> /> <?php if (isset($_newRates) && $_currencyCode != $_rate && isset($_oldRates[$_currencyCode][$_rate])): ?> - <div class="admin__field-note"><?= /* @escapeNotVerified */ __('Old rate:') ?> <b><?= /* @escapeNotVerified */ $_oldRates[$_currencyCode][$_rate] ?></b></div> + <div class="admin__field-note"><?= /* @escapeNotVerified */ __('Old rate:') ?> <strong><?= /* @escapeNotVerified */ $_oldRates[$_currencyCode][$_rate] ?></strong></div> <?php endif; ?> </td> <?php endif; ?> diff --git a/app/code/Magento/Customer/Block/Adminhtml/Group/Edit.php b/app/code/Magento/Customer/Block/Adminhtml/Group/Edit.php index be2d143e7f864..0bf7f607a531b 100644 --- a/app/code/Magento/Customer/Block/Adminhtml/Group/Edit.php +++ b/app/code/Magento/Customer/Block/Adminhtml/Group/Edit.php @@ -57,6 +57,8 @@ public function __construct( * Update Save and Delete buttons. Remove Delete button if group can't be deleted. * * @return void + * @throws \Magento\Framework\Exception\LocalizedException + * @throws \Magento\Framework\Exception\NoSuchEntityException */ protected function _construct() { @@ -68,6 +70,23 @@ protected function _construct() $this->buttonList->update('save', 'label', __('Save Customer Group')); $this->buttonList->update('delete', 'label', __('Delete Customer Group')); + $this->buttonList->update( + 'delete', + 'onclick', + sprintf( + "deleteConfirm('%s','%s', %s)", + 'Are you sure?', + $this->getDeleteUrl(), + json_encode( + [ + 'action' => '', + 'data' => [ + 'form_key' => $this->getFormKey() + ] + ] + ) + ) + ); $groupId = $this->coreRegistry->registry(RegistryConstants::CURRENT_GROUP_ID); if (!$groupId || $this->groupManagement->isReadonly($groupId)) { diff --git a/app/code/Magento/Customer/Block/DataProviders/PostCodesPatternsAttributeData.php b/app/code/Magento/Customer/Block/DataProviders/PostCodesPatternsAttributeData.php new file mode 100644 index 0000000000000..280948439e1f8 --- /dev/null +++ b/app/code/Magento/Customer/Block/DataProviders/PostCodesPatternsAttributeData.php @@ -0,0 +1,50 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Customer\Block\DataProviders; + +use Magento\Framework\Serialize\SerializerInterface; +use Magento\Framework\View\Element\Block\ArgumentInterface; +use Magento\Directory\Model\Country\Postcode\Config as PostCodeConfig; + +/** + * Provides postcodes patterns into template. + */ +class PostCodesPatternsAttributeData implements ArgumentInterface +{ + /** + * @var PostCodeConfig + */ + private $postCodeConfig; + + /** + * @var SerializerInterface + */ + private $serializer; + + /** + * Constructor + * + * @param PostCodeConfig $postCodeConfig + * @param SerializerInterface $serializer + */ + public function __construct(PostCodeConfig $postCodeConfig, SerializerInterface $serializer) + { + $this->postCodeConfig = $postCodeConfig; + $this->serializer = $serializer; + } + + /** + * Get serialized post codes + * + * @return string + */ + public function getSerializedPostCodes(): string + { + return $this->serializer->serialize($this->postCodeConfig->getPostCodes()); + } +} diff --git a/app/code/Magento/Customer/Controller/Account/EditPost.php b/app/code/Magento/Customer/Controller/Account/EditPost.php index da0ad29c5c72f..4d9ec962c292d 100644 --- a/app/code/Magento/Customer/Controller/Account/EditPost.php +++ b/app/code/Magento/Customer/Controller/Account/EditPost.php @@ -6,6 +6,8 @@ */ namespace Magento\Customer\Controller\Account; +use Magento\Customer\Api\Data\CustomerInterface; +use Magento\Customer\Model\AddressRegistry; use Magento\Customer\Model\AuthenticationInterface; use Magento\Customer\Model\Customer\Mapper; use Magento\Customer\Model\EmailNotificationInterface; @@ -23,7 +25,8 @@ use Magento\Framework\Escaper; /** - * Class EditPost + * Class to editing post. + * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class EditPost extends \Magento\Customer\Controller\AbstractAccount @@ -73,9 +76,16 @@ class EditPost extends \Magento\Customer\Controller\AbstractAccount */ private $customerMapper; - /** @var Escaper */ + /** + * @var Escaper + */ private $escaper; + /** + * @var AddressRegistry + */ + private $addressRegistry; + /** * @param Context $context * @param Session $customerSession @@ -84,6 +94,7 @@ class EditPost extends \Magento\Customer\Controller\AbstractAccount * @param Validator $formKeyValidator * @param CustomerExtractor $customerExtractor * @param Escaper|null $escaper + * @param AddressRegistry|null $addressRegistry */ public function __construct( Context $context, @@ -92,7 +103,8 @@ public function __construct( CustomerRepositoryInterface $customerRepository, Validator $formKeyValidator, CustomerExtractor $customerExtractor, - Escaper $escaper = null + Escaper $escaper = null, + AddressRegistry $addressRegistry = null ) { parent::__construct($context); $this->session = $customerSession; @@ -101,6 +113,7 @@ public function __construct( $this->formKeyValidator = $formKeyValidator; $this->customerExtractor = $customerExtractor; $this->escaper = $escaper ?: ObjectManager::getInstance()->get(Escaper::class); + $this->addressRegistry = $addressRegistry ?: ObjectManager::getInstance()->get(AddressRegistry::class); } /** @@ -138,7 +151,7 @@ private function getEmailNotification() } /** - * Change customer email or password action + * Change customer email or password action. * * @return \Magento\Framework\Controller\Result\Redirect */ @@ -162,6 +175,9 @@ public function execute() // whether a customer enabled change password option $isPasswordChanged = $this->changeCustomerPassword($currentCustomerDataObject->getEmail()); + // No need to validate customer address while editing customer profile + $this->disableAddressValidation($customerCandidateDataObject); + $this->customerRepository->save($customerCandidateDataObject); $this->getEmailNotification()->credentialsChanged( $customerCandidateDataObject, @@ -170,6 +186,7 @@ public function execute() ); $this->dispatchSuccessEvent($customerCandidateDataObject); $this->messageManager->addSuccess(__('You saved the account information.')); + return $resultRedirect->setPath('customer/account'); } catch (InvalidEmailOrPasswordException $e) { $this->messageManager->addError($e->getMessage()); @@ -180,6 +197,7 @@ public function execute() $this->session->logout(); $this->session->start(); $this->messageManager->addError($message); + return $resultRedirect->setPath('customer/account/login'); } catch (InputException $e) { $this->messageManager->addErrorMessage($this->escaper->escapeHtml($e->getMessage())); @@ -313,4 +331,18 @@ private function getCustomerMapper() } return $this->customerMapper; } + + /** + * Disable Customer Address Validation. + * + * @param CustomerInterface $customer + * @return void + */ + private function disableAddressValidation(CustomerInterface $customer) + { + foreach ($customer->getAddresses() as $address) { + $addressModel = $this->addressRegistry->retrieve($address->getId()); + $addressModel->setShouldIgnoreValidation(true); + } + } } diff --git a/app/code/Magento/Customer/Controller/Adminhtml/Group/Delete.php b/app/code/Magento/Customer/Controller/Adminhtml/Group/Delete.php index 571ef57702bc3..6fc9f45ab62ff 100644 --- a/app/code/Magento/Customer/Controller/Adminhtml/Group/Delete.php +++ b/app/code/Magento/Customer/Controller/Adminhtml/Group/Delete.php @@ -7,6 +7,7 @@ namespace Magento\Customer\Controller\Adminhtml\Group; use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\Exception\NotFoundException; class Delete extends \Magento\Customer\Controller\Adminhtml\Group { @@ -14,9 +15,14 @@ class Delete extends \Magento\Customer\Controller\Adminhtml\Group * Delete customer group. * * @return \Magento\Backend\Model\View\Result\Redirect + * @throws NotFoundException */ public function execute() { + if (!$this->getRequest()->isPost()) { + throw new NotFoundException(__('Page not found')); + } + $id = $this->getRequest()->getParam('id'); /** @var \Magento\Backend\Model\View\Result\Redirect $resultRedirect */ $resultRedirect = $this->resultRedirectFactory->create(); diff --git a/app/code/Magento/Customer/Controller/Adminhtml/Index/InlineEdit.php b/app/code/Magento/Customer/Controller/Adminhtml/Index/InlineEdit.php index 6753a48d02d6a..1b5160ef31185 100644 --- a/app/code/Magento/Customer/Controller/Adminhtml/Index/InlineEdit.php +++ b/app/code/Magento/Customer/Controller/Adminhtml/Index/InlineEdit.php @@ -8,12 +8,14 @@ use Magento\Backend\App\Action; use Magento\Customer\Api\CustomerRepositoryInterface; use Magento\Customer\Api\Data\CustomerInterface; +use Magento\Customer\Model\AddressRegistry; use Magento\Customer\Model\EmailNotificationInterface; use Magento\Customer\Ui\Component\Listing\AttributeRepository; use Magento\Framework\Message\MessageInterface; +use Magento\Framework\App\ObjectManager; /** - * Customer inline edit action + * Customer inline edit action. * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ @@ -61,6 +63,11 @@ class InlineEdit extends \Magento\Backend\App\Action */ private $emailNotification; + /** + * @var AddressRegistry + */ + private $addressRegistry; + /** * @param Action\Context $context * @param CustomerRepositoryInterface $customerRepository @@ -68,6 +75,7 @@ class InlineEdit extends \Magento\Backend\App\Action * @param \Magento\Customer\Model\Customer\Mapper $customerMapper * @param \Magento\Framework\Api\DataObjectHelper $dataObjectHelper * @param \Psr\Log\LoggerInterface $logger + * @param AddressRegistry|null $addressRegistry */ public function __construct( Action\Context $context, @@ -75,13 +83,15 @@ public function __construct( \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory, \Magento\Customer\Model\Customer\Mapper $customerMapper, \Magento\Framework\Api\DataObjectHelper $dataObjectHelper, - \Psr\Log\LoggerInterface $logger + \Psr\Log\LoggerInterface $logger, + AddressRegistry $addressRegistry = null ) { $this->customerRepository = $customerRepository; $this->resultJsonFactory = $resultJsonFactory; $this->customerMapper = $customerMapper; $this->dataObjectHelper = $dataObjectHelper; $this->logger = $logger; + $this->addressRegistry = $addressRegistry ?: ObjectManager::getInstance()->get(AddressRegistry::class); parent::__construct($context); } @@ -210,7 +220,7 @@ protected function updateDefaultBilling(array $data) } /** - * Save customer with error catching + * Save customer with error catching. * * @param CustomerInterface $customer * @return void @@ -218,6 +228,8 @@ protected function updateDefaultBilling(array $data) protected function saveCustomer(CustomerInterface $customer) { try { + // No need to validate customer address during inline edit action + $this->disableAddressValidation($customer); $this->customerRepository->save($customer); } catch (\Magento\Framework\Exception\InputException $e) { $this->getMessageManager()->addError($this->getErrorWithCustomerId($e->getMessage())); @@ -303,4 +315,18 @@ protected function getErrorWithCustomerId($errorText) { return '[Customer ID: ' . $this->getCustomer()->getId() . '] ' . __($errorText); } + + /** + * Disable Customer Address Validation. + * + * @param CustomerInterface $customer + * @return void + */ + private function disableAddressValidation(CustomerInterface $customer) + { + foreach ($customer->getAddresses() as $address) { + $addressModel = $this->addressRegistry->retrieve($address->getId()); + $addressModel->setShouldIgnoreValidation(true); + } + } } diff --git a/app/code/Magento/Customer/Controller/Adminhtml/Index/MassAssignGroup.php b/app/code/Magento/Customer/Controller/Adminhtml/Index/MassAssignGroup.php index 762b872b97b6d..49a51052beb90 100644 --- a/app/code/Magento/Customer/Controller/Adminhtml/Index/MassAssignGroup.php +++ b/app/code/Magento/Customer/Controller/Adminhtml/Index/MassAssignGroup.php @@ -5,6 +5,7 @@ */ namespace Magento\Customer\Controller\Adminhtml\Index; +use Magento\Customer\Api\Data\CustomerInterface; use Magento\Backend\App\Action\Context; use Magento\Customer\Model\ResourceModel\Customer\CollectionFactory; use Magento\Eav\Model\Entity\Collection\AbstractCollection; @@ -13,7 +14,7 @@ use Magento\Framework\Controller\ResultFactory; /** - * Class MassAssignGroup + * Class to execute MassAssignGroup action. */ class MassAssignGroup extends AbstractMassAction { @@ -39,7 +40,7 @@ public function __construct( } /** - * Customer mass assign group action + * Customer mass assign group action. * * @param AbstractCollection $collection * @return \Magento\Backend\Model\View\Result\Redirect @@ -51,6 +52,8 @@ protected function massAction(AbstractCollection $collection) // Verify customer exists $customer = $this->customerRepository->getById($customerId); $customer->setGroupId($this->getRequest()->getParam('group')); + // No need to validate customer and customer address during assigning customer to the group + $this->setIgnoreValidationFlag($customer); $this->customerRepository->save($customer); $customersUpdated++; } @@ -64,4 +67,15 @@ protected function massAction(AbstractCollection $collection) return $resultRedirect; } + + /** + * Set ignore_validation_flag to skip unnecessary address and customer validation. + * + * @param CustomerInterface $customer + * @return void + */ + private function setIgnoreValidationFlag(CustomerInterface $customer) + { + $customer->setData('ignore_validation_flag', true); + } } diff --git a/app/code/Magento/Customer/Controller/Adminhtml/Index/Save.php b/app/code/Magento/Customer/Controller/Adminhtml/Index/Save.php index 12732f81f78a0..3a03e9064a0a3 100644 --- a/app/code/Magento/Customer/Controller/Adminhtml/Index/Save.php +++ b/app/code/Magento/Customer/Controller/Adminhtml/Index/Save.php @@ -5,6 +5,15 @@ */ namespace Magento\Customer\Controller\Adminhtml\Index; +use Magento\Customer\Api\AccountManagementInterface; +use Magento\Customer\Api\AddressRepositoryInterface; +use Magento\Customer\Api\CustomerRepositoryInterface; +use Magento\Customer\Model\Address\Mapper; +use Magento\Customer\Model\AddressRegistry; +use Magento\Framework\Api\DataObjectHelper; +use Magento\Customer\Api\Data\AddressInterfaceFactory; +use Magento\Customer\Api\Data\CustomerInterfaceFactory; +use Magento\Framework\DataObjectFactory as ObjectFactory; use Magento\Customer\Api\AddressMetadataInterface; use Magento\Customer\Api\CustomerMetadataInterface; use Magento\Customer\Api\Data\CustomerInterface; @@ -12,8 +21,11 @@ use Magento\Customer\Model\EmailNotificationInterface; use Magento\Customer\Model\Metadata\Form; use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\App\ObjectManager; /** + * Class to Save customer. + * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class Save extends \Magento\Customer\Controller\Adminhtml\Index @@ -23,6 +35,98 @@ class Save extends \Magento\Customer\Controller\Adminhtml\Index */ private $emailNotification; + /** + * @var AddressRegistry + */ + private $addressRegistry; + + /** + * @param \Magento\Backend\App\Action\Context $context + * @param \Magento\Framework\Registry $coreRegistry + * @param \Magento\Framework\App\Response\Http\FileFactory $fileFactory + * @param \Magento\Customer\Model\CustomerFactory $customerFactory + * @param \Magento\Customer\Model\AddressFactory $addressFactory + * @param \Magento\Customer\Model\Metadata\FormFactory $formFactory + * @param \Magento\Newsletter\Model\SubscriberFactory $subscriberFactory + * @param \Magento\Customer\Helper\View $viewHelper + * @param \Magento\Framework\Math\Random $random + * @param CustomerRepositoryInterface $customerRepository + * @param \Magento\Framework\Api\ExtensibleDataObjectConverter $extensibleDataObjectConverter + * @param Mapper $addressMapper + * @param AccountManagementInterface $customerAccountManagement + * @param AddressRepositoryInterface $addressRepository + * @param CustomerInterfaceFactory $customerDataFactory + * @param AddressInterfaceFactory $addressDataFactory + * @param \Magento\Customer\Model\Customer\Mapper $customerMapper + * @param \Magento\Framework\Reflection\DataObjectProcessor $dataObjectProcessor + * @param DataObjectHelper $dataObjectHelper + * @param ObjectFactory $objectFactory + * @param \Magento\Framework\View\LayoutFactory $layoutFactory + * @param \Magento\Framework\View\Result\LayoutFactory $resultLayoutFactory + * @param \Magento\Framework\View\Result\PageFactory $resultPageFactory + * @param \Magento\Backend\Model\View\Result\ForwardFactory $resultForwardFactory + * @param \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory + * @param AddressRegistry|null $addressRegistry + * @SuppressWarnings(PHPMD.ExcessiveParameterList) + */ + public function __construct( + \Magento\Backend\App\Action\Context $context, + \Magento\Framework\Registry $coreRegistry, + \Magento\Framework\App\Response\Http\FileFactory $fileFactory, + \Magento\Customer\Model\CustomerFactory $customerFactory, + \Magento\Customer\Model\AddressFactory $addressFactory, + \Magento\Customer\Model\Metadata\FormFactory $formFactory, + \Magento\Newsletter\Model\SubscriberFactory $subscriberFactory, + \Magento\Customer\Helper\View $viewHelper, + \Magento\Framework\Math\Random $random, + CustomerRepositoryInterface $customerRepository, + \Magento\Framework\Api\ExtensibleDataObjectConverter $extensibleDataObjectConverter, + Mapper $addressMapper, + AccountManagementInterface $customerAccountManagement, + AddressRepositoryInterface $addressRepository, + CustomerInterfaceFactory $customerDataFactory, + AddressInterfaceFactory $addressDataFactory, + \Magento\Customer\Model\Customer\Mapper $customerMapper, + \Magento\Framework\Reflection\DataObjectProcessor $dataObjectProcessor, + DataObjectHelper $dataObjectHelper, + ObjectFactory $objectFactory, + \Magento\Framework\View\LayoutFactory $layoutFactory, + \Magento\Framework\View\Result\LayoutFactory $resultLayoutFactory, + \Magento\Framework\View\Result\PageFactory $resultPageFactory, + \Magento\Backend\Model\View\Result\ForwardFactory $resultForwardFactory, + \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory, + AddressRegistry $addressRegistry = null + ) { + parent::__construct( + $context, + $coreRegistry, + $fileFactory, + $customerFactory, + $addressFactory, + $formFactory, + $subscriberFactory, + $viewHelper, + $random, + $customerRepository, + $extensibleDataObjectConverter, + $addressMapper, + $customerAccountManagement, + $addressRepository, + $customerDataFactory, + $addressDataFactory, + $customerMapper, + $dataObjectProcessor, + $dataObjectHelper, + $objectFactory, + $layoutFactory, + $resultLayoutFactory, + $resultPageFactory, + $resultForwardFactory, + $resultJsonFactory + ); + $this->addressRegistry = $addressRegistry ?: ObjectManager::getInstance()->get(AddressRegistry::class); + } + /** * Reformat customer account data to be compatible with customer service interface * @@ -169,7 +273,7 @@ protected function _extractCustomerAddressData(array & $extractedCustomerData) } /** - * Save customer action + * Save customer action. * * @return \Magento\Backend\Model\View\Result\Redirect * @SuppressWarnings(PHPMD.CyclomaticComplexity) @@ -191,6 +295,8 @@ public function execute() if ($customerId) { $currentCustomer = $this->_customerRepository->getById($customerId); + // No need to validate customer address while editing customer profile + $this->disableAddressValidation($currentCustomer); $customerData = array_merge( $this->customerMapper->toFlatArray($currentCustomer), $customerData @@ -306,6 +412,7 @@ public function execute() } else { $resultRedirect->setPath('customer/index'); } + return $resultRedirect; } @@ -380,4 +487,18 @@ private function getCurrentCustomerId() return $customerId; } + + /** + * Disable Customer Address Validation + * + * @param CustomerInterface $customer + * @return void + */ + private function disableAddressValidation(CustomerInterface $customer) + { + foreach ($customer->getAddresses() as $address) { + $addressModel = $this->addressRegistry->retrieve($address->getId()); + $addressModel->setShouldIgnoreValidation(true); + } + } } diff --git a/app/code/Magento/Customer/Model/AccountManagement.php b/app/code/Magento/Customer/Model/AccountManagement.php index b5b1905e68d8d..e837517762473 100644 --- a/app/code/Magento/Customer/Model/AccountManagement.php +++ b/app/code/Magento/Customer/Model/AccountManagement.php @@ -16,7 +16,9 @@ use Magento\Customer\Model\Config\Share as ConfigShare; use Magento\Customer\Model\Customer as CustomerModel; use Magento\Customer\Model\Customer\CredentialsValidator; +use Magento\Customer\Model\Data\Customer; use Magento\Customer\Model\Metadata\Validator; +use Magento\Customer\Model\ResourceModel\Visitor\CollectionFactory; use Magento\Eav\Model\Validator\Attribute\Backend; use Magento\Framework\Api\ExtensibleDataObjectConverter; use Magento\Framework\Api\SearchCriteriaBuilder; @@ -44,21 +46,21 @@ use Magento\Framework\Phrase; use Magento\Framework\Reflection\DataObjectProcessor; use Magento\Framework\Registry; +use Magento\Framework\Session\SaveHandlerInterface; +use Magento\Framework\Session\SessionManagerInterface; use Magento\Framework\Stdlib\DateTime; use Magento\Framework\Stdlib\StringUtils as StringHelper; use Magento\Store\Model\ScopeInterface; use Magento\Store\Model\StoreManagerInterface; use Psr\Log\LoggerInterface as PsrLogger; -use Magento\Framework\Session\SessionManagerInterface; -use Magento\Framework\Session\SaveHandlerInterface; -use Magento\Customer\Model\ResourceModel\Visitor\CollectionFactory; /** - * Handle various customer account actions + * Handle various customer account actions. * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @SuppressWarnings(PHPMD.TooManyFields) * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) + * @SuppressWarnings(PHPMD.CookieAndSessionMisuse) */ class AccountManagement implements AccountManagementInterface { @@ -332,6 +334,11 @@ class AccountManagement implements AccountManagementInterface */ private $searchCriteriaBuilder; + /** + * @var AddressRegistry + */ + private $addressRegistry; + /** * @param CustomerFactory $customerFactory * @param ManagerInterface $eventManager @@ -359,12 +366,13 @@ class AccountManagement implements AccountManagementInterface * @param CredentialsValidator|null $credentialsValidator * @param DateTimeFactory|null $dateTimeFactory * @param AccountConfirmation|null $accountConfirmation - * @param DateTimeFactory $dateTimeFactory * @param SessionManagerInterface|null $sessionManager * @param SaveHandlerInterface|null $saveHandler * @param CollectionFactory|null $visitorCollectionFactory * @param SearchCriteriaBuilder|null $searchCriteriaBuilder + * @param AddressRegistry|null $addressRegistry * @SuppressWarnings(PHPMD.ExcessiveParameterList) + * @SuppressWarnings(PHPMD.NPathComplexity) */ public function __construct( CustomerFactory $customerFactory, @@ -396,7 +404,8 @@ public function __construct( SessionManagerInterface $sessionManager = null, SaveHandlerInterface $saveHandler = null, CollectionFactory $visitorCollectionFactory = null, - SearchCriteriaBuilder $searchCriteriaBuilder = null + SearchCriteriaBuilder $searchCriteriaBuilder = null, + AddressRegistry $addressRegistry = null ) { $this->customerFactory = $customerFactory; $this->eventManager = $eventManager; @@ -434,6 +443,8 @@ public function __construct( ?: ObjectManager::getInstance()->get(CollectionFactory::class); $this->searchCriteriaBuilder = $searchCriteriaBuilder ?: ObjectManager::getInstance()->get(SearchCriteriaBuilder::class); + $this->addressRegistry = $addressRegistry + ?: ObjectManager::getInstance()->get(AddressRegistry::class); } /** @@ -499,8 +510,11 @@ public function activateById($customerId, $confirmationKey) * @param \Magento\Customer\Api\Data\CustomerInterface $customer * @param string $confirmationKey * @return \Magento\Customer\Api\Data\CustomerInterface - * @throws \Magento\Framework\Exception\State\InvalidTransitionException - * @throws \Magento\Framework\Exception\State\InputMismatchException + * @throws InputException + * @throws InputMismatchException + * @throws InvalidTransitionException + * @throws LocalizedException + * @throws NoSuchEntityException */ private function activateCustomer($customer, $confirmationKey) { @@ -514,6 +528,8 @@ private function activateCustomer($customer, $confirmationKey) } $customer->setConfirmation(null); + // No need to validate customer and customer address while activating customer + $this->setIgnoreValidationFlag($customer); $this->customerRepository->save($customer); $this->getEmailNotification()->newAccount($customer, 'confirmed', '', $this->storeManager->getStore()->getId()); return $customer; @@ -574,6 +590,9 @@ public function initiatePasswordReset($email, $template, $websiteId = null) // load customer by email $customer = $this->customerRepository->get($email, $websiteId); + // No need to validate customer address while saving customer reset password token + $this->disableAddressValidation($customer); + $newPasswordToken = $this->mathRandom->getUniqueHash(); $this->changeResetPasswordLinkToken($customer, $newPasswordToken); @@ -611,10 +630,10 @@ public function initiatePasswordReset($email, $template, $websiteId = null) * Match a customer by their RP token. * * @param string $rpToken + * @return CustomerInterface * @throws ExpiredException + * @throws LocalizedException * @throws NoSuchEntityException - * - * @return CustomerInterface */ private function matchCustomerByRpToken(string $rpToken): CustomerInterface { @@ -657,6 +676,11 @@ public function resetPassword($email, $resetToken, $newPassword) } else { $customer = $this->customerRepository->get($email); } + + // No need to validate customer and customer address while saving customer reset password token + $this->disableAddressValidation($customer); + $this->setIgnoreValidationFlag($customer); + //Validate Token and new password strength $this->validateResetPasswordToken($customer->getId(), $resetToken); $this->credentialsValidator->checkPasswordDifferentFromEmail( @@ -781,7 +805,7 @@ public function getConfirmationStatus($customerId) /** * {@inheritdoc} */ - public function createAccount(CustomerInterface $customer, $password = null, $redirectUrl = '') + public function createAccount(CustomerInterface $customer, $password = null, $redirectUrl = '', $extensions = []) { if ($password !== null) { $this->checkPasswordStrength($password); @@ -795,7 +819,8 @@ public function createAccount(CustomerInterface $customer, $password = null, $re } else { $hash = null; } - return $this->createAccountWithPasswordHash($customer, $hash, $redirectUrl); + + return $this->createAccountWithPasswordHash($customer, $hash, $redirectUrl, $extensions); } /** @@ -803,8 +828,12 @@ public function createAccount(CustomerInterface $customer, $password = null, $re * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) */ - public function createAccountWithPasswordHash(CustomerInterface $customer, $hash, $redirectUrl = '') - { + public function createAccountWithPasswordHash( + CustomerInterface $customer, + $hash, + $redirectUrl = '', + $extensions = [] + ) { // This logic allows an existing customer to be added to a different store. No new account is created. // The plan is to move this logic into a new method called something like 'registerAccountWithStore' if ($customer->getId()) { @@ -871,7 +900,7 @@ public function createAccountWithPasswordHash(CustomerInterface $customer, $hash $customer = $this->customerRepository->getById($customer->getId()); $newLinkToken = $this->mathRandom->getUniqueHash(); $this->changeResetPasswordLinkToken($customer, $newLinkToken); - $this->sendEmailConfirmation($customer, $redirectUrl); + $this->sendEmailConfirmation($customer, $redirectUrl, $extensions); return $customer; } @@ -899,9 +928,12 @@ public function getDefaultShippingAddress($customerId) * * @param CustomerInterface $customer * @param string $redirectUrl + * @param array $extensions * @return void + * @throws LocalizedException + * @throws NoSuchEntityException */ - protected function sendEmailConfirmation(CustomerInterface $customer, $redirectUrl) + protected function sendEmailConfirmation(CustomerInterface $customer, $redirectUrl, $extensions = []) { try { $hash = $this->customerRegistry->retrieveSecureData($customer->getId())->getPasswordHash(); @@ -911,7 +943,14 @@ protected function sendEmailConfirmation(CustomerInterface $customer, $redirectU } elseif ($hash == '') { $templateType = self::NEW_ACCOUNT_EMAIL_REGISTERED_NO_PASSWORD; } - $this->getEmailNotification()->newAccount($customer, $templateType, $redirectUrl, $customer->getStoreId()); + $this->getEmailNotification()->newAccount( + $customer, + $templateType, + $redirectUrl, + $customer->getStoreId(), + null, + $extensions + ); } catch (MailException $e) { // If we are not able to send a new account email, this should be ignored $this->logger->critical($e); @@ -947,14 +986,17 @@ public function changePasswordById($customerId, $currentPassword, $newPassword) } /** - * Change customer password + * Change customer password. * * @param CustomerInterface $customer * @param string $currentPassword * @param string $newPassword * @return bool true on success * @throws InputException + * @throws InputMismatchException * @throws InvalidEmailOrPasswordException + * @throws LocalizedException + * @throws NoSuchEntityException * @throws UserLockedException */ private function changePasswordForCustomer($customer, $currentPassword, $newPassword) @@ -972,6 +1014,7 @@ private function changePasswordForCustomer($customer, $currentPassword, $newPass $this->checkPasswordStrength($newPassword); $customerSecure->setPasswordHash($this->createPasswordHash($newPassword)); $this->destroyCustomerSessions($customer->getId()); + $this->disableAddressValidation($customer); $this->customerRepository->save($customer); return true; @@ -1063,10 +1106,11 @@ public function isCustomerInStore($customerWebsiteId, $storeId) * @param int $customerId * @param string $resetPasswordLinkToken * @return bool - * @throws \Magento\Framework\Exception\State\InputMismatchException If token is mismatched - * @throws \Magento\Framework\Exception\State\ExpiredException If token is expired - * @throws \Magento\Framework\Exception\InputException If token or customer id is invalid - * @throws \Magento\Framework\Exception\NoSuchEntityException If customer doesn't exist + * @throws ExpiredException + * @throws InputException + * @throws InputMismatchException + * @throws LocalizedException + * @throws NoSuchEntityException */ private function validateResetPasswordToken($customerId, $resetPasswordLinkToken) { @@ -1156,6 +1200,8 @@ protected function sendNewAccountEmail( * * @param CustomerInterface $customer * @return $this + * @throws LocalizedException + * @throws NoSuchEntityException * @deprecated 100.1.0 */ protected function sendPasswordResetNotificationEmail($customer) @@ -1169,6 +1215,7 @@ protected function sendPasswordResetNotificationEmail($customer) * @param CustomerInterface $customer * @param int|string|null $defaultStoreId * @return int + * @throws LocalizedException * @deprecated 100.1.0 */ protected function getWebsiteStoreId($customer, $defaultStoreId = null) @@ -1182,6 +1229,8 @@ protected function getWebsiteStoreId($customer, $defaultStoreId = null) } /** + * Get email template types + * * @return array * @deprecated 100.1.0 */ @@ -1215,6 +1264,7 @@ protected function getTemplateTypes() * @param int|null $storeId * @param string $email * @return $this + * @throws MailException * @deprecated 100.1.0 */ protected function sendEmailTemplate( @@ -1313,14 +1363,15 @@ public function isResetPasswordLinkTokenExpired($rpToken, $rpTokenCreatedAt) } /** - * Change reset password link token - * - * Stores new reset password link token + * Set a new reset password link token. * * @param CustomerInterface $customer * @param string $passwordLinkToken * @return bool * @throws InputException + * @throws InputMismatchException + * @throws LocalizedException + * @throws NoSuchEntityException */ public function changeResetPasswordLinkToken($customer, $passwordLinkToken) { @@ -1338,8 +1389,10 @@ public function changeResetPasswordLinkToken($customer, $passwordLinkToken) $customerSecure->setRpTokenCreatedAt( $this->dateTimeFactory->create()->format(DateTime::DATETIME_PHP_FORMAT) ); + $this->setIgnoreValidationFlag($customer); $this->customerRepository->save($customer); } + return true; } @@ -1348,6 +1401,8 @@ public function changeResetPasswordLinkToken($customer, $passwordLinkToken) * * @param CustomerInterface $customer * @return $this + * @throws LocalizedException + * @throws NoSuchEntityException * @deprecated 100.1.0 */ public function sendPasswordReminderEmail($customer) @@ -1375,6 +1430,8 @@ public function sendPasswordReminderEmail($customer) * * @param CustomerInterface $customer * @return $this + * @throws LocalizedException + * @throws NoSuchEntityException * @deprecated 100.1.0 */ public function sendPasswordResetConfirmationEmail($customer) @@ -1419,6 +1476,7 @@ protected function getAddressById(CustomerInterface $customer, $addressId) * * @param CustomerInterface $customer * @return Data\CustomerSecure + * @throws NoSuchEntityException * @deprecated 100.1.0 */ protected function getFullCustomerObject($customer) @@ -1444,6 +1502,20 @@ public function getPasswordHash($password) return $this->encryptor->getHash($password); } + /** + * Disable Customer Address Validation + * + * @param CustomerInterface $customer + * @throws NoSuchEntityException + */ + private function disableAddressValidation($customer) + { + foreach ($customer->getAddresses() as $address) { + $addressModel = $this->addressRegistry->retrieve($address->getId()); + $addressModel->setShouldIgnoreValidation(true); + } + } + /** * Get email notification * @@ -1489,4 +1561,15 @@ private function destroyCustomerSessions($customerId) $this->saveHandler->destroy($sessionId); } } + + /** + * Set ignore_validation_flag for reset password flow to skip unnecessary address and customer validation. + * + * @param Customer $customer + * @return void + */ + private function setIgnoreValidationFlag(Customer $customer) + { + $customer->setData('ignore_validation_flag', true); + } } diff --git a/app/code/Magento/Customer/Model/Config/Backend/Address/Street.php b/app/code/Magento/Customer/Model/Config/Backend/Address/Street.php index fc0fa3ebc073d..40a10a1db0935 100644 --- a/app/code/Magento/Customer/Model/Config/Backend/Address/Street.php +++ b/app/code/Magento/Customer/Model/Config/Backend/Address/Street.php @@ -87,15 +87,20 @@ public function afterDelete() { $result = parent::afterDelete(); - if ($this->getScope() == \Magento\Store\Model\ScopeInterface::SCOPE_WEBSITES) { - $attribute = $this->_eavConfig->getAttribute('customer_address', 'street'); - $website = $this->_storeManager->getWebsite($this->getScopeCode()); - $attribute->setWebsite($website); - $attribute->load($attribute->getId()); - $attribute->setData('scope_multiline_count', null); - $attribute->save(); - } + $attribute = $this->_eavConfig->getAttribute('customer_address', 'street'); + switch ($this->getScope()) { + case \Magento\Store\Model\ScopeInterface::SCOPE_WEBSITES: + $website = $this->_storeManager->getWebsite($this->getScopeCode()); + $attribute->setWebsite($website); + $attribute->load($attribute->getId()); + $attribute->setData('scope_multiline_count', null); + break; + case ScopeConfigInterface::SCOPE_TYPE_DEFAULT: + $attribute->setData('multiline_count', 2); + break; + } + $attribute->save(); return $result; } } diff --git a/app/code/Magento/Customer/Model/EmailNotification.php b/app/code/Magento/Customer/Model/EmailNotification.php index 740dc33b72710..679c108ab1d30 100644 --- a/app/code/Magento/Customer/Model/EmailNotification.php +++ b/app/code/Magento/Customer/Model/EmailNotification.php @@ -17,6 +17,8 @@ use Magento\Framework\Exception\LocalizedException; /** + * Class for notification customer. + * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class EmailNotification implements EmailNotificationInterface @@ -63,6 +65,8 @@ class EmailNotification implements EmailNotificationInterface self::NEW_ACCOUNT_EMAIL_CONFIRMATION => self::XML_PATH_CONFIRM_EMAIL_TEMPLATE, ]; + const CUSTOMER_CONFIRM_URL = 'customer/account/confirm/'; + /**#@-*/ /**#@-*/ @@ -362,6 +366,7 @@ public function passwordResetConfirmation(CustomerInterface $customer) * @param string $backUrl * @param string $storeId * @param string $sendemailStoreId + * @param array $extensions * @return void * @throws LocalizedException */ @@ -370,7 +375,8 @@ public function newAccount( $type = self::NEW_ACCOUNT_EMAIL_REGISTERED, $backUrl = '', $storeId = 0, - $sendemailStoreId = null + $sendemailStoreId = null, + $extensions = [] ) { $types = self::TEMPLATE_TYPES; @@ -386,11 +392,26 @@ public function newAccount( $customerEmailData = $this->getFullCustomerObject($customer); + $templateVars = [ + 'customer' => $customerEmailData, + 'back_url' => $backUrl, + 'store' => $store, + ]; + if ($type == self::NEW_ACCOUNT_EMAIL_CONFIRMATION) { + if (empty($extensions)) { + $templateVars['url'] = self::CUSTOMER_CONFIRM_URL; + $templateVars['extensions'] = $extensions; + } else { + $templateVars['url'] = $extensions['url']; + $templateVars['extensions'] = $extensions['extension_info']; + } + } + $this->sendEmailTemplate( $customer, $types[$type], self::XML_PATH_REGISTER_EMAIL_IDENTITY, - ['customer' => $customerEmailData, 'back_url' => $backUrl, 'store' => $store], + $templateVars, $storeId ); } diff --git a/app/code/Magento/Customer/Model/Metadata/AttributeMetadataCache.php b/app/code/Magento/Customer/Model/Metadata/AttributeMetadataCache.php index 5a46fdb9defc4..71f0b393e4a5d 100644 --- a/app/code/Magento/Customer/Model/Metadata/AttributeMetadataCache.php +++ b/app/code/Magento/Customer/Model/Metadata/AttributeMetadataCache.php @@ -12,6 +12,7 @@ use Magento\Framework\App\Cache\StateInterface; use Magento\Framework\App\CacheInterface; use Magento\Framework\Serialize\SerializerInterface; +use Magento\Store\Model\StoreManagerInterface; /** * Cache for attribute metadata @@ -53,6 +54,11 @@ class AttributeMetadataCache */ private $serializer; + /** + * @var StoreManagerInterface + */ + private $storeManager; + /** * Constructor * @@ -60,17 +66,21 @@ class AttributeMetadataCache * @param StateInterface $state * @param SerializerInterface $serializer * @param AttributeMetadataHydrator $attributeMetadataHydrator + * @param StoreManagerInterface|null $storeManager */ public function __construct( CacheInterface $cache, StateInterface $state, SerializerInterface $serializer, - AttributeMetadataHydrator $attributeMetadataHydrator + AttributeMetadataHydrator $attributeMetadataHydrator, + StoreManagerInterface $storeManager = null ) { $this->cache = $cache; $this->state = $state; $this->serializer = $serializer; $this->attributeMetadataHydrator = $attributeMetadataHydrator; + $this->storeManager = $storeManager ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(StoreManagerInterface::class); } /** @@ -82,11 +92,12 @@ public function __construct( */ public function load($entityType, $suffix = '') { - if (isset($this->attributes[$entityType . $suffix])) { - return $this->attributes[$entityType . $suffix]; + $storeId = $this->storeManager->getStore()->getId(); + if (isset($this->attributes[$entityType . $suffix . $storeId])) { + return $this->attributes[$entityType . $suffix . $storeId]; } if ($this->isEnabled()) { - $cacheKey = self::ATTRIBUTE_METADATA_CACHE_PREFIX . $entityType . $suffix; + $cacheKey = self::ATTRIBUTE_METADATA_CACHE_PREFIX . $entityType . $suffix . $storeId; $serializedData = $this->cache->load($cacheKey); if ($serializedData) { $attributesData = $this->serializer->unserialize($serializedData); @@ -94,7 +105,7 @@ public function load($entityType, $suffix = '') foreach ($attributesData as $key => $attributeData) { $attributes[$key] = $this->attributeMetadataHydrator->hydrate($attributeData); } - $this->attributes[$entityType . $suffix] = $attributes; + $this->attributes[$entityType . $suffix . $storeId] = $attributes; return $attributes; } } @@ -111,9 +122,10 @@ public function load($entityType, $suffix = '') */ public function save($entityType, array $attributes, $suffix = '') { - $this->attributes[$entityType . $suffix] = $attributes; + $storeId = $this->storeManager->getStore()->getId(); + $this->attributes[$entityType . $suffix . $storeId] = $attributes; if ($this->isEnabled()) { - $cacheKey = self::ATTRIBUTE_METADATA_CACHE_PREFIX . $entityType . $suffix; + $cacheKey = self::ATTRIBUTE_METADATA_CACHE_PREFIX . $entityType . $suffix . $storeId; $attributesData = []; foreach ($attributes as $key => $attribute) { $attributesData[$key] = $this->attributeMetadataHydrator->extract($attribute); diff --git a/app/code/Magento/Customer/Model/Metadata/CustomerMetadata.php b/app/code/Magento/Customer/Model/Metadata/CustomerMetadata.php index 7ed806e657e82..a9753fc810a99 100644 --- a/app/code/Magento/Customer/Model/Metadata/CustomerMetadata.php +++ b/app/code/Magento/Customer/Model/Metadata/CustomerMetadata.php @@ -33,16 +33,26 @@ class CustomerMetadata implements CustomerMetadataInterface */ private $attributeMetadataDataProvider; + /** + * List of system attributes which should be available to the clients. + * + * @var string[] + */ + private $systemAttributes; + /** * @param AttributeMetadataConverter $attributeMetadataConverter * @param AttributeMetadataDataProvider $attributeMetadataDataProvider + * @param string[] $systemAttributes */ public function __construct( AttributeMetadataConverter $attributeMetadataConverter, - AttributeMetadataDataProvider $attributeMetadataDataProvider + AttributeMetadataDataProvider $attributeMetadataDataProvider, + array $systemAttributes = [] ) { $this->attributeMetadataConverter = $attributeMetadataConverter; $this->attributeMetadataDataProvider = $attributeMetadataDataProvider; + $this->systemAttributes = $systemAttributes; } /** @@ -116,7 +126,7 @@ public function getAllAttributesMetadata() } /** - * {@inheritdoc} + * @inheritdoc */ public function getCustomAttributesMetadata($dataObjectClassName = self::DATA_INTERFACE_NAME) { @@ -134,9 +144,10 @@ public function getCustomAttributesMetadata($dataObjectClassName = self::DATA_IN $isDataObjectMethod = isset($this->customerDataObjectMethods['get' . $camelCaseKey]) || isset($this->customerDataObjectMethods['is' . $camelCaseKey]); - /** Even though disable_auto_group_change is system attribute, it should be available to the clients */ if (!$isDataObjectMethod - && (!$attributeMetadata->isSystem() || $attributeCode == 'disable_auto_group_change') + && (!$attributeMetadata->isSystem() + || in_array($attributeCode, $this->systemAttributes) + ) ) { $customAttributes[] = $attributeMetadata; } diff --git a/app/code/Magento/Customer/Model/Metadata/Form/AbstractData.php b/app/code/Magento/Customer/Model/Metadata/Form/AbstractData.php index f28cce0ea2ae1..0e4fc68503122 100644 --- a/app/code/Magento/Customer/Model/Metadata/Form/AbstractData.php +++ b/app/code/Magento/Customer/Model/Metadata/Form/AbstractData.php @@ -1,7 +1,5 @@ <?php /** - * Form Element Abstract Data Model - * * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ @@ -14,6 +12,8 @@ use Magento\Framework\Validator\EmailAddress; /** + * Form Element Abstract Data Model. + * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ abstract class AbstractData @@ -138,7 +138,8 @@ public function setRequestScope($scope) } /** - * Set scope visibility + * Set scope visibility. + * * Search value only in scope or search value in scope and global * * @param boolean $flag @@ -283,9 +284,13 @@ protected function _validateInputRule($value) ); if (!is_null($inputValidation)) { + $allowWhiteSpace = false; switch ($inputValidation) { + case 'alphanum-with-spaces': + $allowWhiteSpace = true; + // continue to alphanumeric validation case 'alphanumeric': - $validator = new \Zend_Validate_Alnum(true); + $validator = new \Zend_Validate_Alnum($allowWhiteSpace); $validator->setMessage(__('"%1" invalid type entered.', $label), \Zend_Validate_Alnum::INVALID); $validator->setMessage( __('"%1" contains non-alphabetic or non-numeric characters.', $label), diff --git a/app/code/Magento/Customer/Model/ResourceModel/Address.php b/app/code/Magento/Customer/Model/ResourceModel/Address.php index a52c372310843..8b5c9a08931b5 100644 --- a/app/code/Magento/Customer/Model/ResourceModel/Address.php +++ b/app/code/Magento/Customer/Model/ResourceModel/Address.php @@ -13,7 +13,8 @@ use Magento\Framework\App\ObjectManager; /** - * Class Address + * Customer Address resource model. + * * @package Magento\Customer\Model\ResourceModel * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ @@ -31,8 +32,8 @@ class Address extends \Magento\Eav\Model\Entity\VersionControl\AbstractEntity /** * @param \Magento\Eav\Model\Entity\Context $context - * @param \Magento\Framework\Model\ResourceModel\Db\VersionControl\Snapshot $entitySnapshot, - * @param \Magento\Framework\Model\ResourceModel\Db\VersionControl\RelationComposite $entityRelationComposite, + * @param \Magento\Framework\Model\ResourceModel\Db\VersionControl\Snapshot $entitySnapshot + * @param \Magento\Framework\Model\ResourceModel\Db\VersionControl\RelationComposite $entityRelationComposite * @param \Magento\Framework\Validator\Factory $validatorFactory * @param \Magento\Customer\Api\CustomerRepositoryInterface $customerRepository * @param array $data @@ -90,7 +91,7 @@ protected function _beforeSave(\Magento\Framework\DataObject $address) } /** - * Validate customer address entity + * Validate customer address entity. * * @param \Magento\Framework\DataObject $address * @return void @@ -98,6 +99,9 @@ protected function _beforeSave(\Magento\Framework\DataObject $address) */ protected function _validate($address) { + if ($address->getDataByKey('should_ignore_validation')) { + return; + }; $validator = $this->_validatorFactory->createValidator('customer_address', 'save'); if (!$validator->isValid($address)) { diff --git a/app/code/Magento/Customer/Model/ResourceModel/Customer.php b/app/code/Magento/Customer/Model/ResourceModel/Customer.php index 7e5f9d51549ec..daacb2655f588 100644 --- a/app/code/Magento/Customer/Model/ResourceModel/Customer.php +++ b/app/code/Magento/Customer/Model/ResourceModel/Customer.php @@ -11,7 +11,7 @@ use Magento\Framework\Exception\AlreadyExistsException; /** - * Customer entity resource model + * Customer entity resource model. * * @api * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -92,7 +92,7 @@ protected function _getDefaultAttributes() } /** - * Check customer scope, email and confirmation key before saving + * Check customer scope, email and confirmation key before saving. * * @param \Magento\Framework\DataObject $customer * @return $this @@ -150,7 +150,9 @@ protected function _beforeSave(\Magento\Framework\DataObject $customer) $customer->setConfirmation(null); } - $this->_validate($customer); + if (!$customer->getData('ignore_validation_flag')) { + $this->_validate($customer); + } return $this; } diff --git a/app/code/Magento/Customer/Model/ResourceModel/Customer/Relation.php b/app/code/Magento/Customer/Model/ResourceModel/Customer/Relation.php index e55c5d443c9d1..d55a5c0aea2be 100644 --- a/app/code/Magento/Customer/Model/ResourceModel/Customer/Relation.php +++ b/app/code/Magento/Customer/Model/ResourceModel/Customer/Relation.php @@ -7,12 +7,12 @@ namespace Magento\Customer\Model\ResourceModel\Customer; /** - * Class Relation + * Class to process object relations. */ class Relation implements \Magento\Framework\Model\ResourceModel\Db\VersionControl\RelationInterface { /** - * Save relations for Customer + * Save relations for Customer. * * @param \Magento\Framework\Model\AbstractModel $customer * @return void @@ -23,41 +23,43 @@ public function processRelation(\Magento\Framework\Model\AbstractModel $customer $defaultBillingId = $customer->getData('default_billing'); $defaultShippingId = $customer->getData('default_shipping'); - /** @var \Magento\Customer\Model\Address $address */ - foreach ($customer->getAddresses() as $address) { - if ($address->getData('_deleted')) { - if ($address->getId() == $defaultBillingId) { - $customer->setData('default_billing', null); - } + if (!$customer->getData('ignore_validation_flag')) { + /** @var \Magento\Customer\Model\Address $address */ + foreach ($customer->getAddresses() as $address) { + if ($address->getData('_deleted')) { + if ($address->getId() == $defaultBillingId) { + $customer->setData('default_billing', null); + } - if ($address->getId() == $defaultShippingId) { - $customer->setData('default_shipping', null); - } + if ($address->getId() == $defaultShippingId) { + $customer->setData('default_shipping', null); + } - $removedAddressId = $address->getId(); - $address->delete(); + $removedAddressId = $address->getId(); + $address->delete(); - // Remove deleted address from customer address collection - $customer->getAddressesCollection()->removeItemByKey($removedAddressId); - } else { - $address->setParentId( - $customer->getId() - )->setStoreId( - $customer->getStoreId() - )->setIsCustomerSaveTransaction( - true - )->save(); + // Remove deleted address from customer address collection + $customer->getAddressesCollection()->removeItemByKey($removedAddressId); + } else { + $address->setParentId( + $customer->getId() + )->setStoreId( + $customer->getStoreId() + )->setIsCustomerSaveTransaction( + true + )->save(); - if (($address->getIsPrimaryBilling() || - $address->getIsDefaultBilling()) && $address->getId() != $defaultBillingId - ) { - $customer->setData('default_billing', $address->getId()); - } + if (($address->getIsPrimaryBilling() || + $address->getIsDefaultBilling()) && $address->getId() != $defaultBillingId + ) { + $customer->setData('default_billing', $address->getId()); + } - if (($address->getIsPrimaryShipping() || - $address->getIsDefaultShipping()) && $address->getId() != $defaultShippingId - ) { - $customer->setData('default_shipping', $address->getId()); + if (($address->getIsPrimaryShipping() || + $address->getIsDefaultShipping()) && $address->getId() != $defaultShippingId + ) { + $customer->setData('default_shipping', $address->getId()); + } } } } diff --git a/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php b/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php index 9e745769e2c36..d0efdde3d8c59 100644 --- a/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php +++ b/app/code/Magento/Customer/Model/ResourceModel/CustomerRepository.php @@ -12,6 +12,7 @@ use Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface; use Magento\Customer\Model\CustomerFactory; use Magento\Customer\Model\CustomerRegistry; +use \Magento\Customer\Model\Customer as CustomerModel; use Magento\Customer\Model\Data\CustomerSecureFactory; use Magento\Customer\Model\Customer\NotificationStorage; use Magento\Customer\Model\Delegation\Data\NewOperation; @@ -170,7 +171,8 @@ public function __construct( } /** - * {@inheritdoc} + * @inheritdoc + * * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) * @SuppressWarnings(PHPMD.ExcessiveMethodLength) @@ -240,7 +242,7 @@ public function save(\Magento\Customer\Api\Data\CustomerInterface $customer, $pa $prevCustomerDataArr['default_shipping'] ); } - + $this->setIgnoreValidationFlag($customerArr, $customerModel); $customerModel->save(); $this->customerRegistry->push($customerModel); $customerId = $customerModel->getId(); @@ -253,7 +255,7 @@ public function save(\Magento\Customer\Api\Data\CustomerInterface $customer, $pa $delegatedNewOperation->getCustomer()->getAddresses() ); } - if ($customer->getAddresses() !== null) { + if ($customer->getAddresses() !== null && !$customerModel->getData('ignore_validation_flag')) { if ($customer->getId()) { $existingAddresses = $this->getById($customer->getId())->getAddresses(); $getIdFunc = function ($address) { @@ -365,7 +367,7 @@ public function getList(SearchCriteriaInterface $searchCriteria) ->joinAttribute('billing_telephone', 'customer_address/telephone', 'default_billing', null, 'left') ->joinAttribute('billing_region', 'customer_address/region', 'default_billing', null, 'left') ->joinAttribute('billing_country_id', 'customer_address/country_id', 'default_billing', null, 'left') - ->joinAttribute('company', 'customer_address/company', 'default_billing', null, 'left'); + ->joinAttribute('billing_company', 'customer_address/company', 'default_billing', null, 'left'); $this->collectionProcessor->process($searchCriteria, $collection); @@ -420,4 +422,18 @@ protected function addFilterGroupToCollection(FilterGroup $filterGroup, Collecti $collection->addFieldToFilter($fields); } } + + /** + * Set ignore_validation_flag to skip model validation. + * + * @param array $customerArray + * @param CustomerModel $customerModel + * @return void + */ + private function setIgnoreValidationFlag(array $customerArray, CustomerModel $customerModel) + { + if (isset($customerArray['ignore_validation_flag'])) { + $customerModel->setData('ignore_validation_flag', true); + } + } } diff --git a/app/code/Magento/Customer/Model/Vat.php b/app/code/Magento/Customer/Model/Vat.php index f608a6cf4c11c..123a9eef4b75a 100644 --- a/app/code/Magento/Customer/Model/Vat.php +++ b/app/code/Magento/Customer/Model/Vat.php @@ -179,18 +179,21 @@ public function checkVatNumber($countryCode, $vatNumber, $requesterCountryCode = return $gatewayResponse; } + $countryCodeForVatNumber = $this->getCountryCodeForVatNumber($countryCode); + $requesterCountryCodeForVatNumber = $this->getCountryCodeForVatNumber($requesterCountryCode); + try { $soapClient = $this->createVatNumberValidationSoapClient(); $requestParams = []; - $requestParams['countryCode'] = $countryCode; + $requestParams['countryCode'] = $countryCodeForVatNumber; $vatNumberSanitized = $this->isCountryInEU($countryCode) - ? str_replace([' ', '-', $countryCode], ['', '', ''], $vatNumber) + ? str_replace([' ', '-', $countryCodeForVatNumber], ['', '', ''], $vatNumber) : str_replace([' ', '-'], ['', ''], $vatNumber); $requestParams['vatNumber'] = $vatNumberSanitized; - $requestParams['requesterCountryCode'] = $requesterCountryCode; + $requestParams['requesterCountryCode'] = $requesterCountryCodeForVatNumber; $reqVatNumSanitized = $this->isCountryInEU($requesterCountryCode) - ? str_replace([' ', '-', $requesterCountryCode], ['', '', ''], $requesterVatNumber) + ? str_replace([' ', '-', $requesterCountryCodeForVatNumber], ['', '', ''], $requesterVatNumber) : str_replace([' ', '-'], ['', ''], $requesterVatNumber); $requestParams['requesterVatNumber'] = $reqVatNumSanitized; // Send request to service @@ -301,4 +304,22 @@ public function isCountryInEU($countryCode, $storeId = null) ); return in_array($countryCode, $euCountries); } + + /** + * Returns the country code to use in the VAT number which is not always the same as the normal country code + * + * @param string $countryCode + * @return string + */ + private function getCountryCodeForVatNumber(string $countryCode): string + { + // Greece uses a different code for VAT numbers then its country code + // See: http://ec.europa.eu/taxation_customs/vies/faq.html#item_11 + // And https://en.wikipedia.org/wiki/VAT_identification_number: + // "The full identifier starts with an ISO 3166-1 alpha-2 (2 letters) country code + // (except for Greece, which uses the ISO 639-1 language code EL for the Greek language, + // instead of its ISO 3166-1 alpha-2 country code GR)" + + return $countryCode === 'GR' ? 'EL' : $countryCode; + } } diff --git a/app/code/Magento/Customer/Observer/UpgradeCustomerPasswordObserver.php b/app/code/Magento/Customer/Observer/UpgradeCustomerPasswordObserver.php index eb7e81009c92c..831506af17cf6 100644 --- a/app/code/Magento/Customer/Observer/UpgradeCustomerPasswordObserver.php +++ b/app/code/Magento/Customer/Observer/UpgradeCustomerPasswordObserver.php @@ -6,11 +6,15 @@ namespace Magento\Customer\Observer; +use Magento\Customer\Api\Data\CustomerInterface; use Magento\Framework\Encryption\EncryptorInterface; use Magento\Framework\Event\ObserverInterface; use Magento\Customer\Api\CustomerRepositoryInterface; use Magento\Customer\Model\CustomerRegistry; +/** + * Observer to execute upgrading customer password hash when customer has logged in. + */ class UpgradeCustomerPasswordObserver implements ObserverInterface { /** @@ -46,7 +50,7 @@ public function __construct( } /** - * Upgrade customer password hash when customer has logged in + * Upgrade customer password hash when customer has logged in. * * @param \Magento\Framework\Event\Observer $observer * @return void @@ -61,7 +65,20 @@ public function execute(\Magento\Framework\Event\Observer $observer) if (!$this->encryptor->validateHashVersion($customerSecure->getPasswordHash(), true)) { $customerSecure->setPasswordHash($this->encryptor->getHash($password, true)); + // No need to validate customer and customer address while upgrading customer password + $this->setIgnoreValidationFlag($customer); $this->customerRepository->save($customer); } } + + /** + * Set ignore_validation_flag to skip unnecessary address and customer validation. + * + * @param CustomerInterface $customer + * @return void + */ + private function setIgnoreValidationFlag(CustomerInterface $customer) + { + $customer->setData('ignore_validation_flag', true); + } } diff --git a/app/code/Magento/Customer/Test/Mftf/ActionGroup/AdminCreateCustomerWithWebsiteAndStoreViewActionGroup.xml b/app/code/Magento/Customer/Test/Mftf/ActionGroup/AdminCreateCustomerWithWebsiteAndStoreViewActionGroup.xml new file mode 100644 index 0000000000000..8b908fa1456a9 --- /dev/null +++ b/app/code/Magento/Customer/Test/Mftf/ActionGroup/AdminCreateCustomerWithWebsiteAndStoreViewActionGroup.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminCreateCustomerWithWebsiteAndStoreViewActionGroup"> + <arguments> + <argument name="customer"/> + <argument name="address"/> + <argument name="websiteName" type="string"/> + <argument name="storeViewName" type="string"/> + </arguments> + <amOnPage url="{{AdminCustomerPage.url}}" stepKey="goToCustomersPage"/> + <click selector="{{AdminCustomerGridMainActionsSection.addNewCustomer}}" stepKey="addNewCustomer"/> + <selectOption selector="{{AdminCustomerAccountInformationSection.associateToWebsite}}" userInput="{{websiteName}}" stepKey="selectWebSite"/> + <fillField selector="{{AdminCustomerAccountInformationSection.firstName}}" userInput="{{customer.firstname}}" stepKey="fillFirstName"/> + <fillField selector="{{AdminCustomerAccountInformationSection.lastName}}" userInput="{{customer.lastname}}" stepKey="fillLastName"/> + <fillField selector="{{AdminCustomerAccountInformationSection.email}}" userInput="{{customer.email}}" stepKey="fillEmail"/> + <selectOption selector="{{AdminCustomerAccountInformationSection.storeView}}" userInput="{{storeViewName}}" stepKey="selectStoreView"/> + <scrollToTopOfPage stepKey="scrollToTopOfThePage"/> + <click selector="{{AdminCustomerAccountInformationSection.addressesButton}}" stepKey="goToAddresses"/> + <waitForPageLoad stepKey="waitForAddresses"/> + <click selector="{{AdminCustomerEditAddressesSection.addNewAddress}}" stepKey="clickOnAddNewAddress"/> + <waitForPageLoad stepKey="waitForAddressFields"/> + <click selector="{{AdminCustomerEditAddressesSection.defaultBillingAddress}}" stepKey="tickBillingAddress"/> + <click selector="{{AdminCustomerEditAddressesSection.defaultShippingAddress}}" stepKey="tickShippingAddress"/> + <fillField selector="{{AdminCustomerEditAddressesSection.firstName}}" userInput="{{address.firstname}}" stepKey="fillFirstNameForAddress"/> + <fillField selector="{{AdminCustomerEditAddressesSection.lastName}}" userInput="{{address.lastname}}" stepKey="fillLastNameForAddress"/> + <fillField selector="{{AdminCustomerEditAddressesSection.streetAddress}}" userInput="{{address.street[0]}}" stepKey="fillStreetAddress"/> + <fillField selector="{{AdminCustomerEditAddressesSection.city}}" userInput="{{address.city}}" stepKey="fillCity"/> + <selectOption selector="{{AdminCustomerEditAddressesSection.country}}" userInput="{{address.country}}" stepKey="selectCountry"/> + <selectOption selector="{{AdminCustomerEditAddressesSection.state}}" userInput="{{address.state}}" stepKey="selectState"/> + <fillField selector="{{AdminCustomerEditAddressesSection.zip}}" userInput="{{address.postcode}}" stepKey="fillZip"/> + <fillField selector="{{AdminCustomerEditAddressesSection.phoneNumber}}" userInput="{{address.telephone}}" stepKey="fillPhoneNumber"/> + <click selector="{{AdminMainActionsSection.save}}" stepKey="save"/> + <see userInput="You saved the customer." stepKey="seeSuccessMessage"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Customer/Test/Mftf/ActionGroup/CustomerActionGroup.xml b/app/code/Magento/Customer/Test/Mftf/ActionGroup/CustomerActionGroup.xml index 6a253a6053076..959af9f70db29 100644 --- a/app/code/Magento/Customer/Test/Mftf/ActionGroup/CustomerActionGroup.xml +++ b/app/code/Magento/Customer/Test/Mftf/ActionGroup/CustomerActionGroup.xml @@ -13,7 +13,6 @@ <argument name="customer" defaultValue="customer"/> </arguments> <amOnPage stepKey="loginPage" url="customer/account/login/"/> - <waitForPageLoad stepKey="pageLoadBeforeLogin"/> <fillField stepKey="fillEmail" userInput="{{customer.email}}" selector="{{StorefrontCustomerSignInFormSection.emailField}}"/> <fillField stepKey="fillPassword" userInput="{{customer.password}}" selector="{{StorefrontCustomerSignInFormSection.passwordField}}"/> <click stepKey="clickSignInAccountButton" selector="{{StorefrontCustomerSignInFormSection.signInAccountButton}}"/> @@ -21,6 +20,5 @@ <actionGroup name="CustomerLogoutStorefrontActionGroup"> <amOnPage url="customer/account/logout/" stepKey="storefrontSignOut"/> - <waitForPageLoad time="30" stepKey="waitForLogOut"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/Customer/Test/Mftf/ActionGroup/OpenEditCustomerFromAdminActionGroup.xml b/app/code/Magento/Customer/Test/Mftf/ActionGroup/OpenEditCustomerFromAdminActionGroup.xml index 3d6e0fb54b054..3b7aab22f749e 100644 --- a/app/code/Magento/Customer/Test/Mftf/ActionGroup/OpenEditCustomerFromAdminActionGroup.xml +++ b/app/code/Magento/Customer/Test/Mftf/ActionGroup/OpenEditCustomerFromAdminActionGroup.xml @@ -6,18 +6,17 @@ */ --> <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/actionGroupSchema.xsd"> - <actionGroup name="OpenEditCustomerFromAdminActionGroup"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="OpenEditCustomerFromAdminActionGroup" extends="clearFiltersAdminDataGrid"> <arguments> - <argument name="customer"/> + <argument name="customer" defaultValue="CustomerEntityOne"/> </arguments> - <amOnPage url="{{AdminCustomerPage.url}}" stepKey="navigateToCustomers"/> - <waitForPageLoad stepKey="waitForPageLoad1" /> + <amOnPage url="{{AdminCustomerPage.url}}" before="waitForPageLoad" stepKey="navigateToCustomers"/> <click selector="{{AdminCustomerFiltersSection.filtersButton}}" stepKey="openFilter"/> <fillField userInput="{{customer.email}}" selector="{{AdminCustomerFiltersSection.emailInput}}" stepKey="filterEmail"/> <click selector="{{AdminCustomerFiltersSection.apply}}" stepKey="applyFilter"/> <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskToDisappear"/> <click selector="{{AdminCustomerGridSection.firstRowEditLink}}" stepKey="clickEdit"/> - <waitForPageLoad stepKey="waitForPageLoad2" /> + <waitForPageLoad stepKey="waitForPageLoad" /> </actionGroup> </actionGroups> diff --git a/app/code/Magento/Customer/Test/Mftf/ActionGroup/RemoveCustomerFromAdminActionGroup.xml b/app/code/Magento/Customer/Test/Mftf/ActionGroup/RemoveCustomerFromAdminActionGroup.xml index a53968a920806..3ec06d8353a45 100644 --- a/app/code/Magento/Customer/Test/Mftf/ActionGroup/RemoveCustomerFromAdminActionGroup.xml +++ b/app/code/Magento/Customer/Test/Mftf/ActionGroup/RemoveCustomerFromAdminActionGroup.xml @@ -10,10 +10,9 @@ <!--Delete a customer by Email by filtering grid and using delete action--> <actionGroup name="RemoveCustomerFromAdminActionGroup"> <arguments> - <argument name="customer"/> + <argument name="customer" defaultValue="CustomerEntityOne"/> </arguments> <amOnPage url="{{AdminCustomerPage.url}}" stepKey="navigateToCustomers"/> - <waitForPageLoad stepKey="waitForPageLoad1" /> <conditionalClick selector="{{AdminCustomerFiltersSection.clearFilters}}" dependentSelector="{{AdminCustomerFiltersSection.clearFilters}}" visible="true" stepKey="clickClearFiltersInitial"/> <click selector="{{AdminCustomerFiltersSection.filtersButton}}" stepKey="openFilter"/> <fillField userInput="{{customer.email}}" selector="{{AdminCustomerFiltersSection.emailInput}}" stepKey="filterEmail"/> @@ -28,6 +27,6 @@ <click selector="{{AdminConfirmationModalSection.ok}}" stepKey="confirmProductDelete"/> <see selector="{{AdminCustomerMessagesSection.successMessage}}" userInput="A total of 1 record(s) were deleted." stepKey="seeSuccessMessage"/> <conditionalClick selector="{{AdminCustomerFiltersSection.clearFilters}}" dependentSelector="{{AdminCustomerFiltersSection.clearFilters}}" visible="true" stepKey="clickClearFilters"/> - <waitForPageLoad stepKey="waitForPageLoad2"/> + <waitForPageLoad stepKey="waitForPageLoad"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/Customer/Test/Mftf/ActionGroup/SignUpNewUserFromStorefrontActionGroup.xml b/app/code/Magento/Customer/Test/Mftf/ActionGroup/SignUpNewUserFromStorefrontActionGroup.xml index a8ee604edee0a..d86591b799a33 100644 --- a/app/code/Magento/Customer/Test/Mftf/ActionGroup/SignUpNewUserFromStorefrontActionGroup.xml +++ b/app/code/Magento/Customer/Test/Mftf/ActionGroup/SignUpNewUserFromStorefrontActionGroup.xml @@ -9,10 +9,9 @@ xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/actionGroupSchema.xsd"> <actionGroup name="SignUpNewUserFromStorefrontActionGroup"> <arguments> - <argument name="Customer"/> + <argument name="Customer" defaultValue="CustomerEntityOne"/> </arguments> <amOnPage url="{{StorefrontHomePage.url}}" stepKey="amOnStorefrontPage"/> - <waitForPageLoad stepKey="waitForStorefrontPage"/> <click selector="{{StorefrontPanelHeaderSection.createAnAccountLink}}" stepKey="clickOnCreateAccountLink"/> <fillField userInput="{{Customer.firstname}}" selector="{{StorefrontCustomerCreateFormSection.firstnameField}}" stepKey="fillFirstName"/> <fillField userInput="{{Customer.lastname}}" selector="{{StorefrontCustomerCreateFormSection.lastnameField}}" stepKey="fillLastName"/> diff --git a/app/code/Magento/Customer/Test/Mftf/ActionGroup/StorefrontCustomerAccountActionGroup.xml b/app/code/Magento/Customer/Test/Mftf/ActionGroup/StorefrontCustomerAccountActionGroup.xml new file mode 100644 index 0000000000000..50a238323e331 --- /dev/null +++ b/app/code/Magento/Customer/Test/Mftf/ActionGroup/StorefrontCustomerAccountActionGroup.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="StorefrontCustomerAccountCheckTab"> + <arguments> + <argument name="tabName" type="string"/> + </arguments> + + <see selector="{{StorefrontCustomerSidebarSection.sidebarTab(tabName)}}" userInput="{{tabName}}" stepKey="checkTabExists"/> + <click selector="{{StorefrontCustomerSidebarSection.sidebarTab(tabName)}}" stepKey="clickToOpenTab"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + <see selector="{{StorefrontHeaderSection.mainTitle}}" userInput="{{tabName}}" stepKey="checkTabTitle"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Customer/Test/Mftf/ActionGroup/StorefrontCustomerActionGroup.xml b/app/code/Magento/Customer/Test/Mftf/ActionGroup/StorefrontCustomerActionGroup.xml new file mode 100644 index 0000000000000..fc5c1b881752e --- /dev/null +++ b/app/code/Magento/Customer/Test/Mftf/ActionGroup/StorefrontCustomerActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="CustomerLogoutStorefrontByMenuItemsActionGroup"> + <conditionalClick selector="{{StorefrontPanelHeaderSection.customerWelcome}}" + dependentSelector="{{StorefrontPanelHeaderSection.customerWelcomeMenu}}" + visible="false" + stepKey="clickHeaderCustomerMenuButton" /> + <click selector="{{StorefrontPanelHeaderSection.customerLogoutLink}}" stepKey="clickSignOutButton" /> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Customer/Test/Mftf/Data/AddressData.xml b/app/code/Magento/Customer/Test/Mftf/Data/AddressData.xml index c8ba70c7f8b50..573767a12361a 100644 --- a/app/code/Magento/Customer/Test/Mftf/Data/AddressData.xml +++ b/app/code/Magento/Customer/Test/Mftf/Data/AddressData.xml @@ -67,6 +67,7 @@ <data key="state">California</data> <data key="postcode">90230</data> <data key="country_id">US</data> + <data key="country">United States</data> <data key="default_billing">Yes</data> <data key="default_shipping">Yes</data> <requiredEntity type="region">RegionCA</requiredEntity> @@ -85,6 +86,7 @@ <data key="state">New York</data> <data key="postcode">11001</data> <data key="country_id">US</data> + <data key="country">United States</data> <data key="default_billing">Yes</data> <data key="default_shipping">Yes</data> <requiredEntity type="region">RegionNY</requiredEntity> @@ -100,6 +102,15 @@ <data key="state"></data> <data key="postcode">SE1 7RW</data> <data key="country_id">GB</data> + <data key="country">United Kingdom</data> <data key="telephone">444-44-444-44</data> </entity> + <entity name="US_Default_Billing_Address_TX" type="address" extends="US_Address_TX"> + <data key="default_billing">false</data> + <data key="default_shipping">true</data> + </entity> + <entity name="US_Default_Shipping_Address_CA" type="address" extends="US_Address_CA"> + <data key="default_billing">true</data> + <data key="default_shipping">false</data> + </entity> </entities> diff --git a/app/code/Magento/Customer/Test/Mftf/Data/CustomerConfigData.xml b/app/code/Magento/Customer/Test/Mftf/Data/CustomerConfigData.xml new file mode 100644 index 0000000000000..0ec9f79ecee52 --- /dev/null +++ b/app/code/Magento/Customer/Test/Mftf/Data/CustomerConfigData.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <entity name="CustomerAccountSharingDefault" type="customer_account_sharing_config"> + <requiredEntity type="account_share_scope_value">CustomerAccountSharingPerWebsite</requiredEntity> + </entity> + <entity name="CustomerAccountSharingPerWebsite" type="account_share_scope_value"> + <data key="value">1</data> + </entity> + + <entity name="CustomerAccountSharingGlobal" type="customer_account_sharing_config"> + <requiredEntity type="account_share_scope_value">GlobalCustomerAccountSharing</requiredEntity> + </entity> + <entity name="GlobalCustomerAccountSharing" type="account_share_scope_value"> + <data key="value">0</data> + </entity> +</entities> \ No newline at end of file diff --git a/app/code/Magento/Customer/Test/Mftf/Data/CustomerData.xml b/app/code/Magento/Customer/Test/Mftf/Data/CustomerData.xml index 74da84c01b861..a8b8cece39fad 100644 --- a/app/code/Magento/Customer/Test/Mftf/Data/CustomerData.xml +++ b/app/code/Magento/Customer/Test/Mftf/Data/CustomerData.xml @@ -33,7 +33,7 @@ <!--requiredEntity type="extension_attribute">ExtensionAttributeSimple</requiredEntity--> </entity> <entity name="Simple_US_Customer" type="customer"> - <data key="group_id">0</data> + <data key="group_id">1</data> <data key="default_billing">true</data> <data key="default_shipping">true</data> <data key="email" unique="prefix">John.Doe@example.com</data> @@ -74,4 +74,16 @@ <data key="website_id">0</data> <requiredEntity type="address">US_Address_NY</requiredEntity> </entity> + <entity name="Customer_With_Different_Default_Billing_Shipping_Addresses" type="customer"> + <data key="group_id">1</data> + <data key="email" unique="prefix">John.Doe@example.com</data> + <data key="firstname">John</data> + <data key="lastname">Doe</data> + <data key="fullname">John Doe</data> + <data key="password">pwdTest123!</data> + <data key="store_id">0</data> + <data key="website_id">0</data> + <requiredEntity type="address">US_Default_Billing_Address_TX</requiredEntity> + <requiredEntity type="address">US_Default_Shipping_Address_CA</requiredEntity> + </entity> </entities> diff --git a/app/code/Magento/Customer/Test/Mftf/Metadata/customer_config_account_sharing-meta.xml b/app/code/Magento/Customer/Test/Mftf/Metadata/customer_config_account_sharing-meta.xml new file mode 100644 index 0000000000000..53f5ddea27cbd --- /dev/null +++ b/app/code/Magento/Customer/Test/Mftf/Metadata/customer_config_account_sharing-meta.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<operations xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataOperation.xsd"> + <operation name="CustomerAccountShareConfig" dataType="customer_account_sharing_config" type="create" auth="adminFormKey" url="/admin/system_config/save/section/customer/" + successRegex="/messages-message-success/" returnRegex="" method="POST"> + <object key="groups" dataType="customer_account_sharing_config"> + <object key="account_share" dataType="customer_account_sharing_config"> + <object key="fields" dataType="customer_account_sharing_config"> + <object key="scope" dataType="account_share_scope_value"> + <field key="value">string</field> + </object> + </object> + </object> + </object> + </operation> +</operations> \ No newline at end of file diff --git a/app/code/Magento/Customer/Test/Mftf/Page/StorefrontCustomerOrderPage.xml b/app/code/Magento/Customer/Test/Mftf/Page/StorefrontCustomerOrderPage.xml deleted file mode 100644 index 4e2ff7e907aa2..0000000000000 --- a/app/code/Magento/Customer/Test/Mftf/Page/StorefrontCustomerOrderPage.xml +++ /dev/null @@ -1,14 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- - /** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ ---> - -<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/PageObject.xsd"> - <page name="StorefrontCustomerOrderPage" url="/sales/order/history/" area="storefront" module="Magento_Customer"> - <section name="StorefrontCustomerOrderViewSection" /> - </page> -</pages> diff --git a/app/code/Magento/Customer/Test/Mftf/Page/StorefrontCustomerOrderViewPage.xml b/app/code/Magento/Customer/Test/Mftf/Page/StorefrontCustomerOrderViewPage.xml index 3ac90876a7acf..9cd6dc1e69e35 100644 --- a/app/code/Magento/Customer/Test/Mftf/Page/StorefrontCustomerOrderViewPage.xml +++ b/app/code/Magento/Customer/Test/Mftf/Page/StorefrontCustomerOrderViewPage.xml @@ -7,8 +7,9 @@ --> <pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/PageObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> <page name="StorefrontCustomerOrderViewPage" url="sales/order/view/order_id/{{var1}}" area="storefront" module="Magento_Customer" parameterized="true"> <section name="StorefrontCustomerOrderSection" /> + <section name="StorefrontCustomerOrderViewSection" /> </page> -</pages> \ No newline at end of file +</pages> diff --git a/app/code/Magento/Customer/Test/Mftf/Page/StorefrontCustomerOrdersGridPage.xml b/app/code/Magento/Customer/Test/Mftf/Page/StorefrontCustomerOrdersGridPage.xml new file mode 100644 index 0000000000000..effc07d00f408 --- /dev/null +++ b/app/code/Magento/Customer/Test/Mftf/Page/StorefrontCustomerOrdersGridPage.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="StorefrontCustomerOrdersGridPage" url="/sales/order/history/" area="storefront" module="Magento_Customer"> + <section name="StorefrontCustomerOrdersGridSection"/> + </page> +</pages> diff --git a/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerAccountInformationSection.xml b/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerAccountInformationSection.xml index 7eef792f0f048..d651d56504c76 100644 --- a/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerAccountInformationSection.xml +++ b/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerAccountInformationSection.xml @@ -9,6 +9,7 @@ <section name="AdminCustomerAccountInformationSection"> <element name="accountInformationTitle" type="text" selector=".admin__page-nav-title"/> <element name="accountInformationButton" type="text" selector="//a/span[text()='Account Information']"/> + <element name="addressesButton" type="select" selector="//a//span[contains(text(), 'Addresses')]"/> <element name="firstName" type="input" selector="input[name='customer[firstname]']"/> <element name="lastName" type="input" selector="input[name='customer[lastname]']"/> <element name="email" type="input" selector="input[name='customer[email]']"/> diff --git a/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerEditAddressesSection.xml b/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerEditAddressesSection.xml new file mode 100644 index 0000000000000..3c9e14033fb0d --- /dev/null +++ b/app/code/Magento/Customer/Test/Mftf/Section/AdminCustomerEditAddressesSection.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminCustomerEditAddressesSection"> + <element name="addNewAddress" type="button" selector="//span[text()='Add New Addresses']"/> + <element name="defaultBillingAddress" type="button" selector="//label[text()='Default Billing Address']"/> + <element name="defaultShippingAddress" type="button" selector="//label[text()='Default Shipping Address']"/> + <element name="firstName" type="button" selector="input[name*='address'][name*=firstname]"/> + <element name="lastName" type="button" selector="input[name*='address'][name*=lastname]"/> + <element name="streetAddress" type="button" selector="input[name*='address'][name*=street]"/> + <element name="city" type="input" selector="input[name*='address'][name*=city]"/> + <element name="country" type="select" selector="select[name*='address'][name*=country_id]"/> + <element name="state" type="select" selector="select[name*=address][name*=region_id]"/> + <element name="zip" type="input" selector="input[name*=address][name*=postcode]"/> + <element name="phoneNumber" type="input" selector="input[name*=address][name*=telephone]"/> + </section> +</sections> diff --git a/app/code/Magento/Customer/Test/Mftf/Section/StorefrontCustomerOrdersGridSection.xml b/app/code/Magento/Customer/Test/Mftf/Section/StorefrontCustomerOrdersGridSection.xml new file mode 100644 index 0000000000000..419387fd92d1f --- /dev/null +++ b/app/code/Magento/Customer/Test/Mftf/Section/StorefrontCustomerOrdersGridSection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontCustomerOrdersGridSection"> + <element name="viewOrderAction" type="text" selector="//*[@id='my-orders-table']//*[text()='{{orderId}}']/..//a[contains(@class, 'action view')]" parameterized="true"/> + <element name="orderStatus" type="text" selector="//*[@id='my-orders-table']//*[text()='{{orderId}}']/..//td[contains(@class,'status')]" parameterized="true"/> + </section> +</sections> diff --git a/app/code/Magento/Customer/Test/Mftf/Section/StorefrontPanelHeaderSection.xml b/app/code/Magento/Customer/Test/Mftf/Section/StorefrontPanelHeaderSection.xml index bee3adea898aa..fb5edd35a484d 100644 --- a/app/code/Magento/Customer/Test/Mftf/Section/StorefrontPanelHeaderSection.xml +++ b/app/code/Magento/Customer/Test/Mftf/Section/StorefrontPanelHeaderSection.xml @@ -7,8 +7,13 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="StorefrontPanelHeaderSection"> <element name="createAnAccountLink" type="select" selector=".panel.header li:nth-child(3)"/> + <element name="customerWelcome" type="text" selector=".panel.header .customer-welcome"/> + <element name="customerWelcomeMenu" type="text" selector=".panel.header .customer-welcome .customer-menu"/> + <element name="customerLogoutLink" type="text" selector=".panel.header .customer-welcome .customer-menu .authorization-link a" timeout="30"/> + <element name="welcomeMessage" type="text" selector=".greet.welcome span"/> + <element name="notYouLink" type="button" selector=".greet.welcome span a"/> </section> </sections> diff --git a/app/code/Magento/Customer/Test/Unit/Controller/Account/EditPostTest.php b/app/code/Magento/Customer/Test/Unit/Controller/Account/EditPostTest.php index f2860725dbbae..0e8cffc0bf434 100644 --- a/app/code/Magento/Customer/Test/Unit/Controller/Account/EditPostTest.php +++ b/app/code/Magento/Customer/Test/Unit/Controller/Account/EditPostTest.php @@ -19,6 +19,8 @@ use Magento\Framework\Message\ManagerInterface; /** + * Unit tests for Magento\Customer\Controller\Account\EditPost. + * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class EditPostTest extends \PHPUnit\Framework\TestCase @@ -98,6 +100,9 @@ class EditPostTest extends \PHPUnit\Framework\TestCase */ private $customerMapperMock; + /** + * @inheritdoc + */ protected function setUp() { $this->prepareContext(); @@ -707,6 +712,8 @@ protected function prepareContext() } /** + * Executes methods needed for new Customer. + * * @param int $customerId * @param \PHPUnit_Framework_MockObject_MockObject $address * @return \PHPUnit_Framework_MockObject_MockObject @@ -720,9 +727,9 @@ protected function getNewCustomerMock($customerId, $address) ->method('setId') ->with($customerId) ->willReturnSelf(); - $newCustomerMock->expects($this->once()) + $newCustomerMock->expects($this->atLeastOnce()) ->method('getAddresses') - ->willReturn(null); + ->willReturn([]); $newCustomerMock->expects($this->once()) ->method('setAddresses') ->with([$address]) @@ -732,6 +739,8 @@ protected function getNewCustomerMock($customerId, $address) } /** + * Executes methods needed for existing Customer. + * * @param int $customerId * @param \PHPUnit_Framework_MockObject_MockObject $address * @return \PHPUnit_Framework_MockObject_MockObject @@ -741,7 +750,7 @@ protected function getCurrentCustomerMock($customerId, $address) $currentCustomerMock = $this->getMockBuilder(\Magento\Customer\Api\Data\CustomerInterface::class) ->getMockForAbstractClass(); - $currentCustomerMock->expects($this->once()) + $currentCustomerMock->expects($this->atLeastOnce()) ->method('getAddresses') ->willReturn([$address]); diff --git a/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/InlineEditTest.php b/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/InlineEditTest.php index 78d9dd7003522..fe7868ef3eb3f 100644 --- a/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/InlineEditTest.php +++ b/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/InlineEditTest.php @@ -5,10 +5,14 @@ */ namespace Magento\Customer\Test\Unit\Controller\Adminhtml\Index; +use Magento\Customer\Model\AddressRegistry; use Magento\Customer\Model\EmailNotificationInterface; +use Magento\Framework\DataObject; use Magento\Framework\Message\MessageInterface; /** + * Unit tests for Inline customer edit. + * * @SuppressWarnings(PHPMD.TooManyFields) * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ @@ -68,14 +72,27 @@ class InlineEditTest extends \PHPUnit\Framework\TestCase /** @var EmailNotificationInterface|\PHPUnit_Framework_MockObject_MockObject */ private $emailNotification; + /** @var AddressRegistry|\PHPUnit_Framework_MockObject_MockObject */ + private $addressRegistry; + /** @var array */ private $items; + /** + * @inheritdoc + * + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ protected function setUp() { $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); - $this->request = $this->getMockForAbstractClass(\Magento\Framework\App\RequestInterface::class, [], '', false); + $this->request = $this->getMockForAbstractClass( + \Magento\Framework\App\RequestInterface::class, + [], + '', + false + ); $this->messageManager = $this->getMockForAbstractClass( \Magento\Framework\Message\ManagerInterface::class, [], @@ -125,8 +142,12 @@ protected function setUp() '', false ); - $this->logger = $this->getMockForAbstractClass(\Psr\Log\LoggerInterface::class, [], '', false); - + $this->logger = $this->getMockForAbstractClass( + \Psr\Log\LoggerInterface::class, + [], + '', + false + ); $this->emailNotification = $this->getMockBuilder(EmailNotificationInterface::class) ->disableOriginalConstructor() ->getMock(); @@ -138,6 +159,7 @@ protected function setUp() 'messageManager' => $this->messageManager, ] ); + $this->addressRegistry = $this->createMock(\Magento\Customer\Model\AddressRegistry::class); $this->controller = $objectManager->getObject( \Magento\Customer\Controller\Adminhtml\Index\InlineEdit::class, [ @@ -150,6 +172,7 @@ protected function setUp() 'addressDataFactory' => $this->addressDataFactory, 'addressRepository' => $this->addressRepository, 'logger' => $this->logger, + 'addressRegistry' => $this->addressRegistry, ] ); $reflection = new \ReflectionClass(get_class($this->controller)); @@ -204,6 +227,11 @@ protected function prepareMocksForTesting($populateSequence = 0) ->willReturn(12); } + /** + * Prepare mocks for update customers default billing address use case. + * + * @return void + */ protected function prepareMocksForUpdateDefaultBilling() { $this->prepareMocksForProcessAddressData(); @@ -212,12 +240,15 @@ protected function prepareMocksForUpdateDefaultBilling() 'firstname' => 'Firstname', 'lastname' => 'Lastname', ]; - $this->customerData->expects($this->once()) + $this->customerData->expects($this->exactly(2)) ->method('getAddresses') ->willReturn([$this->address]); $this->address->expects($this->once()) ->method('isDefaultBilling') ->willReturn(true); + $this->addressRegistry->expects($this->once()) + ->method('retrieve') + ->willReturn(new DataObject()); $this->dataObjectHelper->expects($this->at(0)) ->method('populateWithArray') ->with( @@ -305,6 +336,11 @@ public function testExecuteWithoutItems() $this->assertSame($this->resultJson, $this->controller->execute()); } + /** + * Unit test for verifying Localized Exception during inline edit. + * + * @return void + */ public function testExecuteLocalizedException() { $exception = new \Magento\Framework\Exception\LocalizedException(__('Exception message')); @@ -312,6 +348,9 @@ public function testExecuteLocalizedException() $this->customerData->expects($this->once()) ->method('getDefaultBilling') ->willReturn(false); + $this->customerData->expects($this->once()) + ->method('getAddresses') + ->willReturn([]); $this->customerRepository->expects($this->once()) ->method('save') ->with($this->customerData) @@ -327,6 +366,11 @@ public function testExecuteLocalizedException() $this->assertSame($this->resultJson, $this->controller->execute()); } + /** + * Unit test for verifying Execute Exception during inline edit. + * + * @return void + */ public function testExecuteException() { $exception = new \Exception('Exception message'); @@ -334,6 +378,9 @@ public function testExecuteException() $this->customerData->expects($this->once()) ->method('getDefaultBilling') ->willReturn(false); + $this->customerData->expects($this->once()) + ->method('getAddresses') + ->willReturn([]); $this->customerRepository->expects($this->once()) ->method('save') ->with($this->customerData) diff --git a/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/MassAssignGroupTest.php b/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/MassAssignGroupTest.php new file mode 100644 index 0000000000000..01f26a8906cc7 --- /dev/null +++ b/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/MassAssignGroupTest.php @@ -0,0 +1,265 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\Customer\Test\Unit\Controller\Adminhtml\Index; + +use Magento\Framework\App\Action\Context; +use Magento\Customer\Model\ResourceModel\Customer\CollectionFactory; +use Magento\Customer\Model\ResourceModel\Customer\Collection; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; + +/** + * Unit tests for Magento\Customer\Controller\Adminhtml\Index\MassAssignGroup. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class MassAssignGroupTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var \Magento\Customer\Controller\Adminhtml\Index\MassAssignGroup + */ + private $massAction; + + /** + * @var Context|\PHPUnit_Framework_MockObject_MockObject + */ + private $contextMock; + + /** + * @var \Magento\Backend\Model\View\Result\Redirect|\PHPUnit_Framework_MockObject_MockObject + */ + private $resultRedirectMock; + + /** + * @var \Magento\Framework\App\Request\Http|\PHPUnit_Framework_MockObject_MockObject + */ + private $requestMock; + + /** + * @var \Magento\Framework\App\ResponseInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $responseMock; + + /** + * @var \Magento\Framework\Message\Manager|\PHPUnit_Framework_MockObject_MockObject + */ + private $messageManagerMock; + + /** + * @var \Magento\Framework\ObjectManager\ObjectManager|\PHPUnit_Framework_MockObject_MockObject + */ + private $objectManagerMock; + + /** + * @var Collection|\PHPUnit_Framework_MockObject_MockObject + */ + private $customerCollectionMock; + + /** + * @var CollectionFactory|\PHPUnit_Framework_MockObject_MockObject + */ + private $customerCollectionFactoryMock; + + /** + * @var \Magento\Ui\Component\MassAction\Filter|\PHPUnit_Framework_MockObject_MockObject + */ + private $filterMock; + + /** + * @var \Magento\Customer\Api\CustomerRepositoryInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $customerRepositoryMock; + + /** + * @var \Magento\Framework\App\RequestInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $requestInterfaceMock; + + /** + * @var \Magento\Framework\Controller\ResultFactory|\PHPUnit_Framework_MockObject_MockObject + */ + private $resultFactoryMock; + + /** + * @var \Magento\Backend\Model\View\Result\Redirect|\PHPUnit_Framework_MockObject_MockObject + */ + private $redirectMock; + + /** + * @var \Magento\Backend\Model\View\Result\RedirectFactory|\PHPUnit_Framework_MockObject_MockObject + */ + private $resultRedirectFactoryMock; + + /** + * @inheritdoc + */ + protected function setUp() + { + $objectManagerHelper = new ObjectManagerHelper($this); + + $this->contextMock = $this->createMock(\Magento\Backend\App\Action\Context::class); + $this->resultRedirectFactoryMock = $this->createMock( + \Magento\Backend\Model\View\Result\RedirectFactory::class + ); + $this->responseMock = $this->createMock(\Magento\Framework\App\ResponseInterface::class); + $this->requestMock = $this->getMockBuilder(\Magento\Framework\App\Request\Http::class) + ->disableOriginalConstructor() + ->getMock(); + $this->objectManagerMock = $this->createPartialMock( + \Magento\Framework\ObjectManager\ObjectManager::class, + ['create'] + ); + $this->requestInterfaceMock = $this->getMockForAbstractClass( + \Magento\Framework\App\RequestInterface::class, + [], + '', + false, + true, + true, + ['isPost'] + ); + $this->messageManagerMock = $this->createMock(\Magento\Framework\Message\Manager::class); + $this->customerCollectionMock = $this->getMockBuilder(Collection::class) + ->disableOriginalConstructor() + ->getMock(); + $this->customerCollectionFactoryMock = $this->getMockBuilder(CollectionFactory::class) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + $this->redirectMock = $this->getMockBuilder(\Magento\Backend\Model\View\Result\Redirect::class) + ->disableOriginalConstructor() + ->getMock(); + $this->resultFactoryMock = $this->getMockBuilder(\Magento\Framework\Controller\ResultFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->resultRedirectMock = $this->getMockBuilder(\Magento\Backend\Model\View\Result\Redirect::class) + ->disableOriginalConstructor() + ->getMock(); + $this->filterMock = $this->createMock(\Magento\Ui\Component\MassAction\Filter::class); + + $this->resultRedirectFactoryMock->expects($this->any()) + ->method('create') + ->willReturn($this->resultRedirectMock); + + $this->contextMock->expects($this->once())->method('getMessageManager')->willReturn($this->messageManagerMock); + $this->contextMock->expects($this->once())->method('getRequest')->willReturn($this->requestMock); + $this->contextMock->expects($this->once())->method('getResponse')->willReturn($this->responseMock); + $this->contextMock->expects($this->once())->method('getObjectManager')->willReturn($this->objectManagerMock); + $this->contextMock->expects($this->once()) + ->method('getResultRedirectFactory') + ->willReturn($this->resultRedirectFactoryMock); + $this->contextMock->expects($this->once()) + ->method('getResultFactory') + ->willReturn($this->resultFactoryMock); + $this->customerRepositoryMock = $this + ->getMockBuilder(\Magento\Customer\Api\CustomerRepositoryInterface::class) + ->getMockForAbstractClass(); + $this->massAction = $objectManagerHelper->getObject( + \Magento\Customer\Controller\Adminhtml\Index\MassAssignGroup::class, + [ + 'context' => $this->contextMock, + 'filter' => $this->filterMock, + 'collectionFactory' => $this->customerCollectionFactoryMock, + 'customerRepository' => $this->customerRepositoryMock, + ] + ); + } + + /** + * Execute Create resultFactory and Create and Get customerCollectionFactory. + * + * @return void + */ + private function expectsCreateAndGetCollectionMethods() + { + $this->resultFactoryMock->expects($this->once()) + ->method('create') + ->with(\Magento\Framework\Controller\ResultFactory::TYPE_REDIRECT) + ->willReturn($this->redirectMock); + $this->customerCollectionFactoryMock->expects($this->once()) + ->method('create') + ->willReturn($this->customerCollectionMock); + $this->filterMock->expects($this->once()) + ->method('getCollection') + ->with($this->customerCollectionMock) + ->willReturnArgument(0); + } + + /** + * Unit test to verify mass customer group assignment use case. + * + * @return void + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function testExecute() + { + + $customersIds = [10, 11, 12]; + $customerMock = $this->getMockBuilder(\Magento\Customer\Api\Data\CustomerInterface::class) + ->setMethods(['setData']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->expectsCreateAndGetCollectionMethods(); + $this->requestMock->expects($this->once())->method('isPost')->willReturn(true); + $this->customerCollectionMock->expects($this->once()) + ->method('getAllIds') + ->willReturn($customersIds); + + $this->customerRepositoryMock->expects($this->any()) + ->method('getById') + ->willReturnMap([[10, $customerMock], [11, $customerMock], [12, $customerMock]]); + + $this->messageManagerMock->expects($this->once()) + ->method('addSuccess') + ->with(__('A total of %1 record(s) were updated.', count($customersIds))); + + $this->resultRedirectMock->expects($this->any()) + ->method('setPath') + ->with('customer/*/index') + ->willReturnSelf(); + + $this->massAction->execute(); + } + + /** + * Unit test to verify expected error during mass customer group assignment use case. + * + * @return void + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function testExecuteWithException() + { + $customersIds = [10, 11, 12]; + $this->expectsCreateAndGetCollectionMethods(); + $this->requestMock->expects($this->once())->method('isPost')->willReturn(true); + $this->customerCollectionMock->expects($this->once()) + ->method('getAllIds') + ->willReturn($customersIds); + + $this->customerRepositoryMock->expects($this->once()) + ->method('getById') + ->willThrowException(new \Exception('Some message.')); + + $this->messageManagerMock->expects($this->once()) + ->method('addError') + ->with('Some message.'); + + $this->massAction->execute(); + } + + /** + * Check that error throws when request is not a POST. + * + * @return void + * @expectedException \Magento\Framework\Exception\NotFoundException + */ + public function testExecuteWithNotPostRequest() + { + $this->requestMock->expects($this->once())->method('isPost')->willReturn(false); + + $this->massAction->execute(); + } +} diff --git a/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/SaveTest.php b/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/SaveTest.php index 5372bb11a89b5..e703e7499d731 100644 --- a/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/SaveTest.php +++ b/app/code/Magento/Customer/Test/Unit/Controller/Adminhtml/Index/SaveTest.php @@ -15,6 +15,8 @@ use Magento\Framework\Controller\Result\Redirect; /** + * Testing Save Customer use case from admin page. + * * @SuppressWarnings(PHPMD.TooManyFields) * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @covers \Magento\Customer\Controller\Adminhtml\Index\Save @@ -275,6 +277,8 @@ protected function setUp() } /** + * Test for Execute method with existent customer. + * * @covers \Magento\Customer\Controller\Adminhtml\Index\Index::execute * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ @@ -540,6 +544,10 @@ public function testExecuteWithExistentCustomer() $customerEmail = 'customer@email.com'; $customerMock->expects($this->once())->method('getEmail')->willReturn($customerEmail); + $customerMock->expects($this->once()) + ->method('getAddresses') + ->willReturn([]); + $this->emailNotificationMock->expects($this->once()) ->method('credentialsChanged') ->with($customerMock, $customerEmail) diff --git a/app/code/Magento/Customer/Test/Unit/Model/AccountManagementTest.php b/app/code/Magento/Customer/Test/Unit/Model/AccountManagementTest.php new file mode 100644 index 0000000000000..f5688c1f87481 --- /dev/null +++ b/app/code/Magento/Customer/Test/Unit/Model/AccountManagementTest.php @@ -0,0 +1,347 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Customer\Test\Unit\Model; + +use Magento\Customer\Api\Data\CustomerInterface; +use Magento\Customer\Model\AccountConfirmation; +use Magento\Customer\Model\AccountManagement; +use Magento\Customer\Model\AuthenticationInterface; +use Magento\Customer\Model\EmailNotificationInterface; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\Intl\DateTimeFactory; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; + +/** + * Unit test for Magento\Customer\Model\AccountManagement. + * + * @SuppressWarnings(PHPMD.TooManyFields) + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class AccountManagementTest extends \PHPUnit\Framework\TestCase +{ + /** @var AccountManagement */ + private $accountManagement; + + /** @var ObjectManagerHelper */ + private $objectManagerHelper; + + /** @var \Magento\Customer\Model\CustomerFactory|\PHPUnit_Framework_MockObject_MockObject */ + private $customerFactoryMock; + + /** @var \Magento\Framework\Event\ManagerInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $managerMock; + + /** @var \Magento\Store\Model\StoreManagerInterface|\PHPUnit_Framework_MockObject_MockObject */ + protected $storeManagerMock; + + /** @var \Magento\Framework\Math\Random|\PHPUnit_Framework_MockObject_MockObject */ + private $randomMock; + + /** @var \Magento\Customer\Model\Metadata\Validator|\PHPUnit_Framework_MockObject_MockObject */ + private $validatorMock; + + /** @var \Magento\Customer\Api\Data\ValidationResultsInterfaceFactory|\PHPUnit_Framework_MockObject_MockObject */ + private $validationResultsInterfaceFactoryMock; + + /** @var \Magento\Customer\Api\AddressRepositoryInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $addressRepositoryMock; + + /** @var \Magento\Customer\Api\CustomerMetadataInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $customerMetadataMock; + + /** @var \Magento\Customer\Model\CustomerRegistry|\PHPUnit_Framework_MockObject_MockObject */ + private $customerRegistryMock; + + /** @var \Psr\Log\LoggerInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $loggerMock; + + /** @var \Magento\Framework\Encryption\EncryptorInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $encryptorMock; + + /** @var \Magento\Customer\Model\Config\Share|\PHPUnit_Framework_MockObject_MockObject */ + private $shareMock; + + /** @var \Magento\Framework\Stdlib\StringUtils|\PHPUnit_Framework_MockObject_MockObject */ + private $stringMock; + + /** @var \Magento\Customer\Api\CustomerRepositoryInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $customerRepositoryMock; + + /** @var \Magento\Framework\App\Config\ScopeConfigInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $scopeConfigMock; + + /** @var \Magento\Framework\Mail\Template\TransportBuilder|\PHPUnit_Framework_MockObject_MockObject */ + private $transportBuilderMock; + + /** @var \Magento\Framework\Reflection\DataObjectProcessor|\PHPUnit_Framework_MockObject_MockObject */ + private $dataObjectProcessorMock; + + /** @var \Magento\Framework\Registry|\PHPUnit_Framework_MockObject_MockObject */ + private $registryMock; + + /** @var \Magento\Customer\Helper\View|\PHPUnit_Framework_MockObject_MockObject */ + private $customerViewHelperMock; + + /** @var \Magento\Framework\Stdlib\DateTime|\PHPUnit_Framework_MockObject_MockObject */ + private $dateTimeMock; + + /** @var \Magento\Customer\Model\Customer|\PHPUnit_Framework_MockObject_MockObject */ + private $customerMock; + + /** @var \Magento\Framework\DataObjectFactory|\PHPUnit_Framework_MockObject_MockObject */ + private $objectFactoryMock; + + /** @var \Magento\Framework\Api\ExtensibleDataObjectConverter|\PHPUnit_Framework_MockObject_MockObject */ + private $extensibleDataObjectConverterMock; + + /** @var \Magento\Customer\Model\Data\CustomerSecure|\PHPUnit_Framework_MockObject_MockObject */ + private $customerSecureMock; + + /** @var AuthenticationInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $authenticationMock; + + /** @var EmailNotificationInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $emailNotificationMock; + + /** @var DateTimeFactory|\PHPUnit_Framework_MockObject_MockObject */ + private $dateTimeFactoryMock; + + /** @var AccountConfirmation|\PHPUnit_Framework_MockObject_MockObject */ + private $accountConfirmationMock; + + /** @var \Magento\Framework\Session\SessionManagerInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $sessionManagerMock; + + /** @var \Magento\Customer\Model\ResourceModel\Visitor\CollectionFactory|\PHPUnit_Framework_MockObject_MockObject */ + private $visitorCollectionFactoryMock; + + /** @var \Magento\Framework\Session\SaveHandlerInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $saveHandlerMock; + + /** @var \Magento\Customer\Model\AddressRegistry|\PHPUnit_Framework_MockObject_MockObject */ + private $addressRegistryMock; + + /** @var SearchCriteriaBuilder|\PHPUnit_Framework_MockObject_MockObject */ + private $searchCriteriaBuilderMock; + + /** + * @inheritdoc + * + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + protected function setUp() + { + $this->customerFactoryMock = $this->createPartialMock( + \Magento\Customer\Model\CustomerFactory::class, + ['create'] + ); + $this->managerMock = $this->createMock(\Magento\Framework\Event\ManagerInterface::class); + $this->storeManagerMock = $this->createMock(\Magento\Store\Model\StoreManagerInterface::class); + $this->randomMock = $this->createMock(\Magento\Framework\Math\Random::class); + $this->validatorMock = $this->createMock(\Magento\Customer\Model\Metadata\Validator::class); + $this->validationResultsInterfaceFactoryMock = $this->createMock( + \Magento\Customer\Api\Data\ValidationResultsInterfaceFactory::class + ); + $this->addressRepositoryMock = $this->createMock(\Magento\Customer\Api\AddressRepositoryInterface::class); + $this->customerMetadataMock = $this->createMock(\Magento\Customer\Api\CustomerMetadataInterface::class); + $this->customerRegistryMock = $this->createMock(\Magento\Customer\Model\CustomerRegistry::class); + $this->loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class); + $this->encryptorMock = $this->createMock(\Magento\Framework\Encryption\EncryptorInterface::class); + $this->shareMock = $this->createMock(\Magento\Customer\Model\Config\Share::class); + $this->stringMock = $this->createMock(\Magento\Framework\Stdlib\StringUtils::class); + $this->customerRepositoryMock = $this->createMock(\Magento\Customer\Api\CustomerRepositoryInterface::class); + $this->scopeConfigMock = $this->createMock(\Magento\Framework\App\Config\ScopeConfigInterface::class); + $this->transportBuilderMock = $this->createMock(\Magento\Framework\Mail\Template\TransportBuilder::class); + $this->dataObjectProcessorMock = $this->createMock(\Magento\Framework\Reflection\DataObjectProcessor::class); + $this->registryMock = $this->createMock(\Magento\Framework\Registry::class); + $this->customerViewHelperMock = $this->createMock(\Magento\Customer\Helper\View::class); + $this->dateTimeMock = $this->createMock(\Magento\Framework\Stdlib\DateTime::class); + $this->customerMock = $this->createMock(\Magento\Customer\Model\Customer::class); + $this->objectFactoryMock = $this->createMock(\Magento\Framework\DataObjectFactory::class); + $this->addressRegistryMock = $this->createMock(\Magento\Customer\Model\AddressRegistry::class); + $this->extensibleDataObjectConverterMock = $this->createMock( + \Magento\Framework\Api\ExtensibleDataObjectConverter::class + ); + $this->authenticationMock = $this->createMock(AuthenticationInterface::class); + $this->emailNotificationMock = $this->createMock(EmailNotificationInterface::class); + + $this->customerSecureMock = $this->getMockBuilder(\Magento\Customer\Model\Data\CustomerSecure::class) + ->setMethods(['setRpToken', 'addData', 'setRpTokenCreatedAt', 'setData']) + ->disableOriginalConstructor() + ->getMock(); + + $this->accountConfirmationMock = $this->createMock(AccountConfirmation::class); + $this->searchCriteriaBuilderMock = $this->createMock(SearchCriteriaBuilder::class); + + $this->visitorCollectionFactoryMock = $this->getMockBuilder( + \Magento\Customer\Model\ResourceModel\Visitor\CollectionFactory::class + )->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + $this->sessionManagerMock = $this->createMock(\Magento\Framework\Session\SessionManagerInterface::class); + $this->saveHandlerMock = $this->createMock(\Magento\Framework\Session\SaveHandlerInterface::class); + + $this->dateTimeInit(); + + $this->objectManagerHelper = new ObjectManagerHelper($this); + $this->accountManagement = $this->objectManagerHelper->getObject( + AccountManagement::class, + [ + 'customerFactory' => $this->customerFactoryMock, + 'eventManager' => $this->managerMock, + 'storeManager' => $this->storeManagerMock, + 'mathRandom' => $this->randomMock, + 'validator' => $this->validatorMock, + 'validationResultsDataFactory' => $this->validationResultsInterfaceFactoryMock, + 'addressRepository' => $this->addressRepositoryMock, + 'customerMetadataService' => $this->customerMetadataMock, + 'customerRegistry' => $this->customerRegistryMock, + 'logger' => $this->loggerMock, + 'encryptor' => $this->encryptorMock, + 'configShare' => $this->shareMock, + 'stringHelper' => $this->stringMock, + 'customerRepository' => $this->customerRepositoryMock, + 'scopeConfig' => $this->scopeConfigMock, + 'transportBuilder' => $this->transportBuilderMock, + 'dataProcessor' => $this->dataObjectProcessorMock, + 'registry' => $this->registryMock, + 'customerViewHelper' => $this->customerViewHelperMock, + 'dateTime' => $this->dateTimeMock, + 'customerModel' => $this->customerMock, + 'objectFactory' => $this->objectFactoryMock, + 'extensibleDataObjectConverter' => $this->extensibleDataObjectConverterMock, + 'dateTimeFactory' => $this->dateTimeFactoryMock, + 'accountConfirmation' => $this->accountConfirmationMock, + 'sessionManager' => $this->sessionManagerMock, + 'saveHandler' => $this->saveHandlerMock, + 'visitorCollectionFactory' => $this->visitorCollectionFactoryMock, + 'searchCriteriaBuilder' => $this->searchCriteriaBuilderMock, + 'addressRegistry' => $this->addressRegistryMock, + ] + ); + } + + /** + * Init DateTimeFactory. + * + * @return void + */ + private function dateTimeInit() + { + $dateTime = '2017-10-25 18:57:08'; + $timestamp = '1508983028'; + $dateTimeMock = $this->getMockBuilder(\DateTime::class) + ->disableOriginalConstructor() + ->setMethods(['format', 'getTimestamp', 'setTimestamp']) + ->getMock(); + + $dateTimeMock->expects($this->once()) + ->method('format') + ->with(\Magento\Framework\Stdlib\DateTime::DATETIME_PHP_FORMAT) + ->willReturn($dateTime); + $dateTimeMock->expects($this->once()) + ->method('getTimestamp') + ->willReturn($timestamp); + $dateTimeMock->expects($this->once()) + ->method('setTimestamp') + ->willReturnSelf(); + $this->dateTimeFactoryMock = $this->getMockBuilder(DateTimeFactory::class) + ->disableOriginalConstructor() + ->setMethods(['create']) + ->getMock(); + $this->dateTimeFactoryMock->expects($this->once())->method('create')->willReturn($dateTimeMock); + } + + /** + * Test for changePassword method. + * + * @return void + */ + public function testChangePassword() + { + $customerId = 7; + $email = 'test@example.com'; + $currentPassword = '1234567'; + $newPassword = 'abcdefg'; + + $customer = $this->createMock(CustomerInterface::class); + + $customer->expects($this->atLeastOnce()) + ->method('getId') + ->willReturn($customerId); + $customer->expects($this->once()) + ->method('getAddresses') + ->willReturn([]); + $this->customerRepositoryMock->expects($this->once()) + ->method('get') + ->with($email) + ->willReturn($customer); + $this->customerSecureMock->expects($this->once()) + ->method('setRpToken') + ->with(null) + ->willReturnSelf(); + $this->customerSecureMock->expects($this->once()) + ->method('setRpTokenCreatedAt') + ->with(null) + ->willReturnSelf(); + $this->customerRegistryMock->expects($this->once()) + ->method('retrieveSecureData') + ->with($customerId) + ->willReturn($this->customerSecureMock); + + $this->scopeConfigMock->expects($this->atLeastOnce()) + ->method('getValue') + ->willReturnMap( + [ + [ + AccountManagement::XML_PATH_MINIMUM_PASSWORD_LENGTH, + 'default', + null, + 7, + ], + [ + AccountManagement::XML_PATH_REQUIRED_CHARACTER_CLASSES_NUMBER, + 'default', + null, + 1, + ], + ] + ); + $this->stringMock->expects($this->atLeastOnce()) + ->method('strlen') + ->with($newPassword) + ->willReturn(7); + $this->customerRepositoryMock + ->expects($this->once()) + ->method('save') + ->with($customer); + $this->sessionManagerMock->expects($this->atLeastOnce())->method('getSessionId'); + $visitor = $this->getMockBuilder(\Magento\Customer\Model\Visitor::class) + ->disableOriginalConstructor() + ->setMethods(['getSessionId']) + ->getMock(); + $visitor->expects($this->atLeastOnce())->method('getSessionId') + ->willReturnOnConsecutiveCalls('session_id_1', 'session_id_2'); + $visitorCollection = $this->getMockBuilder( + \Magento\Customer\Model\ResourceModel\Visitor\CollectionFactory::class + )->disableOriginalConstructor() + ->setMethods(['addFieldToFilter', 'getItems']) + ->getMock(); + $visitorCollection->expects($this->atLeastOnce())->method('addFieldToFilter')->willReturnSelf(); + $visitorCollection->expects($this->once())->method('getItems')->willReturn([$visitor, $visitor]); + $this->visitorCollectionFactoryMock->expects($this->once())->method('create') + ->willReturn($visitorCollection); + $this->saveHandlerMock->expects($this->atLeastOnce())->method('destroy') + ->withConsecutive( + ['session_id_1'], + ['session_id_2'] + ); + + $this->assertTrue($this->accountManagement->changePassword($email, $currentPassword, $newPassword)); + } +} diff --git a/app/code/Magento/Customer/Test/Unit/Model/EmailNotificationTest.php b/app/code/Magento/Customer/Test/Unit/Model/EmailNotificationTest.php index 49a2d5e51f8f8..33757b82db891 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/EmailNotificationTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/EmailNotificationTest.php @@ -603,23 +603,35 @@ public function testPasswordResetConfirmation() } /** + * @dataProvider emailDataProvider + * @param string $emailType + * @param string $template + * @param string $templateIdentifier + * @param string|null $sendemailStoreId + * @param array $extensions + * + * @return void * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - public function testNewAccount() - { + public function testNewAccount( + string $emailType, + string $template, + string $templateIdentifier, + $sendemailStoreId, + array $extensions + ) { $customerId = 1; $customerStoreId = 2; + $customerName = 'Customer Name'; $customerEmail = 'email@email.com'; $customerData = ['key' => 'value']; - $customerName = 'Customer Name'; - $templateIdentifier = 'Template Identifier'; $sender = 'Sender'; - $senderValues = ['name' => $sender, 'email' => $sender]; + $senderEmail = 'sender@sender.com'; + $senderValues = ['name' => $sender, 'email' => $senderEmail]; $this->senderResolverMock ->expects($this->once()) ->method('resolve') - ->with($sender, $customerStoreId) ->willReturn($senderValues); /** @var CustomerInterface | \PHPUnit_Framework_MockObject_MockObject $customer */ @@ -669,23 +681,76 @@ public function testNewAccount() $this->scopeConfigMock->expects($this->at(0)) ->method('getValue') - ->with(EmailNotification::XML_PATH_REGISTER_EMAIL_TEMPLATE, ScopeInterface::SCOPE_STORE, $customerStoreId) + ->with($template, ScopeInterface::SCOPE_STORE, $customerStoreId) ->willReturn($templateIdentifier); + $this->scopeConfigMock->expects($this->at(1)) ->method('getValue') ->with(EmailNotification::XML_PATH_REGISTER_EMAIL_IDENTITY, ScopeInterface::SCOPE_STORE, $customerStoreId) ->willReturn($sender); + $templateVars = [ + 'customer' => $this->customerSecureMock, + 'back_url' => '', + 'store' => $this->storeMock, + ]; + + if ($template === EmailNotification::XML_PATH_CONFIRM_EMAIL_TEMPLATE) { + if (!empty($extensions)) { + $templateVars['url'] = $extensions['url']; + $templateVars['extensions'] = $extensions['extension_info']; + } else { + $templateVars['url'] = EmailNotification::CUSTOMER_CONFIRM_URL; + $templateVars['extensions'] = $extensions; + } + } + $this->mockDefaultTransportBuilder( $templateIdentifier, $customerStoreId, $senderValues, $customerEmail, $customerName, - ['customer' => $this->customerSecureMock, 'back_url' => '', 'store' => $this->storeMock] + $templateVars ); - $this->model->newAccount($customer, EmailNotification::NEW_ACCOUNT_EMAIL_REGISTERED, '', $customerStoreId); + $this->model->newAccount($customer, $emailType, '', $customerStoreId, $sendemailStoreId, $extensions); + } + + /** + * @return array + */ + public function emailDataProvider(): array + { + return + [ + [ + EmailNotification::NEW_ACCOUNT_EMAIL_REGISTERED, + EmailNotification::XML_PATH_REGISTER_EMAIL_TEMPLATE, + 'Register', + null, + [], + ], + [ + EmailNotification::NEW_ACCOUNT_EMAIL_CONFIRMATION, + EmailNotification::XML_PATH_CONFIRM_EMAIL_TEMPLATE, + 'Confirm', + null, + [], + ], + [ + EmailNotification::NEW_ACCOUNT_EMAIL_CONFIRMATION, + EmailNotification::XML_PATH_CONFIRM_EMAIL_TEMPLATE, + 'Confirm', + null, + [ + 'url' => "customer/account/confirm", + 'extension_info' => [ + 'test_extension' => "NTowU0Q5amZneEtSZnRDajRFZFVDVmZCbnhRWnZ0cFFkOA,,", + ], + ], + ], + ]; } /** diff --git a/app/code/Magento/Customer/Test/Unit/Model/Metadata/AttributeMetadataCacheTest.php b/app/code/Magento/Customer/Test/Unit/Model/Metadata/AttributeMetadataCacheTest.php index 658472d13ab93..d93ed3c7b351a 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/Metadata/AttributeMetadataCacheTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/Metadata/AttributeMetadataCacheTest.php @@ -15,7 +15,14 @@ use Magento\Framework\App\CacheInterface; use Magento\Framework\Serialize\SerializerInterface; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\StoreManagerInterface; +/** + * Class AttributeMetadataCache Test + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class AttributeMetadataCacheTest extends \PHPUnit\Framework\TestCase { /** @@ -43,6 +50,16 @@ class AttributeMetadataCacheTest extends \PHPUnit\Framework\TestCase */ private $attributeMetadataCache; + /** + * @var StoreInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $storeMock; + + /** + * @var StoreManagerInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $storeManagerMock; + protected function setUp() { $objectManager = new ObjectManager($this); @@ -50,13 +67,18 @@ protected function setUp() $this->stateMock = $this->createMock(StateInterface::class); $this->serializerMock = $this->createMock(SerializerInterface::class); $this->attributeMetadataHydratorMock = $this->createMock(AttributeMetadataHydrator::class); + $this->storeMock = $this->createMock(StoreInterface::class); + $this->storeManagerMock = $this->createMock(StoreManagerInterface::class); + $this->storeManagerMock->method('getStore')->willReturn($this->storeMock); + $this->storeMock->method('getId')->willReturn(1); $this->attributeMetadataCache = $objectManager->getObject( AttributeMetadataCache::class, [ 'cache' => $this->cacheMock, 'state' => $this->stateMock, 'serializer' => $this->serializerMock, - 'attributeMetadataHydrator' => $this->attributeMetadataHydratorMock + 'attributeMetadataHydrator' => $this->attributeMetadataHydratorMock, + 'storeManager' => $this->storeManagerMock ] ); } @@ -80,7 +102,8 @@ public function testLoadNoCache() { $entityType = 'EntityType'; $suffix = 'none'; - $cacheKey = AttributeMetadataCache::ATTRIBUTE_METADATA_CACHE_PREFIX . $entityType . $suffix; + $storeId = 1; + $cacheKey = AttributeMetadataCache::ATTRIBUTE_METADATA_CACHE_PREFIX . $entityType . $suffix . $storeId; $this->stateMock->expects($this->once()) ->method('isEnabled') ->with(Type::TYPE_IDENTIFIER) @@ -96,7 +119,8 @@ public function testLoad() { $entityType = 'EntityType'; $suffix = 'none'; - $cacheKey = AttributeMetadataCache::ATTRIBUTE_METADATA_CACHE_PREFIX . $entityType . $suffix; + $storeId = 1; + $cacheKey = AttributeMetadataCache::ATTRIBUTE_METADATA_CACHE_PREFIX . $entityType . $suffix . $storeId; $serializedString = 'serialized string'; $attributeMetadataOneData = [ 'attribute_code' => 'attribute_code', @@ -156,7 +180,8 @@ public function testSave() { $entityType = 'EntityType'; $suffix = 'none'; - $cacheKey = AttributeMetadataCache::ATTRIBUTE_METADATA_CACHE_PREFIX . $entityType . $suffix; + $storeId = 1; + $cacheKey = AttributeMetadataCache::ATTRIBUTE_METADATA_CACHE_PREFIX . $entityType . $suffix . $storeId; $serializedString = 'serialized string'; $attributeMetadataOneData = [ 'attribute_code' => 'attribute_code', diff --git a/app/code/Magento/Customer/Test/Unit/Model/Metadata/Form/AbstractDataTest.php b/app/code/Magento/Customer/Test/Unit/Model/Metadata/Form/AbstractDataTest.php index e4dc22ba40e31..667fc87b6a82b 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/Metadata/Form/AbstractDataTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/Metadata/Form/AbstractDataTest.php @@ -205,6 +205,8 @@ public function applyOutputFilterDataProvider() } /** + * Tests input validation rules. + * * @param null|string $value * @param null|string $label * @param null|string $inputValidation @@ -217,25 +219,18 @@ public function testValidateInputRule($value, $label, $inputValidation, $expecte ->disableOriginalConstructor() ->setMethods(['getName', 'getValue']) ->getMockForAbstractClass(); - $validationRule->expects($this->any()) - ->method('getName') - ->will($this->returnValue('input_validation')); - $validationRule->expects($this->any()) - ->method('getValue') - ->will($this->returnValue($inputValidation)); - - $this->_attributeMock->expects($this->any())->method('getStoreLabel')->will($this->returnValue($label)); - $this->_attributeMock->expects( - $this->any() - )->method( - 'getValidationRules' - )->will( - $this->returnValue( - [ - $validationRule, - ] - ) - ); + + $validationRule->method('getName') + ->willReturn('input_validation'); + + $validationRule->method('getValue') + ->willReturn($inputValidation); + + $this->_attributeMock->method('getStoreLabel') + ->willReturn($label); + + $this->_attributeMock->method('getValidationRules') + ->willReturn([$validationRule]); $this->assertEquals($expectedOutput, $this->_model->validateInputRule($value)); } @@ -256,6 +251,16 @@ public function validateInputRuleDataProvider() \Zend_Validate_Alnum::NOT_ALNUM => '"mylabel" contains non-alphabetic or non-numeric characters.' ] ], + [ + 'abc qaz', + 'mylabel', + 'alphanumeric', + [ + \Zend_Validate_Alnum::NOT_ALNUM => '"mylabel" contains non-alphabetic or non-numeric characters.', + ], + ], + ['abcqaz', 'mylabel', 'alphanumeric', true], + ['abc qaz', 'mylabel', 'alphanum-with-spaces', true], [ '!@#$', 'mylabel', diff --git a/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/CustomerRepositoryTest.php b/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/CustomerRepositoryTest.php index 215cc32193b18..01951fac7172e 100644 --- a/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/CustomerRepositoryTest.php +++ b/app/code/Magento/Customer/Test/Unit/Model/ResourceModel/CustomerRepositoryTest.php @@ -740,7 +740,7 @@ public function testGetList() ->willReturnSelf(); $collection->expects($this->at(7)) ->method('joinAttribute') - ->with('company', 'customer_address/company', 'default_billing', null, 'left') + ->with('billing_company', 'customer_address/company', 'default_billing', null, 'left') ->willReturnSelf(); $this->collectionProcessorMock->expects($this->once()) ->method('process') diff --git a/app/code/Magento/Customer/Test/Unit/Observer/UpgradeCustomerPasswordObserverTest.php b/app/code/Magento/Customer/Test/Unit/Observer/UpgradeCustomerPasswordObserverTest.php index 8971f155f782e..313121604e567 100644 --- a/app/code/Magento/Customer/Test/Unit/Observer/UpgradeCustomerPasswordObserverTest.php +++ b/app/code/Magento/Customer/Test/Unit/Observer/UpgradeCustomerPasswordObserverTest.php @@ -7,6 +7,9 @@ use Magento\Customer\Observer\UpgradeCustomerPasswordObserver; +/** + * Unit test for Magento\Customer\Observer\UpgradeCustomerPasswordObserver. + */ class UpgradeCustomerPasswordObserverTest extends \PHPUnit\Framework\TestCase { /** @@ -29,9 +32,13 @@ class UpgradeCustomerPasswordObserverTest extends \PHPUnit\Framework\TestCase */ protected $customerRegistry; + /** + * @inheritdoc + */ protected function setUp() { - $this->customerRepository = $this->getMockBuilder(\Magento\Customer\Api\CustomerRepositoryInterface::class) + $this->customerRepository = $this + ->getMockBuilder(\Magento\Customer\Api\CustomerRepositoryInterface::class) ->getMockForAbstractClass(); $this->customerRegistry = $this->getMockBuilder(\Magento\Customer\Model\CustomerRegistry::class) ->disableOriginalConstructor() @@ -47,6 +54,9 @@ protected function setUp() ); } + /** + * Unit test for verifying customers password upgrade observer + */ public function testUpgradeCustomerPassword() { $customerId = '1'; @@ -57,6 +67,8 @@ public function testUpgradeCustomerPassword() ->setMethods(['getId']) ->getMock(); $customer = $this->getMockBuilder(\Magento\Customer\Api\Data\CustomerInterface::class) + ->setMethods(['setData']) + ->disableOriginalConstructor() ->getMockForAbstractClass(); $customerSecure = $this->getMockBuilder(\Magento\Customer\Model\Data\CustomerSecure::class) ->disableOriginalConstructor() diff --git a/app/code/Magento/Customer/Test/Unit/Ui/Component/Listing/Column/ValidationRulesTest.php b/app/code/Magento/Customer/Test/Unit/Ui/Component/Listing/Column/ValidationRulesTest.php index 130b3acd11e76..b57bc53ef09f9 100644 --- a/app/code/Magento/Customer/Test/Unit/Ui/Component/Listing/Column/ValidationRulesTest.php +++ b/app/code/Magento/Customer/Test/Unit/Ui/Component/Listing/Column/ValidationRulesTest.php @@ -18,12 +18,6 @@ class ValidationRulesTest extends \PHPUnit\Framework\TestCase protected function setUp() { - $this->validationRules = $this->getMockBuilder( - \Magento\Customer\Ui\Component\Listing\Column\ValidationRules::class - ) - ->disableOriginalConstructor() - ->getMock(); - $this->validationRule = $this->getMockBuilder(\Magento\Customer\Api\Data\ValidationRuleInterface::class) ->disableOriginalConstructor() ->getMock(); @@ -31,18 +25,25 @@ protected function setUp() $this->validationRules = new ValidationRules(); } - public function testGetValidationRules() + /** + * Tests input validation rules. + * + * @param string $validationRule + * @param string $validationClass + * @return void + * @dataProvider validationRulesDataProvider + */ + public function testGetValidationRules(string $validationRule, string $validationClass) { $expectsRules = [ 'required-entry' => true, - 'validate-number' => true, + $validationClass => true, ]; - $this->validationRule->expects($this->atLeastOnce()) - ->method('getName') + $this->validationRule->method('getName') ->willReturn('input_validation'); - $this->validationRule->expects($this->atLeastOnce()) - ->method('getValue') - ->willReturn('numeric'); + + $this->validationRule->method('getValue') + ->willReturn($validationRule); $this->assertEquals( $expectsRules, @@ -66,4 +67,21 @@ public function testGetValidationRulesWithOnlyRequiredRule() $this->validationRules->getValidationRules(true, []) ); } + + /** + * Provides possible validation rules. + * + * @return array + */ + public function validationRulesDataProvider(): array + { + return [ + ['alpha', 'validate-alpha'], + ['numeric', 'validate-number'], + ['alphanumeric', 'validate-alphanum'], + ['alphanum-with-spaces', 'validate-alphanum-with-spaces'], + ['url', 'validate-url'], + ['email', 'validate-email'], + ]; + } } diff --git a/app/code/Magento/Customer/Ui/Component/Listing/Column/ValidationRules.php b/app/code/Magento/Customer/Ui/Component/Listing/Column/ValidationRules.php index b8f83421a6d62..6befec8e942a1 100644 --- a/app/code/Magento/Customer/Ui/Component/Listing/Column/ValidationRules.php +++ b/app/code/Magento/Customer/Ui/Component/Listing/Column/ValidationRules.php @@ -7,6 +7,9 @@ use Magento\Customer\Api\Data\ValidationRuleInterface; +/** + * Provides validation classes according to corresponding rules. + */ class ValidationRules { /** @@ -16,6 +19,7 @@ class ValidationRules 'alpha' => 'validate-alpha', 'numeric' => 'validate-number', 'alphanumeric' => 'validate-alphanum', + 'alphanum-with-spaces' => 'validate-alphanum-with-spaces', 'url' => 'validate-url', 'email' => 'validate-email', ]; diff --git a/app/code/Magento/Customer/composer.json b/app/code/Magento/Customer/composer.json index e3d8f22088ef6..af45eb7931308 100644 --- a/app/code/Magento/Customer/composer.json +++ b/app/code/Magento/Customer/composer.json @@ -29,7 +29,7 @@ "magento/module-customer-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "101.0.6", + "version": "101.0.7", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Customer/etc/di.xml b/app/code/Magento/Customer/etc/di.xml index fa7dd80882632..c6e383fb73bde 100644 --- a/app/code/Magento/Customer/etc/di.xml +++ b/app/code/Magento/Customer/etc/di.xml @@ -127,6 +127,13 @@ <argument name="groupManagement" xsi:type="object">Magento\Customer\Api\GroupManagementInterface\Proxy</argument> </arguments> </type> + <type name="Magento\Customer\Model\Metadata\CustomerMetadata"> + <arguments> + <argument name="systemAttributes" xsi:type="array"> + <item name="disable_auto_group_change" xsi:type="string">disable_auto_group_change</item> + </argument> + </arguments> + </type> <virtualType name="SectionInvalidationConfigReader" type="Magento\Framework\Config\Reader\Filesystem"> <arguments> <argument name="idAttributes" xsi:type="array"> diff --git a/app/code/Magento/Customer/view/frontend/email/account_new_confirmation.html b/app/code/Magento/Customer/view/frontend/email/account_new_confirmation.html index 010087ace2d42..9b183d63471f3 100644 --- a/app/code/Magento/Customer/view/frontend/email/account_new_confirmation.html +++ b/app/code/Magento/Customer/view/frontend/email/account_new_confirmation.html @@ -9,7 +9,9 @@ "var this.getUrl($store, 'customer/account/confirm/', [_query:[id:$customer.id, key:$customer.confirmation, back_url:$back_url]])":"Account Confirmation URL", "var this.getUrl($store, 'customer/account/')":"Customer Account URL", "var customer.email":"Customer Email", -"var customer.name":"Customer Name" +"var customer.name":"Customer Name", +"var extensions":"Extensions", +"var url":"Url" } @--> {{template config_path="design/email/header_template"}} @@ -23,7 +25,7 @@ <table class="inner-wrapper" border="0" cellspacing="0" cellpadding="0" align="center"> <tr> <td align="center"> - <a href="{{var this.getUrl($store,'customer/account/confirm/',[_query:[id:$customer.id,key:$customer.confirmation,back_url:$back_url],_nosid:1])}}" target="_blank">{{trans "Confirm Your Account"}}</a> + <a href="{{var this.getUrl($store,$url,[_query:[id:$customer.id,key:$customer.confirmation,extensions:$extensions,back_url:$back_url],_nosid:1])}}" target="_blank">{{trans "Confirm Your Account"}}</a> </td> </tr> </table> diff --git a/app/code/Magento/Customer/view/frontend/layout/customer_address_form.xml b/app/code/Magento/Customer/view/frontend/layout/customer_address_form.xml index f053805409fe5..f5ee2b347a5b2 100644 --- a/app/code/Magento/Customer/view/frontend/layout/customer_address_form.xml +++ b/app/code/Magento/Customer/view/frontend/layout/customer_address_form.xml @@ -20,6 +20,7 @@ <block class="Magento\Customer\Block\Address\Edit" name="customer_address_edit" template="Magento_Customer::address/edit.phtml" cacheable="false"> <arguments> <argument name="attribute_data" xsi:type="object">Magento\Customer\Block\DataProviders\AddressAttributeData</argument> + <argument name="post_code_config" xsi:type="object">Magento\Customer\Block\DataProviders\PostCodesPatternsAttributeData</argument> </arguments> </block> </referenceContainer> diff --git a/app/code/Magento/Customer/view/frontend/templates/address/edit.phtml b/app/code/Magento/Customer/view/frontend/templates/address/edit.phtml index 644238e3949c5..992c866316d79 100644 --- a/app/code/Magento/Customer/view/frontend/templates/address/edit.phtml +++ b/app/code/Magento/Customer/view/frontend/templates/address/edit.phtml @@ -126,6 +126,9 @@ title="<?= /* @noEscape */ $block->getAttributeData()->getFrontendLabel('postcode') ?>" id="zip" class="input-text validate-zip-international <?= $block->escapeHtmlAttr($this->helper(\Magento\Customer\Helper\Address::class)->getAttributeValidationClass('postcode')) ?>"> + <div role="alert" class="message warning" style="display:none"> + <span></span> + </div> </div> </div> <div class="field country required"> @@ -184,7 +187,9 @@ <script type="text/x-magento-init"> { "#form-validate": { - "addressValidation": {} + "addressValidation": { + "postCodes": <?= /* @noEscape */ $block->getPostCodeConfig()->getSerializedPostCodes(); ?> + } }, "#country": { "regionUpdater": { diff --git a/app/code/Magento/Customer/view/frontend/web/js/addressValidation.js b/app/code/Magento/Customer/view/frontend/web/js/addressValidation.js index be2960701deed..c014b814ea98b 100644 --- a/app/code/Magento/Customer/view/frontend/web/js/addressValidation.js +++ b/app/code/Magento/Customer/view/frontend/web/js/addressValidation.js @@ -5,25 +5,38 @@ define([ 'jquery', + 'underscore', + 'mageUtils', + 'mage/translate', + 'Magento_Checkout/js/model/postcode-validator', 'jquery/ui', 'validation' -], function ($) { +], function ($, __, utils, $t, postCodeValidator) { 'use strict'; $.widget('mage.addressValidation', { options: { selectors: { - button: '[data-action=save-address]' + button: '[data-action=save-address]', + zip: '#zip', + country: 'select[name="country_id"]:visible' } }, + zipInput: null, + countrySelect: null, + /** * Validation creation + * * @protected */ _create: function () { var button = $(this.options.selectors.button, this.element); + this.zipInput = $(this.options.selectors.zip, this.element); + this.countrySelect = $(this.options.selectors.country, this.element); + this.element.validation({ /** @@ -36,6 +49,75 @@ define([ form.submit(); } }); + + this._addPostCodeValidation(); + }, + + /** + * Add postcode validation + * + * @protected + */ + _addPostCodeValidation: function () { + var self = this; + + this.zipInput.on('keyup', __.debounce(function (event) { + var valid = self._validatePostCode(event.target.value); + + self._renderValidationResult(valid); + }, 500) + ); + + this.countrySelect.on('change', function () { + var valid = self._validatePostCode(self.zipInput.val()); + + self._renderValidationResult(valid); + }); + }, + + /** + * Validate post code value. + * + * @protected + * @param {String} postCode - post code + * @return {Boolean} Whether is post code valid + */ + _validatePostCode: function (postCode) { + var countryId = this.countrySelect.val(); + + if (postCode === null) { + return true; + } + + return postCodeValidator.validate(postCode, countryId, this.options.postCodes); + }, + + /** + * Renders warning messages for invalid post code. + * + * @protected + * @param {Boolean} valid + */ + _renderValidationResult: function (valid) { + var warnMessage, + alertDiv = this.zipInput.next(); + + if (!valid) { + warnMessage = $t('Provided Zip/Postal Code seems to be invalid.'); + + if (postCodeValidator.validatedPostCodeExample.length) { + warnMessage += $t(' Example: ') + postCodeValidator.validatedPostCodeExample.join('; ') + '. '; + } + warnMessage += $t('If you believe it is the right one you can ignore this notice.'); + } + + alertDiv.children(':first').text(warnMessage); + + if (valid) { + alertDiv.hide(); + } else { + alertDiv.show(); + } } }); diff --git a/app/code/Magento/Customer/view/frontend/web/js/customer-data.js b/app/code/Magento/Customer/view/frontend/web/js/customer-data.js index 39e3f8d95ee3b..66d37cb84e9cb 100644 --- a/app/code/Magento/Customer/view/frontend/web/js/customer-data.js +++ b/app/code/Magento/Customer/view/frontend/web/js/customer-data.js @@ -216,6 +216,9 @@ define([ $.cookieStorage.set(privateContentVersion, privateContent); } $.localStorage.set(privateContentVersion, privateContent); + _.each(dataProvider.getFromStorage(storage.keys()), function (sectionData, sectionName) { + buffer.notify(sectionName, sectionData); + }); this.reload([], false); isLoading = true; } else if (expiredSectionNames.length > 0) { diff --git a/app/code/Magento/CustomerAnalytics/composer.json b/app/code/Magento/CustomerAnalytics/composer.json index d9c428ffcf5b7..901989b9d2550 100644 --- a/app/code/Magento/CustomerAnalytics/composer.json +++ b/app/code/Magento/CustomerAnalytics/composer.json @@ -7,7 +7,7 @@ "magento/module-customer": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/CustomerImportExport/composer.json b/app/code/Magento/CustomerImportExport/composer.json index d71be31723043..ebf8f7f52b99a 100644 --- a/app/code/Magento/CustomerImportExport/composer.json +++ b/app/code/Magento/CustomerImportExport/composer.json @@ -12,7 +12,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Deploy/Collector/Collector.php b/app/code/Magento/Deploy/Collector/Collector.php index b02c8e478d639..0a71af5758b7d 100644 --- a/app/code/Magento/Deploy/Collector/Collector.php +++ b/app/code/Magento/Deploy/Collector/Collector.php @@ -5,9 +5,11 @@ */ namespace Magento\Deploy\Collector; -use Magento\Deploy\Source\SourcePool; use Magento\Deploy\Package\Package; use Magento\Deploy\Package\PackageFactory; +use Magento\Deploy\Source\SourcePool; +use Magento\Deploy\Package\PackageFile; +use Magento\Framework\Module\Manager; use Magento\Framework\View\Asset\PreProcessor\FileNameResolver; /** @@ -44,6 +46,9 @@ class Collector implements CollectorInterface */ private $packageFactory; + /** @var \Magento\Framework\Module\Manager */ + private $moduleManager; + /** * Default values for package primary identifiers * @@ -61,15 +66,19 @@ class Collector implements CollectorInterface * @param SourcePool $sourcePool * @param FileNameResolver $fileNameResolver * @param PackageFactory $packageFactory + * @param Manager $moduleManager */ public function __construct( SourcePool $sourcePool, FileNameResolver $fileNameResolver, - PackageFactory $packageFactory + PackageFactory $packageFactory, + Manager $moduleManager = null ) { $this->sourcePool = $sourcePool; $this->fileNameResolver = $fileNameResolver; $this->packageFactory = $packageFactory; + $this->moduleManager = $moduleManager ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(Manager::class); } /** @@ -81,19 +90,11 @@ public function collect() foreach ($this->sourcePool->getAll() as $source) { $files = $source->get(); foreach ($files as $file) { - $file->setDeployedFileName($this->fileNameResolver->resolve($file->getFileName())); - $params = [ - 'area' => $file->getArea(), - 'theme' => $file->getTheme(), - 'locale' => $file->getLocale(), - 'module' => $file->getModule(), - 'isVirtual' => (!$file->getLocale() || !$file->getTheme() || !$file->getArea()) - ]; - foreach ($this->packageDefaultValues as $name => $value) { - if (!isset($params[$name])) { - $params[$name] = $value; - } + if ($file->getModule() && !$this->moduleManager->isEnabled($file->getModule())) { + continue; } + $file->setDeployedFileName($this->fileNameResolver->resolve($file->getFileName())); + $params = $this->getParams($file); $packagePath = "{$params['area']}/{$params['theme']}/{$params['locale']}"; if (!isset($packages[$packagePath])) { $packages[$packagePath] = $this->packageFactory->create($params); @@ -105,4 +106,28 @@ public function collect() } return $packages; } + + /** + * Retrieve package params. + * + * @param PackageFile $file + * @return array + */ + private function getParams(PackageFile $file) + { + $params = [ + 'area' => $file->getArea(), + 'theme' => $file->getTheme(), + 'locale' => $file->getLocale(), + 'module' => $file->getModule(), + 'isVirtual' => (!$file->getLocale() || !$file->getTheme() || !$file->getArea()) + ]; + foreach ($this->packageDefaultValues as $name => $value) { + if (!isset($params[$name])) { + $params[$name] = $value; + } + } + + return $params; + } } diff --git a/app/code/Magento/Deploy/Model/Filesystem.php b/app/code/Magento/Deploy/Model/Filesystem.php index 0557914f48d24..64c0fa3a3302b 100644 --- a/app/code/Magento/Deploy/Model/Filesystem.php +++ b/app/code/Magento/Deploy/Model/Filesystem.php @@ -123,6 +123,7 @@ public function __construct( * * @param OutputInterface $output * @return void + * @throws \Exception */ public function regenerateStatic( OutputInterface $output @@ -137,9 +138,14 @@ public function regenerateStatic( DirectoryList::STATIC_VIEW ] ); - + + $this->reinitCacheDirectories(); + // Trigger code generation $this->compile($output); + + $this->reinitCacheDirectories(); + // Trigger static assets compilation and deployment $this->deployStaticContent($output); } @@ -190,9 +196,14 @@ private function getAdminUserInterfaceLocales() * * @return array * @throws \InvalidArgumentException if unknown locale is provided by the store configuration + * @throws \Magento\Framework\Exception\FileSystemException */ private function getUsedLocales() { + /** init cache directory */ + $this->filesystem + ->getDirectoryWrite(DirectoryList::CACHE) + ->create(); $usedLocales = array_merge( $this->storeView->retrieveLocales(), $this->getAdminUserInterfaceLocales() @@ -221,13 +232,6 @@ function ($locale) { protected function compile(OutputInterface $output) { $output->writeln('Starting compilation'); - $this->cleanupFilesystem( - [ - DirectoryList::CACHE, - DirectoryList::GENERATED_CODE, - DirectoryList::GENERATED_METADATA, - ] - ); $cmd = $this->functionCallPath . 'setup:di:compile'; /** @@ -251,6 +255,7 @@ protected function compile(OutputInterface $output) * * @param array $directoryCodeList * @return void + * @throws \Magento\Framework\Exception\FileSystemException */ public function cleanupFilesystem($directoryCodeList) { @@ -294,6 +299,7 @@ public function cleanupFilesystem($directoryCodeList) * of inverse mask for setting access permissions to files and directories generated by Magento. * @link http://devdocs.magento.com/guides/v2.0/install-gde/install/post-install-umask.html * @link http://devdocs.magento.com/guides/v2.0/install-gde/prereq/file-system-perms.html + * @throws \Magento\Framework\Exception\FileSystemException */ protected function changePermissions($directoryCodeList, $dirPermissions, $filePermissions) { @@ -319,6 +325,7 @@ protected function changePermissions($directoryCodeList, $dirPermissions, $fileP * of inverse mask for setting access permissions to files and directories generated by Magento. * @link http://devdocs.magento.com/guides/v2.0/install-gde/install/post-install-umask.html * @link http://devdocs.magento.com/guides/v2.0/install-gde/prereq/file-system-perms.html + * @throws \Magento\Framework\Exception\FileSystemException */ public function lockStaticResources() { @@ -333,4 +340,15 @@ public function lockStaticResources() self::PERMISSIONS_FILE ); } + + /** + * Flush cache and restore the basic cache directories. + * + * @throws LocalizedException + */ + private function reinitCacheDirectories() + { + $command = $this->functionCallPath . 'cache:flush '; + $this->shell->execute($command); + } } diff --git a/app/code/Magento/Deploy/Test/Unit/Model/FilesystemTest.php b/app/code/Magento/Deploy/Test/Unit/Model/FilesystemTest.php index d14c86c4a3264..c1391cb7d9c5d 100644 --- a/app/code/Magento/Deploy/Test/Unit/Model/FilesystemTest.php +++ b/app/code/Magento/Deploy/Test/Unit/Model/FilesystemTest.php @@ -116,17 +116,27 @@ public function testRegenerateStatic() $this->storeView->method('retrieveLocales') ->willReturn($storeLocales); - $setupDiCompileCmd = $this->cmdPrefix . 'setup:di:compile'; + $setupDiCompileCmd = $this->cmdPrefix . 'cache:flush '; $this->shell->expects(self::at(0)) ->method('execute') ->with($setupDiCompileCmd); + $setupDiCompileCmd = $this->cmdPrefix . 'setup:di:compile'; + $this->shell->expects(self::at(1)) + ->method('execute') + ->with($setupDiCompileCmd); + + $setupDiCompileCmd = $this->cmdPrefix . 'cache:flush '; + $this->shell->expects(self::at(2)) + ->method('execute') + ->with($setupDiCompileCmd); + $this->initAdminLocaleMock('en_US'); $usedLocales = ['fr_FR', 'de_DE', 'nl_NL', 'en_US']; $staticContentDeployCmd = $this->cmdPrefix . 'setup:static-content:deploy -f ' . implode(' ', $usedLocales); - $this->shell->expects(self::at(1)) + $this->shell->expects(self::at(3)) ->method('execute') ->with($staticContentDeployCmd); diff --git a/app/code/Magento/Deploy/composer.json b/app/code/Magento/Deploy/composer.json index 3c28dc345cba4..79ccab30d2924 100644 --- a/app/code/Magento/Deploy/composer.json +++ b/app/code/Magento/Deploy/composer.json @@ -10,7 +10,7 @@ "magento/module-config": "101.0.*" }, "type": "magento2-module", - "version": "100.2.5", + "version": "100.2.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Deploy/etc/di.xml b/app/code/Magento/Deploy/etc/di.xml index fd604aa1b397b..0c32baebf12df 100644 --- a/app/code/Magento/Deploy/etc/di.xml +++ b/app/code/Magento/Deploy/etc/di.xml @@ -71,18 +71,4 @@ </argument> </arguments> </type> - <type name="Magento\Deploy\App\Mode\ConfigProvider"> - <arguments> - <argument name="config" xsi:type="array"> - <item name="developer" xsi:type="array"> - <item name="production" xsi:type="array"> - <item name="dev/debug/debug_logging" xsi:type="string">0</item> - </item> - <item name="developer" xsi:type="array"> - <item name="dev/debug/debug_logging" xsi:type="null" /> - </item> - </item> - </argument> - </arguments> - </type> </config> diff --git a/app/code/Magento/Developer/Model/Logger/Handler/Debug.php b/app/code/Magento/Developer/Model/Logger/Handler/Debug.php index 9bfee42fa6a83..d14da17b6ef74 100644 --- a/app/code/Magento/Developer/Model/Logger/Handler/Debug.php +++ b/app/code/Magento/Developer/Model/Logger/Handler/Debug.php @@ -5,10 +5,10 @@ */ namespace Magento\Developer\Model\Logger\Handler; +use Magento\Config\Setup\ConfigOptionsList; use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\State; use Magento\Framework\Filesystem\DriverInterface; -use Magento\Store\Model\ScopeInterface; use Magento\Framework\App\DeploymentConfig; /** @@ -60,9 +60,26 @@ public function isHandling(array $record) if ($this->deploymentConfig->isAvailable()) { return parent::isHandling($record) - && $this->scopeConfig->getValue('dev/debug/debug_logging', ScopeInterface::SCOPE_STORE); + && $this->isLoggingEnabled(); } return parent::isHandling($record); } + + /** + * Check that logging functionality is enabled. + * + * @return bool + */ + private function isLoggingEnabled(): bool + { + $configValue = $this->deploymentConfig->get(ConfigOptionsList::CONFIG_PATH_DEBUG_LOGGING); + if ($configValue === null) { + $isEnabled = $this->state->getMode() !== State::MODE_PRODUCTION; + } else { + $isEnabled = (bool)$configValue; + } + + return $isEnabled; + } } diff --git a/app/code/Magento/Developer/Test/Unit/Model/Logger/Handler/DebugTest.php b/app/code/Magento/Developer/Test/Unit/Model/Logger/Handler/DebugTest.php index c116775d582bb..0e009465872cd 100644 --- a/app/code/Magento/Developer/Test/Unit/Model/Logger/Handler/DebugTest.php +++ b/app/code/Magento/Developer/Test/Unit/Model/Logger/Handler/DebugTest.php @@ -10,7 +10,6 @@ use Magento\Framework\App\State; use Magento\Framework\Filesystem\DriverInterface; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; -use Magento\Store\Model\ScopeInterface; use Monolog\Formatter\FormatterInterface; use Monolog\Logger; use Magento\Framework\App\DeploymentConfig; @@ -51,6 +50,9 @@ class DebugTest extends \PHPUnit\Framework\TestCase */ private $deploymentConfigMock; + /** + * @inheritdoc + */ protected function setUp() { $this->filesystemMock = $this->getMockBuilder(DriverInterface::class) @@ -80,70 +82,95 @@ protected function setUp() $this->model->setFormatter($this->formatterMock); } - public function testHandle() + /** + * @return void + */ + public function testHandleEnabledInDeveloperMode() { $this->deploymentConfigMock->expects($this->once()) ->method('isAvailable') ->willReturn(true); - $this->stateMock->expects($this->never()) - ->method('getMode'); - $this->scopeConfigMock->expects($this->once()) - ->method('getValue') - ->with('dev/debug/debug_logging', ScopeInterface::SCOPE_STORE, null) - ->willReturn(true); + $this->stateMock + ->expects($this->once()) + ->method('getMode') + ->willReturn(State::MODE_DEVELOPER); + $this->scopeConfigMock + ->expects($this->never()) + ->method('getValue'); $this->assertTrue($this->model->isHandling(['formatted' => false, 'level' => Logger::DEBUG])); } - public function testHandleDisabledByProduction() + /** + * @return void + */ + public function testHandleEnabledInDefaultMode() { $this->deploymentConfigMock->expects($this->once()) ->method('isAvailable') ->willReturn(true); - $this->stateMock->expects($this->never()) - ->method('getMode'); - $this->scopeConfigMock->expects($this->once()) + $this->stateMock + ->expects($this->once()) + ->method('getMode') + ->willReturn(State::MODE_DEFAULT); + $this->scopeConfigMock + ->expects($this->never()) ->method('getValue'); - $this->assertFalse($this->model->isHandling(['formatted' => false, 'level' => Logger::DEBUG])); + $this->assertTrue($this->model->isHandling(['formatted' => false, 'level' => Logger::DEBUG])); } - public function testHandleDisabledByConfig() + /** + * @return void + */ + public function testHandleDisabledByProduction() { $this->deploymentConfigMock->expects($this->once()) ->method('isAvailable') ->willReturn(true); - $this->stateMock->expects($this->never()) - ->method('getMode'); - $this->scopeConfigMock->expects($this->once()) - ->method('getValue') - ->with('dev/debug/debug_logging', ScopeInterface::SCOPE_STORE, null) - ->willReturn(false); + $this->stateMock + ->expects($this->once()) + ->method('getMode') + ->willReturn(State::MODE_PRODUCTION); + $this->scopeConfigMock + ->expects($this->never()) + ->method('getValue'); $this->assertFalse($this->model->isHandling(['formatted' => false, 'level' => Logger::DEBUG])); } + /** + * @return void + */ public function testHandleDisabledByLevel() { $this->deploymentConfigMock->expects($this->once()) ->method('isAvailable') ->willReturn(true); - $this->stateMock->expects($this->never()) - ->method('getMode'); - $this->scopeConfigMock->expects($this->never()) + $this->stateMock + ->expects($this->never()) + ->method('getMode') + ->willReturn(State::MODE_DEVELOPER); + $this->scopeConfigMock + ->expects($this->never()) ->method('getValue'); $this->assertFalse($this->model->isHandling(['formatted' => false, 'level' => Logger::API])); } + /** + * @return void + */ public function testDeploymentConfigIsNotAvailable() { $this->deploymentConfigMock->expects($this->once()) ->method('isAvailable') ->willReturn(false); - $this->stateMock->expects($this->never()) + $this->stateMock + ->expects($this->never()) ->method('getMode'); - $this->scopeConfigMock->expects($this->never()) + $this->scopeConfigMock + ->expects($this->never()) ->method('getValue'); $this->assertTrue($this->model->isHandling(['formatted' => false, 'level' => Logger::DEBUG])); diff --git a/app/code/Magento/Developer/composer.json b/app/code/Magento/Developer/composer.json index e2236e248cfc3..771853609211a 100644 --- a/app/code/Magento/Developer/composer.json +++ b/app/code/Magento/Developer/composer.json @@ -8,7 +8,7 @@ "magento/module-config": "101.0.*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Developer/etc/adminhtml/system.xml b/app/code/Magento/Developer/etc/adminhtml/system.xml index db685a5453cd4..c64abd6eae725 100644 --- a/app/code/Magento/Developer/etc/adminhtml/system.xml +++ b/app/code/Magento/Developer/etc/adminhtml/system.xml @@ -25,13 +25,6 @@ <backend_model>Magento\Developer\Model\Config\Backend\AllowedIps</backend_model> </field> </group> - <group id="debug" type="text" sortOrder="30" showInDefault="1" showInWebsite="1" showInStore="1"> - <field id="debug_logging" translate="label comment" type="select" sortOrder="30" showInDefault="1" showInWebsite="0" showInStore="0"> - <label>Log to File</label> - <comment>Not available in production mode.</comment> - <source_model>Magento\Config\Model\Config\Source\Yesno</source_model> - </field> - </group> </section> </system> </config> diff --git a/app/code/Magento/Dhl/composer.json b/app/code/Magento/Dhl/composer.json index fae1eb9e1454f..93926d6e1e28b 100644 --- a/app/code/Magento/Dhl/composer.json +++ b/app/code/Magento/Dhl/composer.json @@ -19,7 +19,7 @@ "magento/module-checkout": "100.2.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Directory/Model/Config/Source/WeightUnit.php b/app/code/Magento/Directory/Model/Config/Source/WeightUnit.php index 4e2758b362a43..97d22633af03c 100644 --- a/app/code/Magento/Directory/Model/Config/Source/WeightUnit.php +++ b/app/code/Magento/Directory/Model/Config/Source/WeightUnit.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Directory\Model\Config\Source; /** @@ -14,10 +15,23 @@ class WeightUnit implements \Magento\Framework\Option\ArrayInterface { /** - * {@inheritdoc} + * @var string + */ + const CODE_LBS = 'lbs'; + + /** + * @var string + */ + const CODE_KGS = 'kgs'; + + /** + * @inheritdoc */ public function toOptionArray() { - return [['value' => 'lbs', 'label' => __('lbs')], ['value' => 'kgs', 'label' => __('kgs')]]; + return [ + ['value' => self::CODE_LBS, 'label' => __('lbs')], + ['value' => self::CODE_KGS, 'label' => __('kgs')] + ]; } } diff --git a/app/code/Magento/Directory/Test/Mftf/Data/CurrencyConfigData.xml b/app/code/Magento/Directory/Test/Mftf/Data/CurrencyConfigData.xml new file mode 100644 index 0000000000000..1fd39933f51af --- /dev/null +++ b/app/code/Magento/Directory/Test/Mftf/Data/CurrencyConfigData.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <entity name="DefaultCurrencySetting" type="currency_config_default"> + <requiredEntity type="base_currency">BaseCurrency</requiredEntity> + <requiredEntity type="default_currency">DefaultCurrency</requiredEntity> + <requiredEntity type="allowed_currencies">AllowedCurrencies</requiredEntity> + </entity> + <entity name="BaseCurrency" type="base_currency"> + <data key="value">USD</data> + </entity> + <entity name="DefaultCurrency" type="default_currency"> + <data key="value">USD</data> + </entity> + <entity name="AllowedCurrencies" type="allowed_currencies"> + <array key="value"> + <item>USD</item> + </array> + </entity> + <entity name="CurrencySettingWithEuroAndUSD" type="currency_config_default"> + <requiredEntity type="base_currency">BaseCurrency</requiredEntity> + <requiredEntity type="default_currency">DefaultCurrency</requiredEntity> + <requiredEntity type="allowed_currencies">AllowedCurrenciesEuroAndUSD</requiredEntity> + </entity> + <entity name="AllowedCurrenciesEuroAndUSD" type="allowed_currencies"> + <array key="value"> + <item>EUR</item> + <item>USD</item> + </array> + </entity> +</entities> diff --git a/app/code/Magento/Directory/Test/Mftf/Metadata/currency_config-meta.xml b/app/code/Magento/Directory/Test/Mftf/Metadata/currency_config-meta.xml new file mode 100644 index 0000000000000..5ef1179ea7829 --- /dev/null +++ b/app/code/Magento/Directory/Test/Mftf/Metadata/currency_config-meta.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<operations xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataOperation.xsd"> + <operation name="CurrencyConfigAllowedCurrencies" dataType="currency_config_default" type="create" auth="adminFormKey" url="/admin/system_config/save/section/currency/" method="POST" successRegex="/messages-message-success/"> + <contentType>application/x-www-form-urlencoded</contentType> + <object key="groups" dataType="currency_config_default"> + <object key="options" dataType="currency_config_default"> + <object key="fields" dataType="currency_config_default"> + <object key="base" dataType="base_currency"> + <field key="value">string</field> + </object> + <object key="default" dataType="default_currency"> + <field key="value">string</field> + </object> + <object key="allow" dataType="allowed_currencies"> + <array key="value"> + <value>string</value> + </array> + </object> + </object> + </object> + </object> + </operation> +</operations> diff --git a/app/code/Magento/Directory/Test/Mftf/Section/StorefrontHeaderCurrencySwitcherSection.xml b/app/code/Magento/Directory/Test/Mftf/Section/StorefrontHeaderCurrencySwitcherSection.xml new file mode 100644 index 0000000000000..d7e8bf1a71c1d --- /dev/null +++ b/app/code/Magento/Directory/Test/Mftf/Section/StorefrontHeaderCurrencySwitcherSection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontHeaderCurrencySwitcherSection"> + <element name="currencyTrigger" type="select" selector="#switcher-currency-trigger" timeout="30"/> + <element name="currency" type="select" selector="//ul[@class='dropdown switcher-dropdown']//a[text()='{{arg}}']" parameterized="true"/> + </section> +</sections> diff --git a/app/code/Magento/Directory/composer.json b/app/code/Magento/Directory/composer.json index c6de1d1eefe6c..a4b5991095307 100644 --- a/app/code/Magento/Directory/composer.json +++ b/app/code/Magento/Directory/composer.json @@ -10,7 +10,7 @@ "lib-libxml": "*" }, "type": "magento2-module", - "version": "100.2.5", + "version": "100.2.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Downloadable/Controller/Adminhtml/Product/Initialization/Helper/Plugin/Downloadable.php b/app/code/Magento/Downloadable/Controller/Adminhtml/Product/Initialization/Helper/Plugin/Downloadable.php index f56b219f72db2..a283891afc406 100644 --- a/app/code/Magento/Downloadable/Controller/Adminhtml/Product/Initialization/Helper/Plugin/Downloadable.php +++ b/app/code/Magento/Downloadable/Controller/Adminhtml/Product/Initialization/Helper/Plugin/Downloadable.php @@ -11,6 +11,9 @@ use Magento\Downloadable\Api\Data\SampleInterfaceFactory; use Magento\Downloadable\Api\Data\LinkInterfaceFactory; +/** + * Class for initialization downloadable info from request. + */ class Downloadable { /** @@ -92,6 +95,8 @@ public function afterInitialize( } } $extension->setDownloadableProductLinks($links); + } else { + $extension->setDownloadableProductLinks([]); } if (isset($downloadable['sample']) && is_array($downloadable['sample'])) { $samples = []; @@ -107,6 +112,8 @@ public function afterInitialize( } } $extension->setDownloadableProductSamples($samples); + } else { + $extension->setDownloadableProductSamples([]); } $product->setExtensionAttributes($extension); if ($product->getLinksPurchasedSeparately()) { diff --git a/app/code/Magento/Downloadable/composer.json b/app/code/Magento/Downloadable/composer.json index e737c3121f454..e8a885cb49806 100644 --- a/app/code/Magento/Downloadable/composer.json +++ b/app/code/Magento/Downloadable/composer.json @@ -25,7 +25,7 @@ "magento/module-downloadable-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "100.2.5", + "version": "100.2.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/DownloadableImportExport/composer.json b/app/code/Magento/DownloadableImportExport/composer.json index 763eceaea8460..07336fa5c9957 100644 --- a/app/code/Magento/DownloadableImportExport/composer.json +++ b/app/code/Magento/DownloadableImportExport/composer.json @@ -12,7 +12,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Eav/Model/Attribute/Data/AbstractData.php b/app/code/Magento/Eav/Model/Attribute/Data/AbstractData.php index 12023acc3b33b..f4bcdf7764d95 100644 --- a/app/code/Magento/Eav/Model/Attribute/Data/AbstractData.php +++ b/app/code/Magento/Eav/Model/Attribute/Data/AbstractData.php @@ -144,7 +144,8 @@ public function setRequestScope($scope) } /** - * Set scope visibility + * Set scope visibility. + * * Search value only in scope or search value in scope and global * * @param bool $flag @@ -313,9 +314,13 @@ protected function _validateInputRule($value) if (!empty($validateRules['input_validation'])) { $label = $this->getAttribute()->getStoreLabel(); + $allowWhiteSpace = false; switch ($validateRules['input_validation']) { + case 'alphanum-with-spaces': + $allowWhiteSpace = true; + // continue to alphanumeric validation case 'alphanumeric': - $validator = new \Zend_Validate_Alnum(true); + $validator = new \Zend_Validate_Alnum($allowWhiteSpace); $validator->setMessage(__('"%1" invalid type entered.', $label), \Zend_Validate_Alnum::INVALID); $validator->setMessage( __('"%1" contains non-alphabetic or non-numeric characters.', $label), diff --git a/app/code/Magento/Eav/Model/Attribute/Data/File.php b/app/code/Magento/Eav/Model/Attribute/Data/File.php index 0364330517b88..d6476eefc59b1 100644 --- a/app/code/Magento/Eav/Model/Attribute/Data/File.php +++ b/app/code/Magento/Eav/Model/Attribute/Data/File.php @@ -120,8 +120,7 @@ public function extractValue(RequestInterface $request) } /** - * Validate file by attribute validate rules - * Return array of errors + * Validate file by attribute validate rules and return array of errors. * * @param array $value * @return string[] @@ -147,7 +146,7 @@ protected function _validateByRules($value) return $this->_fileValidator->getMessages(); } - if (!is_uploaded_file($value['tmp_name'])) { + if (!empty($value['tmp_name']) && !is_uploaded_file($value['tmp_name'])) { return [__('"%1" is not a valid file.', $label)]; } @@ -174,12 +173,22 @@ public function validateValue($value) if ($this->getIsAjaxRequest()) { return true; } + $fileData = $value; + + if (is_string($value) && !empty($value)) { + $dir = $this->_directory->getAbsolutePath($this->getAttribute()->getEntityType()->getEntityTypeCode()); + $fileData = [ + 'size' => filesize($dir . $value), + 'name' => $value, + 'tmp_name' => $dir . $value, + ]; + } $errors = []; $attribute = $this->getAttribute(); $toDelete = !empty($value['delete']) ? true : false; - $toUpload = !empty($value['tmp_name']) ? true : false; + $toUpload = !empty($value['tmp_name']) || is_string($value) && !empty($value) ? true : false; if (!$toUpload && !$toDelete && $this->getEntity()->getData($attribute->getAttributeCode())) { return true; @@ -195,11 +204,13 @@ public function validateValue($value) } if ($toUpload) { - $errors = array_merge($errors, $this->_validateByRules($value)); + $errors = array_merge($errors, $this->_validateByRules($fileData)); } if (count($errors) == 0) { return true; + } elseif (is_string($value) && !empty($value)) { + $this->_directory->delete($dir . $value); } return $errors; diff --git a/app/code/Magento/Eav/Model/Entity/Attribute/Frontend/AbstractFrontend.php b/app/code/Magento/Eav/Model/Entity/Attribute/Frontend/AbstractFrontend.php index 13c0b7a627edb..cd7639080509f 100644 --- a/app/code/Magento/Eav/Model/Entity/Attribute/Frontend/AbstractFrontend.php +++ b/app/code/Magento/Eav/Model/Entity/Attribute/Frontend/AbstractFrontend.php @@ -20,6 +20,8 @@ use Magento\Eav\Model\Entity\Attribute\Source\BooleanFactory; /** + * EAV entity attribute form renderer. + * * @api * @since 100.0.2 */ @@ -234,6 +236,9 @@ protected function _getInputValidateClass() case 'alphanumeric': $class = 'validate-alphanum'; break; + case 'alphanum-with-spaces': + $class = 'validate-alphanum-with-spaces'; + break; case 'numeric': $class = 'validate-digits'; break; diff --git a/app/code/Magento/Eav/Model/Entity/Collection/AbstractCollection.php b/app/code/Magento/Eav/Model/Entity/Collection/AbstractCollection.php index 2177686a4a6be..7ac77a66482b8 100644 --- a/app/code/Magento/Eav/Model/Entity/Collection/AbstractCollection.php +++ b/app/code/Magento/Eav/Model/Entity/Collection/AbstractCollection.php @@ -394,7 +394,7 @@ public function addAttributeToFilter($attribute, $condition = null, $joinType = */ public function addFieldToFilter($attribute, $condition = null) { - return $this->addAttributeToFilter($attribute, $condition); + return $this->addAttributeToFilter($attribute, $condition, 'left'); } /** diff --git a/app/code/Magento/Eav/Test/Mftf/Section/AdminAttributesEditSection.xml b/app/code/Magento/Eav/Test/Mftf/Section/AdminAttributesEditSection.xml new file mode 100644 index 0000000000000..da48db84f99ed --- /dev/null +++ b/app/code/Magento/Eav/Test/Mftf/Section/AdminAttributesEditSection.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminAttributesEditSection"> + <element name="label" type="input" selector="#attribute_label"/> + <element name="code" type="input" selector="#attribute_code"/> + <element name="inputType" type="select" selector="#frontend_input"/> + <element name="valuesRequired" type="select" selector="#is_required"/> + </section> +</sections> diff --git a/app/code/Magento/Eav/Test/Mftf/Section/AdminAttributesGridSection.xml b/app/code/Magento/Eav/Test/Mftf/Section/AdminAttributesGridSection.xml new file mode 100644 index 0000000000000..36360a0ebc565 --- /dev/null +++ b/app/code/Magento/Eav/Test/Mftf/Section/AdminAttributesGridSection.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminAttributesGridSection"> + <element name="attribute" type="text" selector="//td[contains(text(), '{{arg3}}')]" parameterized="true"/> + </section> +</sections> diff --git a/app/code/Magento/Eav/Test/Unit/Model/Attribute/Data/TextTest.php b/app/code/Magento/Eav/Test/Unit/Model/Attribute/Data/TextTest.php index 217a04045b939..8b16d286471b4 100644 --- a/app/code/Magento/Eav/Test/Unit/Model/Attribute/Data/TextTest.php +++ b/app/code/Magento/Eav/Test/Unit/Model/Attribute/Data/TextTest.php @@ -6,22 +6,24 @@ namespace Magento\Eav\Test\Unit\Model\Attribute\Data; +use Magento\Framework\Stdlib\StringUtils; + class TextTest extends \PHPUnit\Framework\TestCase { /** * @var \Magento\Eav\Model\Attribute\Data\Text */ - protected $_model; + private $model; protected function setUp() { $locale = $this->createMock(\Magento\Framework\Stdlib\DateTime\TimezoneInterface::class); $localeResolver = $this->createMock(\Magento\Framework\Locale\ResolverInterface::class); $logger = $this->createMock(\Psr\Log\LoggerInterface::class); - $helper = $this->createMock(\Magento\Framework\Stdlib\StringUtils::class); + $helper = new StringUtils; - $this->_model = new \Magento\Eav\Model\Attribute\Data\Text($locale, $logger, $localeResolver, $helper); - $this->_model->setAttribute( + $this->model = new \Magento\Eav\Model\Attribute\Data\Text($locale, $logger, $localeResolver, $helper); + $this->model->setAttribute( $this->createAttribute( [ 'store_label' => 'Test', @@ -35,24 +37,39 @@ protected function setUp() protected function tearDown() { - $this->_model = null; + $this->model = null; } + /** + * Test of string validation. + * + * @return void + */ public function testValidateValueString() { $inputValue = '0'; $expectedResult = true; - $this->assertEquals($expectedResult, $this->_model->validateValue($inputValue)); + $this->assertEquals($expectedResult, $this->model->validateValue($inputValue)); } + /** + * Test of integer validation. + * + * @return void + */ public function testValidateValueInteger() { $inputValue = 0; $expectedResult = ['"Test" is a required value.']; - $result = $this->_model->validateValue($inputValue); + $result = $this->model->validateValue($inputValue); $this->assertEquals($expectedResult, [(string)$result[0]]); } + /** + * Test without length validation. + * + * @return void + */ public function testWithoutLengthValidation() { $expectedResult = true; @@ -64,12 +81,109 @@ public function testWithoutLengthValidation() ]; $defaultAttributeData['validate_rules']['min_text_length'] = 2; - $this->_model->setAttribute($this->createAttribute($defaultAttributeData)); - $this->assertEquals($expectedResult, $this->_model->validateValue('t')); + $this->model->setAttribute($this->createAttribute($defaultAttributeData)); + $this->assertEquals($expectedResult, $this->model->validateValue('t')); $defaultAttributeData['validate_rules']['max_text_length'] = 3; - $this->_model->setAttribute($this->createAttribute($defaultAttributeData)); - $this->assertEquals($expectedResult, $this->_model->validateValue('test')); + $this->model->setAttribute($this->createAttribute($defaultAttributeData)); + $this->assertEquals($expectedResult, $this->model->validateValue('test')); + } + + /** + * Test of alphanumeric validation. + * + * @param string $value + * @param bool|array $expectedResult + * @return void + * @dataProvider alphanumDataProvider + */ + public function testAlphanumericValidation(string $value, $expectedResult) + { + $defaultAttributeData = [ + 'store_label' => 'Test', + 'attribute_code' => 'test', + 'is_required' => 1, + 'validate_rules' => [ + 'min_text_length' => 0, + 'max_text_length' => 10, + 'input_validation' => 'alphanumeric', + ], + ]; + + $this->model->setAttribute($this->createAttribute($defaultAttributeData)); + $this->assertEquals($expectedResult, $this->model->validateValue($value)); + } + + /** + * Provides possible input values. + * + * @return array + */ + public function alphanumDataProvider(): array + { + return [ + ['QazWsx', true], + ['QazWsx123', true], + [ + 'QazWsx 123', + [\Zend_Validate_Alnum::NOT_ALNUM => '"Test" contains non-alphabetic or non-numeric characters.'], + ], + [ + 'QazWsx_123', + [\Zend_Validate_Alnum::NOT_ALNUM => '"Test" contains non-alphabetic or non-numeric characters.'], + ], + [ + 'QazWsx12345', + [__('"%1" length must be equal or less than %2 characters.', 'Test', 10)], + ], + ]; + } + + /** + * Test of alphanumeric validation with spaces. + * + * @param string $value + * @param bool|array $expectedResult + * @return void + * @dataProvider alphanumWithSpacesDataProvider + */ + public function testAlphanumericValidationWithSpaces(string $value, $expectedResult) + { + $defaultAttributeData = [ + 'store_label' => 'Test', + 'attribute_code' => 'test', + 'is_required' => 1, + 'validate_rules' => [ + 'min_text_length' => 0, + 'max_text_length' => 10, + 'input_validation' => 'alphanum-with-spaces', + ], + ]; + + $this->model->setAttribute($this->createAttribute($defaultAttributeData)); + $this->assertEquals($expectedResult, $this->model->validateValue($value)); + } + + /** + * Provides possible input values. + * + * @return array + */ + public function alphanumWithSpacesDataProvider(): array + { + return [ + ['QazWsx', true], + ['QazWsx123', true], + ['QazWsx 123', true], + [ + 'QazWsx_123', + [\Zend_Validate_Alnum::NOT_ALNUM => '"Test" contains non-alphabetic or non-numeric characters.'], + ], + [ + 'QazWsx12345', + [__('"%1" length must be equal or less than %2 characters.', 'Test', 10)], + ], + ]; } /** diff --git a/app/code/Magento/Eav/Test/Unit/Model/Entity/Attribute/Frontend/DefaultFrontendTest.php b/app/code/Magento/Eav/Test/Unit/Model/Entity/Attribute/Frontend/DefaultFrontendTest.php index a61c9ef447458..2c5b1aeb7bbfd 100644 --- a/app/code/Magento/Eav/Test/Unit/Model/Entity/Attribute/Frontend/DefaultFrontendTest.php +++ b/app/code/Magento/Eav/Test/Unit/Model/Entity/Attribute/Frontend/DefaultFrontendTest.php @@ -13,41 +13,42 @@ use Magento\Framework\App\CacheInterface; use Magento\Eav\Model\Entity\Attribute\AbstractAttribute; use Magento\Eav\Model\Entity\Attribute\Source\AbstractSource; +use PHPUnit\Framework\MockObject\MockObject; class DefaultFrontendTest extends \PHPUnit\Framework\TestCase { /** * @var DefaultFrontend */ - protected $model; + private $model; /** - * @var BooleanFactory|\PHPUnit_Framework_MockObject_MockObject + * @var BooleanFactory|MockObject */ - protected $booleanFactory; + private $booleanFactory; /** - * @var Serializer|\PHPUnit_Framework_MockObject_MockObject + * @var Serializer|MockObject */ private $serializerMock; /** - * @var StoreManagerInterface|\PHPUnit_Framework_MockObject_MockObject + * @var StoreManagerInterface|MockObject */ private $storeManagerMock; /** - * @var StoreInterface|\PHPUnit_Framework_MockObject_MockObject + * @var StoreInterface|MockObject */ private $storeMock; /** - * @var CacheInterface|\PHPUnit_Framework_MockObject_MockObject + * @var CacheInterface|MockObject */ private $cacheMock; /** - * @var AbstractAttribute|\PHPUnit_Framework_MockObject_MockObject + * @var AbstractAttribute|MockObject */ private $attributeMock; @@ -57,7 +58,7 @@ class DefaultFrontendTest extends \PHPUnit\Framework\TestCase private $cacheTags; /** - * @var AbstractSource|\PHPUnit_Framework_MockObject_MockObject + * @var AbstractSource|MockObject */ private $sourceMock; @@ -76,44 +77,31 @@ protected function setUp() ->getMockForAbstractClass(); $this->cacheMock = $this->getMockBuilder(CacheInterface::class) ->getMockForAbstractClass(); - $this->attributeMock = $this->getMockBuilder(AbstractAttribute::class) - ->disableOriginalConstructor() - ->setMethods(['getAttributeCode', 'getSource']) - ->getMockForAbstractClass(); + $this->attributeMock = $this->createAttributeMock(); $this->sourceMock = $this->getMockBuilder(AbstractSource::class) ->disableOriginalConstructor() ->setMethods(['getAllOptions']) ->getMockForAbstractClass(); - $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); - $this->model = $objectManager->getObject( - DefaultFrontend::class, - [ - '_attrBooleanFactory' => $this->booleanFactory, - 'cache' => $this->cacheMock, - 'storeManager' => $this->storeManagerMock, - 'serializer' => $this->serializerMock, - '_attribute' => $this->attributeMock, - 'cacheTags' => $this->cacheTags - ] + $this->model = new DefaultFrontend( + $this->booleanFactory, + $this->cacheMock, + null, + $this->cacheTags, + $this->storeManagerMock, + $this->serializerMock ); + + $this->model->setAttribute($this->attributeMock); } public function testGetClassEmpty() { - $attributeMock = $this->getMockBuilder(\Magento\Eav\Model\Entity\Attribute\AbstractAttribute::class) - ->disableOriginalConstructor() - ->setMethods([ - 'getIsRequired', - 'getFrontendClass', - 'getValidateRules', - ]) - ->getMock(); - $attributeMock->expects($this->once()) - ->method('getIsRequired') + /** @var AbstractAttribute|MockObject $attributeMock */ + $attributeMock = $this->createAttributeMock(); + $attributeMock->method('getIsRequired') ->willReturn(false); - $attributeMock->expects($this->once()) - ->method('getFrontendClass') + $attributeMock->method('getFrontendClass') ->willReturn(''); $attributeMock->expects($this->exactly(2)) ->method('getValidateRules') @@ -123,26 +111,26 @@ public function testGetClassEmpty() $this->assertEmpty($this->model->getClass()); } - public function testGetClass() + /** + * Validates generated html classes. + * + * @param string $validationRule + * @param string $expectedClass + * @return void + * @dataProvider validationRulesDataProvider + */ + public function testGetClass(string $validationRule, string $expectedClass) { - $attributeMock = $this->getMockBuilder(\Magento\Eav\Model\Entity\Attribute\AbstractAttribute::class) - ->disableOriginalConstructor() - ->setMethods([ - 'getIsRequired', - 'getFrontendClass', - 'getValidateRules', - ]) - ->getMock(); - $attributeMock->expects($this->once()) - ->method('getIsRequired') + /** @var AbstractAttribute|MockObject $attributeMock */ + $attributeMock = $this->createAttributeMock(); + $attributeMock->method('getIsRequired') ->willReturn(true); - $attributeMock->expects($this->once()) - ->method('getFrontendClass') + $attributeMock->method('getFrontendClass') ->willReturn(''); $attributeMock->expects($this->exactly(3)) ->method('getValidateRules') ->willReturn([ - 'input_validation' => 'alphanumeric', + 'input_validation' => $validationRule, 'min_text_length' => 1, 'max_text_length' => 2, ]); @@ -150,7 +138,7 @@ public function testGetClass() $this->model->setAttribute($attributeMock); $result = $this->model->getClass(); - $this->assertContains('validate-alphanum', $result); + $this->assertContains($expectedClass, $result); $this->assertContains('minimum-length-1', $result); $this->assertContains('maximum-length-2', $result); $this->assertContains('validate-length', $result); @@ -158,19 +146,11 @@ public function testGetClass() public function testGetClassLength() { - $attributeMock = $this->getMockBuilder(\Magento\Eav\Model\Entity\Attribute\AbstractAttribute::class) - ->disableOriginalConstructor() - ->setMethods([ - 'getIsRequired', - 'getFrontendClass', - 'getValidateRules', - ]) - ->getMock(); - $attributeMock->expects($this->once()) - ->method('getIsRequired') + /** @var AbstractAttribute|MockObject $attributeMock */ + $attributeMock = $this->createAttributeMock(); + $attributeMock->method('getIsRequired') ->willReturn(true); - $attributeMock->expects($this->once()) - ->method('getFrontendClass') + $attributeMock->method('getFrontendClass') ->willReturn(''); $attributeMock->expects($this->exactly(3)) ->method('getValidateRules') @@ -196,33 +176,62 @@ public function testGetSelectOptions() $options = ['option1', 'option2']; $serializedOptions = "{['option1', 'option2']}"; - $this->storeManagerMock->expects($this->once()) - ->method('getStore') + $this->storeManagerMock->method('getStore') ->willReturn($this->storeMock); - $this->storeMock->expects($this->once()) - ->method('getId') + $this->storeMock->method('getId') ->willReturn($storeId); - $this->attributeMock->expects($this->once()) - ->method('getAttributeCode') + $this->attributeMock->method('getAttributeCode') ->willReturn($attributeCode); - $this->cacheMock->expects($this->once()) - ->method('load') + $this->cacheMock->method('load') ->with($cacheKey) ->willReturn(false); - $this->attributeMock->expects($this->once()) - ->method('getSource') + $this->attributeMock->method('getSource') ->willReturn($this->sourceMock); - $this->sourceMock->expects($this->once()) - ->method('getAllOptions') + $this->sourceMock->method('getAllOptions') ->willReturn($options); - $this->serializerMock->expects($this->once()) - ->method('serialize') + $this->serializerMock->method('serialize') ->with($options) ->willReturn($serializedOptions); - $this->cacheMock->expects($this->once()) - ->method('save') + $this->cacheMock->method('save') ->with($serializedOptions, $cacheKey, $this->cacheTags); $this->assertSame($options, $this->model->getSelectOptions()); } + + /** + * Provides possible validation types. + * + * @return array + */ + public function validationRulesDataProvider(): array + { + return [ + ['alphanumeric', 'validate-alphanum'], + ['alphanum-with-spaces', 'validate-alphanum-with-spaces'], + ['alpha', 'validate-alpha'], + ['numeric', 'validate-digits'], + ['url', 'validate-url'], + ['email', 'validate-email'], + ['length', 'validate-length'], + ]; + } + + /** + * Entity attribute factory. + * + * @return AbstractAttribute|MockObject + */ + private function createAttributeMock() + { + return $this->getMockBuilder(AbstractAttribute::class) + ->disableOriginalConstructor() + ->setMethods([ + 'getIsRequired', + 'getFrontendClass', + 'getValidateRules', + 'getAttributeCode', + 'getSource' + ]) + ->getMockForAbstractClass(); + } } diff --git a/app/code/Magento/Eav/composer.json b/app/code/Magento/Eav/composer.json index 2f0a7fa3c79a9..b1eec7fcaa602 100644 --- a/app/code/Magento/Eav/composer.json +++ b/app/code/Magento/Eav/composer.json @@ -11,7 +11,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "101.0.5", + "version": "101.0.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Email/Test/Mftf/ActionGroup/AdminEmailTemplateActionGroup.xml b/app/code/Magento/Email/Test/Mftf/ActionGroup/AdminEmailTemplateActionGroup.xml index 4019e0538dd06..b0899c6d5ce4d 100644 --- a/app/code/Magento/Email/Test/Mftf/ActionGroup/AdminEmailTemplateActionGroup.xml +++ b/app/code/Magento/Email/Test/Mftf/ActionGroup/AdminEmailTemplateActionGroup.xml @@ -35,7 +35,8 @@ <argument name="emailTemplate" defaultValue="EmailTemplate"/> </arguments> <seeInCurrentUrl url="email_template/edit/id" stepKey="seeCreatedTemplateUrl"/> - <click selector="{{AdminMainActionsSection.delete}}" stepKey="clickDeleteTemplateButton"/> + <!--Do not change it to use element from AdminMainActionsSection. Refer to AdminEmailTemplateEditSection comment --> + <click selector="{{AdminEmailTemplateEditSection.delete}}" stepKey="clickDeleteTemplateButton"/> <acceptPopup stepKey="acceptDeletingTemplatePopUp"/> <see userInput="You deleted the email template." stepKey="seeSuccessfulMessage"/> <click selector="{{AdminDataGridHeaderSection.clearFilters}}" stepKey="clickResetFilterButton"/> diff --git a/app/code/Magento/Email/Test/Mftf/Section/AdminEmailTemplateEditSection.xml b/app/code/Magento/Email/Test/Mftf/Section/AdminEmailTemplateEditSection.xml index 9da02b64cb2e7..81c29b28a3fd6 100644 --- a/app/code/Magento/Email/Test/Mftf/Section/AdminEmailTemplateEditSection.xml +++ b/app/code/Magento/Email/Test/Mftf/Section/AdminEmailTemplateEditSection.xml @@ -14,5 +14,7 @@ <element name="templateNameField" type="input" selector="#template_code"/> <element name="templateSubjectField" type="input" selector="#template_subject"/> <element name="previewTemplateButton" type="button" selector="#preview"/> + <!--Do not add time to this element. It call alert when clicking, so adding time will brake test--> + <element name="delete" type="button" selector="#delete"/> </section> </sections> diff --git a/app/code/Magento/Email/composer.json b/app/code/Magento/Email/composer.json index bffc825a4c872..482b63a2a34d7 100644 --- a/app/code/Magento/Email/composer.json +++ b/app/code/Magento/Email/composer.json @@ -15,7 +15,7 @@ "magento/module-theme": "100.2.*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Email/view/frontend/email/header.html b/app/code/Magento/Email/view/frontend/email/header.html index a6895f629b641..c4f49698dc69b 100644 --- a/app/code/Magento/Email/view/frontend/email/header.html +++ b/app/code/Magento/Email/view/frontend/email/header.html @@ -43,8 +43,6 @@ {{if logo_height}} height="{{var logo_height}}" - {{else}} - height="52" {{/if}} src="{{var logo_url}}" diff --git a/app/code/Magento/Email/view/frontend/web/logo_email.png b/app/code/Magento/Email/view/frontend/web/logo_email.png index d01b530456e81..215e9d06edcdc 100644 Binary files a/app/code/Magento/Email/view/frontend/web/logo_email.png and b/app/code/Magento/Email/view/frontend/web/logo_email.png differ diff --git a/app/code/Magento/EncryptionKey/composer.json b/app/code/Magento/EncryptionKey/composer.json index 57298d2a9736b..1044173d8921d 100644 --- a/app/code/Magento/EncryptionKey/composer.json +++ b/app/code/Magento/EncryptionKey/composer.json @@ -8,7 +8,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "proprietary" ], diff --git a/app/code/Magento/Fedex/composer.json b/app/code/Magento/Fedex/composer.json index 55ff9fb83eb68..9211bcffb0f71 100644 --- a/app/code/Magento/Fedex/composer.json +++ b/app/code/Magento/Fedex/composer.json @@ -15,7 +15,7 @@ "lib-libxml": "*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/GiftMessage/Block/Message/Inline.php b/app/code/Magento/GiftMessage/Block/Message/Inline.php index 9f68199106cc7..b3b690710ec47 100644 --- a/app/code/Magento/GiftMessage/Block/Message/Inline.php +++ b/app/code/Magento/GiftMessage/Block/Message/Inline.php @@ -139,7 +139,7 @@ public function getType() /** * Define checkout type * - * @param $type string + * @param string $type * @return $this * @codeCoverageIgnore */ @@ -238,7 +238,7 @@ public function getMessage($entity = null) */ public function getItems() { - if (!$this->getData('items')) { + if (!$this->hasData('items')) { $items = []; $entityItems = $this->getEntity()->getAllItems(); @@ -277,13 +277,24 @@ public function countItems() return count($this->getItems()); } + /** + * Call method getItemsHasMessages. + * + * @deprecated Misspelled method + * @see getItemsHasMessages + */ + public function getItemsHasMesssages() + { + return $this->getItemsHasMessages(); + } + /** * Check if items has messages * * @return bool * @SuppressWarnings(PHPMD.BooleanGetMethodName) */ - public function getItemsHasMesssages() + public function getItemsHasMessages() { foreach ($this->getItems() as $item) { if ($item->getGiftMessageId()) { @@ -316,6 +327,21 @@ public function getEscaped($value, $defaultValue = '') return $this->escapeHtml(trim($value) != '' ? $value : $defaultValue); } + /** + * Check availability of order level functionality. + * + * @return bool|null + */ + public function isMessagesOrderAvailable() + { + $entity = $this->getEntity(); + if (!$entity->hasIsGiftOptionsAvailable()) { + $this->_eventManager->dispatch('gift_options_prepare', ['entity' => $entity]); + } + + return $entity->getIsGiftOptionsAvailable(); + } + /** * Check availability of giftmessages on order level * @@ -346,7 +372,7 @@ public function isItemMessagesAvailable($item) protected function _toHtml() { // render HTML when messages are allowed for order or for items only - if ($this->isItemsAvailable() || $this->isMessagesAvailable()) { + if ($this->isItemsAvailable() || $this->isMessagesAvailable() || $this->isMessagesOrderAvailable()) { return parent::_toHtml(); } return ''; diff --git a/app/code/Magento/GiftMessage/Test/Mftf/Data/GiftOptionsData.xml b/app/code/Magento/GiftMessage/Test/Mftf/Data/GiftOptionsData.xml new file mode 100644 index 0000000000000..bb3f566f1cbc6 --- /dev/null +++ b/app/code/Magento/GiftMessage/Test/Mftf/Data/GiftOptionsData.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <entity name="EnableGiftMessageForOrder" type="gift_message_config_state"> + <requiredEntity type="allow_order">AllowGiftMessageForOrder</requiredEntity> + </entity> + <entity name="AllowGiftMessageForOrder" type="allow_order"> + <data key="value">1</data> + </entity> + <entity name="DefaultConfigGiftMessageOptions" type="gift_message_config_state"> + <requiredEntity type="allow_order">OrderDefault</requiredEntity> + <requiredEntity type="allow_items">ItemsDefault</requiredEntity> + </entity> + <entity name="OrderDefault" type="allow_order"> + <data key="value">0</data> + </entity> + <entity name="ItemsDefault" type="allow_items"> + <data key="value">0</data> + </entity> +</entities> diff --git a/app/code/Magento/GiftMessage/Test/Mftf/Metadata/gift_options-meta.xml b/app/code/Magento/GiftMessage/Test/Mftf/Metadata/gift_options-meta.xml new file mode 100644 index 0000000000000..d4b7997e77d15 --- /dev/null +++ b/app/code/Magento/GiftMessage/Test/Mftf/Metadata/gift_options-meta.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<operations xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataOperation.xsd"> + <operation name="SalesGiftMessageConfigState" dataType="gift_message_config_state" type="create" auth="adminFormKey" url="/admin/system_config/save/section/sales/" method="POST" successRegex="/messages-message-success/"> + <object key="groups" dataType="gift_message_config_state"> + <object key="gift_options" dataType="gift_message_config_state"> + <object key="fields" dataType="gift_message_config_state"> + <object key="allow_order" dataType="allow_order"> + <field key="value">string</field> + </object> + <object key="allow_items" dataType="allow_items"> + <field key="value">string</field> + </object> + </object> + </object> + </object> + </operation> +</operations> diff --git a/app/code/Magento/GiftMessage/Test/Mftf/Section/StorefrontCheckoutGiftOptionsSection.xml b/app/code/Magento/GiftMessage/Test/Mftf/Section/StorefrontCheckoutGiftOptionsSection.xml new file mode 100644 index 0000000000000..f80237fc28423 --- /dev/null +++ b/app/code/Magento/GiftMessage/Test/Mftf/Section/StorefrontCheckoutGiftOptionsSection.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontCheckoutGiftOptionsSection"> + <element name="addGiftOptionsCheckbox" type="checkbox" + selector="//span[text()='Add Gift Options']/../../input[@class='checkbox']" timeout="30"/> + </section> +</sections> diff --git a/app/code/Magento/GiftMessage/composer.json b/app/code/Magento/GiftMessage/composer.json index 6a098af78e28b..d8fa39fa590a4 100644 --- a/app/code/Magento/GiftMessage/composer.json +++ b/app/code/Magento/GiftMessage/composer.json @@ -17,7 +17,7 @@ "magento/module-multishipping": "100.2.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/GiftMessage/view/frontend/templates/inline.phtml b/app/code/Magento/GiftMessage/view/frontend/templates/inline.phtml index dec54cfeb9df9..640ef1ba16486 100644 --- a/app/code/Magento/GiftMessage/view/frontend/templates/inline.phtml +++ b/app/code/Magento/GiftMessage/view/frontend/templates/inline.phtml @@ -152,6 +152,7 @@ </div> <dl class="options-items" id="allow-gift-options-container-<?= /* @escapeNotVerified */ $block->getEntity()->getId() ?>"> + <?php if ($block->isMessagesOrderAvailable() || $block->isMessagesAvailable()): ?> <dt id="add-gift-options-for-order-<?= /* @escapeNotVerified */ $block->getEntity()->getId() ?>" class="order-title"> <div class="field choice"> <input type="checkbox" name="allow_gift_options_for_order_<?= /* @escapeNotVerified */ $block->getEntity()->getId() ?>" id="allow_gift_options_for_order_<?= /* @escapeNotVerified */ $block->getEntity()->getId() ?>" data-mage-init='{"giftOptions":{}}' value="1" data-selector='{"id":"#allow-gift-options-for-order-container-<?= /* @escapeNotVerified */ $block->getEntity()->getId() ?>"}'<?php if ($block->getEntityHasMessage()): ?> checked="checked"<?php endif; ?> class="checkbox" /> @@ -192,7 +193,7 @@ </div> <?php endif; ?> </dd> - + <?php endif; ?> <?php if ($block->isItemsAvailable()): ?> <dt id="add-gift-options-for-items-<?= /* @escapeNotVerified */ $block->getEntity()->getId() ?>" class="order-title individual"> <div class="field choice"> diff --git a/app/code/Magento/GoogleAdwords/composer.json b/app/code/Magento/GoogleAdwords/composer.json index da44eb6c785d2..dec1440ecf174 100644 --- a/app/code/Magento/GoogleAdwords/composer.json +++ b/app/code/Magento/GoogleAdwords/composer.json @@ -8,7 +8,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/GoogleAnalytics/composer.json b/app/code/Magento/GoogleAnalytics/composer.json index a5ab4caff2927..138aa560e8bcd 100644 --- a/app/code/Magento/GoogleAnalytics/composer.json +++ b/app/code/Magento/GoogleAnalytics/composer.json @@ -12,7 +12,7 @@ "magento/module-config": "101.0.*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/GoogleOptimizer/composer.json b/app/code/Magento/GoogleOptimizer/composer.json index a84645b897298..5d84613c0e13c 100644 --- a/app/code/Magento/GoogleOptimizer/composer.json +++ b/app/code/Magento/GoogleOptimizer/composer.json @@ -12,7 +12,7 @@ "magento/module-ui": "101.0.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/GroupedImportExport/composer.json b/app/code/Magento/GroupedImportExport/composer.json index 09d5f8894b354..092c40d011580 100644 --- a/app/code/Magento/GroupedImportExport/composer.json +++ b/app/code/Magento/GroupedImportExport/composer.json @@ -11,7 +11,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/GroupedProduct/Test/Mftf/ActionGroup/AdminGroupedProductActionGroup.xml b/app/code/Magento/GroupedProduct/Test/Mftf/ActionGroup/AdminGroupedProductActionGroup.xml new file mode 100644 index 0000000000000..b827115f6e066 --- /dev/null +++ b/app/code/Magento/GroupedProduct/Test/Mftf/ActionGroup/AdminGroupedProductActionGroup.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminAssignProductToGroup"> + <arguments> + <argument name="product"/> + </arguments> + + <scrollTo selector="{{AdminProductFormGroupedProductsSection.toggleGroupedProduct}}" x="0" y="-100" stepKey="scrollToGroupedSection"/> + <conditionalClick selector="{{AdminProductFormGroupedProductsSection.toggleGroupedProduct}}" dependentSelector="{{AdminProductFormGroupedProductsSection.addProductsToGroup}}" visible="false" stepKey="openGroupedProductsSection"/> + <click selector="{{AdminProductFormGroupedProductsSection.addProductsToGroup}}" stepKey="clickAddProductsToGroup"/> + <conditionalClick selector="{{AdminAddProductsToGroupPanelSection.clearFilters}}" dependentSelector="{{AdminAddProductsToGroupPanelSection.clearFilters}}" visible="true" stepKey="clearExistingFilters"/> + <click selector="{{AdminAddProductsToGroupPanelSection.filters}}" stepKey="showFiltersPanel"/> + <fillField userInput="{{product.name}}" selector="{{AdminAddProductsToGroupPanelSection.nameFilter}}" stepKey="fillNameFilter"/> + <click selector="{{AdminAddProductsToGroupPanelSection.applyFilters}}" stepKey="clickApplyFilters"/> + <click selector="{{AdminAddProductsToGroupPanelSection.firstCheckbox}}" stepKey="selectProduct"/> + <click selector="{{AdminAddProductsToGroupPanelSection.addSelectedProducts}}" stepKey="clickAddSelectedGroupProducts"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/GroupedProduct/Test/Mftf/Data/GroupedProductData.xml b/app/code/Magento/GroupedProduct/Test/Mftf/Data/GroupedProductData.xml index e760b877fa33d..4d979953a934e 100644 --- a/app/code/Magento/GroupedProduct/Test/Mftf/Data/GroupedProductData.xml +++ b/app/code/Magento/GroupedProduct/Test/Mftf/Data/GroupedProductData.xml @@ -8,6 +8,15 @@ <entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <entity name="GroupedProduct" type="product"> + <data key="sku" unique="suffix">groupedproduct</data> + <data key="type_id">grouped</data> + <data key="attribute_set_id">4</data> + <data key="name" unique="suffix">GroupedProduct</data> + <data key="status">1</data> + <data key="urlKey" unique="suffix">groupedproduct</data> + <requiredEntity type="product_extension_attribute">EavStockItem</requiredEntity> + </entity> <entity name="ApiGroupedProduct" type="product3"> <data key="sku" unique="suffix">api-grouped-product</data> <data key="type_id">grouped</data> diff --git a/app/code/Magento/GroupedProduct/Test/Mftf/Page/AdminOrderCreatePage.xml b/app/code/Magento/GroupedProduct/Test/Mftf/Page/AdminOrderCreatePage.xml new file mode 100644 index 0000000000000..d9c6c4023c07d --- /dev/null +++ b/app/code/Magento/GroupedProduct/Test/Mftf/Page/AdminOrderCreatePage.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminOrderCreatePage" url="sales/order_create/index" area="admin" module="Magento_Sales"> + <section name="AdminOrderConfigureGroupedProductSection"/> + </page> +</pages> diff --git a/app/code/Magento/GroupedProduct/Test/Mftf/Page/AdminProductCreatePage.xml b/app/code/Magento/GroupedProduct/Test/Mftf/Page/AdminProductCreatePage.xml new file mode 100644 index 0000000000000..4fd214111de93 --- /dev/null +++ b/app/code/Magento/GroupedProduct/Test/Mftf/Page/AdminProductCreatePage.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminProductCreatePage" url="catalog/product/new/set/{{set}}/type/{{type}}/" area="admin" module="Magento_Catalog" parameterized="true"> + <section name="AdminAddProductsToGroupPanelSection"/> + <section name="AdminProductFormGroupedProductsSection"/> + </page> +</pages> diff --git a/app/code/Magento/GroupedProduct/Test/Mftf/Section/AdminAddProductsToGroupPanelSection.xml b/app/code/Magento/GroupedProduct/Test/Mftf/Section/AdminAddProductsToGroupPanelSection.xml new file mode 100644 index 0000000000000..a0de10e580dea --- /dev/null +++ b/app/code/Magento/GroupedProduct/Test/Mftf/Section/AdminAddProductsToGroupPanelSection.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminAddProductsToGroupPanelSection"> + <element name="addSelectedProducts" type="button" selector=".product_form_product_form_grouped_grouped_products_modal button.action-primary" timeout="30"/> + <element name="clearFilters" type="button" selector=".product_form_product_form_grouped_grouped_products_modal [data-action='grid-filter-reset']" timeout="30"/> + <element name="filters" type="button" selector=".product_form_product_form_grouped_grouped_products_modal [data-action='grid-filter-expand']" timeout="30"/> + <element name="applyFilters" type="button" selector=".product_form_product_form_grouped_grouped_products_modal [data-action='grid-filter-apply']" timeout="30"/> + <element name="nameFilter" type="input" selector=".product_form_product_form_grouped_grouped_products_modal [name='name']"/> + <element name="firstCheckbox" type="input" selector=".product_form_product_form_grouped_grouped_products_modal tr[data-repeat-index='0'] .admin__control-checkbox"/> + </section> +</sections> diff --git a/app/code/Magento/GroupedProduct/Test/Mftf/Section/AdminOrderConfigureGroupedProductSection.xml b/app/code/Magento/GroupedProduct/Test/Mftf/Section/AdminOrderConfigureGroupedProductSection.xml new file mode 100644 index 0000000000000..04f9f68a27589 --- /dev/null +++ b/app/code/Magento/GroupedProduct/Test/Mftf/Section/AdminOrderConfigureGroupedProductSection.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminOrderConfigureGroupedProductSection"> + <element name="configureProductOk" type="button" selector="[data-role='modal']._show [data-role='action']" timeout="30"/> + <element name="configureProductQtyField" type="input" selector="#super-product-table tr:nth-of-type({{index}}) td.col-qty input.qty" parameterized="true"/> + </section> +</sections> diff --git a/app/code/Magento/GroupedProduct/Test/Mftf/Section/AdminProductFormGroupedProductsSection.xml b/app/code/Magento/GroupedProduct/Test/Mftf/Section/AdminProductFormGroupedProductsSection.xml new file mode 100644 index 0000000000000..5d9e35adca664 --- /dev/null +++ b/app/code/Magento/GroupedProduct/Test/Mftf/Section/AdminProductFormGroupedProductsSection.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminProductFormGroupedProductsSection"> + <element name="toggleGroupedProduct" type="button" selector="[data-index=grouped] .admin__collapsible-title"/> + <element name="addProductsToGroup" type="button" selector="[data-index=grouped] button[data-index='grouped_products_button']" timeout="30"/> + <element name="nextActionButton" type="button" selector="[data-index='grouped'] .action-next"/> + <element name="previousActionButton" type="button" selector="[data-index='grouped'] .action-previous"/> + <element name="positionProduct" type="input" selector="[data-index='grouped'] tr[class*='data-row']:nth-child({{arg}}) td[data-index='positionCalculated'] .position-widget-input" parameterized="true"/> + <element name="nameProductFromGrid" type="text" selector="[data-index='grouped'] tr[class*='data-row']:nth-child({{arg}}) td[data-index='name'] .admin__field-control span" parameterized="true"/> + <element name="buttonContainer" type="block" selector="[data-index='grouped_products_button_set']"/> + </section> +</sections> diff --git a/app/code/Magento/GroupedProduct/Test/Mftf/Test/AdminSortingAssociatedProductsTest.xml b/app/code/Magento/GroupedProduct/Test/Mftf/Test/AdminSortingAssociatedProductsTest.xml new file mode 100644 index 0000000000000..d1e1a36285890 --- /dev/null +++ b/app/code/Magento/GroupedProduct/Test/Mftf/Test/AdminSortingAssociatedProductsTest.xml @@ -0,0 +1,209 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminSortingAssociatedProductsTest"> + <annotations> + <features value="GroupedProduct"/> + <stories value="MAGETWO-90189: Grouped Products: Associated Products Can't Be Sorted Between Pages"/> + <title value="Grouped Products: Sorting Associated Products Between Pages"/> + <description value="Make sure that products in grid were recalculated when sorting associated products between pages"/> + <severity value="MAJOR"/> + <testCaseId value="MAGETWO-96474"/> + <group value="groupedProduct"/> + </annotations> + <before> + <actionGroup ref="LoginAsAdmin" stepKey="login"/> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <!-- Create 23 products so that grid can have more than one page --> + <createData entity="ApiSimpleProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct1"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct2"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct3"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct4"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct5"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct6"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct7"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct8"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct9"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct10"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct11"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct12"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct13"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct14"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct15"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct16"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct17"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct18"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct19"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct20"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct21"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="ApiSimpleProduct" stepKey="createProduct22"> + <requiredEntity createDataKey="createCategory"/> + </createData> + </before> + <after> + <!--Delete created products--> + <actionGroup ref="deleteProductUsingProductGrid" stepKey="deleteProduct"> + <argument name="product" value="GroupedProduct"/> + </actionGroup> + <deleteData createDataKey="createProduct" stepKey="deleteProduct1"/> + <deleteData createDataKey="createProduct1" stepKey="deleteProduct2"/> + <deleteData createDataKey="createProduct2" stepKey="deleteProduct3"/> + <deleteData createDataKey="createProduct3" stepKey="deleteProduct4"/> + <deleteData createDataKey="createProduct4" stepKey="deleteProduct5"/> + <deleteData createDataKey="createProduct5" stepKey="deleteProduct6"/> + <deleteData createDataKey="createProduct6" stepKey="deleteProduct7"/> + <deleteData createDataKey="createProduct7" stepKey="deleteProduct8"/> + <deleteData createDataKey="createProduct8" stepKey="deleteProduct9"/> + <deleteData createDataKey="createProduct9" stepKey="deleteProduct10"/> + <deleteData createDataKey="createProduct10" stepKey="deleteProduct11"/> + <deleteData createDataKey="createProduct11" stepKey="deleteProduct12"/> + <deleteData createDataKey="createProduct12" stepKey="deleteProduct13"/> + <deleteData createDataKey="createProduct13" stepKey="deleteProduct14"/> + <deleteData createDataKey="createProduct14" stepKey="deleteProduct15"/> + <deleteData createDataKey="createProduct15" stepKey="deleteProduct16"/> + <deleteData createDataKey="createProduct16" stepKey="deleteProduct17"/> + <deleteData createDataKey="createProduct17" stepKey="deleteProduct18"/> + <deleteData createDataKey="createProduct18" stepKey="deleteProduct19"/> + <deleteData createDataKey="createProduct19" stepKey="deleteProduct20"/> + <deleteData createDataKey="createProduct20" stepKey="deleteProduct21"/> + <deleteData createDataKey="createProduct21" stepKey="deleteProduct22"/> + <deleteData createDataKey="createProduct22" stepKey="deleteProduct23"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <!--Create grouped Product--> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndex"/> + <actionGroup ref="goToCreateProductPage" stepKey="goToCreateProduct"> + <argument name="product" value="GroupedProduct"/> + </actionGroup> + <actionGroup ref="fillProductNameAndSkuInProductForm" stepKey="fillProductForm"> + <argument name="product" value="GroupedProduct"/> + </actionGroup> + + <scrollTo selector="{{AdminProductFormGroupedProductsSection.toggleGroupedProduct}}" x="0" y="-100" stepKey="scrollToGroupedSection"/> + <conditionalClick selector="{{AdminProductFormGroupedProductsSection.toggleGroupedProduct}}" + dependentSelector="{{AdminProductFormGroupedProductsSection.addProductsToGroup}}" + visible="false" + stepKey="openGroupedProductsSection" + /> + + <click selector="{{AdminProductFormGroupedProductsSection.buttonContainer}}" stepKey="moveFocusToGroup"/> + <click selector="{{AdminProductFormGroupedProductsSection.addProductsToGroup}}" stepKey="clickAddProductsToGroup"/> + <waitForElementVisible selector="{{AdminAddProductsToGroupPanelSection.filters}}" stepKey="waitForGroupedProductModal"/> + <actionGroup ref="AdminGridFilterSearchResultsByInput" stepKey="filterSimplesForGroupedProduct"> + <argument name="inputName" value="sku"/> + <argument name="value" value="{{ApiSimpleProduct.sku}}"/> + </actionGroup> + + <!-- Select all, then start the bulk update attributes flow --> + <click selector="{{AdminProductGridSection.multicheckDropdown}}" stepKey="openMulticheckDropdown"/> + <click selector="{{AdminProductGridSection.multicheckOption('Select All')}}" stepKey="selectAllProductInFilteredGrid"/> + <click selector="{{AdminAddProductsToGroupPanelSection.addSelectedProducts}}" stepKey="clickAddSelectedGroupProducts"/> + + <actionGroup ref="saveProductForm" stepKey="saveProduct"/> + + <!--Open created Grouped Product--> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="goToProductIndex"/> + <actionGroup ref="searchAdminDataGridByKeyword" stepKey="searchProductGridForm"> + <argument name="keyword" value="GroupedProduct.name"/> + </actionGroup> + <click selector="{{AdminProductGridSection.selectRowBasedOnName(GroupedProduct.name)}}" stepKey="openGroupedProduct"/> + <waitForPageLoad stepKey="waitForProductEditPageLoad"/> + + <scrollTo selector="{{AdminProductFormGroupedProductsSection.toggleGroupedProduct}}" x="0" y="-100" stepKey="scrollToGroupedSection2"/> + <conditionalClick selector="{{AdminProductFormGroupedProductsSection.toggleGroupedProduct}}" + dependentSelector="{{AdminProductFormGroupedProductsSection.addProductsToGroup}}" + visible="false" + stepKey="openGroupedProductsSection2" + /> + + <!--Change position value for the Product Position 0--> + <grabTextFrom selector="{{AdminProductFormGroupedProductsSection.nameProductFromGrid('1')}}" stepKey="grabNameProductPosition0"/> + <grabTextFrom selector="{{AdminProductFormGroupedProductsSection.nameProductFromGrid('2')}}" stepKey="grabNameProductPositionFirst"/> + <fillField selector="{{AdminProductFormGroupedProductsSection.positionProduct('1')}}" userInput="21" stepKey="fillFieldProductPosition0"/> + <click selector="{{AdminProductFormGroupedProductsSection.nextActionButton}}" stepKey="clickButton"/> + <waitForAjaxLoad stepKey="waitForAjax"/> + + <!--Go to next page and verify that Products in grid were recalculated--> + <click selector="{{AdminProductFormGroupedProductsSection.nextActionButton}}" stepKey="clickNextActionButton"/> + <waitForAjaxLoad stepKey="waitForAjax1"/> + + <grabTextFrom selector="{{AdminProductFormGroupedProductsSection.nameProductFromGrid('2')}}" stepKey="grabNameProductPosition21"/> + <assertEquals stepKey="assertProductsRecalculated"> + <actualResult type="string">$grabNameProductPosition0</actualResult> + <expectedResult type="string">$grabNameProductPosition21</expectedResult> + </assertEquals> + + <!--Change position value for the product to 1--> + <fillField selector="{{AdminProductFormGroupedProductsSection.positionProduct('2')}}" userInput="1" stepKey="fillFieldProductPosition1"/> + <click selector="{{AdminProductFormGroupedProductsSection.previousActionButton}}" stepKey="clickButton2"/> + <waitForAjaxLoad stepKey="waitForAjax2"/> + + <!--Go to previous page and verify that Products in grid were recalculated--> + <click selector="{{AdminProductFormGroupedProductsSection.previousActionButton}}" stepKey="clickPreviousActionButton"/> + <waitForAjaxLoad stepKey="waitForAjax3"/> + <grabTextFrom selector="{{AdminProductFormGroupedProductsSection.nameProductFromGrid('2')}}" stepKey="grabNameProductPosition2"/> + <grabTextFrom selector="{{AdminProductFormGroupedProductsSection.nameProductFromGrid('1')}}" stepKey="grabNameProductPositionZero"/> + <assertEquals stepKey="assertProductsRecalculated2"> + <actualResult type="string">$grabNameProductPosition2</actualResult> + <expectedResult type="string">$grabNameProductPosition0</expectedResult> + </assertEquals> + <assertEquals stepKey="assertProductsRecalculated3"> + <actualResult type="string">$grabNameProductPositionFirst</actualResult> + <expectedResult type="string">$grabNameProductPositionZero</expectedResult> + </assertEquals> + </test> +</tests> diff --git a/app/code/Magento/GroupedProduct/Test/Unit/Model/Product/Type/Grouped/PriceTest.php b/app/code/Magento/GroupedProduct/Test/Unit/Model/Product/Type/Grouped/PriceTest.php index f02849c244cb3..1023dcf552d2f 100644 --- a/app/code/Magento/GroupedProduct/Test/Unit/Model/Product/Type/Grouped/PriceTest.php +++ b/app/code/Magento/GroupedProduct/Test/Unit/Model/Product/Type/Grouped/PriceTest.php @@ -64,7 +64,7 @@ public function testGetFinalPrice( $expectedFinalPrice ) { $rawFinalPrice = 10; - $rawPriceCheckStep = 6; + $rawPriceCheckStep = 5; $this->productMock->expects( $this->any() @@ -155,7 +155,7 @@ public function getFinalPriceDataProvider() 'custom_option_null' => [ 'associatedProducts' => [], 'options' => [[], []], - 'expectedPriceCall' => 6, /* product call number to check final price formed correctly */ + 'expectedPriceCall' => 5, /* product call number to check final price formed correctly */ 'expectedFinalPrice' => 10, /* 10(product price) + 2(options count) * 5(qty) * 5(option price) */ ], 'custom_option_exist' => [ @@ -165,9 +165,9 @@ public function getFinalPriceDataProvider() ['associated_product_2', $optionMock], ['associated_product_3', $optionMock], ], - 'expectedPriceCall' => 16, /* product call number to check final price formed correctly */ + 'expectedPriceCall' => 15, /* product call number to check final price formed correctly */ 'expectedFinalPrice' => 35, /* 10(product price) + 2(options count) * 5(qty) * 5(option price) */ - ] + ], ]; } diff --git a/app/code/Magento/GroupedProduct/Test/Unit/Ui/DataProvider/Product/Form/Modifier/GroupedTest.php b/app/code/Magento/GroupedProduct/Test/Unit/Ui/DataProvider/Product/Form/Modifier/GroupedTest.php index 6ef87117deae2..327b47d4a75d8 100644 --- a/app/code/Magento/GroupedProduct/Test/Unit/Ui/DataProvider/Product/Form/Modifier/GroupedTest.php +++ b/app/code/Magento/GroupedProduct/Test/Unit/Ui/DataProvider/Product/Form/Modifier/GroupedTest.php @@ -34,6 +34,7 @@ class GroupedTest extends AbstractModifierTest const LINKED_PRODUCT_NAME = 'linked'; const LINKED_PRODUCT_QTY = '0'; const LINKED_PRODUCT_POSITION = 1; + const LINKED_PRODUCT_POSITION_CALCULATED = 1; const LINKED_PRODUCT_PRICE = '1'; /** @@ -212,6 +213,7 @@ public function testModifyData() 'price' => null, 'qty' => self::LINKED_PRODUCT_QTY, 'position' => self::LINKED_PRODUCT_POSITION, + 'positionCalculated' => self::LINKED_PRODUCT_POSITION_CALCULATED, 'thumbnail' => null, 'type_id' => null, 'status' => null, diff --git a/app/code/Magento/GroupedProduct/Ui/DataProvider/Product/Form/Modifier/Grouped.php b/app/code/Magento/GroupedProduct/Ui/DataProvider/Product/Form/Modifier/Grouped.php index 2d1a1d19757e2..57d9bc78aaf28 100644 --- a/app/code/Magento/GroupedProduct/Ui/DataProvider/Product/Form/Modifier/Grouped.php +++ b/app/code/Magento/GroupedProduct/Ui/DataProvider/Product/Form/Modifier/Grouped.php @@ -133,7 +133,7 @@ public function __construct( } /** - * {@inheritdoc} + * @inheritdoc */ public function modifyData(array $data) { @@ -143,12 +143,17 @@ public function modifyData(array $data) if ($modelId) { $storeId = $this->locator->getStore()->getId(); $data[$product->getId()]['links'][self::LINK_TYPE] = []; - foreach ($this->productLinkRepository->getList($product) as $linkItem) { + $linkedItems = $this->productLinkRepository->getList($product); + usort($linkedItems, function ($a, $b) { + return $a->getPosition() <=> $b->getPosition(); + }); + foreach ($linkedItems as $index => $linkItem) { if ($linkItem->getLinkType() !== self::LINK_TYPE) { continue; } /** @var \Magento\Catalog\Api\Data\ProductInterface $linkedProduct */ $linkedProduct = $this->productRepository->get($linkItem->getLinkedProductSku(), false, $storeId); + $linkItem->setPosition($index); $data[$modelId]['links'][self::LINK_TYPE][] = $this->fillData($linkedProduct, $linkItem); } $data[$modelId][self::DATA_SOURCE_DEFAULT]['current_store_id'] = $storeId; @@ -175,6 +180,7 @@ protected function fillData(ProductInterface $linkedProduct, ProductLinkInterfac 'price' => $currency->toCurrency(sprintf("%f", $linkedProduct->getPrice())), 'qty' => $linkItem->getExtensionAttributes()->getQty(), 'position' => $linkItem->getPosition(), + 'positionCalculated' => $linkItem->getPosition(), 'thumbnail' => $this->imageHelper->init($linkedProduct, 'product_listing_thumbnail')->getUrl(), 'type_id' => $linkedProduct->getTypeId(), 'status' => $this->status->getOptionText($linkedProduct->getStatus()), @@ -185,7 +191,7 @@ protected function fillData(ProductInterface $linkedProduct, ProductLinkInterfac } /** - * {@inheritdoc} + * @inheritdoc */ public function modifyMeta(array $meta) { @@ -454,7 +460,7 @@ protected function getGrid() 'label' => null, 'renderDefaultRecord' => false, 'template' => 'ui/dynamic-rows/templates/grid', - 'component' => 'Magento_Ui/js/dynamic-rows/dynamic-rows-grid', + 'component' => 'Magento_GroupedProduct/js/grouped-product-grid', 'addButton' => false, 'itemTemplate' => 'record', 'dataScope' => 'data.links', @@ -555,6 +561,22 @@ protected function fillMeta() ], ], ], + 'positionCalculated' => [ + 'arguments' => [ + 'data' => [ + 'config' => [ + 'label' => __('Position'), + 'dataType' => Form\Element\DataType\Number::NAME, + 'formElement' => Form\Element\Input::NAME, + 'componentType' => Form\Field::NAME, + 'elementTmpl' => 'Magento_GroupedProduct/components/position', + 'sortOrder' => 90, + 'fit' => true, + 'dataScope' => 'positionCalculated' + ], + ], + ], + ], 'actionDelete' => [ 'arguments' => [ 'data' => [ @@ -563,7 +585,7 @@ protected function fillMeta() 'componentType' => 'actionDelete', 'dataType' => Form\Element\DataType\Text::NAME, 'label' => __('Actions'), - 'sortOrder' => 90, + 'sortOrder' => 100, 'fit' => true, ], ], @@ -577,7 +599,7 @@ protected function fillMeta() 'formElement' => Form\Element\Input::NAME, 'componentType' => Form\Field::NAME, 'dataScope' => 'position', - 'sortOrder' => 100, + 'sortOrder' => 110, 'visible' => false, ], ], diff --git a/app/code/Magento/GroupedProduct/composer.json b/app/code/Magento/GroupedProduct/composer.json index c2031fc3332f0..7107903a91a58 100644 --- a/app/code/Magento/GroupedProduct/composer.json +++ b/app/code/Magento/GroupedProduct/composer.json @@ -21,7 +21,7 @@ "magento/module-grouped-product-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/GroupedProduct/view/adminhtml/web/css/grouped-product.css b/app/code/Magento/GroupedProduct/view/adminhtml/web/css/grouped-product.css index 3d723387d23b0..9142eaf90899e 100644 --- a/app/code/Magento/GroupedProduct/view/adminhtml/web/css/grouped-product.css +++ b/app/code/Magento/GroupedProduct/view/adminhtml/web/css/grouped-product.css @@ -66,3 +66,49 @@ overflow: hidden; text-overflow: ellipsis; } + +.position { + width: 100px; +} + +.icon-rearrange-position > span { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} + +.icon-forward, +.icon-backward { + -webkit-font-smoothing: antialiased; + font-family: 'Admin Icons'; + font-size: 17px; + speak: none; +} + +.position > * { + float: left; + margin: 3px; +} + +.position-widget-input { + text-align: center; + width: 40px; +} + +.icon-forward:before { + content: '\e618'; +} + +.icon-backward:before { + content: '\e619'; +} + +.icon-rearrange-position, .icon-rearrange-position:hover { + color: #d9d9d9; + text-decoration: none; +} diff --git a/app/code/Magento/GroupedProduct/view/adminhtml/web/js/grouped-product-grid.js b/app/code/Magento/GroupedProduct/view/adminhtml/web/js/grouped-product-grid.js new file mode 100644 index 0000000000000..0ac3b58d6e3a7 --- /dev/null +++ b/app/code/Magento/GroupedProduct/view/adminhtml/web/js/grouped-product-grid.js @@ -0,0 +1,219 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'underscore', + 'uiRegistry', + 'Magento_Ui/js/dynamic-rows/dynamic-rows-grid' +], function (_, registry, dynamicRowsGrid) { + 'use strict'; + + return dynamicRowsGrid.extend({ + + /** + * Set max element position + * + * @param {Number} position - element position + * @param {Object} elem - instance + */ + setMaxPosition: function (position, elem) { + + if (position || position === 0) { + this.checkMaxPosition(position); + this.sort(position, elem); + + if (~~position === this.maxPosition && ~~position > this.getDefaultPageBoundary() + 1) { + this.shiftNextPagesPositions(position); + } + } else { + this.maxPosition += 1; + } + }, + + /** + * Shift positions for next page elements + * + * @param {Number} position + */ + shiftNextPagesPositions: function (position) { + + var recordData = this.recordData(), + startIndex = ~~this.currentPage() * this.pageSize, + offset = position - startIndex + 1, + index = startIndex; + + if (~~this.currentPage() === this.pages()) { + return false; + } + + for (index; index < recordData.length; index++) { + recordData[index].position = index + offset; + } + this.recordData(recordData); + }, + + /** + * Update position for element after position from another page is entered + * + * @param {Object} data + * @param {Object} event + */ + updateGridPosition: function (data, event) { + var inputValue = parseInt(event.target.value, 10), + recordData = this.recordData(), + record, + previousValue, + updatedRecord; + + record = this.elems().find(function (obj) { + return obj.dataScope === data.parentScope; + }); + + previousValue = this.getCalculatedPosition(record); + + if (isNaN(inputValue) || inputValue < 0 || inputValue === previousValue) { + return false; + } + + this.elems([]); + + updatedRecord = this.getUpdatedRecordIndex(recordData, record.data().id); + + if (inputValue >= this.recordData().size() - 1) { + recordData[updatedRecord].position = this.getGlobalMaxPosition() + 1; + } else { + recordData.forEach(function (value, index) { + if (~~value.id === ~~record.data().id) { + recordData[index].position = inputValue; + } else if (inputValue > previousValue && index <= inputValue) { + recordData[index].position = index - 1; + } else if (inputValue < previousValue && index >= inputValue) { + recordData[index].position = index + 1; + } + }); + } + + this.reloadGridData(recordData); + + }, + + /** + * Get updated record index + * + * @param {Array} recordData + * @param {Number} recordId + * @return {Number} + */ + getUpdatedRecordIndex: function (recordData, recordId) { + return recordData.map(function (o) { + return ~~o.id; + }).indexOf(~~recordId); + }, + + /** + * + * @param {Array} recordData - to reprocess + */ + reloadGridData: function (recordData) { + this.recordData(recordData.sort(function (a, b) { + return ~~a.position - ~~b.position; + })); + this._updateCollection(); + this.reload(); + }, + + /** + * Event handler for "Send to bottom" button + * + * @param {Object} positionObj + * @return {Boolean} + */ + sendToBottom: function (positionObj) { + + var objectToUpdate = this.getObjectToUpdate(positionObj), + recordData = this.recordData(), + updatedRecord; + + if (~~this.currentPage() === this.pages) { + objectToUpdate.position = this.maxPosition; + } else { + this.elems([]); + updatedRecord = this.getUpdatedRecordIndex(recordData, objectToUpdate.data().id); + recordData[updatedRecord].position = this.getGlobalMaxPosition() + 1; + this.reloadGridData(recordData); + } + + return false; + }, + + /** + * Event handler for "Send to top" button + * + * @param {Object} positionObj + * @return {Boolean} + */ + sendToTop: function (positionObj) { + var objectToUpdate = this.getObjectToUpdate(positionObj), + recordData = this.recordData(), + updatedRecord; + + //isFirst + if (~~this.currentPage() === 1) { + objectToUpdate.position = 0; + } else { + this.elems([]); + updatedRecord = this.getUpdatedRecordIndex(recordData, objectToUpdate.data().id); + recordData.forEach(function (value, index) { + recordData[index].position = index === updatedRecord ? 0 : value.position + 1; + }); + this.reloadGridData(recordData); + } + + return false; + }, + + /** + * Get element from grid for update + * + * @param {Object} object + * @return {*} + */ + getObjectToUpdate: function (object) { + return this.elems().filter(function (item) { + return item.name === object.parentName; + })[0]; + }, + + /** + * Value function for position input + * + * @param {Object} data + * @return {Number} + */ + getCalculatedPosition: function (data) { + return (~~this.currentPage() - 1) * this.pageSize + this.elems().pluck('name').indexOf(data.name); + }, + + /** + * Return Page Boundary + * + * @return {Number} + */ + getDefaultPageBoundary: function () { + return ~~this.currentPage() * this.pageSize - 1; + }, + + /** + * Returns position for last element to be moved after + * + * @return {Number} + */ + getGlobalMaxPosition: function () { + return _.max(this.recordData().map(function (r) { + return ~~r.position; + })); + } + }); +}); diff --git a/app/code/Magento/GroupedProduct/view/adminhtml/web/template/components/position.html b/app/code/Magento/GroupedProduct/view/adminhtml/web/template/components/position.html new file mode 100644 index 0000000000000..9d1e464e58b62 --- /dev/null +++ b/app/code/Magento/GroupedProduct/view/adminhtml/web/template/components/position.html @@ -0,0 +1,15 @@ +<div class="position"> + <a href="#" class="move-top icon-backward icon-rearrange-position" + data-bind="click: $parent.sendToTop.bind($parent)"> + <span>Top</span> + </a> + <input type="text" class="position-widget-input" + data-bind=" + value: $parent.getCalculatedPosition($record()), + event: {blur: $parent.updateGridPosition.bind($parent)} + "/> + <a href="#" class="move-bottom icon-forward icon-rearrange-position" + data-bind="click: $parent.sendToBottom.bind($parent)"> + <span>Bottom</span> + </a> +</div> diff --git a/app/code/Magento/ImportExport/Block/Adminhtml/Export/Filter.php b/app/code/Magento/ImportExport/Block/Adminhtml/Export/Filter.php index 94dc0ee7493b0..7655b0e8929f6 100644 --- a/app/code/Magento/ImportExport/Block/Adminhtml/Export/Filter.php +++ b/app/code/Magento/ImportExport/Block/Adminhtml/Export/Filter.php @@ -236,8 +236,8 @@ protected function _getSelectHtmlWithValue(Attribute $attribute, $value) if ($attribute->getFilterOptions()) { $options = []; - foreach ($attribute->getFilterOptions() as $value => $label) { - $options[] = ['value' => $value, 'label' => $label]; + foreach ($attribute->getFilterOptions() as $optionValue => $label) { + $options[] = ['value' => $optionValue, 'label' => $label]; } } else { $options = $attribute->getSource()->getAllOptions(false); diff --git a/app/code/Magento/ImportExport/Block/Adminhtml/Import/Edit/Form.php b/app/code/Magento/ImportExport/Block/Adminhtml/Import/Edit/Form.php index 822795abb0b44..f67a4c5db1317 100644 --- a/app/code/Magento/ImportExport/Block/Adminhtml/Import/Edit/Form.php +++ b/app/code/Magento/ImportExport/Block/Adminhtml/Import/Edit/Form.php @@ -199,7 +199,10 @@ protected function _prepareForm() 'label' => __('Select File to Import'), 'title' => __('Select File to Import'), 'required' => true, - 'class' => 'input-file' + 'class' => 'input-file', + 'note' => __( + 'File must be saved in UTF-8 encoding for proper import' + ), ] ); $fieldsets['upload']->addField( diff --git a/app/code/Magento/ImportExport/Controller/Adminhtml/Import/Download.php b/app/code/Magento/ImportExport/Controller/Adminhtml/Import/Download.php index 33dbba8320051..8b16e232856cd 100644 --- a/app/code/Magento/ImportExport/Controller/Adminhtml/Import/Download.php +++ b/app/code/Magento/ImportExport/Controller/Adminhtml/Import/Download.php @@ -5,9 +5,9 @@ */ namespace Magento\ImportExport\Controller\Adminhtml\Import; +use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\Component\ComponentRegistrar; use Magento\ImportExport\Controller\Adminhtml\Import as ImportController; -use Magento\Framework\App\Filesystem\DirectoryList; /** * Download sample file controller @@ -36,6 +36,11 @@ class Download extends ImportController */ protected $fileFactory; + /** + * @var \Magento\ImportExport\Model\Import\SampleFileProvider + */ + private $sampleFileProvider; + /** * Constructor * @@ -44,46 +49,48 @@ class Download extends ImportController * @param \Magento\Framework\Controller\Result\RawFactory $resultRawFactory * @param \Magento\Framework\Filesystem\Directory\ReadFactory $readFactory * @param ComponentRegistrar $componentRegistrar + * @param \Magento\ImportExport\Model\Import\SampleFileProvider|null $sampleFileProvider */ public function __construct( \Magento\Backend\App\Action\Context $context, \Magento\Framework\App\Response\Http\FileFactory $fileFactory, \Magento\Framework\Controller\Result\RawFactory $resultRawFactory, \Magento\Framework\Filesystem\Directory\ReadFactory $readFactory, - \Magento\Framework\Component\ComponentRegistrar $componentRegistrar + \Magento\Framework\Component\ComponentRegistrar $componentRegistrar, + \Magento\ImportExport\Model\Import\SampleFileProvider $sampleFileProvider = null ) { - parent::__construct( - $context - ); + parent::__construct($context); $this->fileFactory = $fileFactory; $this->resultRawFactory = $resultRawFactory; $this->readFactory = $readFactory; $this->componentRegistrar = $componentRegistrar; + $this->sampleFileProvider = $sampleFileProvider + ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(\Magento\ImportExport\Model\Import\SampleFileProvider::class); } /** * Download sample file action * - * @return \Magento\Framework\Controller\Result\Raw + * @return \Magento\Framework\Controller\Result\Raw|\Magento\Framework\Controller\Result\Redirect */ public function execute() { - $fileName = $this->getRequest()->getParam('filename') . '.csv'; - $moduleDir = $this->componentRegistrar->getPath(ComponentRegistrar::MODULE, self::SAMPLE_FILES_MODULE); - $fileAbsolutePath = $moduleDir . '/Files/Sample/' . $fileName; - $directoryRead = $this->readFactory->create($moduleDir); - $filePath = $directoryRead->getRelativePath($fileAbsolutePath); + $entityName = $this->getRequest()->getParam('filename'); - if (!$directoryRead->isFile($filePath)) { + try { + $fileContents = $this->sampleFileProvider->getFileContents($entityName); + } catch (\Magento\Framework\Exception\NoSuchEntityException $e) { /** @var \Magento\Backend\Model\View\Result\Redirect $resultRedirect */ $this->messageManager->addError(__('There is no sample file for this entity.')); $resultRedirect = $this->resultRedirectFactory->create(); $resultRedirect->setPath('*/import'); + return $resultRedirect; } - $fileSize = isset($directoryRead->stat($filePath)['size']) - ? $directoryRead->stat($filePath)['size'] : null; + $fileSize = $this->sampleFileProvider->getSize($entityName); + $fileName = $entityName . '.csv'; $this->fileFactory->create( $fileName, @@ -95,7 +102,8 @@ public function execute() /** @var \Magento\Framework\Controller\Result\Raw $resultRaw */ $resultRaw = $this->resultRawFactory->create(); - $resultRaw->setContents($directoryRead->readFile($filePath)); + $resultRaw->setContents($fileContents); + return $resultRaw; } } diff --git a/app/code/Magento/ImportExport/Controller/Adminhtml/Import/Start.php b/app/code/Magento/ImportExport/Controller/Adminhtml/Import/Start.php index 695a0e61709f1..42d3ef696b8bc 100644 --- a/app/code/Magento/ImportExport/Controller/Adminhtml/Import/Start.php +++ b/app/code/Magento/ImportExport/Controller/Adminhtml/Import/Start.php @@ -7,7 +7,11 @@ use Magento\ImportExport\Controller\Adminhtml\ImportResult as ImportResultController; use Magento\Framework\Controller\ResultFactory; +use Magento\ImportExport\Model\Import; +/** + * Controller responsible for initiating the import process. + */ class Start extends ImportResultController { /** @@ -62,6 +66,11 @@ public function execute() $this->importModel->setData($data); $errorAggregator = $this->importModel->getErrorAggregator(); + $errorAggregator->initValidationStrategy( + $this->importModel->getData(Import::FIELD_NAME_VALIDATION_STRATEGY), + $this->importModel->getData(Import::FIELD_NAME_ALLOWED_ERROR_COUNT) + ); + try { $this->importModel->importSource(); } catch (\Exception $e) { diff --git a/app/code/Magento/ImportExport/Controller/Adminhtml/Import/Validate.php b/app/code/Magento/ImportExport/Controller/Adminhtml/Import/Validate.php index df9f63e79b75e..6c99e59d1fe05 100644 --- a/app/code/Magento/ImportExport/Controller/Adminhtml/Import/Validate.php +++ b/app/code/Magento/ImportExport/Controller/Adminhtml/Import/Validate.php @@ -42,12 +42,7 @@ public function execute() /** @var $import \Magento\ImportExport\Model\Import */ $import = $this->getImport()->setData($data); try { - $source = ImportAdapter::findAdapterFor( - $import->uploadSource(), - $this->_objectManager->create(\Magento\Framework\Filesystem::class) - ->getDirectoryWrite(DirectoryList::ROOT), - $data[$import::FIELD_FIELD_SEPARATOR] - ); + $source = $import->uploadFileAndGetSource(); $this->processValidationResult($import->validateSource($source), $resultBlock); } catch (\Magento\Framework\Exception\LocalizedException $e) { $resultBlock->addError($e->getMessage()); diff --git a/app/code/Magento/ImportExport/Model/Import.php b/app/code/Magento/ImportExport/Model/Import.php index 092b721b82435..6ff2ca8c4d0eb 100644 --- a/app/code/Magento/ImportExport/Model/Import.php +++ b/app/code/Magento/ImportExport/Model/Import.php @@ -5,11 +5,13 @@ */ // @codingStandardsIgnoreFile - namespace Magento\ImportExport\Model; +use Magento\Framework\App\ObjectManager; use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\HTTP\Adapter\FileTransferFactory; +use Magento\Framework\Message\ManagerInterface; +use Magento\Framework\Stdlib\DateTime\DateTime; use Magento\ImportExport\Model\Import\ErrorProcessing\ProcessingError; use Magento\ImportExport\Model\Import\ErrorProcessing\ProcessingErrorAggregatorInterface; @@ -21,6 +23,7 @@ * @method string getBehavior() getBehavior() * @method \Magento\ImportExport\Model\Import setEntity() setEntity(string $value) * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) * @since 100.0.2 */ class Import extends \Magento\ImportExport\Model\AbstractModel @@ -79,6 +82,11 @@ class Import extends \Magento\ImportExport\Model\AbstractModel */ const FIELD_FIELD_MULTIPLE_VALUE_SEPARATOR = '_import_multiple_value_separator'; + /** + * Import empty attribute value constant. + */ + const FIELD_EMPTY_ATTRIBUTE_VALUE_CONSTANT = '_import_empty_attribute_value_constant'; + /** * Allow multiple values wrapping in double quotes for additional attributes. */ @@ -91,6 +99,11 @@ class Import extends \Magento\ImportExport\Model\AbstractModel */ const DEFAULT_GLOBAL_MULTI_VALUE_SEPARATOR = ','; + /** + * default empty attribute value constant + */ + const DEFAULT_EMPTY_ATTRIBUTE_VALUE_CONSTANT = '__EMPTY__VALUE__'; + /**#@+ * Import constants */ @@ -159,6 +172,21 @@ class Import extends \Magento\ImportExport\Model\AbstractModel */ protected $_filesystem; + /** + * @var History + */ + private $importHistoryModel; + + /** + * @var DateTime + */ + private $localeDate; + + /** + * @var ManagerInterface + */ + private $messageManager; + /** * @param \Psr\Log\LoggerInterface $logger * @param \Magento\Framework\Filesystem $filesystem @@ -173,8 +201,9 @@ class Import extends \Magento\ImportExport\Model\AbstractModel * @param Source\Import\Behavior\Factory $behaviorFactory * @param \Magento\Framework\Indexer\IndexerRegistry $indexerRegistry * @param History $importHistoryModel - * @param \Magento\Framework\Stdlib\DateTime\DateTime + * @param DateTime $localeDate * @param array $data + * @param ManagerInterface|null $messageManager * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -191,8 +220,9 @@ public function __construct( \Magento\ImportExport\Model\Source\Import\Behavior\Factory $behaviorFactory, \Magento\Framework\Indexer\IndexerRegistry $indexerRegistry, \Magento\ImportExport\Model\History $importHistoryModel, - \Magento\Framework\Stdlib\DateTime\DateTime $localeDate, - array $data = [] + DateTime $localeDate, + array $data = [], + ManagerInterface $messageManager = null ) { $this->_importExportData = $importExportData; $this->_coreConfig = $coreConfig; @@ -207,6 +237,7 @@ public function __construct( $this->_filesystem = $filesystem; $this->importHistoryModel = $importHistoryModel; $this->localeDate = $localeDate; + $this->messageManager = $messageManager ?: ObjectManager::getInstance()->get(ManagerInterface::class); parent::__construct($logger, $filesystem, $data); } @@ -234,7 +265,9 @@ protected function _getEntityAdapter() ) { throw new \Magento\Framework\Exception\LocalizedException( __( - 'The entity adapter object must be an instance of %1 or %2.', \Magento\ImportExport\Model\Import\Entity\AbstractEntity::class, \Magento\ImportExport\Model\Import\AbstractEntity::class + 'The entity adapter object must be an instance of %1 or %2.', + \Magento\ImportExport\Model\Import\Entity\AbstractEntity::class, + \Magento\ImportExport\Model\Import\AbstractEntity::class ) ); } @@ -433,6 +466,8 @@ public function importSource() } /** + * Process import. + * * @return bool * @throws \Magento\Framework\Exception\LocalizedException */ @@ -452,6 +487,8 @@ public function isImportAllowed() } /** + * Get error aggregator instance. + * * @return ProcessingErrorAggregatorInterface * @throws \Magento\Framework\Exception\LocalizedException */ @@ -461,7 +498,7 @@ public function getErrorAggregator() } /** - * Move uploaded file and create source adapter instance. + * Move uploaded file. * * @throws \Magento\Framework\Exception\LocalizedException * @return string Source file path @@ -513,14 +550,27 @@ public function uploadSource() } $this->_removeBom($sourceFile); $this->createHistoryReport($sourceFileRelative, $entity, $extension, $result); - // trying to create source adapter for file and catch possible exception to be convinced in its adequacy + + return $sourceFile; + } + + /** + * Move uploaded file and provide source instance. + * + * @return Import\AbstractSource + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function uploadFileAndGetSource() + { + $sourceFile = $this->uploadSource(); try { - $this->_getSourceAdapter($sourceFile); + $source = $this->_getSourceAdapter($sourceFile); } catch (\Exception $e) { - $this->_varDirectory->delete($sourceFileRelative); + $this->_varDirectory->delete($this->_varDirectory->getRelativePath($sourceFile)); throw new \Magento\Framework\Exception\LocalizedException(__($e->getMessage())); } - return $sourceFile; + + return $source; } /** @@ -574,10 +624,20 @@ public function validateSource(\Magento\ImportExport\Model\Import\AbstractSource $messages = $this->getOperationResultMessages($errorAggregator); $this->addLogComment($messages); - $result = !$errorAggregator->getErrorsCount(); + $errorsCount = $errorAggregator->getErrorsCount(); + $result = !$errorsCount; + $validationStrategy = $this->getData(self::FIELD_NAME_VALIDATION_STRATEGY); + if ($errorsCount + && $validationStrategy === ProcessingErrorAggregatorInterface::VALIDATION_STRATEGY_SKIP_ERRORS + ) { + $this->messageManager->addWarningMessage(__('Skipped errors: %1', $errorsCount)); + $result = true; + } + if ($result) { $this->addLogComment(__('Import data validation is complete.')); } + return $result; } @@ -684,7 +744,9 @@ public function isReportEntityType($entity = null) try { $result = $this->_getEntityAdapter()->isNeedToLogInHistory(); } catch (\Exception $e) { - throw new \Magento\Framework\Exception\LocalizedException(__('Please enter a correct entity model')); + throw new \Magento\Framework\Exception\LocalizedException( + __('Please enter a correct entity model') + ); } } else { throw new \Magento\Framework\Exception\LocalizedException(__('Please enter a correct entity model')); @@ -696,11 +758,11 @@ public function isReportEntityType($entity = null) } /** - * Create history report + * Create history report. * + * @param string $sourceFileRelative * @param string $entity * @param string $extension - * @param string $sourceFileRelative * @param array $result * @return $this * @throws \Magento\Framework\Exception\LocalizedException @@ -713,7 +775,7 @@ protected function createHistoryReport($sourceFileRelative, $entity, $extension $sourceFileRelative = $this->_varDirectory->getRelativePath(self::IMPORT_DIR . $fileName); } elseif (isset($result['name'])) { $fileName = $result['name']; - } elseif (!is_null($extension)) { + } elseif ($extension !== null) { $fileName = $entity . $extension; } else { $fileName = basename($sourceFileRelative); diff --git a/app/code/Magento/ImportExport/Model/Import/Entity/AbstractEntity.php b/app/code/Magento/ImportExport/Model/Import/Entity/AbstractEntity.php index e2398e792b817..f61f21b093fd3 100644 --- a/app/code/Magento/ImportExport/Model/Import/Entity/AbstractEntity.php +++ b/app/code/Magento/ImportExport/Model/Import/Entity/AbstractEntity.php @@ -554,6 +554,7 @@ public function getBehavior() $this->_parameters['behavior'] ) || $this->_parameters['behavior'] != ImportExport::BEHAVIOR_APPEND && + $this->_parameters['behavior'] != ImportExport::BEHAVIOR_ADD_UPDATE && $this->_parameters['behavior'] != ImportExport::BEHAVIOR_REPLACE && $this->_parameters['behavior'] != ImportExport::BEHAVIOR_DELETE ) { diff --git a/app/code/Magento/ImportExport/Model/Import/SampleFileProvider.php b/app/code/Magento/ImportExport/Model/Import/SampleFileProvider.php new file mode 100644 index 0000000000000..badffa6632174 --- /dev/null +++ b/app/code/Magento/ImportExport/Model/Import/SampleFileProvider.php @@ -0,0 +1,127 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ImportExport\Model\Import; + +use Magento\Framework\Component\ComponentRegistrar; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\Filesystem\Directory\ReadInterface; +use Magento\Framework\Filesystem\Directory\ReadFactory; + +/** + * Import sample file provider model. + * + * This class support only *.csv. + */ +class SampleFileProvider +{ + /** + * Associate an import entity to its module, e.g ['entity_name' => 'module_name'] + * + * @var array + */ + private $samples; + + /** + * @var ComponentRegistrar + */ + private $componentRegistrar; + + /** + * @var ReadFactory + */ + private $readFactory; + + /** + * @param ReadFactory $readFactory + * @param ComponentRegistrar $componentRegistrar + * @param array $samples + */ + public function __construct( + ReadFactory $readFactory, + ComponentRegistrar $componentRegistrar, + array $samples = [] + ) { + $this->readFactory = $readFactory; + $this->componentRegistrar = $componentRegistrar; + $this->samples = $samples; + } + + /** + * Returns the size for the given file associated to an import entity. + * + * @param string $entityName + * @return int|null + */ + public function getSize(string $entityName) + { + $directoryRead = $this->getDirectoryRead($entityName); + $filePath = $this->getPath($entityName); + $fileSize = $directoryRead->stat($filePath)['size'] ?? null; + + return $fileSize; + } + + /** + * Returns content for the given file associated to an import entity. + * + * @param string $entityName + * @return string + */ + public function getFileContents(string $entityName): string + { + $directoryRead = $this->getDirectoryRead($entityName); + $filePath = $this->getPath($entityName); + + return $directoryRead->readFile($filePath); + } + + /** + * @param string $entityName + * @return string + * @throws NoSuchEntityException + */ + private function getPath(string $entityName): string + { + $directoryRead = $this->getDirectoryRead($entityName); + $fileAbsolutePath = 'Files/Sample/' . $entityName . '.csv'; + $filePath = $directoryRead->getRelativePath($fileAbsolutePath); + + if (!$directoryRead->isFile($filePath)) { + throw new NoSuchEntityException(__("There is no file: %file", ['file' => $filePath])); + } + + return $filePath; + } + + /** + * @param string $entityName + * @return ReadInterface + */ + private function getDirectoryRead(string $entityName): ReadInterface + { + $moduleName = $this->getModuleName($entityName); + $moduleDir = $this->componentRegistrar->getPath(ComponentRegistrar::MODULE, $moduleName); + $directoryRead = $this->readFactory->create($moduleDir); + + return $directoryRead; + } + + /** + * @param string $entityName + * @return string + * @throws NoSuchEntityException + */ + private function getModuleName(string $entityName): string + { + if (!isset($this->samples[$entityName])) { + throw new NoSuchEntityException(); + } + + return $this->samples[$entityName]; + } +} diff --git a/app/code/Magento/ImportExport/Model/Import/Source/Zip.php b/app/code/Magento/ImportExport/Model/Import/Source/Zip.php index b7fafc43ca4ed..8ff46b9509de7 100644 --- a/app/code/Magento/ImportExport/Model/Import/Source/Zip.php +++ b/app/code/Magento/ImportExport/Model/Import/Source/Zip.php @@ -5,6 +5,8 @@ */ namespace Magento\ImportExport\Model\Import\Source; +use Magento\Framework\Exception\ValidatorException; + /** * Zip import adapter. */ @@ -14,6 +16,7 @@ class Zip extends Csv * @param string $file * @param \Magento\Framework\Filesystem\Directory\Write $directory * @param string $options + * @throws \Magento\Framework\Exception\ValidatorException */ public function __construct( $file, @@ -21,10 +24,14 @@ public function __construct( $options ) { $zip = new \Magento\Framework\Archive\Zip(); - $file = $zip->unpack( - $directory->getRelativePath($file), - $directory->getRelativePath(preg_replace('/\.zip$/i', '.csv', $file)) + $csvFile = $zip->unpack( + $file, + preg_replace('/\.zip$/i', '.csv', $file) ); - parent::__construct($file, $directory, $options); + if (!$csvFile) { + throw new ValidatorException(__('Sorry, but the data is invalid or the file is not uploaded.')); + } + $directory->delete($directory->getRelativePath($file)); + parent::__construct($csvFile, $directory, $options); } } diff --git a/app/code/Magento/ImportExport/Test/Mftf/Page/AdminImportIndexPage.xml b/app/code/Magento/ImportExport/Test/Mftf/Page/AdminImportIndexPage.xml new file mode 100644 index 0000000000000..87807eb9b0e85 --- /dev/null +++ b/app/code/Magento/ImportExport/Test/Mftf/Page/AdminImportIndexPage.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminImportIndexPage" url="admin/import/" area="admin" module="Magento_ImportExport"> + <section name="AdminImportHeaderSection"/> + <section name="AdminImportMainSection"/> + </page> +</pages> diff --git a/app/code/Magento/ImportExport/Test/Mftf/Section/AdminImportHeaderSection.xml b/app/code/Magento/ImportExport/Test/Mftf/Section/AdminImportHeaderSection.xml new file mode 100644 index 0000000000000..748580be09406 --- /dev/null +++ b/app/code/Magento/ImportExport/Test/Mftf/Section/AdminImportHeaderSection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminImportHeaderSection"> + <element name="checkDataButton" type="button" selector="#upload_button" timeout="30"/> + </section> +</sections> diff --git a/app/code/Magento/ImportExport/Test/Mftf/Section/AdminImportMainSection.xml b/app/code/Magento/ImportExport/Test/Mftf/Section/AdminImportMainSection.xml new file mode 100644 index 0000000000000..2ce6b1e35777f --- /dev/null +++ b/app/code/Magento/ImportExport/Test/Mftf/Section/AdminImportMainSection.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminImportMainSection"> + <element name="entityType" type="select" selector="#entity"/> + <element name="importBehavior" type="select" selector="#basic_behavior"/> + <element name="selectFileToImport" type="input" selector="#import_file"/> + <element name="importButton" type="button" selector="#import_validation_container button" timeout="30"/> + </section> +</sections> diff --git a/app/code/Magento/ImportExport/Test/Mftf/Test/AdminImportProductsWithDeleteBehaviorTest.xml b/app/code/Magento/ImportExport/Test/Mftf/Test/AdminImportProductsWithDeleteBehaviorTest.xml new file mode 100644 index 0000000000000..04cef6d6db232 --- /dev/null +++ b/app/code/Magento/ImportExport/Test/Mftf/Test/AdminImportProductsWithDeleteBehaviorTest.xml @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminImportProductsWithDeleteBehaviorTest"> + <annotations> + <description value="Verify Magento native import products with delete behavior."/> + <stories value="Verify Magento native import products with delete behavior."/> + <features value="Import/Export"/> + <title value="Verify Magento native import products with delete behavior."/> + <severity value="CRITICAL"/> + <testCaseId value="MAGETWO-79495"/> + <group value="importExport"/> + </annotations> + <before> + <!--Create Simple product--> + <createData entity="SimpleProduct3" stepKey="createSimpleProduct"> + <field key="name">Simple Product for Test</field> + <field key="sku">Simple Product for Test</field> + </createData> + <!--Create Virtual product--> + <createData entity="VirtualProduct" stepKey="createVirtualProduct"> + <field key="name">Virtual Product for Test</field> + <field key="sku">Virtual Product for Test</field> + </createData> + <!-- Create Downloadable product --> + <createData entity="ApiDownloadableProduct" stepKey="createDownloadableProduct"> + <field key="name">Api Downloadable Product for Test</field> + <field key="sku">Api Downloadable Product for Test</field> + </createData> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + </before> + <after> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="navigateToProductIndexPage"/> + <actionGroup ref="clearFiltersAdminDataGrid" stepKey="clearProductFilters"/> + <actionGroup ref="logout" stepKey="logoutFromAdmin"/> + </after> + <amOnPage url="{{AdminImportIndexPage.url}}" stepKey="goToImportIndexPage"/> + <selectOption selector="{{AdminImportMainSection.entityType}}" userInput="Products" stepKey="selectProductsOption"/> + <waitForElementVisible selector="{{AdminImportMainSection.importBehavior}}" stepKey="waitForImportBehaviorElementVisible"/> + <selectOption selector="{{AdminImportMainSection.importBehavior}}" userInput="Delete" stepKey="selectDeleteOption"/> + <attachFile selector="{{AdminImportMainSection.selectFileToImport}}" userInput="catalog_products.csv" stepKey="attachFileForImport"/> + <click selector="{{AdminImportHeaderSection.checkDataButton}}" stepKey="clickCheckDataButton"/> + <click selector="{{AdminImportMainSection.importButton}}" stepKey="clickImportButton"/> + <see selector="{{AdminMessagesSection.successMessage}}" userInput="Import successfully done" stepKey="assertSuccessMessage"/> + <actionGroup ref="SearchForProductOnBackendActionGroup" stepKey="searchSimpleProductOnBackend"> + <argument name="product" value="$$createSimpleProduct$$"/> + </actionGroup> + <see selector="{{AdminDataGridTableSection.dataGridEmpty}}" userInput="We couldn't find any records." stepKey="assertDataGridEmptyMessage"/> + <actionGroup ref="SearchForProductOnBackendActionGroup" stepKey="searchVirtualProductOnBackend"> + <argument name="product" value="$$createVirtualProduct$$"/> + </actionGroup> + <see selector="{{AdminDataGridTableSection.dataGridEmpty}}" userInput="We couldn't find any records." stepKey="assertDataGridEmptyMessage1"/> + <actionGroup ref="SearchForProductOnBackendActionGroup" stepKey="searchDownloadableProductOnBackend"> + <argument name="product" value="$$createDownloadableProduct$$"/> + </actionGroup> + <see selector="{{AdminDataGridTableSection.dataGridEmpty}}" userInput="We couldn't find any records." stepKey="assertDataGridEmptyMessage2"/> + </test> +</tests> diff --git a/app/code/Magento/ImportExport/Test/Unit/Model/Import/SampleFileProviderTest.php b/app/code/Magento/ImportExport/Test/Unit/Model/Import/SampleFileProviderTest.php new file mode 100644 index 0000000000000..28ed10ac3c3b9 --- /dev/null +++ b/app/code/Magento/ImportExport/Test/Unit/Model/Import/SampleFileProviderTest.php @@ -0,0 +1,155 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +namespace Magento\ImportExport\Test\Unit\Model\Import; + +use Magento\Framework\Filesystem\Directory\ReadFactory; +use Magento\Framework\Filesystem\Directory\ReadInterface; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\ImportExport\Model\Import\SampleFileProvider; + +/** + * Test class for Magento\ImportExport\Model\Import\SampleFileProvider. + */ +class SampleFileProviderTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var ReadInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $readerMock; + + /** + * @var ReadFactory|\PHPUnit_Framework_MockObject_MockObject + */ + private $readerFactoryMock; + + /** + * @var ObjectManager + */ + private $objectManager; + + /** + * @var SampleFileProvider + */ + private $model; + + /** + * @var string + */ + private $entityName = 'test_sample'; + + /** + * @var string + */ + private $moduleName = 'Test_Sample'; + + /** + * @var string + */ + private $filePath = 'Files/Sample/test_sample.csv'; + + /** + * @inheritdoc + */ + protected function setUp() + { + $this->objectManager = new ObjectManager($this); + $this->readerMock = $this->getMockBuilder(ReadInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->readerFactoryMock = $this->createMock(ReadFactory::class); + $this->readerFactoryMock->expects($this->any())->method('create')->willReturn($this->readerMock); + $this->readerMock->expects($this->any())->method('getRelativePath')->willReturnArgument(0); + $this->model = $this->objectManager->getObject( + SampleFileProvider::class, + [ + 'readFactory' => $this->readerFactoryMock, + ] + ); + } + + /** + * @return void + */ + public function testGetSize() + { + $fileSize = 10; + + $this->objectManager->setBackwardCompatibleProperty( + $this->model, + 'samples', + [$this->entityName => $this->moduleName] + ); + $this->readerMock->expects($this->atLeastOnce())->method('isFile')->willReturn(true); + $this->readerMock->expects($this->once())->method('stat') + ->with($this->filePath) + ->willReturn(['size' => $fileSize]); + + $actualSize = $this->model->getSize($this->entityName); + $this->assertEquals($fileSize, $actualSize); + } + + /** + * @return void + */ + public function testGetFileContents() + { + $fileContent = 'test'; + + $this->objectManager->setBackwardCompatibleProperty( + $this->model, + 'samples', + [$this->entityName => $this->moduleName] + ); + $this->readerMock->expects($this->atLeastOnce())->method('isFile')->willReturn(true); + $this->readerMock->expects($this->once())->method('readFile') + ->with($this->filePath) + ->willReturn($fileContent); + + $actualContent = $this->model->getFileContents($this->entityName); + $this->assertEquals($fileContent, $actualContent); + } + + /** + * @dataProvider methodDataProvider + * @expectedException \Magento\Framework\Exception\NoSuchEntityException + * @param string $methodName + * @return void + */ + public function testMethodCallMissingSample(string $methodName) + { + $this->model->{$methodName}('missingType'); + } + + /** + * @dataProvider methodDataProvider + * @expectedException \Magento\Framework\Exception\NoSuchEntityException + * @expectedExceptionMessage There is no file: Files/Sample/test_sample.csv + * @param string $methodName + * @return void + */ + public function testMethodCallMissingFile(string $methodName) + { + $this->objectManager->setBackwardCompatibleProperty( + $this->model, + 'samples', + [$this->entityName => $this->moduleName] + ); + $this->readerMock->expects($this->atLeastOnce())->method('isFile')->willReturn(false); + + $this->model->{$methodName}($this->entityName); + } + + /** + * @return array + */ + public function methodDataProvider(): array + { + return [ + ['getSize'], + ['getFileContents'], + ]; + } +} diff --git a/app/code/Magento/ImportExport/composer.json b/app/code/Magento/ImportExport/composer.json index affe85aed48bd..7df4f98de95f4 100644 --- a/app/code/Magento/ImportExport/composer.json +++ b/app/code/Magento/ImportExport/composer.json @@ -12,7 +12,7 @@ "ext-ctype": "*" }, "type": "magento2-module", - "version": "100.2.6", + "version": "100.2.7", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/ImportExport/etc/di.xml b/app/code/Magento/ImportExport/etc/di.xml index 47acf7a356d93..36c76022a41c7 100644 --- a/app/code/Magento/ImportExport/etc/di.xml +++ b/app/code/Magento/ImportExport/etc/di.xml @@ -17,4 +17,16 @@ </argument> </arguments> </type> + <type name="Magento\ImportExport\Model\Import\SampleFileProvider"> + <arguments> + <argument name="samples" xsi:type="array"> + <item name="advanced_pricing" xsi:type="string">Magento_ImportExport</item> + <item name="catalog_product" xsi:type="string">Magento_ImportExport</item> + <item name="customer" xsi:type="string">Magento_ImportExport</item> + <item name="customer_address" xsi:type="string">Magento_ImportExport</item> + <item name="customer_composite" xsi:type="string">Magento_ImportExport</item> + <item name="customer_finance" xsi:type="string">Magento_ImportExport</item> + </argument> + </arguments> + </type> </config> diff --git a/app/code/Magento/Indexer/Test/Mftf/ActionGroup/AdminIndexerActionGroup.xml b/app/code/Magento/Indexer/Test/Mftf/ActionGroup/AdminIndexerActionGroup.xml new file mode 100644 index 0000000000000..5cf6656f9fb50 --- /dev/null +++ b/app/code/Magento/Indexer/Test/Mftf/ActionGroup/AdminIndexerActionGroup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="UpdateIndexerMode"> + <arguments> + <argument name="indexerId" type="string"/> + <argument name="indexerMode" type="string" defaultValue="Update on Save"/> + </arguments> + <amOnPage url="{{AdminIndexManagementPage.url}}" stepKey="amOnIndexManagementPage"/> + <checkOption selector="{{AdminIndexManagementSection.indexerCheckbox(indexerId)}}" stepKey="selectIndexer"/> + <selectOption selector="{{AdminIndexManagementSection.massActionSelect}}" userInput="{{indexerMode}}" stepKey="selectIndexerMode"/> + <click selector="{{AdminIndexManagementSection.massActionSubmit}}" stepKey="submitIndexerForm"/> + <see selector="{{AdminMessagesSection.success}}" userInput='1 indexer(s) are in "{{indexerMode}}" mode.' stepKey="seeSuccessMessage"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Indexer/Test/Mftf/Page/AdminIndexManagementPage.xml b/app/code/Magento/Indexer/Test/Mftf/Page/AdminIndexManagementPage.xml new file mode 100644 index 0000000000000..504608d0721fe --- /dev/null +++ b/app/code/Magento/Indexer/Test/Mftf/Page/AdminIndexManagementPage.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminIndexManagementPage" url="indexer/indexer/list" module="Magento_Indexer" area="admin"> + <section name="AdminIndexManagementSection"/> + </page> +</pages> diff --git a/app/code/Magento/Indexer/Test/Mftf/Section/AdminIndexManagementSection.xml b/app/code/Magento/Indexer/Test/Mftf/Section/AdminIndexManagementSection.xml new file mode 100644 index 0000000000000..07d93ff850221 --- /dev/null +++ b/app/code/Magento/Indexer/Test/Mftf/Section/AdminIndexManagementSection.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminIndexManagementSection"> + <element name="indexerCheckbox" type="checkbox" selector="#gridIndexer_table input[value='{{indexerId}}']" parameterized="true"/> + <element name="massActionSelect" type="select" selector="#gridIndexer_massaction-select"/> + <element name="massActionSubmit" type="button" selector="#gridIndexer_massaction-form button" timeout="30"/> + </section> +</sections> diff --git a/app/code/Magento/Indexer/composer.json b/app/code/Magento/Indexer/composer.json index 1667f8a05a5e8..a0fa62c3d7358 100644 --- a/app/code/Magento/Indexer/composer.json +++ b/app/code/Magento/Indexer/composer.json @@ -7,7 +7,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/InstantPurchase/composer.json b/app/code/Magento/InstantPurchase/composer.json index cd7684254e408..d7257c7a46d90 100644 --- a/app/code/Magento/InstantPurchase/composer.json +++ b/app/code/Magento/InstantPurchase/composer.json @@ -2,7 +2,7 @@ "name": "magento/module-instant-purchase", "description": "N/A", "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Integration/composer.json b/app/code/Magento/Integration/composer.json index 75bd7a04d0dcc..74f2db3d68238 100644 --- a/app/code/Magento/Integration/composer.json +++ b/app/code/Magento/Integration/composer.json @@ -12,7 +12,7 @@ "magento/module-authorization": "100.2.*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/LayeredNavigation/Test/Mftf/Test/StorefrontCheckingResultsOfFiltersTest.xml b/app/code/Magento/LayeredNavigation/Test/Mftf/Test/StorefrontCheckingResultsOfFiltersTest.xml index 1d4a861ffe7b8..e6c3cb1788b31 100644 --- a/app/code/Magento/LayeredNavigation/Test/Mftf/Test/StorefrontCheckingResultsOfFiltersTest.xml +++ b/app/code/Magento/LayeredNavigation/Test/Mftf/Test/StorefrontCheckingResultsOfFiltersTest.xml @@ -37,7 +37,7 @@ <click selector="{{AdminProductAttributeSetGridSection.deleteOptionByName('Black')}}" stepKey="deleteOption5"/> <!--Save attribute--> - <click selector="{{AdminMainActionsSection.save}}" stepKey="clickSaveColorAttribute"/> + <actionGroup ref="SaveProductAttribute" stepKey="clickSaveColorAttribute"/> <!--Delete Category--> <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> @@ -87,7 +87,7 @@ <waitForElementVisible selector="{{AdminNewAttributePanelSection.isDefault('5')}}" time="30" stepKey="waitForOptionRow5"/> <fillField selector="{{AdminNewAttributePanelSection.optionAdminValue('4')}}" userInput="{{ColorProductAttribute5.name}}" stepKey="fillAdminLabel5"/> <!--Save attribute--> - <click selector="{{AdminMainActionsSection.save}}" stepKey="clickSaveAttribute"/> + <actionGroup ref="SaveProductAttribute" stepKey="clickSaveAttribute"/> <!--Create 'material' attribute:--> <amOnPage url="{{ProductAttributePage.url}}" stepKey="goToNewProductAttributePage"/> @@ -122,9 +122,7 @@ <selectOption selector="{{AttributePropertiesSection.useInLayeredNavigation}}" userInput="1" stepKey="selectUseInLayeredNavigation"/> <!--Save attribute--> - <click selector="{{AdminNewAttributePanel.saveAttribute}}" stepKey="clickSaveNewAttribute"/> - <waitForPageLoad stepKey="waitForSavingNewAttribute"/> - <see userInput="You saved the product attribute." stepKey="seeSuccessMessage"/> + <actionGroup ref="SaveProductAttribute" stepKey="clickSaveNewAttribute"/> <click selector="{{AdminUserGridSection.resetButton}}" stepKey="clickResetButton"/> <!--Create attribute set--> @@ -204,8 +202,7 @@ <dragAndDrop selector1="{{AdminNewAttributePanelSection.attributeName('cotton')}}" selector2="{{AdminNewAttributePanelSection.attributeName('fabric')}}" stepKey="selectMaterialsSimple"/> <!-- Save the product --> - <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="saveSimpleProduct"/> - <see selector="{{AdminProductMessagesSection.successMessage}}" userInput="You saved the product." stepKey="assertSuccessSimpleProduct"/> + <actionGroup ref="saveProductForm" stepKey="saveSimpleProduct"/> <!--Clear caches--> <magentoCLI command="cache:flush" stepKey="flushCache"/> diff --git a/app/code/Magento/LayeredNavigation/composer.json b/app/code/Magento/LayeredNavigation/composer.json index b8dfc77532784..8ce17fe30ec35 100644 --- a/app/code/Magento/LayeredNavigation/composer.json +++ b/app/code/Magento/LayeredNavigation/composer.json @@ -8,7 +8,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Marketplace/composer.json b/app/code/Magento/Marketplace/composer.json index 5ce8666ec1bb2..02824a6796fc7 100644 --- a/app/code/Magento/Marketplace/composer.json +++ b/app/code/Magento/Marketplace/composer.json @@ -7,7 +7,7 @@ "magento/module-backend": "100.2.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/MediaStorage/composer.json b/app/code/Magento/MediaStorage/composer.json index afb290a682274..a891dce37624d 100644 --- a/app/code/Magento/MediaStorage/composer.json +++ b/app/code/Magento/MediaStorage/composer.json @@ -9,7 +9,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Msrp/composer.json b/app/code/Magento/Msrp/composer.json index 97bc64e48ce01..625bec5d6c9d2 100644 --- a/app/code/Magento/Msrp/composer.json +++ b/app/code/Magento/Msrp/composer.json @@ -16,7 +16,7 @@ "magento/module-msrp-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Msrp/view/base/web/js/msrp.js b/app/code/Magento/Msrp/view/base/web/js/msrp.js index deeadd9b55b82..72dd1d8bbecbe 100644 --- a/app/code/Magento/Msrp/view/base/web/js/msrp.js +++ b/app/code/Magento/Msrp/view/base/web/js/msrp.js @@ -73,6 +73,7 @@ define([ this.initTierPopup(); } $(this.options.cartButtonId).on('click', this._addToCartSubmit.bind(this)); + $(this.options.cartForm).on('submit', this._onSubmitForm.bind(this)); }, /** @@ -249,8 +250,10 @@ define([ /** * Handler for addToCart action + * + * @param {Object} e */ - _addToCartSubmit: function () { + _addToCartSubmit: function (e) { this.element.trigger('addToCart', this.element); if (this.element.data('stop-processing')) { @@ -266,8 +269,20 @@ define([ if (this.options.addToCartUrl) { $('.mage-dropdown-dialog > .ui-dialog-content').dropdownDialog('close'); } + + e.preventDefault(); $(this.options.cartForm).submit(); + }, + /** + * Handler for submit form + * + * @private + */ + _onSubmitForm: function () { + if ($(this.options.cartForm).valid()) { + $(this.options.cartButtonId).prop('disabled', true); + } } }); diff --git a/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping.php b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping.php index ea7838085ddfb..0ea0abc135f9e 100644 --- a/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping.php +++ b/app/code/Magento/Multishipping/Model/Checkout/Type/Multishipping.php @@ -1168,7 +1168,7 @@ private function removePlacedItemsFromQuote(array $shippingAddresses, array $pla { foreach ($shippingAddresses as $address) { foreach ($address->getAllItems() as $addressItem) { - if (in_array($addressItem->getId(), $placedAddressItems)) { + if (in_array($addressItem->getQuoteItemId(), $placedAddressItems)) { if ($addressItem->getProduct()->getIsVirtual()) { $addressItem->isDeleted(true); } else { @@ -1218,7 +1218,7 @@ private function searchQuoteAddressId(OrderInterface $order, array $addresses): $item = array_pop($items); foreach ($addresses as $address) { foreach ($address->getAllItems() as $addressItem) { - if ($addressItem->getId() == $item->getQuoteItemId()) { + if ($addressItem->getQuoteItemId() == $item->getQuoteItemId()) { return (int)$address->getId(); } } diff --git a/app/code/Magento/Multishipping/Test/Mftf/Page/StorefrontMultishippingCheckoutAddressesPage.xml b/app/code/Magento/Multishipping/Test/Mftf/Page/StorefrontMultishippingCheckoutAddressesPage.xml new file mode 100644 index 0000000000000..b1f665f335f0a --- /dev/null +++ b/app/code/Magento/Multishipping/Test/Mftf/Page/StorefrontMultishippingCheckoutAddressesPage.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="StorefrontMultishippingCheckoutAddressesPage" url="/multishipping/checkout/addresses" area="storefront" + module="Magento_Multishipping"> + <section name="StorefrontMultishippingCheckoutSection"/> + </page> +</pages> diff --git a/app/code/Magento/Multishipping/Test/Mftf/Page/StorefrontMultishippingCheckoutShippingPage.xml b/app/code/Magento/Multishipping/Test/Mftf/Page/StorefrontMultishippingCheckoutShippingPage.xml new file mode 100644 index 0000000000000..98dbb7e355f27 --- /dev/null +++ b/app/code/Magento/Multishipping/Test/Mftf/Page/StorefrontMultishippingCheckoutShippingPage.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="StorefrontMultishippingCheckoutShippingPage" url="/multishipping/checkout/shipping" area="storefront" + module="Magento_Multishipping"> + <section name="StorefrontCheckoutGiftOptionsSection"/> + </page> +</pages> diff --git a/app/code/Magento/Multishipping/Test/Mftf/Section/StorefrontCheckoutCartSummarySection.xml b/app/code/Magento/Multishipping/Test/Mftf/Section/StorefrontCheckoutCartSummarySection.xml new file mode 100644 index 0000000000000..6d0707bb46d75 --- /dev/null +++ b/app/code/Magento/Multishipping/Test/Mftf/Section/StorefrontCheckoutCartSummarySection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontCheckoutCartSummarySection"> + <element name="checkoutWithMultipleAddresses" type="button" selector=".multicheckout" timeout="30"/> + </section> +</sections> diff --git a/app/code/Magento/Multishipping/Test/Mftf/Section/StorefrontMultishippingCheckoutSection.xml b/app/code/Magento/Multishipping/Test/Mftf/Section/StorefrontMultishippingCheckoutSection.xml new file mode 100644 index 0000000000000..6539c17de9e57 --- /dev/null +++ b/app/code/Magento/Multishipping/Test/Mftf/Section/StorefrontMultishippingCheckoutSection.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontMultishippingCheckoutSection"> + <element name="goToShippingInformation" type="button" selector="button[title='Go to Shipping Information']" + timeout="30"/> + </section> +</sections> diff --git a/app/code/Magento/Multishipping/composer.json b/app/code/Magento/Multishipping/composer.json index 111204915e4c8..d32bb1f5bd82a 100644 --- a/app/code/Magento/Multishipping/composer.json +++ b/app/code/Magento/Multishipping/composer.json @@ -15,7 +15,7 @@ "magento/module-theme": "100.2.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_customer_address.xml b/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_customer_address.xml index c6bcdeb7b0413..fee3cb790a522 100644 --- a/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_customer_address.xml +++ b/app/code/Magento/Multishipping/view/frontend/layout/multishipping_checkout_customer_address.xml @@ -12,6 +12,7 @@ <block class="Magento\Customer\Block\Address\Edit" name="customer_address_edit" template="Magento_Customer::address/edit.phtml" cacheable="false"> <arguments> <argument name="attribute_data" xsi:type="object">Magento\Customer\Block\DataProviders\AddressAttributeData</argument> + <argument name="post_code_config" xsi:type="object">Magento\Customer\Block\DataProviders\PostCodesPatternsAttributeData</argument> </arguments> </block> </referenceContainer> diff --git a/app/code/Magento/NewRelicReporting/composer.json b/app/code/Magento/NewRelicReporting/composer.json index 18a1a2df57336..abb2a200ed723 100644 --- a/app/code/Magento/NewRelicReporting/composer.json +++ b/app/code/Magento/NewRelicReporting/composer.json @@ -13,7 +13,7 @@ "magento/magento-composer-installer": "*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Newsletter/Controller/Manage/Save.php b/app/code/Magento/Newsletter/Controller/Manage/Save.php index 419cbac10ffd1..1aa2a4505d518 100644 --- a/app/code/Magento/Newsletter/Controller/Manage/Save.php +++ b/app/code/Magento/Newsletter/Controller/Manage/Save.php @@ -8,8 +8,12 @@ namespace Magento\Newsletter\Controller\Manage; use Magento\Customer\Api\CustomerRepositoryInterface as CustomerRepository; +use Magento\Customer\Model\Data\Customer; use Magento\Newsletter\Model\Subscriber; +/** + * Controller for customer newsletter subscription save. + */ class Save extends \Magento\Newsletter\Controller\Manage { /** @@ -58,7 +62,7 @@ public function __construct( } /** - * Save newsletter subscription preference action + * Save newsletter subscription preference action. * * @return void|null */ @@ -81,6 +85,8 @@ public function execute() $isSubscribedParam = (boolean)$this->getRequest() ->getParam('is_subscribed', false); if ($isSubscribedParam !== $isSubscribedState) { + // No need to validate customer and customer address while saving subscription preferences + $this->setIgnoreValidationFlag($customer); $this->customerRepository->save($customer); if ($isSubscribedParam) { $subscribeModel = $this->subscriberFactory->create() @@ -105,4 +111,15 @@ public function execute() } $this->_redirect('customer/account/'); } + + /** + * Set ignore_validation_flag to skip unnecessary address and customer validation. + * + * @param Customer $customer + * @return void + */ + private function setIgnoreValidationFlag(Customer $customer) + { + $customer->setData('ignore_validation_flag', true); + } } diff --git a/app/code/Magento/Newsletter/Controller/Subscriber/NewAction.php b/app/code/Magento/Newsletter/Controller/Subscriber/NewAction.php index fd2a61702e909..ab4950b8a3452 100644 --- a/app/code/Magento/Newsletter/Controller/Subscriber/NewAction.php +++ b/app/code/Magento/Newsletter/Controller/Subscriber/NewAction.php @@ -77,9 +77,10 @@ public function __construct( protected function validateEmailAvailable($email) { $websiteId = $this->_storeManager->getStore()->getWebsiteId(); - if ($this->_customerSession->getCustomerDataObject()->getEmail() !== $email + if ($this->_customerSession->isLoggedIn() + && ($this->_customerSession->getCustomerDataObject()->getEmail() !== $email && !$this->customerAccountManagement->isEmailAvailable($email, $websiteId) - ) { + )) { throw new LocalizedException( __('This email address is already assigned to another user.') ); diff --git a/app/code/Magento/Newsletter/Model/ResourceModel/Subscriber.php b/app/code/Magento/Newsletter/Model/ResourceModel/Subscriber.php index 131eca5f33487..33e3826e9180b 100644 --- a/app/code/Magento/Newsletter/Model/ResourceModel/Subscriber.php +++ b/app/code/Magento/Newsletter/Model/ResourceModel/Subscriber.php @@ -132,43 +132,34 @@ public function loadByEmail($subscriberEmail) */ public function loadByCustomerData(\Magento\Customer\Api\Data\CustomerInterface $customer) { - $storeId = (int)$customer->getStoreId() ?: $this->storeManager - ->getWebsite($customer->getWebsiteId())->getDefaultStore()->getId(); - - $select = $this->connection - ->select() - ->from($this->getMainTable()) - ->where('customer_id=:customer_id and store_id=:store_id'); - - $result = $this->connection - ->fetchRow( - $select, - [ - 'customer_id' => $customer->getId(), - 'store_id' => $storeId - ] - ); + $storeIds = $this->storeManager->getWebsite($customer->getWebsiteId())->getStoreIds(); + + if ($customer->getId()) { + $select = $this->connection + ->select() + ->from($this->getMainTable()) + ->where('customer_id = ?', $customer->getId()) + ->where('store_id IN (?)', $storeIds); + + $result = $this->connection->fetchRow($select); - if ($result) { - return $result; + if ($result) { + return $result; + } } - $select = $this->connection - ->select() - ->from($this->getMainTable()) - ->where('subscriber_email=:subscriber_email and store_id=:store_id'); - - $result = $this->connection - ->fetchRow( - $select, - [ - 'subscriber_email' => $customer->getEmail(), - 'store_id' => $storeId - ] - ); + if ($customer->getEmail()) { + $select = $this->connection + ->select() + ->from($this->getMainTable()) + ->where('subscriber_email = ?', $customer->getEmail()) + ->where('store_id IN (?)', $storeIds); + + $result = $this->connection->fetchRow($select); - if ($result) { - return $result; + if ($result) { + return $result; + } } return []; diff --git a/app/code/Magento/Newsletter/Model/Subscriber.php b/app/code/Magento/Newsletter/Model/Subscriber.php index a1d929d8db7bf..2c6f494053efc 100644 --- a/app/code/Magento/Newsletter/Model/Subscriber.php +++ b/app/code/Magento/Newsletter/Model/Subscriber.php @@ -389,6 +389,9 @@ public function loadByCustomerId($customerId) try { $customerData = $this->customerRepository->getById($customerId); $customerData->setStoreId($this->_storeManager->getStore()->getId()); + if ($customerData->getWebsiteId() === null) { + $customerData->setWebsiteId($this->_storeManager->getStore()->getWebsiteId()); + } $data = $this->getResource()->loadByCustomerData($customerData); $this->addData($data); if (!empty($data) && $customerData->getId() && !$this->getCustomerId()) { @@ -549,7 +552,7 @@ public function updateSubscription($customerId) } /** - * Saving customer subscription status + * Saving customer subscription status. * * @param int $customerId * @param bool $subscribe indicates whether the customer should be subscribed or unsubscribed @@ -585,7 +588,12 @@ protected function _updateCustomerSubscription($customerId, $subscribe) if (AccountManagementInterface::ACCOUNT_CONFIRMATION_REQUIRED == $this->customerAccountManagement->getConfirmationStatus($customerId) ) { - $status = self::STATUS_UNCONFIRMED; + if ($this->getId() && $this->getStatus() == self::STATUS_SUBSCRIBED) { + // if a customer was already subscribed then keep the subscribed + $status = self::STATUS_SUBSCRIBED; + } else { + $status = self::STATUS_UNCONFIRMED; + } } elseif ($isConfirmNeed) { if ($this->getStatus() != self::STATUS_SUBSCRIBED) { $status = self::STATUS_NOT_ACTIVE; diff --git a/app/code/Magento/Newsletter/Test/Mftf/ActionGroup/VerifySubscribedNewsletterDisplayedActionGroup.xml b/app/code/Magento/Newsletter/Test/Mftf/ActionGroup/VerifySubscribedNewsletterDisplayedActionGroup.xml new file mode 100644 index 0000000000000..81b444fb5c1dc --- /dev/null +++ b/app/code/Magento/Newsletter/Test/Mftf/ActionGroup/VerifySubscribedNewsletterDisplayedActionGroup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + + <!--Create an Account. Check Sign Up for Newsletter checkbox --> + <actionGroup name="StorefrontCreateNewAccountNewsletterChecked" extends="SignUpNewUserFromStorefrontActionGroup"> + <checkOption selector="{{StorefrontCustomerCreateFormSection.signUpForNewsletter}}" stepKey="selectSignUpForNewsletterCheckbox" after="fillLastName"/> + <see stepKey="seenewsletterDescription" userInput='You are subscribed to "General Subscription".' selector="{{StorefrontCustomerDashboardAccountInformationSection.newsletterDescription}}" /> + </actionGroup> + + <!--Check Subscribed Newsletter via StoreFront--> + <actionGroup name="CheckSubscribedNewsletterActionGroup"> + <amOnPage url="{{StorefrontNewsletterManagePage.url}}" stepKey="goToNewsletterManage"/> + <seeCheckboxIsChecked selector="{{StorefrontNewsletterManageSection.subscriptionCheckbox}}" stepKey="checkSubscribedNewsletter"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Newsletter/Test/Mftf/Page/StorefrontNewsletterManagePage.xml b/app/code/Magento/Newsletter/Test/Mftf/Page/StorefrontNewsletterManagePage.xml new file mode 100644 index 0000000000000..81fd3eb7c391c --- /dev/null +++ b/app/code/Magento/Newsletter/Test/Mftf/Page/StorefrontNewsletterManagePage.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="StorefrontNewsletterManagePage" url="newsletter/manage/" area="storefront" module="Magento_Newsletter"> + <section name="StorefrontNewsletterManageSection"/> + </page> +</pages> diff --git a/app/code/Magento/Newsletter/Test/Mftf/Section/StorefrontCustomerCreateFormSection.xml b/app/code/Magento/Newsletter/Test/Mftf/Section/StorefrontCustomerCreateFormSection.xml new file mode 100644 index 0000000000000..36870fbfb0182 --- /dev/null +++ b/app/code/Magento/Newsletter/Test/Mftf/Section/StorefrontCustomerCreateFormSection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontCustomerCreateFormSection"> + <element name="signUpForNewsletter" type="checkbox" selector="#is_subscribed"/> + </section> +</sections> diff --git a/app/code/Magento/Newsletter/Test/Mftf/Section/StorefrontCustomerDashboardAccountInformationSection.xml b/app/code/Magento/Newsletter/Test/Mftf/Section/StorefrontCustomerDashboardAccountInformationSection.xml new file mode 100644 index 0000000000000..15d6debd7ef25 --- /dev/null +++ b/app/code/Magento/Newsletter/Test/Mftf/Section/StorefrontCustomerDashboardAccountInformationSection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontCustomerDashboardAccountInformationSection"> + <element name="newsletterDescription" type="text" selector=".box-newsletter p"/> + </section> +</sections> diff --git a/app/code/Magento/Newsletter/Test/Mftf/Section/StorefrontNewsletterManageSection.xml b/app/code/Magento/Newsletter/Test/Mftf/Section/StorefrontNewsletterManageSection.xml new file mode 100644 index 0000000000000..96a944a4952ac --- /dev/null +++ b/app/code/Magento/Newsletter/Test/Mftf/Section/StorefrontNewsletterManageSection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontNewsletterManageSection"> + <element name="subscriptionCheckbox" type="checkbox" selector="#subscription" /> + </section> +</sections> diff --git a/app/code/Magento/Newsletter/Test/Unit/Model/SubscriberTest.php b/app/code/Magento/Newsletter/Test/Unit/Model/SubscriberTest.php index 9c543c831ded3..0bf6f24a6b989 100644 --- a/app/code/Magento/Newsletter/Test/Unit/Model/SubscriberTest.php +++ b/app/code/Magento/Newsletter/Test/Unit/Model/SubscriberTest.php @@ -209,8 +209,14 @@ public function testSubscribeNotLoggedIn() $this->assertEquals(Subscriber::STATUS_NOT_ACTIVE, $this->subscriber->subscribe($email)); } + /** + * Update status with Confirmation Status - required. + * + * @return void + */ public function testUpdateSubscription() { + $websiteId = 1; $storeId = 2; $customerId = 1; $customerDataMock = $this->getMockBuilder(\Magento\Customer\Api\Data\CustomerInterface::class) @@ -224,7 +230,7 @@ public function testUpdateSubscription() ->willReturn( [ 'subscriber_id' => 1, - 'subscriber_status' => Subscriber::STATUS_SUBSCRIBED + 'subscriber_status' => Subscriber::STATUS_SUBSCRIBED, ] ); $customerDataMock->expects($this->atLeastOnce())->method('getId')->willReturn('id'); @@ -234,15 +240,19 @@ public function testUpdateSubscription() ->with($customerId) ->willReturn('account_confirmation_required'); $customerDataMock->expects($this->exactly(2))->method('getStoreId')->willReturn($storeId); + $customerDataMock->expects($this->exactly(2))->method('getWebsiteId')->willReturn(null); + $customerDataMock->expects($this->exactly(2))->method('setWebsiteId')->with($websiteId)->willReturnSelf(); $customerDataMock->expects($this->once())->method('getEmail')->willReturn('email'); $storeModel = $this->getMockBuilder(\Magento\Store\Model\Store::class) ->disableOriginalConstructor() - ->setMethods(['getId']) + ->setMethods(['getId', 'getWebsiteId']) ->getMock(); $this->storeManager->expects($this->any())->method('getStore')->willReturn($storeModel); + $storeModel->expects($this->exactly(2))->method('getWebsiteId')->willReturn($websiteId); + $data = $this->subscriber->updateSubscription($customerId); - $this->assertEquals($this->subscriber, $this->subscriber->updateSubscription($customerId)); + $this->assertEquals(Subscriber::STATUS_SUBSCRIBED, $data->getSubscriberStatus()); } public function testUnsubscribeCustomerById() diff --git a/app/code/Magento/Newsletter/composer.json b/app/code/Magento/Newsletter/composer.json index ef6158960aeee..a97d0bca5634d 100644 --- a/app/code/Magento/Newsletter/composer.json +++ b/app/code/Magento/Newsletter/composer.json @@ -14,7 +14,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.5", + "version": "100.2.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Newsletter/etc/adminhtml/system.xml b/app/code/Magento/Newsletter/etc/adminhtml/system.xml index 1173f64310304..277005240eabc 100644 --- a/app/code/Magento/Newsletter/etc/adminhtml/system.xml +++ b/app/code/Magento/Newsletter/etc/adminhtml/system.xml @@ -11,39 +11,39 @@ <label>Newsletter</label> <tab>customer</tab> <resource>Magento_Newsletter::newsletter</resource> - <group id="subscription" translate="label" type="text" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="1"> + <group id="subscription" translate="label" type="text" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1"> <label>Subscription Options</label> - <field id="allow_guest_subscribe" translate="label" type="select" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1"> + <field id="allow_guest_subscribe" translate="label" type="select" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1"> <label>Allow Guest Subscription</label> <source_model>Magento\Config\Model\Config\Source\Yesno</source_model> </field> - <field id="confirm" translate="label" type="select" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1"> + <field id="confirm" translate="label" type="select" sortOrder="20" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1"> <label>Need to Confirm</label> <source_model>Magento\Config\Model\Config\Source\Yesno</source_model> </field> - <field id="confirm_email_identity" translate="label" type="select" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1"> + <field id="confirm_email_identity" translate="label" type="select" sortOrder="30" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1"> <label>Confirmation Email Sender</label> <source_model>Magento\Config\Model\Config\Source\Email\Identity</source_model> </field> - <field id="confirm_email_template" translate="label comment" type="select" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1"> + <field id="confirm_email_template" translate="label comment" type="select" sortOrder="40" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1"> <label>Confirmation Email Template</label> <comment>Email template chosen based on theme fallback when "Default" option is selected.</comment> <source_model>Magento\Config\Model\Config\Source\Email\Template</source_model> </field> - <field id="success_email_identity" translate="label" type="select" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1"> + <field id="success_email_identity" translate="label" type="select" sortOrder="50" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1"> <label>Success Email Sender</label> <source_model>Magento\Config\Model\Config\Source\Email\Identity</source_model> </field> - <field id="success_email_template" translate="label comment" type="select" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1"> + <field id="success_email_template" translate="label comment" type="select" sortOrder="60" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1"> <label>Success Email Template</label> <comment>Email template chosen based on theme fallback when "Default" option is selected.</comment> <source_model>Magento\Config\Model\Config\Source\Email\Template</source_model> </field> - <field id="un_email_identity" translate="label" type="select" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1"> + <field id="un_email_identity" translate="label" type="select" sortOrder="70" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1"> <label>Unsubscription Email Sender</label> <source_model>Magento\Config\Model\Config\Source\Email\Identity</source_model> </field> - <field id="un_email_template" translate="label comment" type="select" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1"> + <field id="un_email_template" translate="label comment" type="select" sortOrder="80" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1"> <label>Unsubscription Email Template</label> <comment>Email template chosen based on theme fallback when "Default" option is selected.</comment> <source_model>Magento\Config\Model\Config\Source\Email\Template</source_model> diff --git a/app/code/Magento/Newsletter/view/adminhtml/templates/preview/iframeswitcher.phtml b/app/code/Magento/Newsletter/view/adminhtml/templates/preview/iframeswitcher.phtml index a64185ce67958..033bc799ba73e 100644 --- a/app/code/Magento/Newsletter/view/adminhtml/templates/preview/iframeswitcher.phtml +++ b/app/code/Magento/Newsletter/view/adminhtml/templates/preview/iframeswitcher.phtml @@ -16,7 +16,14 @@ </div> <?php endif;?> </div> - <iframe name="preview_iframe" id="preview_iframe" frameborder="0" title="<?= $block->escapeHtmlAttr(__('Preview')) ?>" width="100%"></iframe> + <iframe + name="preview_iframe" + id="preview_iframe" + frameborder="0" + title="<?= $block->escapeHtmlAttr(__('Preview')) ?>" + width="100%" + sandbox="allow-forms allow-pointer-lock allow-scripts"> + </iframe> <?= $block->getChildHtml('preview_form') ?> </div> diff --git a/app/code/Magento/OfflinePayments/composer.json b/app/code/Magento/OfflinePayments/composer.json index b737149d30a94..747db3d70602e 100644 --- a/app/code/Magento/OfflinePayments/composer.json +++ b/app/code/Magento/OfflinePayments/composer.json @@ -11,7 +11,7 @@ "magento/module-config": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/OfflineShipping/composer.json b/app/code/Magento/OfflineShipping/composer.json index 24b5f877c1279..e04816a3b1128 100644 --- a/app/code/Magento/OfflineShipping/composer.json +++ b/app/code/Magento/OfflineShipping/composer.json @@ -19,7 +19,7 @@ "magento/module-offline-shipping-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/PageCache/Model/System/Config/Backend/Ttl.php b/app/code/Magento/PageCache/Model/System/Config/Backend/Ttl.php index dab8d7be16dd7..95b0ebe72ecd1 100644 --- a/app/code/Magento/PageCache/Model/System/Config/Backend/Ttl.php +++ b/app/code/Magento/PageCache/Model/System/Config/Backend/Ttl.php @@ -6,15 +6,49 @@ namespace Magento\PageCache\Model\System\Config\Backend; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Escaper; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\Exception\LocalizedException; + /** - * Backend model for processing Public content cache lifetime settings + * Backend model for processing Public content cache lifetime settings. * * Class Ttl */ class Ttl extends \Magento\Framework\App\Config\Value { /** - * Throw exception if Ttl data is invalid or empty + * @var Escaper + */ + private $escaper; + + /** + * @param \Magento\Framework\Model\Context $context + * @param \Magento\Framework\Registry $registry + * @param ScopeConfigInterface $config + * @param \Magento\Framework\App\Cache\TypeListInterface $cacheTypeList + * @param \Magento\Framework\Model\ResourceModel\AbstractResource|null $resource + * @param \Magento\Framework\Data\Collection\AbstractDb|null $resourceCollection + * @param array $data + * @param Escaper|null $escaper + */ + public function __construct( + \Magento\Framework\Model\Context $context, + \Magento\Framework\Registry $registry, + ScopeConfigInterface $config, + \Magento\Framework\App\Cache\TypeListInterface $cacheTypeList, + \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null, + \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null, + array $data = [], + Escaper $escaper = null + ) { + parent::__construct($context, $registry, $config, $cacheTypeList, $resource, $resourceCollection, $data); + $this->escaper = $escaper ?: ObjectManager::getInstance()->create(Escaper::class); + } + + /** + * Throw exception if Ttl data is invalid or empty. * * @return $this * @throws \Magento\Framework\Exception\LocalizedException @@ -23,10 +57,14 @@ public function beforeSave() { $value = $this->getValue(); if ($value < 0 || !preg_match('/^[0-9]+$/', $value)) { - throw new \Magento\Framework\Exception\LocalizedException( - __('Ttl value "%1" is not valid. Please use only numbers equal or greater than zero.', $value) + throw new LocalizedException( + __( + 'Ttl value "%1" is not valid. Please use only numbers equal or greater than zero.', + $this->escaper->escapeHtml($value) + ) ); } + return $this; } } diff --git a/app/code/Magento/PageCache/Test/Unit/Model/System/Config/Backend/TtlTest.php b/app/code/Magento/PageCache/Test/Unit/Model/System/Config/Backend/TtlTest.php new file mode 100644 index 0000000000000..6fd3307de726c --- /dev/null +++ b/app/code/Magento/PageCache/Test/Unit/Model/System/Config/Backend/TtlTest.php @@ -0,0 +1,109 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\PageCache\Test\Unit\Model\System\Config\Backend; + +use Magento\PageCache\Model\System\Config\Backend\Ttl; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\Escaper; +use Magento\Framework\Exception\LocalizedException; +use PHPUnit\Framework\TestCase; + +/** + * Class for tesing backend model for processing Public content cache lifetime settings. + */ +class TtlTest extends TestCase +{ + /** + * @var Ttl + */ + private $ttl; + + /* + * @var \Magento\Framework\Escaper|\PHPUnit_Framework_MockObject_MockObject + */ + private $escaperMock; + + /** + * @inheritDoc + */ + protected function setUp() + { + $objectManager = new ObjectManager($this); + $configMock = $this->getMockForAbstractClass(ScopeConfigInterface::class); + $configMock->expects($this->any()) + ->method('getValue') + ->with('system/full_page_cache/default') + ->willReturn(['ttl' => 86400]); + + $this->escaperMock = $this->getMockBuilder(Escaper::class)->disableOriginalConstructor()->getMock(); + + $this->ttl = $objectManager->getObject( + Ttl::class, + [ + 'config' => $configMock, + 'data' => ['field' => 'ttl'], + 'escaper' => $this->escaperMock, + ] + ); + } + + /** + * @return array + */ + public function getValidValues(): array + { + return [ + ['3600', '3600'], + ['10000', '10000'], + ['100000', '100000'], + ['1000000', '1000000'], + ]; + } + + /** + * @param string $value + * @param string $expectedValue + * @return void + * @dataProvider getValidValues + */ + public function testBeforeSave(string $value, string $expectedValue) + { + $this->ttl->setValue($value); + $this->ttl->beforeSave(); + $this->assertEquals($expectedValue, $this->ttl->getValue()); + } + + /** + * @return array + */ + public function getInvalidValues(): array + { + return [ + ['<script>alert(1)</script>'], + ['apple'], + ['123 street'], + ['-123'], + ]; + } + + /** + * @param string $value + * @return void + * @expectedException \Magento\Framework\Exception\LocalizedException + * @expectedExceptionMessageRegExp /Ttl value ".+" is not valid. Please .+ only numbers equal or greater than zero./ + * @dataProvider getInvalidValues + */ + public function testBeforeSaveInvalid(string $value) + { + $this->ttl->setValue($value); + $this->escaperMock->expects($this->any())->method('escapeHtml')->with($value)->willReturn($value); + $this->ttl->beforeSave(); + } +} diff --git a/app/code/Magento/PageCache/composer.json b/app/code/Magento/PageCache/composer.json index d492f3bc23a5f..2b6b62aef2c47 100644 --- a/app/code/Magento/PageCache/composer.json +++ b/app/code/Magento/PageCache/composer.json @@ -9,7 +9,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Payment/Api/Data/PaymentAdditionalInfoInterface.php b/app/code/Magento/Payment/Api/Data/PaymentAdditionalInfoInterface.php new file mode 100644 index 0000000000000..c658baece7779 --- /dev/null +++ b/app/code/Magento/Payment/Api/Data/PaymentAdditionalInfoInterface.php @@ -0,0 +1,16 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\Payment\Api\Data; + +use Magento\Framework\DataObject\KeyValueObjectInterface; + +/** + * Payment additional info interface. + */ +interface PaymentAdditionalInfoInterface extends KeyValueObjectInterface +{ +} diff --git a/app/code/Magento/Payment/Helper/Data.php b/app/code/Magento/Payment/Helper/Data.php index 5fd23c195f0c4..97dac29b4918b 100644 --- a/app/code/Magento/Payment/Helper/Data.php +++ b/app/code/Magento/Payment/Helper/Data.php @@ -267,10 +267,13 @@ public function getPaymentMethodList($sorted = true, $asLabelValue = false, $wit $groupRelations = []; foreach ($this->getPaymentMethods() as $code => $data) { - if (isset($data['title'])) { - $methods[$code] = $data['title']; - } else { - $methods[$code] = $this->getMethodInstance($code)->getConfigData('title', $store); + if (!empty($data['active'])) { + $storedTitle = $this->getMethodInstance($code)->getConfigData('title', $store); + if (!empty($storedTitle)) { + $methods[$code] = $storedTitle; + } elseif (!empty($data['title'])) { + $methods[$code] = $data['title']; + } } if ($asLabelValue && $withGroups && isset($data['group'])) { $groupRelations[$code] = $data['group']; diff --git a/app/code/Magento/Payment/Model/PaymentAdditionalInfo.php b/app/code/Magento/Payment/Model/PaymentAdditionalInfo.php new file mode 100644 index 0000000000000..4ce41181a008a --- /dev/null +++ b/app/code/Magento/Payment/Model/PaymentAdditionalInfo.php @@ -0,0 +1,60 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Payment\Model; + +use Magento\Payment\Api\Data\PaymentAdditionalInfoInterface; + +/** + * Payment additional info class. + */ +class PaymentAdditionalInfo implements PaymentAdditionalInfoInterface +{ + /** + * @var string + */ + private $key; + + /** + * @var string + */ + private $value; + + /** + * @inheritdoc + */ + public function getKey() + { + return $this->key; + } + + /** + * @inheritdoc + */ + public function getValue() + { + return $this->value; + } + + /** + * @inheritdoc + */ + public function setKey($key) + { + $this->key = $key; + return $this; + } + + /** + * @inheritdoc + */ + public function setValue($value) + { + $this->value = $value; + return $this; + } +} diff --git a/app/code/Magento/Payment/Test/Unit/Helper/DataTest.php b/app/code/Magento/Payment/Test/Unit/Helper/DataTest.php index 3752e82fd1e5b..1df07f87a3054 100644 --- a/app/code/Magento/Payment/Test/Unit/Helper/DataTest.php +++ b/app/code/Magento/Payment/Test/Unit/Helper/DataTest.php @@ -18,6 +18,9 @@ class DataTest extends \PHPUnit\Framework\TestCase /** @var \PHPUnit_Framework_MockObject_MockObject */ private $scopeConfig; + /** @var \PHPUnit_Framework_MockObject_MockObject */ + private $paymentConfig; + /** @var \PHPUnit_Framework_MockObject_MockObject */ private $initialConfig; @@ -48,6 +51,7 @@ protected function setUp() $this->methodFactory = $arguments['paymentMethodFactory']; $this->appEmulation = $arguments['appEmulation']; + $this->paymentConfig = $arguments['paymentConfig']; $this->initialConfig = $arguments['initialConfig']; $this->helper = $objectManagerHelper->getObject($className, $arguments); @@ -253,6 +257,56 @@ public function testGetInfoBlockHtml() $this->assertEquals($blockHtml, $this->helper->getInfoBlockHtml($infoMock, $storeId)); } + /** + * @param bool $sorted + * @param bool $asLabelValue + * @param bool $withGroups + * @param string|null $configTitle + * @param array $paymentMethod + * @param array $expectedPaymentMethodList + * @return void + * + * @dataProvider paymentMethodListDataProvider + */ + public function testGetPaymentMethodList( + bool $sorted, + bool $asLabelValue, + bool $withGroups, + $configTitle, + array $paymentMethod, + array $expectedPaymentMethodList + ) { + $groups = ['group' => 'Group Title']; + + $this->initialConfig->method('getData') + ->with('default') + ->willReturn( + [ + Data::XML_PATH_PAYMENT_METHODS => [ + $paymentMethod['code'] => $paymentMethod['data'], + ], + ] + ); + + $this->scopeConfig->method('getValue') + ->with(sprintf('%s/%s/model', Data::XML_PATH_PAYMENT_METHODS, $paymentMethod['code'])) + ->willReturn(\Magento\Payment\Model\Method\AbstractMethod::class); + + $methodInstanceMock = $this->getMockBuilder(\Magento\Payment\Model\MethodInterface::class) + ->getMockForAbstractClass(); + $methodInstanceMock->method('getConfigData') + ->with('title', null) + ->willReturn($configTitle); + $this->methodFactory->method('create') + ->willReturn($methodInstanceMock); + + $this->paymentConfig->method('getGroups') + ->willReturn($groups); + + $paymentMethodList = $this->helper->getPaymentMethodList($sorted, $asLabelValue, $withGroups); + $this->assertEquals($expectedPaymentMethodList, $paymentMethodList); + } + /** * @return array */ @@ -269,4 +323,89 @@ public function getSortMethodsDataProvider() ] ]; } + + /** + * @return array + */ + public function paymentMethodListDataProvider(): array + { + return [ + 'Payment method with changed title' => + [ + true, + false, + false, + 'Config Payment Title', + [ + 'code' => 'payment_method', + 'data' => [ + 'active' => 1, + 'title' => 'Payment Title', + ], + ], + ['payment_method' => 'Config Payment Title'], + ], + 'Payment method with default title' => + [ + true, + false, + false, + null, + [ + 'code' => 'payment_method', + 'data' => [ + 'active' => 1, + 'title' => 'Payment Title', + ], + ], + ['payment_method' => 'Payment Title'], + ], + 'Payment method as value => label' => + [ + true, + true, + false, + null, + [ + 'code' => 'payment_method', + 'data' => [ + 'active' => 1, + 'title' => 'Payment Title', + ], + ], + [ + 'payment_method' => [ + 'value' => 'payment_method', + 'label' => 'Payment Title', + ], + ], + ], + 'Payment method with group' => + [ + true, + true, + true, + null, + [ + 'code' => 'payment_method', + 'data' => [ + 'active' => 1, + 'title' => 'Payment Title', + 'group' => 'group', + ], + ], + [ + 'group' => [ + 'label' => 'Group Title', + 'value' => [ + 'payment_method' => [ + 'value' => 'payment_method', + 'label' => 'Payment Title', + ], + ], + ], + ], + ], + ]; + } } diff --git a/app/code/Magento/Payment/composer.json b/app/code/Magento/Payment/composer.json index 678aaa01c2ae8..b5e7ecdfdaff9 100644 --- a/app/code/Magento/Payment/composer.json +++ b/app/code/Magento/Payment/composer.json @@ -12,7 +12,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Payment/etc/di.xml b/app/code/Magento/Payment/etc/di.xml index 74f553cc64094..b7422bb00d543 100644 --- a/app/code/Magento/Payment/etc/di.xml +++ b/app/code/Magento/Payment/etc/di.xml @@ -7,6 +7,7 @@ --> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> <preference for="Magento\Payment\Api\Data\PaymentMethodInterface" type="Magento\Payment\Model\PaymentMethod"/> + <preference for="Magento\Payment\Api\Data\PaymentAdditionalInfoInterface" type="Magento\Payment\Model\PaymentAdditionalInfo"/> <preference for="Magento\Payment\Api\PaymentMethodListInterface" type="Magento\Payment\Model\PaymentMethodList"/> <preference for="Magento\Payment\Gateway\Validator\ResultInterface" type="Magento\Payment\Gateway\Validator\Result"/> <preference for="Magento\Payment\Gateway\ConfigFactoryInterface" type="Magento\Payment\Gateway\Config\ConfigFactory" /> diff --git a/app/code/Magento/Payment/view/adminhtml/web/transparent.js b/app/code/Magento/Payment/view/adminhtml/web/js/transparent.js similarity index 100% rename from app/code/Magento/Payment/view/adminhtml/web/transparent.js rename to app/code/Magento/Payment/view/adminhtml/web/js/transparent.js diff --git a/app/code/Magento/Payment/view/base/web/js/model/credit-card-validation/credit-card-number-validator.js b/app/code/Magento/Payment/view/base/web/js/model/credit-card-validation/credit-card-number-validator.js index 8fb12093e36e4..785b636d5832f 100644 --- a/app/code/Magento/Payment/view/base/web/js/model/credit-card-validation/credit-card-number-validator.js +++ b/app/code/Magento/Payment/view/base/web/js/model/credit-card-validation/credit-card-number-validator.js @@ -36,7 +36,7 @@ define([ return resultWrapper(null, false, false); } - value = value.replace(/\-|\s/g, ''); + value = value.replace(/|\s/g, ''); if (!/^\d*$/.test(value)) { return resultWrapper(null, false, false); diff --git a/app/code/Magento/Payment/view/base/web/js/model/credit-card-validation/validator.js b/app/code/Magento/Payment/view/base/web/js/model/credit-card-validation/validator.js index c6f1bad31fc07..c41be40cba144 100644 --- a/app/code/Magento/Payment/view/base/web/js/model/credit-card-validation/validator.js +++ b/app/code/Magento/Payment/view/base/web/js/model/credit-card-validation/validator.js @@ -23,6 +23,12 @@ }(function ($, cvvValidator, creditCardNumberValidator, yearValidator, monthValidator, creditCardData) { 'use strict'; + $('.payment-method-content input[type="number"]').on('keyup', function () { + if ($(this).val() < 0) { + $(this).val($(this).val().replace(/^-/, '')); + } + }); + $.each({ 'validate-card-type': [ function (number, item, allowedTypes) { diff --git a/app/code/Magento/Payment/view/frontend/templates/transparent/iframe.phtml b/app/code/Magento/Payment/view/frontend/templates/transparent/iframe.phtml index 163aaf979ec2e..afa71fe591495 100644 --- a/app/code/Magento/Payment/view/frontend/templates/transparent/iframe.phtml +++ b/app/code/Magento/Payment/view/frontend/templates/transparent/iframe.phtml @@ -40,7 +40,7 @@ $params = $block->getParams(); $(parent).trigger('clearTimeout'); fullScreenLoader.stopLoader(); globalMessageList.addErrorMessage({ - message: $t('An error occurred on the server. Please try to place the order again.') + message: $t(<?= /* @escapeNotVerified */ json_encode($params['error_msg'])?>) }); } ); diff --git a/app/code/Magento/Paypal/Controller/Express/AbstractExpress.php b/app/code/Magento/Paypal/Controller/Express/AbstractExpress.php index 571d73d07b68e..95804ebe45d8c 100644 --- a/app/code/Magento/Paypal/Controller/Express/AbstractExpress.php +++ b/app/code/Magento/Paypal/Controller/Express/AbstractExpress.php @@ -137,6 +137,14 @@ protected function _initCheckout() $this->getResponse()->setStatusHeader(403, '1.1', 'Forbidden'); throw new \Magento\Framework\Exception\LocalizedException(__('We can\'t initialize Express Checkout.')); } + if (!(float)$quote->getGrandTotal()) { + throw new \Magento\Framework\Exception\LocalizedException( + __( + 'PayPal can\'t process orders with a zero balance due. ' + . 'To finish your purchase, please go through the standard checkout process.' + ) + ); + } if (!isset($this->_checkoutTypes[$this->_checkoutType])) { $parameters = [ 'params' => [ @@ -151,6 +159,8 @@ protected function _initCheckout() } /** + * Get proper checkout token. + * * Search for proper checkout token in request or session or (un)set specified one * Combined getter/setter * @@ -221,8 +231,7 @@ protected function _getQuote() } /** - * Returns before_auth_url redirect parameter for customer session - * @return null + * @inheritdoc */ public function getCustomerBeforeAuthUrl() { @@ -230,8 +239,7 @@ public function getCustomerBeforeAuthUrl() } /** - * Returns a list of action flags [flag_key] => boolean - * @return array + * @inheritdoc */ public function getActionFlagList() { @@ -240,6 +248,7 @@ public function getActionFlagList() /** * Returns login url parameter for redirect + * * @return string */ public function getLoginUrl() @@ -249,6 +258,7 @@ public function getLoginUrl() /** * Returns action name which requires redirect + * * @return string */ public function getRedirectActionName() diff --git a/app/code/Magento/Paypal/Model/Config/Structure/Element/FieldPlugin.php b/app/code/Magento/Paypal/Model/Config/Structure/Element/FieldPlugin.php index c2056aea08c00..5d5db0128b1eb 100644 --- a/app/code/Magento/Paypal/Model/Config/Structure/Element/FieldPlugin.php +++ b/app/code/Magento/Paypal/Model/Config/Structure/Element/FieldPlugin.php @@ -5,7 +5,6 @@ */ namespace Magento\Paypal\Model\Config\Structure\Element; -use Magento\Framework\App\RequestInterface; use Magento\Config\Model\Config\Structure\Element\Field as FieldConfigStructure; use Magento\Paypal\Model\Config\StructurePlugin as ConfigStructurePlugin; @@ -14,19 +13,6 @@ */ class FieldPlugin { - /** - * @var RequestInterface - */ - private $request; - - /** - * @param RequestInterface $request - */ - public function __construct(RequestInterface $request) - { - $this->request = $request; - } - /** * Get original configPath (not changed by PayPal configuration inheritance) * @@ -36,7 +22,7 @@ public function __construct(RequestInterface $request) */ public function afterGetConfigPath(FieldConfigStructure $subject, $result) { - if (!$result && $this->request->getParam('section') == 'payment') { + if (!$result && strpos($subject->getPath(), 'payment_') === 0) { $result = preg_replace( '@^(' . implode('|', ConfigStructurePlugin::getPaypalConfigCountries(true)) . ')/@', 'payment/', diff --git a/app/code/Magento/Paypal/Model/Express.php b/app/code/Magento/Paypal/Model/Express.php index 196e59c6593b9..a44fb9e7c15b0 100644 --- a/app/code/Magento/Paypal/Model/Express.php +++ b/app/code/Magento/Paypal/Model/Express.php @@ -44,7 +44,7 @@ class Express extends \Magento\Payment\Model\Method\AbstractMethod * * @var bool */ - protected $_isGateway = false; + protected $_isGateway = true; /** * Availability option diff --git a/app/code/Magento/Paypal/Model/Payflowpro.php b/app/code/Magento/Paypal/Model/Payflowpro.php index f6fb4ae8e078a..855ab88de5038 100644 --- a/app/code/Magento/Paypal/Model/Payflowpro.php +++ b/app/code/Magento/Paypal/Model/Payflowpro.php @@ -419,6 +419,7 @@ public function capture(\Magento\Payment\Model\InfoInterface $payment, $amount) $request->setTrxtype(self::TRXTYPE_SALE); $request->setOrigid($payment->getAdditionalInformation(self::PNREF)); $payment->unsAdditionalInformation(self::PNREF); + $request->setData('currency', $payment->getOrder()->getBaseCurrencyCode()); } elseif ($payment->getParentTransactionId()) { $request = $this->buildBasicRequest(); $request->setOrigid($payment->getParentTransactionId()); diff --git a/app/code/Magento/Paypal/Test/Unit/Model/Config/Structure/Element/FieldPluginTest.php b/app/code/Magento/Paypal/Test/Unit/Model/Config/Structure/Element/FieldPluginTest.php index 8615b91383aaa..72c13c80b31e4 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/Config/Structure/Element/FieldPluginTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/Config/Structure/Element/FieldPluginTest.php @@ -7,7 +7,6 @@ use Magento\Paypal\Model\Config\Structure\Element\FieldPlugin as FieldConfigStructurePlugin; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; -use Magento\Framework\App\RequestInterface; use Magento\Config\Model\Config\Structure\Element\Field as FieldConfigStructureMock; class FieldPluginTest extends \PHPUnit\Framework\TestCase @@ -22,11 +21,6 @@ class FieldPluginTest extends \PHPUnit\Framework\TestCase */ private $objectManagerHelper; - /** - * @var RequestInterface|\PHPUnit_Framework_MockObject_MockObject - */ - private $requestMock; - /** * @var FieldConfigStructureMock|\PHPUnit_Framework_MockObject_MockObject */ @@ -34,16 +28,13 @@ class FieldPluginTest extends \PHPUnit\Framework\TestCase protected function setUp() { - $this->requestMock = $this->getMockBuilder(RequestInterface::class) - ->getMockForAbstractClass(); $this->subjectMock = $this->getMockBuilder(FieldConfigStructureMock::class) ->disableOriginalConstructor() ->getMock(); $this->objectManagerHelper = new ObjectManagerHelper($this); $this->plugin = $this->objectManagerHelper->getObject( - FieldConfigStructurePlugin::class, - ['request' => $this->requestMock] + FieldConfigStructurePlugin::class ); } @@ -56,10 +47,8 @@ public function testAroundGetConfigPathHasResult() public function testAroundGetConfigPathNonPaymentSection() { - $this->requestMock->expects(static::once()) - ->method('getParam') - ->with('section') - ->willReturn('non-payment'); + $this->subjectMock->method('getPath') + ->willReturn('non-payment/group/field'); $this->assertNull($this->plugin->afterGetConfigPath($this->subjectMock, null)); } @@ -72,12 +61,7 @@ public function testAroundGetConfigPathNonPaymentSection() */ public function testAroundGetConfigPath($subjectPath, $expectedConfigPath) { - $this->requestMock->expects(static::once()) - ->method('getParam') - ->with('section') - ->willReturn('payment'); - $this->subjectMock->expects(static::once()) - ->method('getPath') + $this->subjectMock->method('getPath') ->willReturn($subjectPath); $this->assertEquals($expectedConfigPath, $this->plugin->afterGetConfigPath($this->subjectMock, null)); diff --git a/app/code/Magento/Paypal/composer.json b/app/code/Magento/Paypal/composer.json index 281b7084e7f1b..331198b474783 100644 --- a/app/code/Magento/Paypal/composer.json +++ b/app/code/Magento/Paypal/composer.json @@ -26,7 +26,7 @@ "magento/module-checkout-agreements": "100.2.*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "proprietary" ], diff --git a/app/code/Magento/Persistent/Block/Header/Additional.php b/app/code/Magento/Persistent/Block/Header/Additional.php index c740f5a3469fb..28e967f201230 100644 --- a/app/code/Magento/Persistent/Block/Header/Additional.php +++ b/app/code/Magento/Persistent/Block/Header/Additional.php @@ -5,6 +5,10 @@ */ namespace Magento\Persistent\Block\Header; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Serialize\Serializer\Json; +use Magento\Persistent\Helper\Data; + /** * Remember Me block * @@ -30,20 +34,37 @@ class Additional extends \Magento\Framework\View\Element\Html\Link protected $customerRepository; /** - * Constructor - * + * @var string + */ + protected $_template = 'Magento_Persistent::additional.phtml'; + + /** + * @var Json + */ + private $jsonSerializer; + + /** + * @var Data + */ + private $persistentHelper; + + /** * @param \Magento\Framework\View\Element\Template\Context $context * @param \Magento\Customer\Helper\View $customerViewHelper * @param \Magento\Persistent\Helper\Session $persistentSessionHelper * @param \Magento\Customer\Api\CustomerRepositoryInterface $customerRepository * @param array $data + * @param Json|null $jsonSerializer + * @param Data|null $persistentHelper */ public function __construct( \Magento\Framework\View\Element\Template\Context $context, \Magento\Customer\Helper\View $customerViewHelper, \Magento\Persistent\Helper\Session $persistentSessionHelper, \Magento\Customer\Api\CustomerRepositoryInterface $customerRepository, - array $data = [] + array $data = [], + Json $jsonSerializer = null, + Data $persistentHelper = null ) { $this->isScopePrivate = true; $this->_customerViewHelper = $customerViewHelper; @@ -51,6 +72,8 @@ public function __construct( $this->customerRepository = $customerRepository; parent::__construct($context, $data); $this->_isScopePrivate = true; + $this->jsonSerializer = $jsonSerializer ?: ObjectManager::getInstance()->get(Json::class); + $this->persistentHelper = $persistentHelper ?: ObjectManager::getInstance()->get(Data::class); } /** @@ -64,17 +87,25 @@ public function getHref() } /** - * Render additional header html + * @return int + */ + public function getCustomerId() + { + return $this->_persistentSessionHelper->getSession()->getCustomerId(); + } + + /** + * Get persistent config. * * @return string */ - protected function _toHtml() + public function getConfig() { - if ($this->_persistentSessionHelper->getSession()->getCustomerId()) { - return '<span><a ' . $this->getLinkAttributes() . ' >' . __('Not you?') - . '</a></span>'; - } - - return ''; + return + $this->jsonSerializer->serialize( + [ + 'expirationLifetime' => $this->persistentHelper->getLifeTime(), + ] + ); } } diff --git a/app/code/Magento/Persistent/CustomerData/Persistent.php b/app/code/Magento/Persistent/CustomerData/Persistent.php new file mode 100644 index 0000000000000..98b36b2e36612 --- /dev/null +++ b/app/code/Magento/Persistent/CustomerData/Persistent.php @@ -0,0 +1,71 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\Persistent\CustomerData; + +use Magento\Customer\Api\CustomerRepositoryInterface; +use Magento\Customer\CustomerData\SectionSourceInterface; +use Magento\Customer\Helper\View; +use Magento\Persistent\Helper\Session; + +/** + * Customer persistent section + */ +class Persistent implements SectionSourceInterface +{ + /** + * @var Session + */ + private $persistentSession; + + /** + * @var View + */ + private $customerViewHelper; + + /** + * @var CustomerRepositoryInterface + */ + private $customerRepository; + + /** + * @param Session $persistentSession + * @param View $customerViewHelper + * @param CustomerRepositoryInterface $customerRepository + */ + public function __construct( + Session $persistentSession, + View $customerViewHelper, + CustomerRepositoryInterface $customerRepository + ) { + $this->persistentSession = $persistentSession; + $this->customerViewHelper = $customerViewHelper; + $this->customerRepository = $customerRepository; + } + + /** + * Get data + * + * @return array + */ + public function getSectionData() + { + if (!$this->persistentSession->isPersistent()) { + return []; + } + + $customerId = $this->persistentSession->getSession()->getCustomerId(); + if (!$customerId) { + return []; + } + + $customer = $this->customerRepository->getById($customerId); + + return [ + 'fullname' => $this->customerViewHelper->getCustomerName($customer), + ]; + } +} diff --git a/app/code/Magento/Persistent/Model/Checkout/GuestPaymentInformationManagementPlugin.php b/app/code/Magento/Persistent/Model/Checkout/GuestPaymentInformationManagementPlugin.php index 2c0259bd12b00..2641102ca4d72 100644 --- a/app/code/Magento/Persistent/Model/Checkout/GuestPaymentInformationManagementPlugin.php +++ b/app/code/Magento/Persistent/Model/Checkout/GuestPaymentInformationManagementPlugin.php @@ -108,8 +108,8 @@ public function beforeSavePaymentInformationAndPlaceOrder( $this->customerSession->setCustomerId(null); $this->customerSession->setCustomerGroupId(null); $this->quoteManager->convertCustomerCartToGuest(); - /** @var \Magento\Quote\Api\Data\CartInterface $quote */ - $quote = $this->cartRepository->get($this->checkoutSession->getQuote()->getId()); + $quoteId = $this->checkoutSession->getQuoteId(); + $quote = $this->cartRepository->get($quoteId); $quote->setCustomerEmail($email); $quote->getAddressesCollection()->walk('setEmail', ['email' => $email]); $this->cartRepository->save($quote); diff --git a/app/code/Magento/Persistent/Model/Observer.php b/app/code/Magento/Persistent/Model/Observer.php index 53fe5f95531e1..81c2870071a2e 100644 --- a/app/code/Magento/Persistent/Model/Observer.php +++ b/app/code/Magento/Persistent/Model/Observer.php @@ -86,13 +86,8 @@ public function __construct( */ public function emulateWelcomeBlock($block) { - $customerName = $this->_customerViewHelper->getCustomerName( - $this->customerRepository->getById($this->_persistentSession->getSession()->getCustomerId()) - ); + $block->setWelcome(' '); - $this->_applyAccountLinksPersistentData(); - $welcomeMessage = __('Welcome, %1!', $customerName); - $block->setWelcome($welcomeMessage); return $this; } diff --git a/app/code/Magento/Persistent/Model/Plugin/PersistentCustomerContext.php b/app/code/Magento/Persistent/Model/Plugin/PersistentCustomerContext.php new file mode 100644 index 0000000000000..04b4450d93cec --- /dev/null +++ b/app/code/Magento/Persistent/Model/Plugin/PersistentCustomerContext.php @@ -0,0 +1,39 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +namespace Magento\Persistent\Model\Plugin; + +/** + * Plugin for Magento\Framework\App\Http\Context to create new page cache variation for persistent session. + */ +class PersistentCustomerContext +{ + /** + * Persistent session + * + * @var \Magento\Persistent\Helper\Session + */ + private $persistentSession; + + /** + * @param \Magento\Persistent\Helper\Session $persistentSession + */ + public function __construct( + \Magento\Persistent\Helper\Session $persistentSession + ) { + $this->persistentSession = $persistentSession; + } + + /** + * @param \Magento\Framework\App\Http\Context $subject + * @return mixed + */ + public function beforeGetVaryString(\Magento\Framework\App\Http\Context $subject) + { + if ($this->persistentSession->isPersistent()) { + $subject->setValue('PERSISTENT', 1, 0); + } + } +} diff --git a/app/code/Magento/Persistent/Model/QuoteManager.php b/app/code/Magento/Persistent/Model/QuoteManager.php index 8937a4920cb23..35c2c70be30dc 100644 --- a/app/code/Magento/Persistent/Model/QuoteManager.php +++ b/app/code/Magento/Persistent/Model/QuoteManager.php @@ -108,8 +108,9 @@ public function setGuest($checkQuote = false) */ public function convertCustomerCartToGuest() { + $quoteId = $this->checkoutSession->getQuoteId(); /** @var $quote \Magento\Quote\Model\Quote */ - $quote = $this->quoteRepository->get($this->checkoutSession->getQuote()->getId()); + $quote = $this->quoteRepository->get($quoteId); if ($quote && $quote->getId()) { $this->_setQuotePersistent = false; $quote->setIsActive(true) diff --git a/app/code/Magento/Persistent/Observer/SetCheckoutSessionPersistentDataObserver.php b/app/code/Magento/Persistent/Observer/SetCheckoutSessionPersistentDataObserver.php new file mode 100644 index 0000000000000..030eca854c801 --- /dev/null +++ b/app/code/Magento/Persistent/Observer/SetCheckoutSessionPersistentDataObserver.php @@ -0,0 +1,88 @@ +<?php +/** + * + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Persistent\Observer; + +use Magento\Framework\Event\ObserverInterface; + +/** + * Observer for a work with persistent data. + */ +class SetCheckoutSessionPersistentDataObserver implements ObserverInterface +{ + /** + * Persistent session. + * + * @var \Magento\Persistent\Helper\Session + */ + private $persistentSession = null; + + /** + * Customer session. + * + * @var \Magento\Customer\Model\Session + */ + private $customerSession; + + /** + * Persistent data. + * + * @var \Magento\Persistent\Helper\Data + */ + private $persistentData = null; + + /** + * Customer Repository. + * + * @var \Magento\Customer\Api\CustomerRepositoryInterface + */ + private $customerRepository = null; + + /** + * @param \Magento\Persistent\Helper\Session $persistentSession + * @param \Magento\Customer\Model\Session $customerSession + * @param \Magento\Persistent\Helper\Data $persistentData + * @param \Magento\Customer\Api\CustomerRepositoryInterface $customerRepository + */ + public function __construct( + \Magento\Persistent\Helper\Session $persistentSession, + \Magento\Customer\Model\Session $customerSession, + \Magento\Persistent\Helper\Data $persistentData, + \Magento\Customer\Api\CustomerRepositoryInterface $customerRepository + ) { + $this->persistentSession = $persistentSession; + $this->customerSession = $customerSession; + $this->persistentData = $persistentData; + $this->customerRepository = $customerRepository; + } + + /** + * Pass customer data from persistent session to checkout session and set quote to be loaded even if not active. + * + * @param \Magento\Framework\Event\Observer $observer + * @return void + */ + public function execute(\Magento\Framework\Event\Observer $observer) + { + /** @var $checkoutSession \Magento\Checkout\Model\Session */ + $checkoutSession = $observer->getEvent()->getData('checkout_session'); + if ($this->persistentData->isShoppingCartPersist() && $this->persistentSession->isPersistent()) { + $checkoutSession->setCustomerData( + $this->customerRepository->getById($this->persistentSession->getSession()->getCustomerId()) + ); + } + if (!(($this->persistentSession->isPersistent() && !$this->customerSession->isLoggedIn()) + && !$this->persistentData->isShoppingCartPersist() + )) { + return; + } + if ($checkoutSession) { + $checkoutSession->setLoadInactive(); + } + } +} diff --git a/app/code/Magento/Persistent/Observer/SetLoadPersistentQuoteObserver.php b/app/code/Magento/Persistent/Observer/SetLoadPersistentQuoteObserver.php deleted file mode 100644 index 6eeab94a91cca..0000000000000 --- a/app/code/Magento/Persistent/Observer/SetLoadPersistentQuoteObserver.php +++ /dev/null @@ -1,78 +0,0 @@ -<?php -/** - * - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -namespace Magento\Persistent\Observer; - -use Magento\Framework\Event\ObserverInterface; - -class SetLoadPersistentQuoteObserver implements ObserverInterface -{ - /** - * Customer session - * - * @var \Magento\Customer\Model\Session - */ - protected $_customerSession; - - /** - * Checkout session - * - * @var \Magento\Checkout\Model\Session - */ - protected $_checkoutSession; - - /** - * Persistent session - * - * @var \Magento\Persistent\Helper\Session - */ - protected $_persistentSession = null; - - /** - * Persistent data - * - * @var \Magento\Persistent\Helper\Data - */ - protected $_persistentData = null; - - /** - * @param \Magento\Persistent\Helper\Session $persistentSession - * @param \Magento\Persistent\Helper\Data $persistentData - * @param \Magento\Customer\Model\Session $customerSession - * @param \Magento\Checkout\Model\Session $checkoutSession - */ - public function __construct( - \Magento\Persistent\Helper\Session $persistentSession, - \Magento\Persistent\Helper\Data $persistentData, - \Magento\Customer\Model\Session $customerSession, - \Magento\Checkout\Model\Session $checkoutSession - ) { - $this->_persistentSession = $persistentSession; - $this->_customerSession = $customerSession; - $this->_checkoutSession = $checkoutSession; - $this->_persistentData = $persistentData; - } - - /** - * Set quote to be loaded even if not active - * - * @param \Magento\Framework\Event\Observer $observer - * @return void - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - */ - public function execute(\Magento\Framework\Event\Observer $observer) - { - if (!(($this->_persistentSession->isPersistent() && !$this->_customerSession->isLoggedIn()) - && !$this->_persistentData->isShoppingCartPersist() - )) { - return; - } - - if ($this->_checkoutSession) { - $this->_checkoutSession->setLoadInactive(); - } - } -} diff --git a/app/code/Magento/Persistent/Test/Mftf/ActionGroup/StorefrontCustomerActionGroup.xml b/app/code/Magento/Persistent/Test/Mftf/ActionGroup/StorefrontCustomerActionGroup.xml new file mode 100644 index 0000000000000..2d8c1a174b013 --- /dev/null +++ b/app/code/Magento/Persistent/Test/Mftf/ActionGroup/StorefrontCustomerActionGroup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="CustomerLoginOnStorefrontWithRememberMeChecked" extends="CustomerLoginOnStorefront"> + <checkOption selector="{{StorefrontCustomerSignInFormSection.rememberMe}}" + before="clickSignInAccountButton" + stepKey="checkRememberMe"/> + </actionGroup> + + <actionGroup name="CustomerLoginOnStorefrontWithRememberMeUnChecked" extends="CustomerLoginOnStorefront"> + <uncheckOption selector="{{StorefrontCustomerSignInFormSection.rememberMe}}" + before="clickSignInAccountButton" + stepKey="unCheckRememberMe"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Persistent/Test/Mftf/Data/PersistentData.xml b/app/code/Magento/Persistent/Test/Mftf/Data/PersistentData.xml index a23d1169b6865..df8fb91bdabc6 100644 --- a/app/code/Magento/Persistent/Test/Mftf/Data/PersistentData.xml +++ b/app/code/Magento/Persistent/Test/Mftf/Data/PersistentData.xml @@ -20,4 +20,18 @@ <entity name="persistentEnabledState" type="persistent_options_enabled"> <data key="value">1</data> </entity> + + <entity name="PersistentLogoutClearEnabled" type="persistent_config_state"> + <requiredEntity type="persistent_options_logout_clear">LogoutClearActive</requiredEntity> + </entity> + <entity name="LogoutClearActive" type="logout_clear"> + <data key="value">1</data> + </entity> + + <entity name="PersistentLogoutClearDisabled" type="persistent_config_state"> + <requiredEntity type="persistent_options_logout_clear">LogoutClearInactive</requiredEntity> + </entity> + <entity name="LogoutClearInactive" type="logout_clear"> + <data key="value">0</data> + </entity> </entities> diff --git a/app/code/Magento/Persistent/Test/Mftf/Metadata/perstistent_config-meta.xml b/app/code/Magento/Persistent/Test/Mftf/Metadata/perstistent_config-meta.xml index 7a84b5deba68f..dc9d0eddc09c3 100644 --- a/app/code/Magento/Persistent/Test/Mftf/Metadata/perstistent_config-meta.xml +++ b/app/code/Magento/Persistent/Test/Mftf/Metadata/perstistent_config-meta.xml @@ -14,6 +14,9 @@ <object key="enabled" dataType="persistent_options_enabled"> <field key="value">string</field> </object> + <object key="logout_clear" dataType="persistent_options_logout_clear"> + <field key="value">string</field> + </object> </object> </object> </object> diff --git a/app/code/Magento/Persistent/Test/Mftf/Section/StorefrontCustomerSignInFormSection.xml b/app/code/Magento/Persistent/Test/Mftf/Section/StorefrontCustomerSignInFormSection.xml new file mode 100644 index 0000000000000..c2220c33a6052 --- /dev/null +++ b/app/code/Magento/Persistent/Test/Mftf/Section/StorefrontCustomerSignInFormSection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontCustomerSignInFormSection"> + <element name="rememberMe" type="checkbox" selector="[name='persistent_remember_me']"/> + </section> +</sections> diff --git a/app/code/Magento/Persistent/Test/Mftf/Test/StorefrontCheckShoppingCartBehaviorAfterSessionExpiredTest.xml b/app/code/Magento/Persistent/Test/Mftf/Test/StorefrontCheckShoppingCartBehaviorAfterSessionExpiredTest.xml new file mode 100644 index 0000000000000..de09d4a02fc3e --- /dev/null +++ b/app/code/Magento/Persistent/Test/Mftf/Test/StorefrontCheckShoppingCartBehaviorAfterSessionExpiredTest.xml @@ -0,0 +1,55 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="CheckShoppingCartBehaviorAfterSessionExpiredTest"> + <annotations> + <features value="Persistent"/> + <stories value="MAGETWO-86549 - Unusual behavior with the persistent shopping cart after the session is expired"/> + <title value="Checking behavior with the persistent shopping cart after the session is expired"/> + <description value="Checking behavior with the persistent shopping cart after the session is expired"/> + <severity value="MAJOR"/> + <testCaseId value="MAGETWO-96637"/> + <group value="persistent"/> + </annotations> + <before> + <!--Enable Persistence--> + <createData entity="PersistentConfigEnabled" stepKey="enablePersistent"/> + <!--Create product and customer--> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <createData entity="_defaultProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + </before> + <after> + <!--Roll back configuration--> + <createData entity="PersistentConfigDefault" stepKey="setDefaultPersistentState"/> + <!--Delete product and customer--> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + </after> + <!--Login as a Customer--> + <actionGroup ref="CustomerLoginOnStorefront" stepKey="loginToStorefrontAccount"> + <argument name="customer" value="$$createCustomer$$"/> + </actionGroup> + <!-- Add simple product to cart --> + <actionGroup ref="AddSimpleProductToCart" stepKey="addProductToCart"> + <argument name="product" value="$$createProduct$$"/> + </actionGroup> + <actionGroup ref="StorefrontOpenCartFromMinicartActionGroup" stepKey="goToShoppingCartFromMinicart"/> + <!--Reset cookies and refresh the page--> + <resetCookie userInput="PHPSESSID" stepKey="resetCookieForCart"/> + <reloadPage stepKey="reloadPage"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + <!--Check product exists in cart--> + <see userInput="$$createProduct.name$$" stepKey="ProductExistsInCart"/> + </test> +</tests> diff --git a/app/code/Magento/Persistent/Test/Mftf/Test/StorefrontCorrectWelcomeMessageAfterCustomerIsLoggedOutTest.xml b/app/code/Magento/Persistent/Test/Mftf/Test/StorefrontCorrectWelcomeMessageAfterCustomerIsLoggedOutTest.xml new file mode 100644 index 0000000000000..785fc66a4929a --- /dev/null +++ b/app/code/Magento/Persistent/Test/Mftf/Test/StorefrontCorrectWelcomeMessageAfterCustomerIsLoggedOutTest.xml @@ -0,0 +1,79 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontCorrectWelcomeMessageAfterCustomerIsLoggedOutTest"> + <annotations> + <features value="Persistent"/> + <stories value="MAGETWO-95850 - Incorrect use of cookies for customer"/> + <title value="Checking welcome message for persistent customer after logout"/> + <description value="Checking welcome message for persistent customer after logout"/> + <severity value="MAJOR"/> + <testCaseId value="MAGETWO-97095"/> + <group value="persistent"/> + <group value="customer"/> + </annotations> + <before> + <!--Enable Persistence--> + <createData entity="PersistentConfigEnabled" stepKey="enablePersistent"/> + <createData entity="PersistentLogoutClearDisabled" stepKey="persistentLogoutClearDisable"/> + + <!--Create customers--> + <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + <createData entity="Simple_US_Customer" stepKey="createCustomerForPersistent"> + <field key="firstname">John1</field> + <field key="lastname">Doe1</field> + </createData> + </before> + <after> + <!--Roll back configuration--> + <createData entity="PersistentConfigDefault" stepKey="setDefaultPersistentState"/> + <createData entity="PersistentLogoutClearEnabled" stepKey="persistentLogoutClearEnabled"/> + + <!-- Logout customer on Storefront--> + <actionGroup ref="CustomerLogoutStorefrontActionGroup" stepKey="customerLogoutStorefront"/> + <!--Delete customers--> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <deleteData createDataKey="createCustomerForPersistent" stepKey="deleteCustomerForPersistent"/> + </after> + <!--Login as a Customer with remember me unchecked--> + <actionGroup ref="CustomerLoginOnStorefrontWithRememberMeUnChecked" stepKey="loginToStorefrontAccountWithRememberMeUnchecked"> + <argument name="customer" value="$$createCustomer$$"/> + </actionGroup> + + <!--Check customer name and last name in welcome message--> + <seeCurrentUrlMatches regex="~/customer/account/~" stepKey="seeCustomerAccountPageUrl"/> + <see userInput="Welcome, $$createCustomer.firstname$$ $$createCustomer.lastname$$!" + selector="{{StorefrontHeaderSection.welcomeMessage}}" + stepKey="seeLoggedInCustomerWelcomeMessage"/> + <!--Logout and check default welcome message--> + <actionGroup ref="CustomerLogoutStorefrontByMenuItemsActionGroup" stepKey="storefrontCustomerLogout"/> + <seeCurrentUrlMatches regex="~/customer/account/logoutSuccess/~" wait="5" stepKey="seeCustomerSignOutPageUrl"/> + <see userInput="Default welcome msg!" + selector="{{StorefrontHeaderSection.welcomeMessage}}" + stepKey="seeDefaultWelcomeMessage"/> + + <!--Login as a Customer with remember me checked--> + <actionGroup ref="CustomerLoginOnStorefrontWithRememberMeChecked" stepKey="loginToStorefrontAccountWithRememberMeChecked"> + <argument name="customer" value="$$createCustomerForPersistent$$"/> + </actionGroup> + <!--Check customer name and last name in welcome message--> + <seeCurrentUrlMatches regex="~/customer/account/~" stepKey="seeCustomerAccountPageUrl1"/> + <see userInput="Welcome, $$createCustomerForPersistent.firstname$$ $$createCustomerForPersistent.lastname$$!" + selector="{{StorefrontHeaderSection.welcomeMessage}}" + stepKey="seeLoggedInCustomerWelcomeMessage1"/> + + <!--Logout and check persistent customer welcome message--> + <actionGroup ref="CustomerLogoutStorefrontByMenuItemsActionGroup" stepKey="storefrontCustomerLogout1"/> + <seeCurrentUrlMatches regex="~/customer/account/logoutSuccess/~" wait="5" stepKey="seeCustomerSignOutPageUrl1"/> + <see userInput="Welcome, $$createCustomerForPersistent.firstname$$ $$createCustomerForPersistent.lastname$$! Not you?" + selector="{{StorefrontHeaderSection.welcomeMessage}}" + stepKey="seePersistentWelcomeMessage"/> + </test> +</tests> diff --git a/app/code/Magento/Persistent/Test/Unit/Block/Header/AdditionalTest.php b/app/code/Magento/Persistent/Test/Unit/Block/Header/AdditionalTest.php index b88b02ab4cfb5..a6ad8b1aaab33 100644 --- a/app/code/Magento/Persistent/Test/Unit/Block/Header/AdditionalTest.php +++ b/app/code/Magento/Persistent/Test/Unit/Block/Header/AdditionalTest.php @@ -34,44 +34,14 @@ class AdditionalTest extends \PHPUnit\Framework\TestCase protected $contextMock; /** - * @var \Magento\Framework\Event\ManagerInterface|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Framework\Serialize\Serializer\Json|\PHPUnit_Framework_MockObject_MockObject */ - protected $eventManagerMock; + private $jsonSerializerMock; /** - * @var \Magento\Framework\App\Config\ScopeConfigInterface|\PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Persistent\Helper\Data|\PHPUnit_Framework_MockObject_MockObject */ - protected $scopeConfigMock; - - /** - * @var \Magento\Framework\App\Cache\StateInterface|\PHPUnit_Framework_MockObject_MockObject - */ - protected $cacheStateMock; - - /** - * @var \Magento\Framework\App\CacheInterface|\PHPUnit_Framework_MockObject_MockObject - */ - protected $cacheMock; - - /** - * @var \Magento\Framework\Session\SidResolverInterface|\PHPUnit_Framework_MockObject_MockObject - */ - protected $sidResolverMock; - - /** - * @var \Magento\Framework\Session\SessionManagerInterface|\PHPUnit_Framework_MockObject_MockObject - */ - protected $sessionMock; - - /** - * @var \Magento\Framework\Escaper|\PHPUnit_Framework_MockObject_MockObject - */ - protected $escaperMock; - - /** - * @var \Magento\Framework\UrlInterface|\PHPUnit_Framework_MockObject_MockObject - */ - protected $urlBuilderMock; + private $persistentHelperMock; /** * @var \Magento\Persistent\Block\Header\Additional @@ -93,17 +63,7 @@ protected function setUp() { $this->objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); - $this->contextMock = $this->createPartialMock(\Magento\Framework\View\Element\Template\Context::class, [ - 'getEventManager', - 'getScopeConfig', - 'getCacheState', - 'getCache', - 'getInlineTranslation', - 'getSidResolver', - 'getSession', - 'getEscaper', - 'getUrlBuilder' - ]); + $this->contextMock = $this->createPartialMock(\Magento\Framework\View\Element\Template\Context::class, []); $this->customerViewHelperMock = $this->createMock(\Magento\Customer\Helper\View::class); $this->persistentSessionHelperMock = $this->createPartialMock( \Magento\Persistent\Helper\Session::class, @@ -119,103 +79,14 @@ protected function setUp() ['getById'] ); - $this->eventManagerMock = $this->getMockForAbstractClass( - \Magento\Framework\Event\ManagerInterface::class, - [], - '', - false, - true, - true, - ['dispatch'] - ); - $this->scopeConfigMock = $this->getMockForAbstractClass( - \Magento\Framework\App\Config\ScopeConfigInterface::class, - [], - '', - false, - true, - true, - ['getValue'] - ); - $this->cacheStateMock = $this->getMockForAbstractClass( - \Magento\Framework\App\Cache\StateInterface::class, - [], - '', - false, - true, - true, - ['isEnabled'] + $this->jsonSerializerMock = $this->createPartialMock( + \Magento\Framework\Serialize\Serializer\Json::class, + ['serialize'] ); - $this->cacheMock = $this->getMockForAbstractClass( - \Magento\Framework\App\CacheInterface::class, - [], - '', - false, - true, - true, - ['load'] + $this->persistentHelperMock = $this->createPartialMock( + \Magento\Persistent\Helper\Data::class, + ['getLifeTime'] ); - $this->sidResolverMock = $this->getMockForAbstractClass( - \Magento\Framework\Session\SidResolverInterface::class, - [], - '', - false, - true, - true, - ['getSessionIdQueryParam'] - ); - $this->sessionMock = $this->getMockForAbstractClass( - \Magento\Framework\Session\SessionManagerInterface::class, - [], - '', - false, - true, - true, - ['getSessionId'] - ); - $this->escaperMock = $this->getMockForAbstractClass( - \Magento\Framework\Escaper::class, - [], - '', - false, - true, - true, - ['escapeHtml'] - ); - $this->urlBuilderMock = $this->getMockForAbstractClass( - \Magento\Framework\UrlInterface::class, - [], - '', - false, - true, - true, - ['getUrl'] - ); - - $this->contextMock->expects($this->once()) - ->method('getEventManager') - ->willReturn($this->eventManagerMock); - $this->contextMock->expects($this->once()) - ->method('getScopeConfig') - ->willReturn($this->scopeConfigMock); - $this->contextMock->expects($this->once()) - ->method('getCacheState') - ->willReturn($this->cacheStateMock); - $this->contextMock->expects($this->once()) - ->method('getCache') - ->willReturn($this->cacheMock); - $this->contextMock->expects($this->once()) - ->method('getSidResolver') - ->willReturn($this->sidResolverMock); - $this->contextMock->expects($this->once()) - ->method('getSession') - ->willReturn($this->sessionMock); - $this->contextMock->expects($this->once()) - ->method('getEscaper') - ->willReturn($this->escaperMock); - $this->contextMock->expects($this->once()) - ->method('getUrlBuilder') - ->willReturn($this->urlBuilderMock); $this->additional = $this->objectManager->getObject( \Magento\Persistent\Block\Header\Additional::class, @@ -224,91 +95,48 @@ protected function setUp() 'customerViewHelper' => $this->customerViewHelperMock, 'persistentSessionHelper' => $this->persistentSessionHelperMock, 'customerRepository' => $this->customerRepositoryMock, - 'data' => [] + 'data' => [], + 'jsonSerializer' => $this->jsonSerializerMock, + 'persistentHelper' => $this->persistentHelperMock, ] ); } /** - * Run test toHtml method - * - * @param bool $customerId * @return void - * - * @dataProvider dataProviderToHtml */ - public function testToHtml($customerId) + public function testGetCustomerId() { - $cacheData = false; - $idQueryParam = 'id-query-param'; - $sessionId = 'session-id'; - - $this->additional->setData('cache_lifetime', 789); - $this->additional->setData('cache_key', 'cache-key'); - - $this->eventManagerMock->expects($this->at(0)) - ->method('dispatch') - ->with('view_block_abstract_to_html_before', ['block' => $this->additional]); - $this->eventManagerMock->expects($this->at(1)) - ->method('dispatch') - ->with('view_block_abstract_to_html_after'); - $this->scopeConfigMock->expects($this->once()) - ->method('getValue') - ->with( - 'advanced/modules_disable_output/Magento_Persistent', - \Magento\Store\Model\ScopeInterface::SCOPE_STORE - )->willReturn(false); - - // get cache - $this->cacheStateMock->expects($this->at(0)) - ->method('isEnabled') - ->with(\Magento\Persistent\Block\Header\Additional::CACHE_GROUP) - ->willReturn(true); - // save cache - $this->cacheStateMock->expects($this->at(1)) - ->method('isEnabled') - ->with(\Magento\Persistent\Block\Header\Additional::CACHE_GROUP) - ->willReturn(false); - - $this->cacheMock->expects($this->once()) - ->method('load') - ->willReturn($cacheData); - $this->sidResolverMock->expects($this->never()) - ->method('getSessionIdQueryParam') - ->with($this->sessionMock) - ->willReturn($idQueryParam); - $this->sessionMock->expects($this->never()) - ->method('getSessionId') - ->willReturn($sessionId); - - // call protected _toHtml method + $customerId = 1; + /** @var \Magento\Persistent\Model\Session|\PHPUnit_Framework_MockObject_MockObject $sessionMock */ $sessionMock = $this->createPartialMock(\Magento\Persistent\Model\Session::class, ['getCustomerId']); - - $this->persistentSessionHelperMock->expects($this->atLeastOnce()) - ->method('getSession') - ->willReturn($sessionMock); - - $sessionMock->expects($this->atLeastOnce()) + $sessionMock->expects($this->once()) ->method('getCustomerId') ->willReturn($customerId); + $this->persistentSessionHelperMock->expects($this->once()) + ->method('getSession') + ->willReturn($sessionMock); - if ($customerId) { - $this->assertEquals('<span><a >Not you?</a></span>', $this->additional->toHtml()); - } else { - $this->assertEquals('', $this->additional->toHtml()); - } + $this->assertEquals($customerId, $this->additional->getCustomerId()); } /** - * Data provider for dataProviderToHtml method - * - * @return array + * @return void */ - public function dataProviderToHtml() + public function testGetConfig() { - return [ - ['customerId' => 2], - ['customerId' => null], - ]; + $lifeTime = 500; + $arrayToSerialize = ['expirationLifetime' => $lifeTime]; + $serializedArray = '{"expirationLifetime":' . $lifeTime . '}'; + + $this->persistentHelperMock->expects($this->once()) + ->method('getLifeTime') + ->willReturn($lifeTime); + $this->jsonSerializerMock->expects($this->once()) + ->method('serialize') + ->with($arrayToSerialize) + ->willReturn($serializedArray); + + $this->assertEquals($serializedArray, $this->additional->getConfig()); } } diff --git a/app/code/Magento/Persistent/Test/Unit/Model/Checkout/GuestPaymentInformationManagementPluginTest.php b/app/code/Magento/Persistent/Test/Unit/Model/Checkout/GuestPaymentInformationManagementPluginTest.php index b9285715146a5..c7f84b476fa7e 100644 --- a/app/code/Magento/Persistent/Test/Unit/Model/Checkout/GuestPaymentInformationManagementPluginTest.php +++ b/app/code/Magento/Persistent/Test/Unit/Model/Checkout/GuestPaymentInformationManagementPluginTest.php @@ -102,8 +102,7 @@ public function testBeforeSavePaymentInformationAndPlaceOrderCartConvertsToGuest ['setCustomerEmail', 'getAddressesCollection'], false ); - $this->checkoutSessionMock->expects($this->once())->method('getQuote')->willReturn($quoteMock); - $quoteMock->expects($this->once())->method('getId')->willReturn($cartId); + $this->checkoutSessionMock->method('getQuoteId')->willReturn($cartId); $this->cartRepositoryMock->expects($this->once())->method('get')->with($cartId)->willReturn($quoteMock); $quoteMock->expects($this->once())->method('setCustomerEmail')->with($email); /** @var \Magento\Framework\Data\Collection|\PHPUnit_Framework_MockObject_MockObject $collectionMock */ diff --git a/app/code/Magento/Persistent/Test/Unit/Model/ObserverTest.php b/app/code/Magento/Persistent/Test/Unit/Model/ObserverTest.php index 7008a9eb25e5d..dd299fe30d646 100644 --- a/app/code/Magento/Persistent/Test/Unit/Model/ObserverTest.php +++ b/app/code/Magento/Persistent/Test/Unit/Model/ObserverTest.php @@ -80,31 +80,18 @@ protected function setUp() ); } + /** + * @return void + */ public function testEmulateWelcomeBlock() { - $customerId = 1; - $customerName = 'Test Customer Name'; - $welcomeMessage = __('Welcome, %1!', $customerName); - $customerMock = $this->getMockForAbstractClass(\Magento\Customer\Api\Data\CustomerInterface::class); + $welcomeMessage = __(' '); $block = $this->getMockBuilder(\Magento\Framework\View\Element\AbstractBlock::class) ->disableOriginalConstructor() ->setMethods(['setWelcome']) ->getMock(); - $headerAdditionalBlock = $this->getMockBuilder(\Magento\Framework\View\Element\AbstractBlock::class) - ->disableOriginalConstructor() - ->getMock(); - $this->persistentSessionMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock); - $this->sessionMock->expects($this->once())->method('getCustomerId')->willReturn($customerId); - $this->customerRepositoryMock - ->expects($this->once()) - ->method('getById') - ->with($customerId)->willReturn($customerMock); - $this->customerViewHelperMock->expects($this->once())->method('getCustomerName')->willReturn($customerName); - $this->layoutMock->expects($this->once()) - ->method('getBlock') - ->with('header.additional') - ->willReturn($headerAdditionalBlock); $block->expects($this->once())->method('setWelcome')->with($welcomeMessage); + $this->observer->emulateWelcomeBlock($block); } } diff --git a/app/code/Magento/Persistent/Test/Unit/Model/QuoteManagerTest.php b/app/code/Magento/Persistent/Test/Unit/Model/QuoteManagerTest.php index 7d3d1c8487627..86b906f3cb35e 100644 --- a/app/code/Magento/Persistent/Test/Unit/Model/QuoteManagerTest.php +++ b/app/code/Magento/Persistent/Test/Unit/Model/QuoteManagerTest.php @@ -247,8 +247,8 @@ public function testConvertCustomerCartToGuest() $emailArgs = ['email' => null]; $this->checkoutSessionMock->expects($this->once()) - ->method('getQuote')->willReturn($this->quoteMock); - $this->quoteMock->expects($this->exactly(2))->method('getId')->willReturn($quoteId); + ->method('getQuoteId')->willReturn($quoteId); + $this->quoteMock->expects($this->once())->method('getId')->willReturn($quoteId); $this->quoteRepositoryMock->expects($this->once())->method('get')->with($quoteId)->willReturn($this->quoteMock); $this->quoteMock->expects($this->once()) ->method('setIsActive')->with(true)->willReturn($this->quoteMock); @@ -288,18 +288,15 @@ public function testConvertCustomerCartToGuest() public function testConvertCustomerCartToGuestWithEmptyQuote() { $this->checkoutSessionMock->expects($this->once()) - ->method('getQuote')->willReturn($this->quoteMock); - $this->quoteMock->expects($this->once())->method('getId')->willReturn(null); + ->method('getQuoteId')->willReturn(null); $this->quoteRepositoryMock->expects($this->once())->method('get')->with(null)->willReturn(null); - $this->model->convertCustomerCartToGuest(); } public function testConvertCustomerCartToGuestWithEmptyQuoteId() { $this->checkoutSessionMock->expects($this->once()) - ->method('getQuote')->willReturn($this->quoteMock); - $this->quoteMock->expects($this->once())->method('getId')->willReturn(1); + ->method('getQuoteId')->willReturn(1); $quoteWithNoId = $this->quoteMock = $this->createMock(\Magento\Quote\Model\Quote::class); $quoteWithNoId->expects($this->once())->method('getId')->willReturn(null); $this->quoteRepositoryMock->expects($this->once())->method('get')->with(1)->willReturn($quoteWithNoId); diff --git a/app/code/Magento/Persistent/Test/Unit/Observer/SetCheckoutSessionPersistentDataObserverTest.php b/app/code/Magento/Persistent/Test/Unit/Observer/SetCheckoutSessionPersistentDataObserverTest.php new file mode 100644 index 0000000000000..2f0e90e330a1e --- /dev/null +++ b/app/code/Magento/Persistent/Test/Unit/Observer/SetCheckoutSessionPersistentDataObserverTest.php @@ -0,0 +1,147 @@ +<?php +/** + * + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Persistent\Test\Unit\Observer; + +/** + * Test for SetCheckoutSessionPersistentDataObserver. + */ +class SetCheckoutSessionPersistentDataObserverTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var \Magento\Persistent\Observer\SetCheckoutSessionPersistentDataObserver + */ + private $model; + + /** + * @var \Magento\Persistent\Helper\Data| \PHPUnit_Framework_MockObject_MockObject + */ + private $helperMock; + + /** + * @var \Magento\Persistent\Helper\Session| \PHPUnit_Framework_MockObject_MockObject + */ + private $sessionHelperMock; + + /** + * @var \Magento\Checkout\Model\Session| \PHPUnit_Framework_MockObject_MockObject + */ + private $checkoutSessionMock; + + /** + * @var \Magento\Customer\Model\Session| \PHPUnit_Framework_MockObject_MockObject + */ + private $customerSessionMock; + + /** + * @var \Magento\Persistent\Model\Session| \PHPUnit_Framework_MockObject_MockObject + */ + private $persistentSessionMock; + + /** + * @var \Magento\Customer\Api\CustomerRepositoryInterface| \PHPUnit_Framework_MockObject_MockObject + */ + private $customerRepositoryMock; + + /** + * @var \Magento\Framework\Event\Observer|\PHPUnit_Framework_MockObject_MockObject + */ + private $observerMock; + + /** + * @var \Magento\Framework\Event|\PHPUnit_Framework_MockObject_MockObject + */ + private $eventMock; + + /** + * @inheritdoc + */ + protected function setUp() + { + $this->helperMock = $this->createMock(\Magento\Persistent\Helper\Data::class); + $this->sessionHelperMock = $this->createMock(\Magento\Persistent\Helper\Session::class); + $this->checkoutSessionMock = $this->createMock(\Magento\Checkout\Model\Session::class); + $this->customerSessionMock = $this->createMock(\Magento\Customer\Model\Session::class); + $this->observerMock = $this->createMock(\Magento\Framework\Event\Observer::class); + $this->eventMock = $this->createPartialMock(\Magento\Framework\Event::class, ['getData']); + $this->persistentSessionMock = $this->createPartialMock( + \Magento\Persistent\Model\Session::class, + ['getCustomerId'] + ); + $this->customerRepositoryMock = $this->createMock( + \Magento\Customer\Api\CustomerRepositoryInterface::class + ); + $this->model = new \Magento\Persistent\Observer\SetCheckoutSessionPersistentDataObserver( + $this->sessionHelperMock, + $this->customerSessionMock, + $this->helperMock, + $this->customerRepositoryMock + ); + } + + /** + * Test execute method when session is not persistent. + * + * @return void + */ + public function testExecuteWhenSessionIsNotPersistent() + { + $this->observerMock->expects($this->once()) + ->method('getEvent') + ->willReturn($this->eventMock); + $this->eventMock->expects($this->once()) + ->method('getData') + ->willReturn($this->checkoutSessionMock); + $this->sessionHelperMock->expects($this->once()) + ->method('isPersistent') + ->willReturn(false); + $this->checkoutSessionMock->expects($this->never()) + ->method('setLoadInactive'); + $this->checkoutSessionMock->expects($this->never()) + ->method('setCustomerData'); + $this->model->execute($this->observerMock); + } + + /** + * Test execute method when session is persistent. + * + * @return void + */ + public function testExecute() + { + $this->observerMock->expects($this->once()) + ->method('getEvent') + ->willReturn($this->eventMock); + $this->eventMock->expects($this->once()) + ->method('getData') + ->willReturn($this->checkoutSessionMock); + $this->sessionHelperMock->expects($this->exactly(2)) + ->method('isPersistent') + ->willReturn(true); + $this->customerSessionMock->expects($this->once()) + ->method('isLoggedIn') + ->willReturn(false); + $this->helperMock->expects($this->exactly(2)) + ->method('isShoppingCartPersist') + ->willReturn(true); + $this->persistentSessionMock->expects($this->once()) + ->method('getCustomerId') + ->willReturn(123); + $this->sessionHelperMock->expects($this->once()) + ->method('getSession') + ->willReturn($this->persistentSessionMock); + $this->customerRepositoryMock->expects($this->once()) + ->method('getById') + ->willReturn(1); + $this->checkoutSessionMock->expects($this->never()) + ->method('setLoadInactive'); + $this->checkoutSessionMock->expects($this->once()) + ->method('setCustomerData'); + $this->model->execute($this->observerMock); + } +} diff --git a/app/code/Magento/Persistent/Test/Unit/Observer/SetLoadPersistentQuoteObserverTest.php b/app/code/Magento/Persistent/Test/Unit/Observer/SetLoadPersistentQuoteObserverTest.php deleted file mode 100644 index fd78a6852ea59..0000000000000 --- a/app/code/Magento/Persistent/Test/Unit/Observer/SetLoadPersistentQuoteObserverTest.php +++ /dev/null @@ -1,73 +0,0 @@ -<?php -/** - * - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ - -namespace Magento\Persistent\Test\Unit\Observer; - -class SetLoadPersistentQuoteObserverTest extends \PHPUnit\Framework\TestCase -{ - /** - * @var \Magento\Persistent\Observer\SetLoadPersistentQuoteObserver - */ - protected $model; - - /** - * @var \PHPUnit_Framework_MockObject_MockObject - */ - protected $helperMock; - - /** - * @var \PHPUnit_Framework_MockObject_MockObject - */ - protected $sessionHelperMock; - - /** - * @var \PHPUnit_Framework_MockObject_MockObject - */ - protected $checkoutSessionMock; - - /** - * @var \PHPUnit_Framework_MockObject_MockObject - */ - protected $customerSessionMock; - - /** - * @var \PHPUnit_Framework_MockObject_MockObject - */ - protected $observerMock; - - protected function setUp() - { - $this->helperMock = $this->createMock(\Magento\Persistent\Helper\Data::class); - $this->sessionHelperMock = $this->createMock(\Magento\Persistent\Helper\Session::class); - $this->checkoutSessionMock = $this->createMock(\Magento\Checkout\Model\Session::class); - $this->customerSessionMock = $this->createMock(\Magento\Customer\Model\Session::class); - $this->observerMock = $this->createMock(\Magento\Framework\Event\Observer::class); - - $this->model = new \Magento\Persistent\Observer\SetLoadPersistentQuoteObserver( - $this->sessionHelperMock, - $this->helperMock, - $this->customerSessionMock, - $this->checkoutSessionMock - ); - } - - public function testExecuteWhenSessionIsNotPersistent() - { - $this->sessionHelperMock->expects($this->once())->method('isPersistent')->will($this->returnValue(false)); - $this->checkoutSessionMock->expects($this->never())->method('setLoadInactive'); - $this->model->execute($this->observerMock); - } - - public function testExecute() - { - $this->sessionHelperMock->expects($this->once())->method('isPersistent')->will($this->returnValue(true)); - $this->customerSessionMock->expects($this->once())->method('isLoggedIn')->will($this->returnValue(false)); - $this->helperMock->expects($this->once())->method('isShoppingCartPersist')->will($this->returnValue(true)); - $this->checkoutSessionMock->expects($this->never())->method('setLoadInactive'); - $this->model->execute($this->observerMock); - } -} diff --git a/app/code/Magento/Persistent/composer.json b/app/code/Magento/Persistent/composer.json index 9debadd193a9d..73184a0648d24 100644 --- a/app/code/Magento/Persistent/composer.json +++ b/app/code/Magento/Persistent/composer.json @@ -12,7 +12,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Persistent/etc/di.xml b/app/code/Magento/Persistent/etc/di.xml index f49d4361acb52..c28426b4f25bf 100644 --- a/app/code/Magento/Persistent/etc/di.xml +++ b/app/code/Magento/Persistent/etc/di.xml @@ -12,4 +12,7 @@ <type name="Magento\Customer\CustomerData\Customer"> <plugin name="section_data" type="Magento\Persistent\Model\Plugin\CustomerData" /> </type> + <type name="Magento\Framework\App\Http\Context"> + <plugin name="persistent_page_cache_variation" type="Magento\Persistent\Model\Plugin\PersistentCustomerContext" /> + </type> </config> diff --git a/app/code/Magento/Persistent/etc/frontend/di.xml b/app/code/Magento/Persistent/etc/frontend/di.xml index f976f4de79c21..3c33f8a51c418 100644 --- a/app/code/Magento/Persistent/etc/frontend/di.xml +++ b/app/code/Magento/Persistent/etc/frontend/di.xml @@ -35,4 +35,18 @@ <type name="Magento\Checkout\Model\GuestPaymentInformationManagement"> <plugin name="inject_guest_address_for_nologin" type="Magento\Persistent\Model\Checkout\GuestPaymentInformationManagementPlugin" /> </type> + <type name="Magento\Customer\CustomerData\SectionPoolInterface"> + <arguments> + <argument name="sectionSourceMap" xsi:type="array"> + <item name="persistent" xsi:type="string">Magento\Persistent\CustomerData\Persistent</item> + </argument> + </arguments> + </type> + <type name="Magento\Customer\Block\CustomerData"> + <arguments> + <argument name="expirableSectionNames" xsi:type="array"> + <item name="persistent" xsi:type="string">persistent</item> + </argument> + </arguments> + </type> </config> diff --git a/app/code/Magento/Persistent/etc/frontend/events.xml b/app/code/Magento/Persistent/etc/frontend/events.xml index 193b9a10818e4..79720695ea6f6 100644 --- a/app/code/Magento/Persistent/etc/frontend/events.xml +++ b/app/code/Magento/Persistent/etc/frontend/events.xml @@ -49,7 +49,7 @@ <observer name="persistent" instance="Magento\Persistent\Observer\SetQuotePersistentDataObserver" /> </event> <event name="custom_quote_process"> - <observer name="persistent" instance="Magento\Persistent\Observer\SetLoadPersistentQuoteObserver" /> + <observer name="persistent" instance="Magento\Persistent\Observer\SetCheckoutSessionPersistentDataObserver" /> </event> <event name="customer_register_success"> <observer name="persistent" instance="Magento\Persistent\Observer\RemovePersistentCookieOnRegisterObserver" /> diff --git a/app/code/Magento/Persistent/etc/frontend/sections.xml b/app/code/Magento/Persistent/etc/frontend/sections.xml new file mode 100644 index 0000000000000..16b44c502fc47 --- /dev/null +++ b/app/code/Magento/Persistent/etc/frontend/sections.xml @@ -0,0 +1,13 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Customer:etc/sections.xsd"> + <action name="persistent/index/unsetCookie"> + <section name="persistent"/> + </action> +</config> diff --git a/app/code/Magento/Persistent/etc/webapi_rest/events.xml b/app/code/Magento/Persistent/etc/webapi_rest/events.xml index 1eff845386bf4..79dffa1834563 100644 --- a/app/code/Magento/Persistent/etc/webapi_rest/events.xml +++ b/app/code/Magento/Persistent/etc/webapi_rest/events.xml @@ -22,7 +22,7 @@ <observer name="persistent" instance="Magento\Persistent\Observer\SetQuotePersistentDataObserver" /> </event> <event name="custom_quote_process"> - <observer name="persistent" instance="Magento\Persistent\Observer\SetLoadPersistentQuoteObserver" /> + <observer name="persistent" instance="Magento\Persistent\Observer\SetCheckoutSessionPersistentDataObserver" /> </event> <event name="customer_register_success"> <observer name="persistent" instance="Magento\Persistent\Observer\RemovePersistentCookieOnRegisterObserver" /> diff --git a/app/code/Magento/Persistent/etc/webapi_soap/events.xml b/app/code/Magento/Persistent/etc/webapi_soap/events.xml index 1eff845386bf4..79dffa1834563 100644 --- a/app/code/Magento/Persistent/etc/webapi_soap/events.xml +++ b/app/code/Magento/Persistent/etc/webapi_soap/events.xml @@ -22,7 +22,7 @@ <observer name="persistent" instance="Magento\Persistent\Observer\SetQuotePersistentDataObserver" /> </event> <event name="custom_quote_process"> - <observer name="persistent" instance="Magento\Persistent\Observer\SetLoadPersistentQuoteObserver" /> + <observer name="persistent" instance="Magento\Persistent\Observer\SetCheckoutSessionPersistentDataObserver" /> </event> <event name="customer_register_success"> <observer name="persistent" instance="Magento\Persistent\Observer\RemovePersistentCookieOnRegisterObserver" /> diff --git a/app/code/Magento/Persistent/view/frontend/requirejs-config.js b/app/code/Magento/Persistent/view/frontend/requirejs-config.js new file mode 100644 index 0000000000000..e30e07c454be5 --- /dev/null +++ b/app/code/Magento/Persistent/view/frontend/requirejs-config.js @@ -0,0 +1,14 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +var config = { + config: { + mixins: { + 'Magento_Customer/js/customer-data': { + 'Magento_Persistent/js/view/customer-data-mixin': true + } + } + } +}; diff --git a/app/code/Magento/Persistent/view/frontend/templates/additional.phtml b/app/code/Magento/Persistent/view/frontend/templates/additional.phtml new file mode 100644 index 0000000000000..28dce5dc23cc9 --- /dev/null +++ b/app/code/Magento/Persistent/view/frontend/templates/additional.phtml @@ -0,0 +1,21 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +?> +<?php if ($block->getCustomerId()) :?> + <span> + <a <?= /* @escapeNotVerified */ $block->getLinkAttributes()?>><?= $block->escapeHtml(__('Not you?'));?></a> + </span> +<?php endif;?> +<script type="application/javascript"> + window.persistent = <?= /* @noEscape */ $block->getConfig(); ?>; +</script> +<script type="text/x-magento-init"> + { + "li.greet.welcome > span.not-logged-in": { + "Magento_Persistent/js/view/additional-welcome": {} + } + } +</script> diff --git a/app/code/Magento/Persistent/view/frontend/web/js/view/additional-welcome.js b/app/code/Magento/Persistent/view/frontend/web/js/view/additional-welcome.js new file mode 100644 index 0000000000000..47949671fed52 --- /dev/null +++ b/app/code/Magento/Persistent/view/frontend/web/js/view/additional-welcome.js @@ -0,0 +1,55 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'jquery', + 'mage/translate', + 'Magento_Customer/js/customer-data' +], function ($, $t, customerData) { + 'use strict'; + + return { + /** + * Init + */ + init: function () { + var persistent = customerData.get('persistent'); + + if (persistent().fullname === undefined) { + customerData.get('persistent').subscribe(this.replacePersistentWelcome); + } else { + this.replacePersistentWelcome(); + } + }, + + /** + * Replace welcome message for customer with persistent cookie. + */ + replacePersistentWelcome: function () { + var persistent = customerData.get('persistent'), + welcomeElems; + + if (persistent().fullname !== undefined) { + welcomeElems = $('li.greet.welcome > span.not-logged-in'); + + if (welcomeElems.length) { + $(welcomeElems).each(function () { + var html = $t('Welcome, %1!').replace('%1', persistent().fullname); + + $(this).attr('data-bind', html); + $(this).html(html); + }); + } + } + }, + + /** + * @constructor + */ + 'Magento_Persistent/js/view/additional-welcome': function () { + this.init(); + } + }; +}); diff --git a/app/code/Magento/Persistent/view/frontend/web/js/view/customer-data-mixin.js b/app/code/Magento/Persistent/view/frontend/web/js/view/customer-data-mixin.js new file mode 100644 index 0000000000000..855404c6f6f32 --- /dev/null +++ b/app/code/Magento/Persistent/view/frontend/web/js/view/customer-data-mixin.js @@ -0,0 +1,51 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +define([ + 'jquery', + 'mage/utils/wrapper' +], function ($, wrapper) { + 'use strict'; + + var mixin = { + + /** + * Check if persistent section is expired due to lifetime. + * + * @param {Function} originFn - Original method. + * @return {Array} + */ + getExpiredSectionNames: function (originFn) { + var expiredSections = originFn(), + storage = $.initNamespaceStorage('mage-cache-storage').localStorage, + currentTimestamp = Math.floor(Date.now() / 1000), + persistentIndex = expiredSections.indexOf('persistent'), + persistentLifeTime = 0, + sectionData; + + if (window.persistent !== undefined && window.persistent.expirationLifetime !== undefined) { + persistentLifeTime = window.persistent.expirationLifetime; + } + + if (persistentIndex !== -1) { + sectionData = storage.get('persistent'); + + if (typeof sectionData === 'object' && + sectionData['data_id'] + persistentLifeTime >= currentTimestamp + ) { + expiredSections.splice(persistentIndex, 1); + } + } + + return expiredSections; + } + }; + + /** + * Override default customer-data.getExpiredSectionNames(). + */ + return function (target) { + return wrapper.extend(target, mixin); + }; +}); diff --git a/app/code/Magento/ProductAlert/composer.json b/app/code/Magento/ProductAlert/composer.json index 11dc94edcbcd3..d2b7a8014d9a6 100644 --- a/app/code/Magento/ProductAlert/composer.json +++ b/app/code/Magento/ProductAlert/composer.json @@ -13,7 +13,7 @@ "magento/module-config": "101.0.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/ProductVideo/Model/Plugin/Catalog/Product/Gallery/CreateHandler.php b/app/code/Magento/ProductVideo/Model/Plugin/Catalog/Product/Gallery/CreateHandler.php index c56e5e3139517..d554b5dd68db2 100644 --- a/app/code/Magento/ProductVideo/Model/Plugin/Catalog/Product/Gallery/CreateHandler.php +++ b/app/code/Magento/ProductVideo/Model/Plugin/Catalog/Product/Gallery/CreateHandler.php @@ -20,6 +20,8 @@ class CreateHandler extends AbstractHandler const ADDITIONAL_STORE_DATA_KEY = 'additional_store_data'; /** + * Execute before Plugin + * * @param \Magento\Catalog\Model\Product\Gallery\CreateHandler $mediaGalleryCreateHandler * @param \Magento\Catalog\Model\Product $product * @param array $arguments @@ -45,6 +47,8 @@ public function beforeExecute( } /** + * Execute plugin + * * @param \Magento\Catalog\Model\Product\Gallery\CreateHandler $mediaGalleryCreateHandler * @param \Magento\Catalog\Model\Product $product * @return \Magento\Catalog\Model\Product @@ -59,6 +63,9 @@ public function afterExecute( ); if (!empty($mediaCollection)) { + if ($product->getIsDuplicate() === true) { + $mediaCollection = $this->makeAllNewVideos($product->getId(), $mediaCollection); + } $newVideoCollection = $this->collectNewVideos($mediaCollection); $this->saveVideoData($newVideoCollection, 0); @@ -71,6 +78,8 @@ public function afterExecute( } /** + * Saves video data + * * @param array $videoDataCollection * @param int $storeId * @return void @@ -84,6 +93,8 @@ protected function saveVideoData(array $videoDataCollection, $storeId) } /** + * Saves additioanal video data + * * @param array $videoDataCollection * @return void */ @@ -100,6 +111,8 @@ protected function saveAdditionalStoreData(array $videoDataCollection) } /** + * Saves video data + * * @param array $item * @return void */ @@ -112,6 +125,8 @@ protected function saveVideoValuesItem(array $item) } /** + * Excludes current store data + * * @param array $mediaCollection * @param int $currentStoreId * @return array @@ -127,6 +142,8 @@ function ($item) use ($currentStoreId) { } /** + * Prepare video data for saving + * * @param array $rowData * @return array */ @@ -144,6 +161,8 @@ protected function prepareVideoRowDataForSave(array $rowData) } /** + * Loads video data + * * @param array $mediaCollection * @param int $excludedStore * @return array @@ -166,6 +185,8 @@ protected function loadStoreViewVideoData(array $mediaCollection, $excludedStore } /** + * Collect video data + * * @param array $mediaCollection * @return array */ @@ -183,6 +204,8 @@ protected function collectVideoData(array $mediaCollection) } /** + * Extract video data + * * @param array $rowData * @return array */ @@ -195,6 +218,8 @@ protected function extractVideoDataFromRowData(array $rowData) } /** + * Collect items for additional data adding + * * @param array $mediaCollection * @return array */ @@ -210,6 +235,8 @@ protected function collectVideoEntriesIdsToAdditionalLoad(array $mediaCollection } /** + * Add additional data + * * @param array $mediaCollection * @param array $data * @return array @@ -230,6 +257,8 @@ protected function addAdditionalStoreData(array $mediaCollection, array $data): } /** + * Creates additional video data + * * @param array $storeData * @param int $valueId * @return array @@ -248,6 +277,8 @@ protected function createAdditionalStoreDataCollection(array $storeData, $valueI } /** + * Collect new videos + * * @param array $mediaCollection * @return array */ @@ -263,6 +294,8 @@ private function collectNewVideos(array $mediaCollection): array } /** + * Checks if gallery item is video + * * @param $item * @return bool */ @@ -274,6 +307,8 @@ private function isVideoItem($item): bool } /** + * Checks if video is new + * * @param $item * @return bool */ @@ -283,4 +318,23 @@ private function isNewVideo($item): bool || empty($item['video_url_default']) || empty($item['video_title_default']); } + + /** + * Mark all videos as new + * + * @param int $entityId + * @param array $mediaCollection + * @return array + */ + private function makeAllNewVideos($entityId, array $mediaCollection): array + { + foreach ($mediaCollection as $key => $video) { + if ($this->isVideoItem($video)) { + unset($video['video_url_default'], $video['video_title_default']); + $video['entity_id'] = $entityId; + $mediaCollection[$key] = $video; + } + } + return $mediaCollection; + } } diff --git a/app/code/Magento/ProductVideo/Test/Mftf/ActionGroup/AdminProductVideoActionGroup.xml b/app/code/Magento/ProductVideo/Test/Mftf/ActionGroup/AdminProductVideoActionGroup.xml new file mode 100644 index 0000000000000..621caea0cfc6d --- /dev/null +++ b/app/code/Magento/ProductVideo/Test/Mftf/ActionGroup/AdminProductVideoActionGroup.xml @@ -0,0 +1,46 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <!-- Add video in Admin Product page --> + <actionGroup name="addProductVideo"> + <arguments> + <argument name="video" defaultValue="mftfTestProductVideo"/> + </arguments> + + <scrollTo selector="{{AdminProductImagesSection.productImagesToggle}}" x="0" y="-100" stepKey="scrollToArea"/> + <conditionalClick selector="{{AdminProductImagesSection.productImagesToggle}}" + dependentSelector="{{AdminProductImagesSection.imageUploadButton}}" + visible="false" + stepKey="openProductVideoSection"/> + <waitForElementVisible selector="{{AdminProductImagesSection.addVideoButton}}" stepKey="waitForAddVideoButtonVisible"/> + <click selector="{{AdminProductImagesSection.addVideoButton}}" stepKey="addVideo"/> + <waitForElementVisible selector="{{AdminProductNewVideoSection.videoUrlTextField}}" stepKey="waitForUrlElementVisible"/> + <fillField selector="{{AdminProductNewVideoSection.videoUrlTextField}}" userInput="{{video.videoUrl}}" stepKey="fillFieldVideoUrl"/> + <fillField selector="{{AdminProductNewVideoSection.videoTitleTextField}}" userInput="{{video.videoTitle}}" stepKey="fillFieldVideoTitle"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + <waitForElementNotVisible selector="{{AdminProductNewVideoSection.saveButtonDisabled}}" wait="30" stepKey="waitForSaveButtonVisible"/> + <click selector="{{AdminProductNewVideoSection.saveButton}}" stepKey="saveVideo"/> + <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskToDisappear"/> + </actionGroup> + + <!-- Assert product video in Admin Product page --> + <actionGroup name="assertProductVideoAdminProductPage"> + <arguments> + <argument name="video" defaultValue="mftfTestProductVideo"/> + </arguments> + <scrollTo selector="{{AdminProductImagesSection.productImagesToggle}}" x="0" y="-100" stepKey="scrollToArea"/> + <conditionalClick selector="{{AdminProductImagesSection.productImagesToggle}}" + dependentSelector="{{AdminProductImagesSection.imageUploadButton}}" + visible="false" + stepKey="openProductVideoSection"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + <seeElement selector="{{AdminProductImagesSection.videoTitleText(video.videoShortTitle)}}" stepKey="seeVideoTitle"/> + <seeElementInDOM selector="{{AdminProductImagesSection.videoUrlHiddenField(video.videoUrl)}}" stepKey="seeVideoItem"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/ProductVideo/Test/Mftf/ActionGroup/StorefrontProductVideoActionGroup.xml b/app/code/Magento/ProductVideo/Test/Mftf/ActionGroup/StorefrontProductVideoActionGroup.xml new file mode 100644 index 0000000000000..28634f41deec1 --- /dev/null +++ b/app/code/Magento/ProductVideo/Test/Mftf/ActionGroup/StorefrontProductVideoActionGroup.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <!--Assert product video in Storefront Product page --> + <actionGroup name="assertProductVideoStorefrontProductPage"> + <arguments> + <argument name="dataTypeAttribute" defaultValue="'youtube'"/> + </arguments> + <seeElement selector="{{StorefrontProductInfoMainSection.productVideo(dataTypeAttribute)}}" stepKey="seeProductVideoDataType"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/ProductVideo/Test/Mftf/Data/ProductVideoConfigData.xml b/app/code/Magento/ProductVideo/Test/Mftf/Data/ProductVideoConfigData.xml new file mode 100644 index 0000000000000..8fe5899e91ef8 --- /dev/null +++ b/app/code/Magento/ProductVideo/Test/Mftf/Data/ProductVideoConfigData.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <!-- mftf test youtube api key configuration --> + <entity name="ProductVideoYoutubeApiKeyConfig" type="product_video_config"> + <requiredEntity type="youtube_api_key_config">YouTubeApiKey</requiredEntity> + </entity> + <entity name="YouTubeApiKey" type="youtube_api_key_config"> + <data key="value">AIzaSyDwqDWuw1lra-LnpJL2Mr02DYuFmkuRSns</data> + </entity> + + <!-- default configuration used to restore Magento config --> + <entity name="DefaultProductVideoConfig" type="product_video_config"> + <requiredEntity type="youtube_api_key_config">DefaultYouTubeApiKey</requiredEntity> + </entity> + <entity name="DefaultYouTubeApiKey" type="youtube_api_key_config"> + <data key="value"/> + </entity> +</entities> diff --git a/app/code/Magento/ProductVideo/Test/Mftf/Data/ProductVideoData.xml b/app/code/Magento/ProductVideo/Test/Mftf/Data/ProductVideoData.xml new file mode 100644 index 0000000000000..5bc4ad86e0f06 --- /dev/null +++ b/app/code/Magento/ProductVideo/Test/Mftf/Data/ProductVideoData.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <entity name="mftfTestProductVideo" type="product_video"> + <data key="videoUrl">https://youtu.be/bpOSxM0rNPM</data> + <data key="videoTitle">Arctic Monkeys - Do I Wanna Know? (Official Video)</data> + <data key="videoShortTitle">Arctic Monkeys</data> + </entity> +</entities> diff --git a/app/code/Magento/ProductVideo/Test/Mftf/Metadata/product_video_config-meta.xml b/app/code/Magento/ProductVideo/Test/Mftf/Metadata/product_video_config-meta.xml new file mode 100644 index 0000000000000..dc6d3af1c52c5 --- /dev/null +++ b/app/code/Magento/ProductVideo/Test/Mftf/Metadata/product_video_config-meta.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<operations xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataOperation.xsd"> + <operation name="CreateProductVideoYouTubeApiKeyConfig" dataType="product_video_config" type="create" auth="adminFormKey" url="admin/system_config/save/section/catalog/" method="POST" successRegex="/messages-message-success/"> + <object key="groups" dataType="product_video_config"> + <object key="product_video" dataType="product_video_config"> + <object key="fields" dataType="product_video_config"> + <object key="youtube_api_key" dataType="youtube_api_key_config"> + <field key="value">string</field> + </object> + </object> + </object> + </object> + </operation> +</operations> diff --git a/app/code/Magento/ProductVideo/Test/Mftf/Page/AdminProductCreatePage.xml b/app/code/Magento/ProductVideo/Test/Mftf/Page/AdminProductCreatePage.xml new file mode 100644 index 0000000000000..52f13a1da5188 --- /dev/null +++ b/app/code/Magento/ProductVideo/Test/Mftf/Page/AdminProductCreatePage.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminProductCreatePage" url="catalog/product/new/set/{{set}}/type/{{type}}/" area="admin" module="Magento_Catalog" parameterized="true"> + <section name="AdminProductNewVideoSection"/> + </page> +</pages> diff --git a/app/code/Magento/ProductVideo/Test/Mftf/Section/AdminProductImagesSection.xml b/app/code/Magento/ProductVideo/Test/Mftf/Section/AdminProductImagesSection.xml new file mode 100644 index 0000000000000..913ae8c955340 --- /dev/null +++ b/app/code/Magento/ProductVideo/Test/Mftf/Section/AdminProductImagesSection.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminProductImagesSection"> + <element name="addVideoButton" type="button" selector="#add_video_button" timeout="60"/> + <element name="videoUrlHiddenField" type="text" selector="#media_gallery_content input[value*='{{title}}']" parameterized="true"/> + <element name="videoTitleText" type="text" selector="//*[@id='media_gallery_content']//div[contains(text(), '{{title}}')]" parameterized="true"/> + </section> +</sections> diff --git a/app/code/Magento/ProductVideo/Test/Mftf/Section/AdminProductNewVideoSection.xml b/app/code/Magento/ProductVideo/Test/Mftf/Section/AdminProductNewVideoSection.xml new file mode 100644 index 0000000000000..8df254aea7c50 --- /dev/null +++ b/app/code/Magento/ProductVideo/Test/Mftf/Section/AdminProductNewVideoSection.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminProductNewVideoSection"> + <element name="saveButton" type="button" selector=".action-primary.video-create-button" timeout="30"/> + <element name="saveButtonDisabled" type="text" selector="button.action-primary.video-create-button[disabled='disabled']"/> + <element name="videoUrlTextField" type="input" selector="#video_url"/> + <element name="videoTitleTextField" type="input" selector="#video_title"/> + </section> +</sections> diff --git a/app/code/Magento/ProductVideo/Test/Mftf/Section/StorefrontProductInfoMainSection.xml b/app/code/Magento/ProductVideo/Test/Mftf/Section/StorefrontProductInfoMainSection.xml new file mode 100644 index 0000000000000..1ce56ff4996ab --- /dev/null +++ b/app/code/Magento/ProductVideo/Test/Mftf/Section/StorefrontProductInfoMainSection.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontProductInfoMainSection"> + <element name="productVideo" type="text" selector=".product-video[data-type='{{videoType}}']" parameterized="true"/> + <element name="clickInVideo" type="video" selector=".fotorama__stage__shaft"/> + <element name="videoPausedMode" type="video" selector="[class*='paused-mode']"/> + <element name="videoPlayedMode" type="video" selector="[class*='playing-mode']"/> + <element name="frameVideo" type="video" selector="widget2"/> + </section> +</sections> diff --git a/app/code/Magento/ProductVideo/Test/Mftf/Test/StorefrontYoutubeVideoWindowOnProductPageTest.xml b/app/code/Magento/ProductVideo/Test/Mftf/Test/StorefrontYoutubeVideoWindowOnProductPageTest.xml new file mode 100644 index 0000000000000..15888d1af8ee5 --- /dev/null +++ b/app/code/Magento/ProductVideo/Test/Mftf/Test/StorefrontYoutubeVideoWindowOnProductPageTest.xml @@ -0,0 +1,95 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontYoutubeVideoWindowOnProductPageTest"> + <annotations> + <features value="ProductVideo"/> + <stories value="MAGETWO-87734: [Sigma Beauty]Cannot pause Youtube video in IE 11"/> + <testCaseId value="MAGETWO-96677"/> + <title value="Youtube video window on the product page"/> + <description value="Check Youtube video window on the product page"/> + <severity value="MAJOR"/> + <group value="productVideo"/> + </annotations> + + <before> + <!--Log In--> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <!--Create category--> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <!--Create product--> + <createData entity="SimpleProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <!-- Set product video Youtube api key configuration --> + <createData entity="ProductVideoYoutubeApiKeyConfig" stepKey="setStoreConfig"/> + </before> + + <after> + <!--Clear all filters on products grid page--> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="goToProductsIndex"/> + <actionGroup ref="clearFiltersAdminDataGrid" stepKey="clearExistingProductFilters"/> + <!--Delete created product and category--> + <deleteData createDataKey="createProduct" stepKey="deleteSimpleProduct"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategoryFirst"/> + <!-- Set product video configuration to default --> + <createData entity="DefaultProductVideoConfig" stepKey="setStoreDefaultConfig"/> + <!--Log Out--> + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <!--Open simple product--> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="goToProductIndex"/> + <actionGroup ref="AdminGridFilterSearchResultsByInput" stepKey="filterProductGridBySku"> + <argument name="inputName" value="sku"/> + <argument name="value" value="$$createProduct.sku$$"/> + </actionGroup> + <click selector="{{AdminDataGridTableSection.firstRow}}" stepKey="openFirstProductForEdit"/> + <waitForPageLoad stepKey="waitProductPageIsLoaded"/> + + <!-- Add product video --> + <actionGroup ref="addProductVideo" stepKey="addProductVideo"/> + <!-- Assert product video in admin product form --> + <actionGroup ref="assertProductVideoAdminProductPage" stepKey="assertProductVideoAdminProductPage"/> + + <!-- Save the product --> + <click selector="{{AdminProductFormActionSection.saveButton}}" stepKey="saveProduct"/> + <waitForPageLoad stepKey="waitForProductSaved"/> + + <!-- Assert product video in storefront product page --> + <amOnPage url="{{StorefrontProductPage.url($$createProduct.name$$)}}" stepKey="goToStorefrontProductPage"/> + <actionGroup ref="assertProductVideoStorefrontProductPage" stepKey="assertProductVideoStorefrontProductPage"/> + + <!--Click Play video button--> + <click selector="{{StorefrontProductInfoMainSection.clickInVideo}}" stepKey="clickToPlayVideo"/> + <wait time="5" stepKey="waitFiveSecondToPlayVideo"/> + <switchToIFrame selector="{{StorefrontProductInfoMainSection.frameVideo}}" stepKey="switchToFrame"/> + <waitForElementVisible selector="{{StorefrontProductInfoMainSection.videoPlayedMode}}" stepKey="waitForVideoPlayed"/> + <seeElement selector="{{StorefrontProductInfoMainSection.videoPlayedMode}}" stepKey="assertVideoIsPlayed"/> + <switchToIFrame stepKey="switchBack"/> + + <!--Click Pause button--> + <click selector="{{StorefrontProductInfoMainSection.clickInVideo}}" stepKey="clickToStopVideo"/> + <wait time="5" stepKey="waitFiveSecondToStopVideo"/> + <switchToIFrame selector="{{StorefrontProductInfoMainSection.frameVideo}}" stepKey="switchToFrame1"/> + <waitForElementVisible selector="{{StorefrontProductInfoMainSection.videoPausedMode}}" stepKey="waitForVideoPaused"/> + <seeElement selector="{{StorefrontProductInfoMainSection.videoPausedMode}}" stepKey="assertVideoIsPaused"/> + <switchToIFrame stepKey="switchBack1"/> + + <!--Click Play video button again. Make sure that Video continued playing--> + <click selector="{{StorefrontProductInfoMainSection.clickInVideo}}" stepKey="clickAgainToPlayVideo"/> + <wait time="5" stepKey="waitAgainFiveSecondToPlayVideo"/> + <switchToIFrame selector="{{StorefrontProductInfoMainSection.frameVideo}}" stepKey="switchToFrame2"/> + <waitForElementVisible selector="{{StorefrontProductInfoMainSection.videoPlayedMode}}" stepKey="waitForVideoPlayedAgain"/> + <seeElement selector="{{StorefrontProductInfoMainSection.videoPlayedMode}}" stepKey="assertVideoIsPlayedAgain"/> + <switchToIFrame stepKey="switchBack2"/> + </test> +</tests> + diff --git a/app/code/Magento/ProductVideo/composer.json b/app/code/Magento/ProductVideo/composer.json index 811422d160d8d..7c5017eef4a5a 100644 --- a/app/code/Magento/ProductVideo/composer.json +++ b/app/code/Magento/ProductVideo/composer.json @@ -16,7 +16,7 @@ "magento/module-config": "101.0.*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "proprietary" ], diff --git a/app/code/Magento/ProductVideo/view/frontend/web/js/fotorama-add-video-events.js b/app/code/Magento/ProductVideo/view/frontend/web/js/fotorama-add-video-events.js index 3966321f6072c..b7f4adb857a91 100644 --- a/app/code/Magento/ProductVideo/view/frontend/web/js/fotorama-add-video-events.js +++ b/app/code/Magento/ProductVideo/view/frontend/web/js/fotorama-add-video-events.js @@ -179,12 +179,14 @@ define([ * @private */ clearEvents: function () { - this.fotoramaItem.off( - 'fotorama:show.' + this.PV + - ' fotorama:showend.' + this.PV + - ' fotorama:fullscreenenter.' + this.PV + - ' fotorama:fullscreenexit.' + this.PV - ); + if (this.fotoramaItem !== undefined) { + this.fotoramaItem.off( + 'fotorama:show.' + this.PV + + ' fotorama:showend.' + this.PV + + ' fotorama:fullscreenenter.' + this.PV + + ' fotorama:fullscreenexit.' + this.PV + ); + } }, /** diff --git a/app/code/Magento/Quote/Api/Data/CartInterface.php b/app/code/Magento/Quote/Api/Data/CartInterface.php index 551833e3effb1..b87869de6b3df 100644 --- a/app/code/Magento/Quote/Api/Data/CartInterface.php +++ b/app/code/Magento/Quote/Api/Data/CartInterface.php @@ -223,14 +223,14 @@ public function setBillingAddress(\Magento\Quote\Api\Data\AddressInterface $bill /** * Returns the reserved order ID for the cart. * - * @return int|null Reserved order ID. Otherwise, null. + * @return string|null Reserved order ID. Otherwise, null. */ public function getReservedOrderId(); /** * Sets the reserved order ID for the cart. * - * @param int $reservedOrderId + * @param string $reservedOrderId * @return $this */ public function setReservedOrderId($reservedOrderId); diff --git a/app/code/Magento/Quote/Model/Quote.php b/app/code/Magento/Quote/Model/Quote.php index 640f89844546e..a3f5d1aaa6a6a 100644 --- a/app/code/Magento/Quote/Model/Quote.php +++ b/app/code/Magento/Quote/Model/Quote.php @@ -2218,6 +2218,11 @@ public function validateMinimumAmount($multishipping = false) if (!$minOrderActive) { return true; } + $includeDiscount = $this->_scopeConfig->getValue( + 'sales/minimum_order/include_discount_amount', + \Magento\Store\Model\ScopeInterface::SCOPE_STORE, + $storeId + ); $minOrderMulti = $this->_scopeConfig->isSetFlag( 'sales/minimum_order/multi_address', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, @@ -2251,7 +2256,10 @@ public function validateMinimumAmount($multishipping = false) $taxes = ($taxInclude) ? $address->getBaseTaxAmount() : 0; foreach ($address->getQuote()->getItemsCollection() as $item) { /** @var \Magento\Quote\Model\Quote\Item $item */ - $amount = $item->getBaseRowTotal() - $item->getBaseDiscountAmount() + $taxes; + $amount = $includeDiscount ? + $item->getBaseRowTotal() - $item->getBaseDiscountAmount() + $taxes : + $item->getBaseRowTotal() + $taxes; + if ($amount < $minAmount) { return false; } @@ -2261,7 +2269,9 @@ public function validateMinimumAmount($multishipping = false) $baseTotal = 0; foreach ($addresses as $address) { $taxes = ($taxInclude) ? $address->getBaseTaxAmount() : 0; - $baseTotal += $address->getBaseSubtotalWithDiscount() + $taxes; + $baseTotal += $includeDiscount ? + $address->getBaseSubtotalWithDiscount() + $taxes : + $address->getBaseSubtotal() + $taxes; } if ($baseTotal < $minAmount) { return false; diff --git a/app/code/Magento/Quote/Model/Quote/Address.php b/app/code/Magento/Quote/Model/Quote/Address.php index f0419c7f96095..38a97783ca012 100644 --- a/app/code/Magento/Quote/Model/Quote/Address.php +++ b/app/code/Magento/Quote/Model/Quote/Address.php @@ -1150,6 +1150,11 @@ public function validateMinimumAmount() return true; } + $includeDiscount = $this->_scopeConfig->getValue( + 'sales/minimum_order/include_discount_amount', + \Magento\Store\Model\ScopeInterface::SCOPE_STORE, + $storeId + ); $amount = $this->_scopeConfig->getValue( 'sales/minimum_order/amount', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, @@ -1160,9 +1165,12 @@ public function validateMinimumAmount() \Magento\Store\Model\ScopeInterface::SCOPE_STORE, $storeId ); + $taxes = $taxInclude ? $this->getBaseTaxAmount() : 0; - return ($this->getBaseSubtotalWithDiscount() + $taxes >= $amount); + return $includeDiscount ? + ($this->getBaseSubtotalWithDiscount() + $taxes >= $amount) : + ($this->getBaseSubtotal() + $taxes >= $amount); } /** diff --git a/app/code/Magento/Quote/Model/Quote/Address/Total.php b/app/code/Magento/Quote/Model/Quote/Address/Total.php index 42224c970ed27..00060c15c10d8 100644 --- a/app/code/Magento/Quote/Model/Quote/Address/Total.php +++ b/app/code/Magento/Quote/Model/Quote/Address/Total.php @@ -6,6 +6,8 @@ namespace Magento\Quote\Model\Quote\Address; /** + * Class Total + * * @method string getCode() * * @api @@ -54,6 +56,8 @@ public function __construct( */ public function setTotalAmount($code, $amount) { + $amount = is_float($amount) ? round($amount, 4) : $amount; + $this->totalAmounts[$code] = $amount; if ($code != 'subtotal') { $code = $code . '_amount'; @@ -72,6 +76,8 @@ public function setTotalAmount($code, $amount) */ public function setBaseTotalAmount($code, $amount) { + $amount = is_float($amount) ? round($amount, 4) : $amount; + $this->baseTotalAmounts[$code] = $amount; if ($code != 'subtotal') { $code = $code . '_amount'; @@ -167,6 +173,7 @@ public function getAllBaseTotalAmounts() /** * Set the full info, which is used to capture tax related information. + * * If a string is used, it is assumed to be serialized. * * @param array|string $info diff --git a/app/code/Magento/Quote/Model/Quote/Address/Total/Shipping.php b/app/code/Magento/Quote/Model/Quote/Address/Total/Shipping.php index 84f1fc1c35adf..e9a63dad6e169 100644 --- a/app/code/Magento/Quote/Model/Quote/Address/Total/Shipping.php +++ b/app/code/Magento/Quote/Model/Quote/Address/Total/Shipping.php @@ -9,6 +9,9 @@ use Magento\Quote\Api\Data\AddressInterface; use Magento\Quote\Model\Quote\Address\FreeShippingInterface; +/** + * Collect totals for shipping. + */ class Shipping extends \Magento\Quote\Model\Quote\Address\Total\AbstractTotal { /** @@ -111,7 +114,7 @@ public function fetch(\Magento\Quote\Model\Quote $quote, \Magento\Quote\Model\Qu { $amount = $total->getShippingAmount(); $shippingDescription = $total->getShippingDescription(); - $title = ($amount != 0 && $shippingDescription) + $title = ($shippingDescription) ? __('Shipping & Handling (%1)', $shippingDescription) : __('Shipping & Handling'); @@ -227,7 +230,7 @@ private function getAssignmentWeightData(AddressInterface $address, array $items * @param bool $addressFreeShipping * @param float $itemWeight * @param float $itemQty - * @param $freeShipping + * @param bool $freeShipping * @return float */ private function getItemRowWeight( diff --git a/app/code/Magento/Quote/Model/Quote/Item/ToOrderItem.php b/app/code/Magento/Quote/Model/Quote/Item/ToOrderItem.php index 32687499274f8..6192d3471ccb0 100644 --- a/app/code/Magento/Quote/Model/Quote/Item/ToOrderItem.php +++ b/app/code/Magento/Quote/Model/Quote/Item/ToOrderItem.php @@ -48,6 +48,8 @@ public function __construct( } /** + * Convert quote item(quote address item) into order item. + * * @param Item|AddressItem $item * @param array $data * @return OrderItemInterface @@ -63,6 +65,16 @@ public function convert($item, $data = []) 'to_order_item', $item ); + if ($item instanceof \Magento\Quote\Model\Quote\Address\Item) { + $orderItemData = array_merge( + $orderItemData, + $this->objectCopyService->getDataFromFieldset( + 'quote_convert_address_item', + 'to_order_item', + $item + ) + ); + } if (!$item->getNoDiscount()) { $data = array_merge( $data, diff --git a/app/code/Magento/Quote/Model/Quote/Item/Updater.php b/app/code/Magento/Quote/Model/Quote/Item/Updater.php index 6a7a3c1c1839e..05244d4ecc43a 100644 --- a/app/code/Magento/Quote/Model/Quote/Item/Updater.php +++ b/app/code/Magento/Quote/Model/Quote/Item/Updater.php @@ -145,8 +145,8 @@ protected function unsetCustomPrice(Item $item) $item->addOption($infoBuyRequest); } - $item->unsetData('custom_price'); - $item->unsetData('original_custom_price'); + $item->setData('custom_price', null); + $item->setData('original_custom_price', null); } /** diff --git a/app/code/Magento/Quote/Test/Unit/Model/Quote/AddressTest.php b/app/code/Magento/Quote/Test/Unit/Model/Quote/AddressTest.php index e25b770b7a81e..8784310d540bd 100644 --- a/app/code/Magento/Quote/Test/Unit/Model/Quote/AddressTest.php +++ b/app/code/Magento/Quote/Test/Unit/Model/Quote/AddressTest.php @@ -216,6 +216,7 @@ public function testValidateMiniumumAmountVirtual() $scopeConfigValues = [ ['sales/minimum_order/active', ScopeInterface::SCOPE_STORE, $storeId, true], ['sales/minimum_order/amount', ScopeInterface::SCOPE_STORE, $storeId, 20], + ['sales/minimum_order/include_discount_amount', ScopeInterface::SCOPE_STORE, $storeId, true], ['sales/minimum_order/tax_including', ScopeInterface::SCOPE_STORE, $storeId, true], ]; @@ -240,6 +241,31 @@ public function testValidateMiniumumAmount() $scopeConfigValues = [ ['sales/minimum_order/active', ScopeInterface::SCOPE_STORE, $storeId, true], ['sales/minimum_order/amount', ScopeInterface::SCOPE_STORE, $storeId, 20], + ['sales/minimum_order/include_discount_amount', ScopeInterface::SCOPE_STORE, $storeId, true], + ['sales/minimum_order/tax_including', ScopeInterface::SCOPE_STORE, $storeId, true], + ]; + + $this->quote->expects($this->once()) + ->method('getStoreId') + ->willReturn($storeId); + $this->quote->expects($this->once()) + ->method('getIsVirtual') + ->willReturn(false); + + $this->scopeConfig->expects($this->once()) + ->method('isSetFlag') + ->willReturnMap($scopeConfigValues); + + $this->assertTrue($this->address->validateMinimumAmount()); + } + + public function testValidateMiniumumAmountWithoutDiscount() + { + $storeId = 1; + $scopeConfigValues = [ + ['sales/minimum_order/active', ScopeInterface::SCOPE_STORE, $storeId, true], + ['sales/minimum_order/amount', ScopeInterface::SCOPE_STORE, $storeId, 20], + ['sales/minimum_order/include_discount_amount', ScopeInterface::SCOPE_STORE, $storeId, false], ['sales/minimum_order/tax_including', ScopeInterface::SCOPE_STORE, $storeId, true], ]; @@ -263,6 +289,7 @@ public function testValidateMiniumumAmountNegative() $scopeConfigValues = [ ['sales/minimum_order/active', ScopeInterface::SCOPE_STORE, $storeId, true], ['sales/minimum_order/amount', ScopeInterface::SCOPE_STORE, $storeId, 20], + ['sales/minimum_order/include_discount_amount', ScopeInterface::SCOPE_STORE, $storeId, true], ['sales/minimum_order/tax_including', ScopeInterface::SCOPE_STORE, $storeId, true], ]; diff --git a/app/code/Magento/Quote/Test/Unit/Model/Quote/Item/UpdaterTest.php b/app/code/Magento/Quote/Test/Unit/Model/Quote/Item/UpdaterTest.php index 7933da7c5fe37..8e6a3723caa7c 100644 --- a/app/code/Magento/Quote/Test/Unit/Model/Quote/Item/UpdaterTest.php +++ b/app/code/Magento/Quote/Test/Unit/Model/Quote/Item/UpdaterTest.php @@ -67,7 +67,7 @@ protected function setUp() 'addOption', 'setCustomPrice', 'setOriginalCustomPrice', - 'unsetData', + 'setData', 'hasData', 'setIsQtyDecimal' ]); @@ -301,7 +301,7 @@ public function testUpdateUnsetCustomPrice() 'setProduct', 'getData', 'unsetData', - 'hasData' + 'hasData', ]); $buyRequestMock->expects($this->never())->method('setCustomPrice'); $buyRequestMock->expects($this->once())->method('getData')->will($this->returnValue([])); @@ -353,7 +353,11 @@ public function testUpdateUnsetCustomPrice() ->will($this->returnValue($buyRequestMock)); $this->itemMock->expects($this->exactly(2)) - ->method('unsetData'); + ->method('setData') + ->withConsecutive( + ['custom_price', null], + ['original_custom_price', null] + ); $this->itemMock->expects($this->once()) ->method('hasData') diff --git a/app/code/Magento/Quote/Test/Unit/Model/QuoteTest.php b/app/code/Magento/Quote/Test/Unit/Model/QuoteTest.php index 6f5e5937a32c8..9e921f744642f 100644 --- a/app/code/Magento/Quote/Test/Unit/Model/QuoteTest.php +++ b/app/code/Magento/Quote/Test/Unit/Model/QuoteTest.php @@ -951,6 +951,7 @@ public function testValidateMiniumumAmount() ['sales/minimum_order/active', ScopeInterface::SCOPE_STORE, $storeId, true], ['sales/minimum_order/multi_address', ScopeInterface::SCOPE_STORE, $storeId, true], ['sales/minimum_order/amount', ScopeInterface::SCOPE_STORE, $storeId, 20], + ['sales/minimum_order/include_discount_amount', ScopeInterface::SCOPE_STORE, $storeId, true], ['sales/minimum_order/tax_including', ScopeInterface::SCOPE_STORE, $storeId, true], ]; $this->scopeConfig->expects($this->any()) @@ -977,6 +978,7 @@ public function testValidateMiniumumAmountNegative() ['sales/minimum_order/active', ScopeInterface::SCOPE_STORE, $storeId, true], ['sales/minimum_order/multi_address', ScopeInterface::SCOPE_STORE, $storeId, true], ['sales/minimum_order/amount', ScopeInterface::SCOPE_STORE, $storeId, 20], + ['sales/minimum_order/include_discount_amount', ScopeInterface::SCOPE_STORE, $storeId, true], ['sales/minimum_order/tax_including', ScopeInterface::SCOPE_STORE, $storeId, true], ]; $this->scopeConfig->expects($this->any()) diff --git a/app/code/Magento/Quote/composer.json b/app/code/Magento/Quote/composer.json index 0d67eaaf6ec74..5391d7779b420 100644 --- a/app/code/Magento/Quote/composer.json +++ b/app/code/Magento/Quote/composer.json @@ -23,7 +23,7 @@ "magento/module-webapi": "100.2.*" }, "type": "magento2-module", - "version": "101.0.5", + "version": "101.0.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Quote/etc/fieldset.xml b/app/code/Magento/Quote/etc/fieldset.xml index 55ec76a647fcd..85ee20c7f8520 100644 --- a/app/code/Magento/Quote/etc/fieldset.xml +++ b/app/code/Magento/Quote/etc/fieldset.xml @@ -186,6 +186,11 @@ <aspect name="to_order_address" /> </field> </fieldset> + <fieldset id="quote_convert_address_item"> + <field name="quote_item_id"> + <aspect name="to_order_item" /> + </field> + </fieldset> <fieldset id="quote_convert_item"> <field name="sku"> <aspect name="to_order_item" /> diff --git a/app/code/Magento/QuoteAnalytics/composer.json b/app/code/Magento/QuoteAnalytics/composer.json index 65978de6d0785..bc43c38421650 100644 --- a/app/code/Magento/QuoteAnalytics/composer.json +++ b/app/code/Magento/QuoteAnalytics/composer.json @@ -7,7 +7,7 @@ "magento/module-quote": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/ReleaseNotification/composer.json b/app/code/Magento/ReleaseNotification/composer.json index 45452997757cd..d16734d45f048 100644 --- a/app/code/Magento/ReleaseNotification/composer.json +++ b/app/code/Magento/ReleaseNotification/composer.json @@ -8,7 +8,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Reports/Block/Adminhtml/Sales/Tax/Grid.php b/app/code/Magento/Reports/Block/Adminhtml/Sales/Tax/Grid.php index 79deb27423be5..7ca6b803f5b2b 100644 --- a/app/code/Magento/Reports/Block/Adminhtml/Sales/Tax/Grid.php +++ b/app/code/Magento/Reports/Block/Adminhtml/Sales/Tax/Grid.php @@ -123,7 +123,6 @@ protected function _prepareColumns() [ 'header' => __('Orders'), 'index' => 'orders_count', - 'total' => 'sum', 'type' => 'number', 'sortable' => false, 'header_css_class' => 'col-qty', diff --git a/app/code/Magento/Reports/Model/ResourceModel/Order/Collection.php b/app/code/Magento/Reports/Model/ResourceModel/Order/Collection.php index 82ebc74a0468e..fd9adbe734101 100644 --- a/app/code/Magento/Reports/Model/ResourceModel/Order/Collection.php +++ b/app/code/Magento/Reports/Model/ResourceModel/Order/Collection.php @@ -3,7 +3,6 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ - namespace Magento\Reports\Model\ResourceModel\Order; use Magento\Framework\DB\Select; @@ -81,7 +80,7 @@ class Collection extends \Magento\Sales\Model\ResourceModel\Order\Collection * @param \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate * @param \Magento\Sales\Model\Order\Config $orderConfig * @param \Magento\Sales\Model\ResourceModel\Report\OrderFactory $reportOrderFactory - * @param null $connection + * @param \Magento\Framework\DB\Adapter\AdapterInterface $connection * @param \Magento\Framework\Model\ResourceModel\Db\AbstractDb $resource * * @SuppressWarnings(PHPMD.ExcessiveParameterList) @@ -825,7 +824,7 @@ protected function getTotalsExpression( ) { $template = ($storeId != 0) ? '(main_table.base_subtotal - %2$s - %1$s - ABS(main_table.base_discount_amount) - %3$s)' - : '((main_table.base_subtotal - %1$s - %2$s - ABS(main_table.base_discount_amount) - %3$s) ' + : '((main_table.base_subtotal - %1$s - %2$s - ABS(main_table.base_discount_amount) + %3$s) ' . ' * main_table.base_to_global_rate)'; return sprintf($template, $baseSubtotalRefunded, $baseSubtotalCanceled, $baseDiscountCanceled); } diff --git a/app/code/Magento/Reports/Test/Mftf/ActionGroup/AdminReviewOrderActionGroup.xml b/app/code/Magento/Reports/Test/Mftf/ActionGroup/AdminReviewOrderActionGroup.xml new file mode 100644 index 0000000000000..90ea72ce9dda9 --- /dev/null +++ b/app/code/Magento/Reports/Test/Mftf/ActionGroup/AdminReviewOrderActionGroup.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminReviewOrderActionGroup"> + <arguments> + <argument name="productName" type="string"/> + </arguments> + <click selector="{{AdminMenuSection.reports}}" stepKey="openReports"/> + <waitForPageLoad time="5" stepKey="waitForReports"/> + <click selector="{{AdminMenuSection.ordered}}" stepKey="openOrdered"/> + <waitForPageLoad time="5" stepKey="waitForOrdersPage"/> + <click selector="{{AdminOrderedProductsSection.refresh}}" stepKey="refresh"/> + <scrollTo selector="{{AdminOrderedProductsSection.total}}" stepKey="scrollTo"/> + <see userInput="{{productName}}" stepKey="seeOrderedProduct"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Reports/Test/Mftf/Page/AdminOrderedProductsPage.xml b/app/code/Magento/Reports/Test/Mftf/Page/AdminOrderedProductsPage.xml new file mode 100644 index 0000000000000..fc9784498cbb3 --- /dev/null +++ b/app/code/Magento/Reports/Test/Mftf/Page/AdminOrderedProductsPage.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminOrderedProductsPage" url="admin/reports/report_product/sold" module="Magento_Reports" area="admin"> + <section name="AdminOrderedProductsSection"/> + </page> +</pages> diff --git a/app/code/Magento/Reports/Test/Mftf/Page/AdminReportSalesTaxPage.xml b/app/code/Magento/Reports/Test/Mftf/Page/AdminReportSalesTaxPage.xml new file mode 100644 index 0000000000000..ff90be1ced389 --- /dev/null +++ b/app/code/Magento/Reports/Test/Mftf/Page/AdminReportSalesTaxPage.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminReportSalesTaxPage" url="reports/report_sales/tax/" module="Magento_Reports" area="admin"> + <section name="AdminReportMainActionSection"/> + <section name="AdminReportTaxSection"/> + </page> +</pages> diff --git a/app/code/Magento/Reports/Test/Mftf/Section/AdminMenuSection.xml b/app/code/Magento/Reports/Test/Mftf/Section/AdminMenuSection.xml new file mode 100644 index 0000000000000..b8f0f0750bbf4 --- /dev/null +++ b/app/code/Magento/Reports/Test/Mftf/Section/AdminMenuSection.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminMenuSection"> + <element name="reports" type="button" selector="#menu-magento-reports-report"/> + <element name="ordered" type="button" selector=".item-report-products-sold"/> + </section> +</sections> diff --git a/app/code/Magento/Reports/Test/Mftf/Section/AdminOrderedProductsSection.xml b/app/code/Magento/Reports/Test/Mftf/Section/AdminOrderedProductsSection.xml new file mode 100644 index 0000000000000..dd4e78f4ee3f9 --- /dev/null +++ b/app/code/Magento/Reports/Test/Mftf/Section/AdminOrderedProductsSection.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminOrderedProductsSection"> + <element name="refresh" type="button" selector="button[title='Refresh']" timeout="30"/> + <element name="total" type="text" selector="#gridProductsSold_table tfoot th.col-period"/> + </section> +</sections> diff --git a/app/code/Magento/Reports/Test/Mftf/Section/AdminReportMainActionSection.xml b/app/code/Magento/Reports/Test/Mftf/Section/AdminReportMainActionSection.xml new file mode 100644 index 0000000000000..207e508b6b020 --- /dev/null +++ b/app/code/Magento/Reports/Test/Mftf/Section/AdminReportMainActionSection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminReportMainActionSection"> + <element name="showReport" type="button" selector="#filter_form_submit" timeout="30"/> + </section> +</sections> diff --git a/app/code/Magento/Reports/Test/Mftf/Section/AdminReportTaxSection.xml b/app/code/Magento/Reports/Test/Mftf/Section/AdminReportTaxSection.xml new file mode 100644 index 0000000000000..a9583d8772688 --- /dev/null +++ b/app/code/Magento/Reports/Test/Mftf/Section/AdminReportTaxSection.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminReportTaxSection"> + <element name="refreshStatistics" type="button" selector="//*[@id='messages']//a[text()='here']" timeout="30"/> + <element name="dataPickerFrom" type="button" selector="#sales_report_from + button.ui-datepicker-trigger"/> + <element name="dataPickerTo" type="button" selector="#sales_report_to + button.ui-datepicker-trigger"/> + <element name="goTodayButton" type="button" selector="#ui-datepicker-div [data-handler='today']"/> + <element name="closeButton" type="button" selector="#ui-datepicker-div [data-handler='hide']"/> + <element name="row" type="button" selector="//td[contains(text(),'{{var1}}')]/..//td[last()]" parameterized="true"/> + </section> +</sections> diff --git a/app/code/Magento/Reports/Test/Mftf/Test/AdminTaxReportGridTest.xml b/app/code/Magento/Reports/Test/Mftf/Test/AdminTaxReportGridTest.xml new file mode 100644 index 0000000000000..073b4ce6fb90c --- /dev/null +++ b/app/code/Magento/Reports/Test/Mftf/Test/AdminTaxReportGridTest.xml @@ -0,0 +1,130 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminTaxReportGridTest"> + <annotations> + <stories value="MAGETWO-86649: Reports / Sales / Tax report show incorrect amount"/> + <title value="Checking Tax Report grid"/> + <description value="Checking Tax Report grid with tax rates and same zip code"/> + <severity value="MAJOR"/> + <testCaseId value="MAGETWO-97018"/> + <group value="tax_report"/> + <group value="reports"/> + </annotations> + <before> + <!--Log in as Admin--> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <!--Create Customer--> + <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + <!--Create Product--> + <createData entity="SimpleSubCategory" stepKey="createCategory"/> + <createData entity="SimpleProduct" stepKey="createSimpleProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <!--Create product Tax Class and Tax Rates--> + <createData entity="ProductTaxClass" stepKey="createProductTaxClass"/> + <createData entity="TexasTaxRate" stepKey="createStateTaxRate"/> + <createData entity="AustinTaxRate" stepKey="createCityTaxRate"/> + <!--Get Data from product Tax Class--> + <getData entity="ProductTaxClassGetter" stepKey="getProductTaxClass"> + <requiredEntity createDataKey="createProductTaxClass"/> + </getData> + </before> + <after> + <!--Delete Tax Rules--> + <actionGroup ref="DeleteTaxRule" stepKey="deleteCityTaxRule"> + <argument name="taxRuleName" value="CityTaxRule"/> + </actionGroup> + <actionGroup ref="DeleteTaxRule" stepKey="deleteStateTaxRule"> + <argument name="taxRuleName" value="StateTaxRule"/> + </actionGroup> + <!--Delete Customer--> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <!--Delete Product--> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createSimpleProduct" stepKey="deleteProduct"/> + <!--Delete Tax Rates and product Tax Class--> + <deleteData createDataKey="createStateTaxRate" stepKey="deleteTaxClass1"/> + <deleteData createDataKey="createCityTaxRate" stepKey="deleteTaxClass2"/> + <deleteData createDataKey="createProductTaxClass" stepKey="deleteProductTaxClass"/> + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <!--Adding Tax Rule with all (*) zip code--> + <actionGroup ref="AddTaxRule" stepKey="addStateTaxRule"> + <argument name="taxRuleName" value="StateTaxRule"/> + <argument name="taxRate" value="$$createStateTaxRate$$"/> + <argument name="productTaxClass" value="$$getProductTaxClass$$"/> + </actionGroup> + <!--Adding Tax Rule with zip code--> + <actionGroup ref="AddTaxRule" stepKey="addCityTaxRule"> + <argument name="taxRuleName" value="CityTaxRule"/> + <argument name="taxRate" value="$$createCityTaxRate$$"/> + <argument name="productTaxClass" value="$$getProductTaxClass$$"/> + </actionGroup> + <!-- Open Product Edit --> + <amOnPage url="{{AdminProductEditPage.url($$createSimpleProduct.id$$)}}" stepKey="goToEditPage"/> + <selectOption selector="{{AdminProductFormSection.productTaxClass}}" userInput="$$getProductTaxClass.class_name$$" stepKey="selectCustomTaxClass"/> + <actionGroup ref="saveProductForm" stepKey="saveProduct"/> + <!--Create new order with existing Customer --> + <actionGroup ref="navigateToNewOrderPageExistingCustomer" stepKey="createNewOrder"> + <argument name="customer" value="$$createCustomer$$"/> + </actionGroup> + <!--Add product to order--> + <actionGroup ref="addSimpleProductToOrder" stepKey="addSimpleProductToOrder"> + <argument name="product" value="$$createSimpleProduct$$"/> + </actionGroup> + <!--Select shipping--> + <actionGroup ref="orderSelectFlatRateShipping" stepKey="selectFlatRateShipping"/> + <!--Select payment--> + <actionGroup ref="SelectCheckMoneyPaymentMethod" stepKey="selectCheckMoneyPayment"/> + <!--Submit order--> + <click selector="{{AdminOrderFormActionSection.submitOrder}}" stepKey="clickSubmitOrder"/> + <see selector="{{AdminMessagesSection.success}}" userInput="You created the order." stepKey="seeOrderSuccessMessage"/> + <!--Create and submit Invoice--> + <actionGroup ref="StartCreateInvoiceFromOrderPage" stepKey="createInvoice"/> + <actionGroup ref="SubmitInvoice" stepKey="submitInvoice"/> + <!--Create and submit Shipment --> + <actionGroup ref="StartCreateShipmentFromOrderPage" stepKey="createShipment"/> + <actionGroup ref="SubmitShipment" stepKey="submitShipment"/> + <!--Go to Report -> Tax --> + <amOnPage url="{{AdminReportSalesTaxPage.url}}" stepKey="goToReportTax"/> + <click selector="{{AdminReportTaxSection.refreshStatistics}}" stepKey="clickRefreshStatistics"/> + <see selector="{{AdminMessagesSection.success}}" userInput="Recent statistics have been updated." stepKey="seeReportSuccessMessage"/> + <!--Select Date From--> + <click selector="{{AdminReportTaxSection.dataPickerFrom}}" stepKey="clickOnDatePickerFrom"/> + <waitForElementVisible selector="{{AdminReportTaxSection.goTodayButton}}" stepKey="waitForGoTodayButton1"/> + <click selector="{{AdminReportTaxSection.goTodayButton}}" stepKey="selectToday1"/> + <click selector="{{AdminReportTaxSection.closeButton}}" stepKey="selectClose1"/> + <!--Select Date To--> + <click selector="{{AdminReportTaxSection.dataPickerTo}}" stepKey="clickOnDatePickerTo"/> + <waitForElementVisible selector="{{AdminReportTaxSection.goTodayButton}}" stepKey="waitForGoTodayButton2"/> + <click selector="{{AdminReportTaxSection.goTodayButton}}" stepKey="selectToday2"/> + <click selector="{{AdminReportTaxSection.closeButton}}" stepKey="selectClose2"/> + <click selector="{{AdminReportMainActionSection.showReport}}" stepKey="clickOnShowReportButton"/> + <!--Assert taxes--> + <grabTextFrom selector="{{AdminReportTaxSection.row($$createStateTaxRate.code$$)}}" stepKey="grabStateTax"/> + <assertEquals stepKey="checkStateTaxForProduct"> + <expectedResult type="string">$2.00</expectedResult> + <actualResult type="variable">$grabStateTax</actualResult> + </assertEquals> + <grabTextFrom selector="{{AdminReportTaxSection.row($$createCityTaxRate.code$$)}}" stepKey="grabCityTax"/> + <assertEquals stepKey="checkCityTaxForProduct"> + <expectedResult type="string">$10.00</expectedResult> + <actualResult type="variable">$grabCityTax</actualResult> + </assertEquals> + <!--Assert total--> + <grabTextFrom selector="{{AdminReportTaxSection.row('Subtotal')}}" stepKey="grabTotalTax"/> + <assertEquals stepKey="checkTotalTaxForProduct"> + <expectedResult type="string">$12.00</expectedResult> + <actualResult type="variable">$grabTotalTax</actualResult> + </assertEquals> + </test> +</tests> diff --git a/app/code/Magento/Reports/composer.json b/app/code/Magento/Reports/composer.json index 6b5a43f040109..1abd03339a22a 100644 --- a/app/code/Magento/Reports/composer.json +++ b/app/code/Magento/Reports/composer.json @@ -22,7 +22,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.6", + "version": "100.2.7", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/RequireJs/composer.json b/app/code/Magento/RequireJs/composer.json index 4c6afcd77b4fa..5c947c8d8e85d 100644 --- a/app/code/Magento/RequireJs/composer.json +++ b/app/code/Magento/RequireJs/composer.json @@ -6,7 +6,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Review/Block/Product/ReviewRenderer.php b/app/code/Magento/Review/Block/Product/ReviewRenderer.php index 3cd15aba30420..3183196ebf30c 100644 --- a/app/code/Magento/Review/Block/Product/ReviewRenderer.php +++ b/app/code/Magento/Review/Block/Product/ReviewRenderer.php @@ -9,7 +9,11 @@ use Magento\Catalog\Block\Product\ReviewRendererInterface; use Magento\Catalog\Model\Product; +use Magento\Review\Observer\PredispatchReviewObserver; +/** + * Class ReviewRenderer + */ class ReviewRenderer extends \Magento\Framework\View\Element\Template implements ReviewRendererInterface { /** @@ -43,6 +47,19 @@ public function __construct( parent::__construct($context, $data); } + /** + * Review module availability + * + * @return string + */ + public function isReviewEnabled() : string + { + return $this->_scopeConfig->getValue( + PredispatchReviewObserver::XML_PATH_REVIEW_ACTIVE, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + } + /** * Get review summary html * diff --git a/app/code/Magento/Review/Model/ResourceModel/Rating.php b/app/code/Magento/Review/Model/ResourceModel/Rating.php index 3f54c17f6ff7c..5567c21ba12ee 100644 --- a/app/code/Magento/Review/Model/ResourceModel/Rating.php +++ b/app/code/Magento/Review/Model/ResourceModel/Rating.php @@ -5,6 +5,9 @@ */ namespace Magento\Review\Model\ResourceModel; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\App\ObjectManager; + /** * Rating resource model * @@ -12,6 +15,7 @@ * * @author Magento Core Team <core@magentocommerce.com> * @since 100.0.2 + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class Rating extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb { @@ -34,13 +38,19 @@ class Rating extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb */ protected $_logger; + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + /** * @param \Magento\Framework\Model\ResourceModel\Db\Context $context * @param \Psr\Log\LoggerInterface $logger * @param \Magento\Framework\Module\Manager $moduleManager * @param \Magento\Store\Model\StoreManagerInterface $storeManager - * @param \Magento\Review\Model\ResourceModel\Review\Summary $reviewSummary + * @param Review\Summary $reviewSummary * @param string $connectionName + * @param ScopeConfigInterface|null $scopeConfig */ public function __construct( \Magento\Framework\Model\ResourceModel\Db\Context $context, @@ -48,12 +58,14 @@ public function __construct( \Magento\Framework\Module\Manager $moduleManager, \Magento\Store\Model\StoreManagerInterface $storeManager, \Magento\Review\Model\ResourceModel\Review\Summary $reviewSummary, - $connectionName = null + $connectionName = null, + ScopeConfigInterface $scopeConfig = null ) { $this->moduleManager = $moduleManager; $this->_storeManager = $storeManager; $this->_logger = $logger; $this->_reviewSummary = $reviewSummary; + $this->scopeConfig = $scopeConfig ?: ObjectManager::getInstance()->get(ScopeConfigInterface::class); parent::__construct($context, $connectionName); } @@ -178,6 +190,8 @@ protected function _afterSave(\Magento\Framework\Model\AbstractModel $object) } /** + * Process rating codes. + * * @param \Magento\Framework\Model\AbstractModel $object * @return $this */ @@ -201,6 +215,8 @@ protected function processRatingCodes(\Magento\Framework\Model\AbstractModel $ob } /** + * Process rating stores. + * * @param \Magento\Framework\Model\AbstractModel $object * @return $this */ @@ -224,6 +240,8 @@ protected function processRatingStores(\Magento\Framework\Model\AbstractModel $o } /** + * Delete rating data. + * * @param int $ratingId * @param string $table * @param array $storeIds @@ -247,6 +265,8 @@ protected function deleteRatingData($ratingId, $table, array $storeIds) } /** + * Insert rating data. + * * @param string $table * @param array $data * @return void @@ -269,6 +289,7 @@ protected function insertRatingData($table, array $data) /** * Perform actions after object delete + * * Prepare rating data for reaggregate all data for reviews * * @param \Magento\Framework\Model\AbstractModel $object @@ -277,7 +298,12 @@ protected function insertRatingData($table, array $data) protected function _afterDelete(\Magento\Framework\Model\AbstractModel $object) { parent::_afterDelete($object); - if (!$this->moduleManager->isEnabled('Magento_Review')) { + if (!$this->moduleManager->isEnabled('Magento_Review') && + !$this->scopeConfig->getValue( + \Magento\Review\Observer\PredispatchReviewObserver::XML_PATH_REVIEW_ACTIVE, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ) + ) { return $this; } $data = $this->_getEntitySummaryData($object); @@ -425,9 +451,11 @@ public function getReviewSummary($object, $onlyForCurrentStore = true) $data = $connection->fetchAll($select, [':review_id' => $object->getReviewId()]); + $currentStore = $this->_storeManager->isSingleStoreMode() ? $this->_storeManager->getStore()->getId() : null; + if ($onlyForCurrentStore) { foreach ($data as $row) { - if ($row['store_id'] == $this->_storeManager->getStore()->getId()) { + if ($row['store_id'] !== $currentStore) { $object->addData($row); } } diff --git a/app/code/Magento/Review/Observer/PredispatchReviewObserver.php b/app/code/Magento/Review/Observer/PredispatchReviewObserver.php new file mode 100644 index 0000000000000..bdca0f5ecb1ec --- /dev/null +++ b/app/code/Magento/Review/Observer/PredispatchReviewObserver.php @@ -0,0 +1,72 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types = 1); + +namespace Magento\Review\Observer; + +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\Event\Observer; +use Magento\Framework\Event\ObserverInterface; +use Magento\Framework\UrlInterface; +use Magento\Review\Block\Product\ReviewRenderer; +use Magento\Store\Model\ScopeInterface; + +/** + * Class PredispatchReviewObserver + */ +class PredispatchReviewObserver implements ObserverInterface +{ + /** + * Configuration path to review active setting + */ + const XML_PATH_REVIEW_ACTIVE = 'catalog/review/active'; + + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + + /** + * @var UrlInterface + */ + private $url; + + /** + * PredispatchReviewObserver constructor. + * + * @param ScopeConfigInterface $scopeConfig + * @param UrlInterface $url + */ + public function __construct( + ScopeConfigInterface $scopeConfig, + UrlInterface $url + ) { + $this->scopeConfig = $scopeConfig; + $this->url = $url; + } + /** + * Redirect review routes to 404 when review module is disabled. + * + * @param Observer $observer + */ + public function execute(Observer $observer) + { + if (!$this->scopeConfig->getValue( + self::XML_PATH_REVIEW_ACTIVE, + ScopeInterface::SCOPE_STORE + ) + ) { + $defaultNoRouteUrl = $this->scopeConfig->getValue( + 'web/default/no_route', + ScopeInterface::SCOPE_STORE + ); + $redirectUrl = $this->url->getUrl($defaultNoRouteUrl); + $observer->getControllerAction() + ->getResponse() + ->setRedirect($redirectUrl); + } + } +} diff --git a/app/code/Magento/Review/Test/Unit/Observer/PredispatchReviewObserverTest.php b/app/code/Magento/Review/Test/Unit/Observer/PredispatchReviewObserverTest.php new file mode 100644 index 0000000000000..2a8f8d8e38a64 --- /dev/null +++ b/app/code/Magento/Review/Test/Unit/Observer/PredispatchReviewObserverTest.php @@ -0,0 +1,147 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types = 1); + +namespace Magento\Review\Test\Unit\Observer; + +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\App\Response\RedirectInterface; +use Magento\Framework\App\ResponseInterface; +use Magento\Framework\Event\Observer; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Framework\UrlInterface; +use Magento\Review\Observer\PredispatchReviewObserver; +use Magento\Store\Model\ScopeInterface; +use PHPUnit\Framework\TestCase; + +/** + * Test class for \Magento\Review\Observer\PredispatchReviewObserver + */ +class PredispatchReviewObserverTest extends TestCase +{ + /** + * @var Observer|\PHPUnit_Framework_MockObject_MockObject + */ + private $mockObject; + + /** + * @var ScopeConfigInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $configMock; + + /** + * @var UrlInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $urlMock; + + /** + * @var \Magento\Framework\App\Response\RedirectInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $redirectMock; + + /** + * @var ResponseInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $responseMock; + + /** + * @var ObjectManager + */ + private $objectManager; + + /** + * @inheritdoc + */ + protected function setUp() + { + $this->configMock = $this->getMockBuilder(ScopeConfigInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->urlMock = $this->getMockBuilder(UrlInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->responseMock = $this->getMockBuilder(ResponseInterface::class) + ->disableOriginalConstructor() + ->setMethods(['setRedirect']) + ->getMockForAbstractClass(); + $this->redirectMock = $this->getMockBuilder(RedirectInterface::class) + ->getMock(); + $this->objectManager = new ObjectManager($this); + $this->mockObject = $this->objectManager->getObject( + PredispatchReviewObserver::class, + [ + 'scopeConfig' => $this->configMock, + 'url' => $this->urlMock + ] + ); + } + + /** + * Test with enabled review active config. + */ + public function testReviewEnabled() + { + $observerMock = $this->getMockBuilder(Observer::class) + ->disableOriginalConstructor() + ->setMethods(['getResponse', 'getData', 'setRedirect']) + ->getMockForAbstractClass(); + + $this->configMock->method('getValue') + ->with(PredispatchReviewObserver::XML_PATH_REVIEW_ACTIVE, ScopeInterface::SCOPE_STORE) + ->willReturn(true); + $observerMock->expects($this->never()) + ->method('getData') + ->with('controller_action') + ->willReturnSelf(); + + $observerMock->expects($this->never()) + ->method('getResponse') + ->willReturnSelf(); + + $this->assertNull($this->mockObject->execute($observerMock)); + } + + /** + * Test with disabled review active config. + */ + public function testReviewDisabled() + { + $observerMock = $this->getMockBuilder(Observer::class) + ->disableOriginalConstructor() + ->setMethods(['getControllerAction', 'getResponse']) + ->getMockForAbstractClass(); + + $this->configMock->expects($this->at(0)) + ->method('getValue') + ->with(PredispatchReviewObserver::XML_PATH_REVIEW_ACTIVE, ScopeInterface::SCOPE_STORE) + ->willReturn(false); + + $expectedRedirectUrl = 'https://test.com/index'; + + $this->configMock->expects($this->at(1)) + ->method('getValue') + ->with('web/default/no_route', ScopeInterface::SCOPE_STORE) + ->willReturn($expectedRedirectUrl); + + $this->urlMock->expects($this->once()) + ->method('getUrl') + ->willReturn($expectedRedirectUrl); + + $observerMock->expects($this->once()) + ->method('getControllerAction') + ->willReturnSelf(); + + $observerMock->expects($this->once()) + ->method('getResponse') + ->willReturn($this->responseMock); + + $this->responseMock->expects($this->once()) + ->method('setRedirect') + ->with($expectedRedirectUrl); + + $this->assertNull($this->mockObject->execute($observerMock)); + } +} diff --git a/app/code/Magento/Review/composer.json b/app/code/Magento/Review/composer.json index c6c8c920ed138..c1d687c665199 100644 --- a/app/code/Magento/Review/composer.json +++ b/app/code/Magento/Review/composer.json @@ -18,7 +18,7 @@ "magento/module-review-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "100.2.6", + "version": "100.2.7", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Review/etc/adminhtml/system.xml b/app/code/Magento/Review/etc/adminhtml/system.xml index c0574e9491782..a24ed29dc2c23 100644 --- a/app/code/Magento/Review/etc/adminhtml/system.xml +++ b/app/code/Magento/Review/etc/adminhtml/system.xml @@ -10,7 +10,11 @@ <section id="catalog"> <group id="review" translate="label" type="text" sortOrder="100" showInDefault="1" showInWebsite="1" showInStore="0"> <label>Product Reviews</label> - <field id="allow_guest" translate="label" type="select" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="0" canRestore="1"> + <field id="active" translate="label" type="select" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1"> + <label>Enabled</label> + <source_model>Magento\Config\Model\Config\Source\Yesno</source_model> + </field> + <field id="allow_guest" translate="label" type="select" sortOrder="2" showInDefault="1" showInWebsite="1" showInStore="0" canRestore="1"> <label>Allow Guests to Write Reviews</label> <source_model>Magento\Config\Model\Config\Source\Yesno</source_model> </field> diff --git a/app/code/Magento/Review/etc/config.xml b/app/code/Magento/Review/etc/config.xml index 78dc87960f090..9fd9443be67ef 100644 --- a/app/code/Magento/Review/etc/config.xml +++ b/app/code/Magento/Review/etc/config.xml @@ -9,6 +9,7 @@ <default> <catalog> <review> + <active>1</active> <allow_guest>1</allow_guest> </review> </catalog> diff --git a/app/code/Magento/Review/etc/frontend/events.xml b/app/code/Magento/Review/etc/frontend/events.xml index bc94277d69709..8e883ce328a2c 100644 --- a/app/code/Magento/Review/etc/frontend/events.xml +++ b/app/code/Magento/Review/etc/frontend/events.xml @@ -12,4 +12,7 @@ <event name="catalog_block_product_list_collection"> <observer name="review" instance="Magento\Review\Observer\CatalogBlockProductCollectionBeforeToHtmlObserver" shared="false" /> </event> + <event name="controller_action_predispatch_review"> + <observer name="catalog_review_enabled" instance="Magento\Review\Observer\PredispatchReviewObserver" /> + </event> </config> diff --git a/app/code/Magento/Review/view/frontend/layout/catalog_product_view.xml b/app/code/Magento/Review/view/frontend/layout/catalog_product_view.xml index d7c5c19d4d813..6fcf5b0c82b4f 100644 --- a/app/code/Magento/Review/view/frontend/layout/catalog_product_view.xml +++ b/app/code/Magento/Review/view/frontend/layout/catalog_product_view.xml @@ -9,7 +9,7 @@ <update handle="review_product_form_component"/> <body> <referenceContainer name="content"> - <block class="Magento\Cookie\Block\RequireCookie" name="require-cookie" template="Magento_Cookie::require_cookie.phtml"> + <block class="Magento\Cookie\Block\RequireCookie" name="require-cookie" template="Magento_Cookie::require_cookie.phtml" ifconfig="catalog/review/active"> <arguments> <argument name="triggers" xsi:type="array"> <item name="submitReviewButton" xsi:type="string">.review .action.submit</item> @@ -18,8 +18,8 @@ </block> </referenceContainer> <referenceBlock name="product.info.details"> - <block class="Magento\Review\Block\Product\Review" name="reviews.tab" as="reviews" template="Magento_Review::review.phtml" group="detailed_info"> - <block class="Magento\Review\Block\Form" name="product.review.form" as="review_form"> + <block class="Magento\Review\Block\Product\Review" name="reviews.tab" as="reviews" template="Magento_Review::review.phtml" group="detailed_info" ifconfig="catalog/review/active"> + <block class="Magento\Review\Block\Form" name="product.review.form" as="review_form" ifconfig="catalog/review/active"> <container name="product.review.form.fields.before" as="form_fields_before" label="Review Form Fields Before"/> </block> </block> diff --git a/app/code/Magento/Review/view/frontend/layout/checkout_cart_configure.xml b/app/code/Magento/Review/view/frontend/layout/checkout_cart_configure.xml index 0a7ddd8b8903d..8a853cdd2e409 100644 --- a/app/code/Magento/Review/view/frontend/layout/checkout_cart_configure.xml +++ b/app/code/Magento/Review/view/frontend/layout/checkout_cart_configure.xml @@ -9,7 +9,7 @@ <update handle="catalog_product_view"/> <body> <referenceBlock name="reviews.tab"> - <block class="Magento\Review\Block\Form\Configure" name="product.review.form" as="review_form"> + <block class="Magento\Review\Block\Form\Configure" name="product.review.form" as="review_form" ifconfig="catalog/review/active"> <arguments> <argument name="jsLayout" xsi:type="array"> <item name="components" xsi:type="array"> diff --git a/app/code/Magento/Review/view/frontend/layout/customer_account.xml b/app/code/Magento/Review/view/frontend/layout/customer_account.xml index 54d171cbf1322..9f759dba41782 100644 --- a/app/code/Magento/Review/view/frontend/layout/customer_account.xml +++ b/app/code/Magento/Review/view/frontend/layout/customer_account.xml @@ -8,7 +8,7 @@ <page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> <body> <referenceBlock name="customer_account_navigation"> - <block class="Magento\Customer\Block\Account\SortLinkInterface" name="customer-account-navigation-product-reviews-link"> + <block class="Magento\Customer\Block\Account\SortLinkInterface" name="customer-account-navigation-product-reviews-link" ifconfig="catalog/review/active"> <arguments> <argument name="path" xsi:type="string">review/customer</argument> <argument name="label" xsi:type="string" translate="true">My Product Reviews</argument> diff --git a/app/code/Magento/Review/view/frontend/layout/customer_account_index.xml b/app/code/Magento/Review/view/frontend/layout/customer_account_index.xml index 73174f0570e28..2e898a539a954 100644 --- a/app/code/Magento/Review/view/frontend/layout/customer_account_index.xml +++ b/app/code/Magento/Review/view/frontend/layout/customer_account_index.xml @@ -8,7 +8,7 @@ <page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> <body> <referenceContainer name="content"> - <block class="Magento\Review\Block\Customer\Recent" name="customer_account_dashboard_info1" template="Magento_Review::customer/recent.phtml" after="customer_account_dashboard_address" cacheable="false"/> + <block class="Magento\Review\Block\Customer\Recent" name="customer_account_dashboard_info1" template="Magento_Review::customer/recent.phtml" after="customer_account_dashboard_address" cacheable="false" ifconfig="catalog/review/active"/> </referenceContainer> </body> </page> diff --git a/app/code/Magento/Review/view/frontend/layout/review_customer_index.xml b/app/code/Magento/Review/view/frontend/layout/review_customer_index.xml index 2857e859aa06c..b5f7562963314 100644 --- a/app/code/Magento/Review/view/frontend/layout/review_customer_index.xml +++ b/app/code/Magento/Review/view/frontend/layout/review_customer_index.xml @@ -9,7 +9,7 @@ <update handle="customer_account"/> <body> <referenceContainer name="content"> - <block class="Magento\Review\Block\Customer\ListCustomer" name="review_customer_list" template="Magento_Review::customer/list.phtml" cacheable="false"/> + <block class="Magento\Review\Block\Customer\ListCustomer" name="review_customer_list" template="Magento_Review::customer/list.phtml" cacheable="false" ifconfig="catalog/review/active"/> </referenceContainer> </body> </page> diff --git a/app/code/Magento/Review/view/frontend/layout/review_customer_view.xml b/app/code/Magento/Review/view/frontend/layout/review_customer_view.xml index d51c89a1abe1a..d3adbd7950cf9 100644 --- a/app/code/Magento/Review/view/frontend/layout/review_customer_view.xml +++ b/app/code/Magento/Review/view/frontend/layout/review_customer_view.xml @@ -9,7 +9,7 @@ <update handle="customer_account"/> <body> <referenceContainer name="content"> - <block class="Magento\Review\Block\Customer\View" name="customers_review" cacheable="false"/> + <block class="Magento\Review\Block\Customer\View" name="customers_review" cacheable="false" ifconfig="catalog/review/active"/> </referenceContainer> </body> </page> diff --git a/app/code/Magento/Review/view/frontend/layout/review_product_list.xml b/app/code/Magento/Review/view/frontend/layout/review_product_list.xml index c83cfe95d7964..8c5c1297cdda3 100644 --- a/app/code/Magento/Review/view/frontend/layout/review_product_list.xml +++ b/app/code/Magento/Review/view/frontend/layout/review_product_list.xml @@ -9,15 +9,15 @@ <update handle="catalog_product_view"/> <body> <referenceContainer name="product.info.main"> - <block class="Magento\Review\Block\Product\View\Other" name="product.info.other" as="other" template="Magento_Review::product/view/other.phtml" before="product.info.addto"/> + <block class="Magento\Review\Block\Product\View\Other" name="product.info.other" as="other" template="Magento_Review::product/view/other.phtml" before="product.info.addto" ifconfig="catalog/review/active"/> </referenceContainer> <referenceContainer name="content"> <container name="product.info.details" htmlTag="div" htmlClass="product info detailed" after="product.info.media"> - <block class="Magento\Review\Block\Form" name="product.review.form" as="review_form"> + <block class="Magento\Review\Block\Form" name="product.review.form" as="review_form" ifconfig="catalog/review/active"> <container name="product.review.form.fields.before" as="form_fields_before" label="Review Form Fields Before" htmlTag="div" htmlClass="rewards"/> </block> - <block class="Magento\Review\Block\Product\View\ListView" name="product.info.product_additional_data" as="product_additional_data" template="Magento_Review::product/view/list.phtml"/> - <block class="Magento\Theme\Block\Html\Pager" name="product_review_list.toolbar"/> + <block class="Magento\Review\Block\Product\View\ListView" name="product.info.product_additional_data" as="product_additional_data" template="Magento_Review::product/view/list.phtml" ifconfig="catalog/review/active"/> + <block class="Magento\Theme\Block\Html\Pager" name="product_review_list.toolbar" ifconfig="catalog/review/active"/> </container> </referenceContainer> </body> diff --git a/app/code/Magento/Review/view/frontend/layout/review_product_listajax.xml b/app/code/Magento/Review/view/frontend/layout/review_product_listajax.xml index af8d2dc2f506f..36fa71ea5125a 100644 --- a/app/code/Magento/Review/view/frontend/layout/review_product_listajax.xml +++ b/app/code/Magento/Review/view/frontend/layout/review_product_listajax.xml @@ -7,8 +7,8 @@ --> <layout xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/layout_generic.xsd"> <container name="root"> - <block class="Magento\Review\Block\Product\View\ListView" name="product.info.product_additional_data" as="product_additional_data" template="Magento_Review::product/view/list.phtml" /> - <block class="Magento\Theme\Block\Html\Pager" name="product_review_list.toolbar"> + <block class="Magento\Review\Block\Product\View\ListView" name="product.info.product_additional_data" as="product_additional_data" template="Magento_Review::product/view/list.phtml" ifconfig="catalog/review/active"/> + <block class="Magento\Theme\Block\Html\Pager" name="product_review_list.toolbar" ifconfig="catalog/review/active"> <arguments> <argument name="show_per_page" xsi:type="boolean">false</argument> <argument name="show_amounts" xsi:type="boolean">false</argument> diff --git a/app/code/Magento/Review/view/frontend/layout/review_product_view.xml b/app/code/Magento/Review/view/frontend/layout/review_product_view.xml index b70aec3f00b68..3bfc98cad9736 100644 --- a/app/code/Magento/Review/view/frontend/layout/review_product_view.xml +++ b/app/code/Magento/Review/view/frontend/layout/review_product_view.xml @@ -8,7 +8,7 @@ <page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" layout="1column" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> <body> <referenceContainer name="content"> - <block class="Magento\Review\Block\View" name="review_view"/> + <block class="Magento\Review\Block\View" name="review_view" ifconfig="catalog/review/active"/> </referenceContainer> </body> </page> diff --git a/app/code/Magento/Review/view/frontend/layout/wishlist_index_configure.xml b/app/code/Magento/Review/view/frontend/layout/wishlist_index_configure.xml index 0a7ddd8b8903d..8a853cdd2e409 100644 --- a/app/code/Magento/Review/view/frontend/layout/wishlist_index_configure.xml +++ b/app/code/Magento/Review/view/frontend/layout/wishlist_index_configure.xml @@ -9,7 +9,7 @@ <update handle="catalog_product_view"/> <body> <referenceBlock name="reviews.tab"> - <block class="Magento\Review\Block\Form\Configure" name="product.review.form" as="review_form"> + <block class="Magento\Review\Block\Form\Configure" name="product.review.form" as="review_form" ifconfig="catalog/review/active"> <arguments> <argument name="jsLayout" xsi:type="array"> <item name="components" xsi:type="array"> diff --git a/app/code/Magento/Review/view/frontend/templates/helper/summary.phtml b/app/code/Magento/Review/view/frontend/templates/helper/summary.phtml index da689960dfe54..23cb6699aeb21 100644 --- a/app/code/Magento/Review/view/frontend/templates/helper/summary.phtml +++ b/app/code/Magento/Review/view/frontend/templates/helper/summary.phtml @@ -11,7 +11,7 @@ $url = $block->getReviewsUrl() . '#reviews'; $urlForm = $block->getReviewsUrl() . '#review-form'; ?> -<?php if ($block->getReviewsCount()): ?> +<?php if ($block->isReviewEnabled() && $block->getReviewsCount()): ?> <?php $rating = $block->getRatingSummary(); ?> <div class="product-reviews-summary<?= !$rating ? ' no-rating' : '' ?>" itemprop="aggregateRating" itemscope itemtype="http://schema.org/AggregateRating"> <?php if ($rating):?> @@ -35,7 +35,7 @@ $urlForm = $block->getReviewsUrl() . '#review-form'; <a class="action add" href="<?= $block->escapeUrl($urlForm) ?>"><?= $block->escapeHtml(__('Add Your Review')) ?></a> </div> </div> -<?php elseif ($block->getDisplayIfEmpty()): ?> +<?php elseif ($block->isReviewEnabled() && $block->getDisplayIfEmpty()): ?> <div class="product-reviews-summary empty"> <div class="reviews-actions"> <a class="action add" href="<?= $block->escapeUrl($urlForm) ?>"> diff --git a/app/code/Magento/Review/view/frontend/templates/helper/summary_short.phtml b/app/code/Magento/Review/view/frontend/templates/helper/summary_short.phtml index c3eb11f03fd7d..a3ff56505f06f 100644 --- a/app/code/Magento/Review/view/frontend/templates/helper/summary_short.phtml +++ b/app/code/Magento/Review/view/frontend/templates/helper/summary_short.phtml @@ -11,7 +11,7 @@ $url = $block->getReviewsUrl() . '#reviews'; $urlForm = $block->getReviewsUrl() . '#review-form'; ?> -<?php if ($block->getReviewsCount()): ?> +<?php if ($block->isReviewEnabled() && $block->getReviewsCount()): ?> <?php $rating = $block->getRatingSummary(); ?> <div class="product-reviews-summary short<?= !$rating ? ' no-rating' : '' ?>"> <?php if ($rating):?> @@ -26,7 +26,7 @@ $urlForm = $block->getReviewsUrl() . '#review-form'; <a class="action view" href="<?= $block->escapeUrl($url) ?>"><?= $block->escapeHtml($block->getReviewsCount()) ?> <span><?= ($block->getReviewsCount() == 1) ? $block->escapeHtml(__('Review')) : $block->escapeHtml(__('Reviews')) ?></span></a> </div> </div> -<?php elseif ($block->getDisplayIfEmpty()): ?> +<?php elseif ($block->isReviewEnabled() && $block->getDisplayIfEmpty()): ?> <div class="product-reviews-summary short empty"> <div class="reviews-actions"> <a class="action add" href="<?= $block->escapeUrl($urlForm) ?>"> diff --git a/app/code/Magento/ReviewAnalytics/composer.json b/app/code/Magento/ReviewAnalytics/composer.json index 229b486e3f8d4..b95b473c0af70 100644 --- a/app/code/Magento/ReviewAnalytics/composer.json +++ b/app/code/Magento/ReviewAnalytics/composer.json @@ -7,7 +7,7 @@ "magento/module-review": "100.2.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Robots/composer.json b/app/code/Magento/Robots/composer.json index ccf8356419581..9cf847e152ae7 100644 --- a/app/code/Magento/Robots/composer.json +++ b/app/code/Magento/Robots/composer.json @@ -10,7 +10,7 @@ "magento/module-theme": "100.2.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Rss/composer.json b/app/code/Magento/Rss/composer.json index d75274856e793..0a8acfe4981e1 100644 --- a/app/code/Magento/Rss/composer.json +++ b/app/code/Magento/Rss/composer.json @@ -9,7 +9,7 @@ "magento/module-customer": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Rule/Model/Condition/Sql/Builder.php b/app/code/Magento/Rule/Model/Condition/Sql/Builder.php index e2118445b6de7..51d79363cecfe 100644 --- a/app/code/Magento/Rule/Model/Condition/Sql/Builder.php +++ b/app/code/Magento/Rule/Model/Condition/Sql/Builder.php @@ -32,9 +32,13 @@ class Builder '==' => ':field = ?', '!=' => ':field <> ?', '>=' => ':field >= ?', + '>=' => ':field >= ?', '>' => ':field > ?', + '>' => ':field > ?', '<=' => ':field <= ?', + '<=' => ':field <= ?', '<' => ':field < ?', + '<' => ':field < ?', '{}' => ':field IN (?)', '!{}' => ':field NOT IN (?)', '()' => ':field IN (?)', diff --git a/app/code/Magento/Rule/Test/Unit/Model/Condition/Sql/BuilderTest.php b/app/code/Magento/Rule/Test/Unit/Model/Condition/Sql/BuilderTest.php index 0a2767a94668a..7be94bf690e8b 100644 --- a/app/code/Magento/Rule/Test/Unit/Model/Condition/Sql/BuilderTest.php +++ b/app/code/Magento/Rule/Test/Unit/Model/Condition/Sql/BuilderTest.php @@ -72,4 +72,69 @@ public function testAttachConditionToCollection() $this->_builder->attachConditionToCollection($collection, $combine); } + + /** + * Test for attach condition to collection with operator in html format. + * + * @covers \Magento\Rule\Model\Condition\Sql\Builder::attachConditionToCollection() + * @return void + */ + public function testAttachConditionAsHtmlToCollection() + { + $abstractCondition = $this->getMockForAbstractClass( + \Magento\Rule\Model\Condition\AbstractCondition::class, + [], + '', + false, + false, + true, + ['getOperatorForValidate', 'getMappedSqlField', 'getAttribute', 'getBindArgumentValue'] + ); + + $abstractCondition->expects($this->once())->method('getMappedSqlField')->will($this->returnValue('argument')); + $abstractCondition->expects($this->once())->method('getOperatorForValidate')->will($this->returnValue('>')); + $abstractCondition->expects($this->at(1))->method('getAttribute')->will($this->returnValue('attribute')); + $abstractCondition->expects($this->at(2))->method('getAttribute')->will($this->returnValue('attribute')); + $abstractCondition->expects($this->once())->method('getBindArgumentValue')->will($this->returnValue(10)); + + $conditions = [$abstractCondition]; + $collection = $this->createPartialMock( + \Magento\Eav\Model\Entity\Collection\AbstractCollection::class, + [ + 'getResource', + 'getSelect', + ] + ); + $combine = $this->createPartialMock( + \Magento\Rule\Model\Condition\Combine::class, + [ + 'getConditions', + 'getValue', + 'getAggregator', + ] + ); + + $resource = $this->createPartialMock(\Magento\Framework\DB\Adapter\Pdo\Mysql::class, ['getConnection']); + $select = $this->createPartialMock(\Magento\Framework\DB\Select::class, ['where']); + $select->expects($this->never())->method('where'); + + $connection = $this->getMockForAbstractClass( + \Magento\Framework\DB\Adapter\AdapterInterface::class, + ['quoteInto'], + '', + false + ); + + $connection->expects($this->once())->method('quoteInto')->with(' > ?', 10)->will($this->returnValue(' > 10')); + $collection->expects($this->once())->method('getResource')->will($this->returnValue($resource)); + $resource->expects($this->once())->method('getConnection')->will($this->returnValue($connection)); + $combine->expects($this->once())->method('getValue')->willReturn('attribute'); + $combine->expects($this->once())->method('getAggregator')->willReturn(' AND '); + $combine->expects($this->at(0))->method('getConditions')->will($this->returnValue($conditions)); + $combine->expects($this->at(1))->method('getConditions')->will($this->returnValue($conditions)); + $combine->expects($this->at(2))->method('getConditions')->will($this->returnValue($conditions)); + $combine->expects($this->at(3))->method('getConditions')->will($this->returnValue($conditions)); + + $this->_builder->attachConditionToCollection($collection, $combine); + } } diff --git a/app/code/Magento/Rule/composer.json b/app/code/Magento/Rule/composer.json index 2589db8fe86e6..e37274c19a969 100644 --- a/app/code/Magento/Rule/composer.json +++ b/app/code/Magento/Rule/composer.json @@ -11,7 +11,7 @@ "lib-libxml": "*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Sales/Block/Adminhtml/Order/Create/Form/Account.php b/app/code/Magento/Sales/Block/Adminhtml/Order/Create/Form/Account.php index 0f92fa2320e89..b61a5cf83734b 100644 --- a/app/code/Magento/Sales/Block/Adminhtml/Order/Create/Form/Account.php +++ b/app/code/Magento/Sales/Block/Adminhtml/Order/Create/Form/Account.php @@ -11,6 +11,7 @@ use Magento\Framework\Api\ExtensibleDataObjectConverter; use Magento\Framework\Data\Form\Element\AbstractElement; use Magento\Framework\Pricing\PriceCurrencyInterface; +use Magento\Store\Model\ScopeInterface; /** * Create order account form @@ -134,15 +135,8 @@ protected function _prepareForm() $this->_addAttributesToForm($attributes, $fieldset); $this->_form->addFieldNameSuffix('order[account]'); - - $formValues = $this->getFormValues(); - foreach ($attributes as $code => $attribute) { - $defaultValue = $attribute->getDefaultValue(); - if (isset($defaultValue) && !isset($formValues[$code])) { - $formValues[$code] = $defaultValue; - } - } - $this->_form->setValues($formValues); + $storeId = (int)$this->_sessionQuote->getStoreId(); + $this->_form->setValues($this->extractValuesFromAttributes($attributes, $storeId)); return $this; } @@ -189,4 +183,42 @@ public function getFormValues() return $data; } + + /** + * Extract the form values from attributes. + * + * @param array $attributes + * @param int $storeId + * @return array + */ + private function extractValuesFromAttributes(array $attributes, int $storeId): array + { + $formValues = $this->getFormValues(); + foreach ($attributes as $code => $attribute) { + $defaultValue = $attribute->getDefaultValue(); + if (isset($defaultValue) && !isset($formValues[$code])) { + $formValues[$code] = $defaultValue; + } + if ($code === 'group_id' && empty($defaultValue)) { + $formValues[$code] = $this->getDefaultCustomerGroup($storeId); + } + } + + return $formValues; + } + + /** + * Gets default customer group. + * + * @param int $storeId + * @return string|null + */ + private function getDefaultCustomerGroup(int $storeId) + { + return $this->_scopeConfig->getValue( + 'customer/create_account/default_group', + ScopeInterface::SCOPE_STORE, + $storeId + ); + } } diff --git a/app/code/Magento/Sales/Block/Adminhtml/Order/Create/Sidebar/Cart.php b/app/code/Magento/Sales/Block/Adminhtml/Order/Create/Sidebar/Cart.php index 34d7a3f8ee25e..8179c0e8d282a 100644 --- a/app/code/Magento/Sales/Block/Adminhtml/Order/Create/Sidebar/Cart.php +++ b/app/code/Magento/Sales/Block/Adminhtml/Order/Create/Sidebar/Cart.php @@ -3,8 +3,13 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Sales\Block\Adminhtml\Order\Create\Sidebar; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Pricing\Price\FinalPrice; + /** * Adminhtml sales order create sidebar cart block * @@ -58,6 +63,17 @@ public function getItemCollection() return $collection; } + /** + * @inheritdoc + */ + public function getItemPrice(Product $product) + { + $customPrice = $this->getCartItemCustomPrice($product); + $price = $customPrice ?? $product->getPriceInfo()->getPrice(FinalPrice::PRICE_CODE)->getValue(); + + return $this->convertPrice($price); + } + /** * Retrieve display item qty availability * @@ -111,4 +127,23 @@ protected function _prepareLayout() return parent::_prepareLayout(); } + + /** + * Returns cart item custom price. + * + * @param Product $product + * @return float|null + */ + private function getCartItemCustomPrice(Product $product) + { + $items = $this->getItemCollection(); + foreach ($items as $item) { + $productItemId = $this->getProduct($item)->getId(); + if ($productItemId === $product->getId() && $item->getCustomPrice()) { + return (float)$item->getCustomPrice(); + } + } + + return null; + } } diff --git a/app/code/Magento/Sales/Block/Order/Item/Renderer/DefaultRenderer.php b/app/code/Magento/Sales/Block/Order/Item/Renderer/DefaultRenderer.php index 4c6a2b586cfc4..ad6e0082929ac 100644 --- a/app/code/Magento/Sales/Block/Order/Item/Renderer/DefaultRenderer.php +++ b/app/code/Magento/Sales/Block/Order/Item/Renderer/DefaultRenderer.php @@ -278,4 +278,21 @@ public function getItemRowTotalAfterDiscountHtml($item = null) $block->setItem($item); return $block->toHtml(); } + + /** + * Return the base total amount minus discount. + * + * @param OrderItem|InvoiceItem|CreditmemoItem $item + * @return float|null + */ + public function getBaseTotalAmount($item) + { + $baseTotalAmount = $item->getBaseRowTotal() + + $item->getBaseTaxAmount() + + $item->getBaseDiscountTaxCompensationAmount() + + $item->getBaseWeeeTaxAppliedAmount() + - $item->getBaseDiscountAmount(); + + return $baseTotalAmount; + } } diff --git a/app/code/Magento/Sales/Controller/AbstractController/Reorder.php b/app/code/Magento/Sales/Controller/AbstractController/Reorder.php index d7ab99377e1b5..619b0828ed33d 100644 --- a/app/code/Magento/Sales/Controller/AbstractController/Reorder.php +++ b/app/code/Magento/Sales/Controller/AbstractController/Reorder.php @@ -7,7 +7,11 @@ namespace Magento\Sales\Controller\AbstractController; use Magento\Framework\App\Action; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Data\Form\FormKey\Validator; use Magento\Framework\Registry; +use Magento\Framework\Exception\NotFoundException; +use Magento\Framework\Controller\ResultFactory; abstract class Reorder extends Action\Action { @@ -21,18 +25,26 @@ abstract class Reorder extends Action\Action */ protected $_coreRegistry; + /** + * @var Validator + */ + private $formKeyValidator; + /** * @param Action\Context $context * @param OrderLoaderInterface $orderLoader * @param Registry $registry + * @param Validator|null $formKeyValidator */ public function __construct( Action\Context $context, OrderLoaderInterface $orderLoader, - Registry $registry + Registry $registry, + Validator $formKeyValidator = null ) { $this->orderLoader = $orderLoader; $this->_coreRegistry = $registry; + $this->formKeyValidator = $formKeyValidator ?: ObjectManager::getInstance()->create(Validator::class); parent::__construct($context); } @@ -43,6 +55,20 @@ public function __construct( */ public function execute() { + if ($this->getRequest()->isPost()) { + if (!$this->formKeyValidator->validate($this->getRequest())) { + $this->messageManager->addErrorMessage(__('Invalid Form Key. Please refresh the page.')); + + /** @var \Magento\Framework\Controller\Result\Redirect $redirect */ + $redirect = $this->resultFactory->create(ResultFactory::TYPE_REDIRECT); + $redirect->setPath('*/*/history'); + + return $redirect; + } + } else { + throw new NotFoundException(__('Page not found.')); + } + $result = $this->orderLoader->load($this->_request); if ($result instanceof \Magento\Framework\Controller\ResultInterface) { return $result; diff --git a/app/code/Magento/Sales/Controller/Adminhtml/Order/AddComment.php b/app/code/Magento/Sales/Controller/Adminhtml/Order/AddComment.php index 12038ee375059..88e05a80f3797 100644 --- a/app/code/Magento/Sales/Controller/Adminhtml/Order/AddComment.php +++ b/app/code/Magento/Sales/Controller/Adminhtml/Order/AddComment.php @@ -9,17 +9,27 @@ use Magento\Backend\App\Action; use Magento\Sales\Model\Order\Email\Sender\OrderCommentSender; +/** + * Controller to execute Adding Comments. + */ class AddComment extends \Magento\Sales\Controller\Adminhtml\Order { /** - * Authorization level of a basic admin session + * Authorization level of a basic admin session. * * @see _isAllowed() */ const ADMIN_RESOURCE = 'Magento_Sales::comment'; /** - * Add order comment action + * ACL resource needed to send comment email notification. + * + * @see _isAllowed() + */ + const ADMIN_SALES_EMAIL_RESOURCE = 'Magento_Sales::emails'; + + /** + * Add order comment action. * * @return \Magento\Framework\Controller\ResultInterface */ @@ -33,8 +43,12 @@ public function execute() throw new \Magento\Framework\Exception\LocalizedException(__('Please enter a comment.')); } - $notify = isset($data['is_customer_notified']) ? $data['is_customer_notified'] : false; - $visible = isset($data['is_visible_on_front']) ? $data['is_visible_on_front'] : false; + $notify = $data['is_customer_notified'] ?? false; + $visible = $data['is_visible_on_front'] ?? false; + + if ($notify && !$this->_authorization->isAllowed(self::ADMIN_SALES_EMAIL_RESOURCE)) { + $notify = false; + } $history = $order->addStatusHistoryComment($data['comment'], $data['status']); $history->setIsVisibleOnFront($visible); @@ -59,9 +73,11 @@ public function execute() if (is_array($response)) { $resultJson = $this->resultJsonFactory->create(); $resultJson->setData($response); + return $resultJson; } } + return $this->resultRedirectFactory->create()->setPath('sales/*/'); } } diff --git a/app/code/Magento/Sales/Controller/Adminhtml/Order/Invoice/Save.php b/app/code/Magento/Sales/Controller/Adminhtml/Order/Invoice/Save.php index c45a1982784e1..8e2f1e951606d 100644 --- a/app/code/Magento/Sales/Controller/Adminhtml/Order/Invoice/Save.php +++ b/app/code/Magento/Sales/Controller/Adminhtml/Order/Invoice/Save.php @@ -190,13 +190,7 @@ public function execute() } } $transactionSave->save(); - - if (!empty($data['do_shipment'])) { - $this->messageManager->addSuccess(__('You created the invoice and shipment.')); - } else { - $this->messageManager->addSuccess(__('The invoice has been created.')); - } - + // send invoice/shipment emails try { if (!empty($data['send_email'])) { @@ -206,6 +200,7 @@ public function execute() $this->_objectManager->get(\Psr\Log\LoggerInterface::class)->critical($e); $this->messageManager->addError(__('We can\'t send the invoice email right now.')); } + if ($shipment) { try { if (!empty($data['send_email'])) { @@ -216,6 +211,13 @@ public function execute() $this->messageManager->addError(__('We can\'t send the shipment right now.')); } } + + if (!empty($data['do_shipment'])) { + $this->messageManager->addSuccess(__('You created the invoice and shipment.')); + } else { + $this->messageManager->addSuccess(__('The invoice has been created.')); + } + $this->_objectManager->get(\Magento\Backend\Model\Session::class)->getCommentText(true); return $resultRedirect->setPath('sales/order/view', ['order_id' => $orderId]); } catch (LocalizedException $e) { diff --git a/app/code/Magento/Sales/Helper/Admin.php b/app/code/Magento/Sales/Helper/Admin.php index 45a6dd1252ba3..3cdc8f4bca210 100644 --- a/app/code/Magento/Sales/Helper/Admin.php +++ b/app/code/Magento/Sales/Helper/Admin.php @@ -3,8 +3,12 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Sales\Helper; +/** + * Sales admin helper. + */ class Admin extends \Magento\Framework\App\Helper\AbstractHelper { /** @@ -148,22 +152,15 @@ public function escapeHtmlWithLinks($data, $allowedTags = null) $links = []; $i = 1; $data = str_replace('%', '%%', $data); - $regexp = "/<a\s[^>]*href\s*?=\s*?([\"\']??)([^\" >]*?)\\1[^>]*>(.*)<\/a>/siU"; + $regexp = "#(?J)<a" + ."(?:(?:\s+(?:(?:href\s*=\s*(['\"])(?<link>.*?)\\1\s*)|(?:\S+\s*=\s*(['\"])(.*?)\\3)\s*)*)|>)" + .">?(?:(?:(?<text>.*?)(?:<\/a\s*>?|(?=<\w))|(?<text>.*)))#si"; while (preg_match($regexp, $data, $matches)) { - //Revert the sprintf escaping - $url = str_replace('%%', '%', $matches[2]); - $text = str_replace('%%', '%', $matches[3]); - //Check for an valid url - if ($url) { - $urlScheme = strtolower(parse_url($url, PHP_URL_SCHEME)); - if ($urlScheme !== 'http' && $urlScheme !== 'https') { - $url = null; - } - } - //Use hash tag as fallback - if (!$url) { - $url = '#'; + $text = ''; + if (!empty($matches['text'])) { + $text = str_replace('%%', '%', $matches['text']); } + $url = $this->filterUrl($matches['link'] ?? ''); //Recreate a minimalistic secure a tag $links[] = sprintf( '<a href="%s">%s</a>', @@ -178,4 +175,29 @@ public function escapeHtmlWithLinks($data, $allowedTags = null) } return $this->escaper->escapeHtml($data, $allowedTags); } + + /** + * Filter the URL for allowed protocols. + * + * @param string $url + * @return string + */ + private function filterUrl(string $url): string + { + if ($url) { + //Revert the sprintf escaping + $url = str_replace('%%', '%', $url); + $urlScheme = parse_url($url, PHP_URL_SCHEME); + $urlScheme = $urlScheme ? strtolower($urlScheme) : ''; + if ($urlScheme !== 'http' && $urlScheme !== 'https') { + $url = null; + } + } + + if (!$url) { + $url = '#'; + } + + return $url; + } } diff --git a/app/code/Magento/Sales/Model/CronJob/CleanExpiredOrders.php b/app/code/Magento/Sales/Model/CronJob/CleanExpiredOrders.php index 8a7bd0260df0f..999bb1786cf83 100644 --- a/app/code/Magento/Sales/Model/CronJob/CleanExpiredOrders.php +++ b/app/code/Magento/Sales/Model/CronJob/CleanExpiredOrders.php @@ -3,11 +3,19 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Sales\Model\CronJob; +use Magento\Framework\App\ObjectManager; +use Magento\Sales\Api\OrderManagementInterface; +use Magento\Sales\Model\ResourceModel\Order\CollectionFactory; use Magento\Store\Model\StoresConfig; use Magento\Sales\Model\Order; +/** + * Class that provides functionality of cleaning expired quotes by cron + */ class CleanExpiredOrders { /** @@ -16,20 +24,28 @@ class CleanExpiredOrders protected $storesConfig; /** - * @var \Magento\Sales\Model\ResourceModel\Order\CollectionFactory + * @var CollectionFactory */ protected $orderCollectionFactory; + /** + * @var OrderManagementInterface + */ + private $orderManagement; + /** * @param StoresConfig $storesConfig - * @param \Magento\Sales\Model\ResourceModel\Order\CollectionFactory $collectionFactory + * @param CollectionFactory $collectionFactory + * @param OrderManagementInterface|null $orderManagement */ public function __construct( StoresConfig $storesConfig, - \Magento\Sales\Model\ResourceModel\Order\CollectionFactory $collectionFactory + CollectionFactory $collectionFactory, + OrderManagementInterface $orderManagement = null ) { $this->storesConfig = $storesConfig; $this->orderCollectionFactory = $collectionFactory; + $this->orderManagement = $orderManagement ?: ObjectManager::getInstance()->get(OrderManagementInterface::class); } /** @@ -48,8 +64,10 @@ public function execute() $orders->getSelect()->where( new \Zend_Db_Expr('TIME_TO_SEC(TIMEDIFF(CURRENT_TIMESTAMP, `updated_at`)) >= ' . $lifetime * 60) ); - $orders->walk('cancel'); - $orders->walk('save'); + + foreach ($orders->getAllIds() as $entityId) { + $this->orderManagement->cancel((int) $entityId); + } } } } diff --git a/app/code/Magento/Sales/Model/Order.php b/app/code/Magento/Sales/Model/Order.php index d20493060c3fd..9d66433be8f3e 100644 --- a/app/code/Magento/Sales/Model/Order.php +++ b/app/code/Magento/Sales/Model/Order.php @@ -12,6 +12,7 @@ use Magento\Framework\Locale\ResolverInterface; use Magento\Framework\Pricing\PriceCurrencyInterface; use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\Data\OrderItemInterface; use Magento\Sales\Api\Data\OrderStatusHistoryInterface; use Magento\Sales\Model\Order\Payment; use Magento\Sales\Model\Order\ProductOption; @@ -665,8 +666,8 @@ private function canCreditmemoForZeroTotalRefunded(float $totalRefunded): bool $isRefundZero = abs($totalRefunded) < .0001; // Case when Adjustment Fee (adjustment_negative) has been used for first creditmemo $hasAdjustmentFee = abs($totalRefunded - $this->getAdjustmentNegative()) < .0001; - $hasActinFlag = $this->getActionFlag(self::ACTION_FLAG_EDIT) === false; - if ($isRefundZero || $hasAdjustmentFee || $hasActinFlag) { + $hasActionFlag = $this->getActionFlag(self::ACTION_FLAG_EDIT) === false; + if ($isRefundZero || $hasAdjustmentFee || $hasActionFlag) { return false; } @@ -682,15 +683,15 @@ private function canCreditmemoForZeroTotalRefunded(float $totalRefunded): bool private function canCreditmemoForZeroTotal(float $totalRefunded): bool { $totalPaid = $this->getTotalPaid(); - //check if total paid is less than grandtotal + //check if total paid is less than grand total $checkAmtTotalPaid = $totalPaid <= $this->getGrandTotal(); //case when amount is due for invoice - $dueAmountCondition = $this->canInvoice() && $checkAmtTotalPaid; + $hasDueAmount = $this->canInvoice() && $checkAmtTotalPaid; //case when paid amount is refunded and order has creditmemo created $creditmemos = ($this->getCreditmemosCollection() === false) ? true : (count($this->getCreditmemosCollection()) > 0); $paidAmtIsRefunded = $this->getTotalRefunded() == $totalPaid && $creditmemos; - if (($dueAmountCondition || $paidAmtIsRefunded) + if (($hasDueAmount || $paidAmtIsRefunded) || (!$checkAmtTotalPaid && abs($totalRefunded - $this->getAdjustmentNegative()) < .0001) ) { return false; @@ -750,7 +751,7 @@ public function canComment() } /** - * Retrieve order shipment availability + * Retrieve order shipment availability. * * @return bool * @SuppressWarnings(PHPMD.CyclomaticComplexity) @@ -770,13 +771,29 @@ public function canShip() } foreach ($this->getAllItems() as $item) { - if ($item->getQtyToShip() > 0 && !$item->getIsVirtual() && !$item->getLockedDoShip()) { + if ($item->getQtyToShip() > 0 + && !$item->getIsVirtual() + && !$item->getLockedDoShip() + && !$this->isRefunded($item) + ) { return true; } } + return false; } + /** + * Check if item is refunded. + * + * @param OrderItemInterface $item + * @return bool + */ + private function isRefunded(OrderItemInterface $item): bool + { + return $item->getQtyRefunded() == $item->getQtyOrdered(); + } + /** * Retrieve order edit availability * @@ -1383,7 +1400,7 @@ public function getParentItemsRandomCollection($limit = 1) } /** - * Get random items collection with or without related children + * Get random items collection with or without related children. * * @param int $limit * @param bool $nonChildrenOnly @@ -1391,7 +1408,10 @@ public function getParentItemsRandomCollection($limit = 1) */ protected function _getItemsRandomCollection($limit, $nonChildrenOnly = false) { - $collection = $this->_orderItemCollectionFactory->create()->setOrderFilter($this)->setRandomOrder(); + $collection = $this->_orderItemCollectionFactory->create() + ->setOrderFilter($this) + ->setRandomOrder() + ->setPageSize($limit); if ($nonChildrenOnly) { $collection->filterByParent(); @@ -1405,9 +1425,7 @@ protected function _getItemsRandomCollection($limit, $nonChildrenOnly = false) $products )->setVisibility( $this->_productVisibility->getVisibleInSiteIds() - )->addPriceData()->setPageSize( - $limit - )->load(); + )->addPriceData()->load(); foreach ($collection as $item) { $product = $productsCollection->getItemById($item->getProductId()); @@ -2130,11 +2148,18 @@ public function getStatusHistories() /** * @inheritdoc * - * @return \Magento\Sales\Api\Data\OrderExtensionInterface|null + * @return \Magento\Sales\Api\Data\OrderExtensionInterface */ public function getExtensionAttributes() { - return $this->_getExtensionAttributes(); + $extensionAttributes = $this->_getExtensionAttributes(); + if (null === $extensionAttributes) { + /** @var \Magento\Sales\Api\Data\OrderExtensionInterface $extensionAttributes */ + $extensionAttributes = $this->extensionAttributesFactory->create(OrderInterface::class); + $this->setExtensionAttributes($extensionAttributes); + } + + return $extensionAttributes; } /** diff --git a/app/code/Magento/Sales/Model/Order/Creditmemo/Total/Tax.php b/app/code/Magento/Sales/Model/Order/Creditmemo/Total/Tax.php index 5ab9469441bef..d4c2e7b2d6854 100644 --- a/app/code/Magento/Sales/Model/Order/Creditmemo/Total/Tax.php +++ b/app/code/Magento/Sales/Model/Order/Creditmemo/Total/Tax.php @@ -75,10 +75,11 @@ public function collect(\Magento\Sales\Model\Order\Creditmemo $creditmemo) } $isPartialShippingRefunded = false; + $baseOrderShippingAmount = (float)$order->getBaseShippingAmount(); if ($invoice = $creditmemo->getInvoice()) { //recalculate tax amounts in case if refund shipping value was changed - if ($order->getBaseShippingAmount() && $creditmemo->getBaseShippingAmount()) { - $taxFactor = $creditmemo->getBaseShippingAmount() / $order->getBaseShippingAmount(); + if ($baseOrderShippingAmount && $creditmemo->getBaseShippingAmount() !== null) { + $taxFactor = $creditmemo->getBaseShippingAmount() / $baseOrderShippingAmount; $shippingTaxAmount = $invoice->getShippingTaxAmount() * $taxFactor; $baseShippingTaxAmount = $invoice->getBaseShippingTaxAmount() * $taxFactor; $totalDiscountTaxCompensation += $invoice->getShippingDiscountTaxCompensationAmount() * $taxFactor; @@ -96,7 +97,6 @@ public function collect(\Magento\Sales\Model\Order\Creditmemo $creditmemo) } } else { $orderShippingAmount = $order->getShippingAmount(); - $baseOrderShippingAmount = $order->getBaseShippingAmount(); $baseOrderShippingRefundedAmount = $order->getBaseShippingRefunded(); diff --git a/app/code/Magento/Sales/Model/Order/CreditmemoFactory.php b/app/code/Magento/Sales/Model/Order/CreditmemoFactory.php index 50523015d87eb..da41f99a65c83 100644 --- a/app/code/Magento/Sales/Model/Order/CreditmemoFactory.php +++ b/app/code/Magento/Sales/Model/Order/CreditmemoFactory.php @@ -200,6 +200,7 @@ protected function initData($creditmemo, $data) { if (isset($data['shipping_amount'])) { $creditmemo->setBaseShippingAmount((double)$data['shipping_amount']); + $creditmemo->setBaseShippingInclTax((double)$data['shipping_amount']); } if (isset($data['adjustment_positive'])) { $creditmemo->setAdjustmentPositive($data['adjustment_positive']); @@ -210,6 +211,8 @@ protected function initData($creditmemo, $data) } /** + * Calculate product options. + * * @param Item $orderItem * @param int $parentQty * @return int diff --git a/app/code/Magento/Sales/Model/Order/CustomerAssignment.php b/app/code/Magento/Sales/Model/Order/CustomerAssignment.php new file mode 100644 index 0000000000000..8bcfc1dc49de4 --- /dev/null +++ b/app/code/Magento/Sales/Model/Order/CustomerAssignment.php @@ -0,0 +1,59 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Sales\Model\Order; + +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Customer\Api\Data\CustomerInterface; +use Magento\Framework\Event\ManagerInterface; + +class CustomerAssignment +{ + /** + * @var ManagerInterface + */ + private $eventManager; + + /** + * @var OrderRepositoryInterface + */ + private $orderRepository; + + /** + * CustomerAssignment constructor. + * + * @param ManagerInterface $eventManager + * @param OrderRepositoryInterface $orderRepository + */ + public function __construct( + ManagerInterface $eventManager, + OrderRepositoryInterface $orderRepository + ) { + $this->eventManager = $eventManager; + $this->orderRepository = $orderRepository; + } + + /** + * @param OrderInterface $order + * @param CustomerInterface $customer + */ + public function execute(OrderInterface $order, CustomerInterface $customer)/*: void*/ + { + $order->setCustomerId($customer->getId()); + $order->setCustomerIsGuest(false); + $this->orderRepository->save($order); + + $this->eventManager->dispatch( + 'sales_order_customer_assign_after', + [ + 'order' => $order, + 'customer' => $customer + ] + ); + } +} diff --git a/app/code/Magento/Sales/Model/Order/Webapi/ChangeOutputArray.php b/app/code/Magento/Sales/Model/Order/Webapi/ChangeOutputArray.php new file mode 100644 index 0000000000000..a1015c102b3af --- /dev/null +++ b/app/code/Magento/Sales/Model/Order/Webapi/ChangeOutputArray.php @@ -0,0 +1,59 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Sales\Model\Order\Webapi; + +use Magento\Sales\Api\Data\OrderItemInterface; +use Magento\Sales\Block\Adminhtml\Items\Column\DefaultColumn; +use Magento\Sales\Block\Order\Item\Renderer\DefaultRenderer; + +/** + * Class for changing row total in response. + */ +class ChangeOutputArray +{ + /** + * @var DefaultColumn + */ + private $priceRenderer; + + /** + * @var DefaultRenderer + */ + private $defaultRenderer; + + /** + * @param DefaultColumn $priceRenderer + * @param DefaultRenderer $defaultRenderer + */ + public function __construct( + DefaultColumn $priceRenderer, + DefaultRenderer $defaultRenderer + ) { + $this->priceRenderer = $priceRenderer; + $this->defaultRenderer = $defaultRenderer; + } + + /** + * Changing row total for webapi order item response. + * + * @param OrderItemInterface $dataObject + * @param array $result + * @return array + */ + public function execute( + OrderItemInterface $dataObject, + array $result + ): array { + $result[OrderItemInterface::ROW_TOTAL] = $this->priceRenderer->getTotalAmount($dataObject); + $result[OrderItemInterface::BASE_ROW_TOTAL] = $this->priceRenderer->getBaseTotalAmount($dataObject); + $result[OrderItemInterface::ROW_TOTAL_INCL_TAX] = $this->defaultRenderer->getTotalAmount($dataObject); + $result[OrderItemInterface::BASE_ROW_TOTAL_INCL_TAX] = $this->defaultRenderer->getBaseTotalAmount($dataObject); + + return $result; + } +} diff --git a/app/code/Magento/Sales/Model/OrderRepository.php b/app/code/Magento/Sales/Model/OrderRepository.php index 691b803166050..6b1228ebd52b7 100644 --- a/app/code/Magento/Sales/Model/OrderRepository.php +++ b/app/code/Magento/Sales/Model/OrderRepository.php @@ -17,6 +17,10 @@ use Magento\Sales\Api\Data\ShippingAssignmentInterface; use Magento\Sales\Model\Order\ShippingAssignmentBuilder; use Magento\Sales\Model\ResourceModel\Metadata; +use Magento\Tax\Api\OrderTaxManagementInterface; +use Magento\Payment\Api\Data\PaymentAdditionalInfoInterface; +use Magento\Payment\Api\Data\PaymentAdditionalInfoInterfaceFactory; +use Magento\Framework\Serialize\Serializer\Json as JsonSerializer; /** * Repository class @@ -60,6 +64,21 @@ class OrderRepository implements \Magento\Sales\Api\OrderRepositoryInterface */ private $extensionAttributesJoinProcessor; + /** + * @var OrderTaxManagementInterface + */ + private $orderTaxManagement; + + /** + * @var PaymentAdditionalInfoFactory + */ + private $paymentAdditionalInfoFactory; + + /** + * @var JsonSerializer + */ + private $serializer; + /** * Constructor * @@ -68,13 +87,19 @@ class OrderRepository implements \Magento\Sales\Api\OrderRepositoryInterface * @param CollectionProcessorInterface|null $collectionProcessor * @param \Magento\Sales\Api\Data\OrderExtensionFactory|null $orderExtensionFactory * @param JoinProcessorInterface $extensionAttributesJoinProcessor + * @param OrderTaxManagementInterface|null $orderTaxManagement + * @param PaymentAdditionalInfoInterfaceFactory|null $paymentAdditionalInfoFactory + * @param JsonSerializer|null $serializer */ public function __construct( Metadata $metadata, SearchResultFactory $searchResultFactory, CollectionProcessorInterface $collectionProcessor = null, \Magento\Sales\Api\Data\OrderExtensionFactory $orderExtensionFactory = null, - JoinProcessorInterface $extensionAttributesJoinProcessor = null + JoinProcessorInterface $extensionAttributesJoinProcessor = null, + OrderTaxManagementInterface $orderTaxManagement = null, + PaymentAdditionalInfoInterfaceFactory $paymentAdditionalInfoFactory = null, + JsonSerializer $serializer = null ) { $this->metadata = $metadata; $this->searchResultFactory = $searchResultFactory; @@ -84,10 +109,16 @@ public function __construct( ->get(\Magento\Sales\Api\Data\OrderExtensionFactory::class); $this->extensionAttributesJoinProcessor = $extensionAttributesJoinProcessor ?: ObjectManager::getInstance()->get(JoinProcessorInterface::class); + $this->orderTaxManagement = $orderTaxManagement ?: ObjectManager::getInstance() + ->get(OrderTaxManagementInterface::class); + $this->paymentAdditionalInfoFactory = $paymentAdditionalInfoFactory ?: ObjectManager::getInstance() + ->get(PaymentAdditionalInfoInterfaceFactory::class); + $this->serializer = $serializer ?: ObjectManager::getInstance() + ->get(JsonSerializer::class); } /** - * load entity + * Load entity. * * @param int $id * @return \Magento\Sales\Api\Data\OrderInterface @@ -105,12 +136,65 @@ public function get($id) if (!$entity->getEntityId()) { throw new NoSuchEntityException(__('Requested entity doesn\'t exist')); } + $this->setOrderTaxDetails($entity); $this->setShippingAssignments($entity); + $this->setPaymentAdditionalInfo($entity); $this->registry[$id] = $entity; } return $this->registry[$id]; } + /** + * Set order tax details to extension attributes. + * + * @param OrderInterface $order + * @return void + */ + private function setOrderTaxDetails(OrderInterface $order) + { + $extensionAttributes = $order->getExtensionAttributes(); + $orderTaxDetails = $this->orderTaxManagement->getOrderTaxDetails($order->getEntityId()); + $appliedTaxes = $orderTaxDetails->getAppliedTaxes(); + + $extensionAttributes->setAppliedTaxes($appliedTaxes); + if (!empty($appliedTaxes)) { + $extensionAttributes->setConvertingFromQuote(true); + } + + $items = $orderTaxDetails->getItems(); + $extensionAttributes->setItemAppliedTaxes($items); + + $order->setExtensionAttributes($extensionAttributes); + } + + /** + * Set payment additional info to the order. + * + * @param OrderInterface $order + * @return void + */ + private function setPaymentAdditionalInfo(OrderInterface $order) + { + $extensionAttributes = $order->getExtensionAttributes(); + $paymentAdditionalInformation = $order->getPayment()->getAdditionalInformation(); + + $objects = []; + foreach ($paymentAdditionalInformation as $key => $value) { + /** @var PaymentAdditionalInfoInterface $additionalInformationObject */ + $additionalInformationObject = $this->paymentAdditionalInfoFactory->create(); + $additionalInformationObject->setKey($key); + + if (!is_string($value)) { + $value = $this->serializer->serialize($value); + } + + $additionalInformationObject->setValue($value); + $objects[] = $additionalInformationObject; + } + $extensionAttributes->setPaymentAdditionalInfo($objects); + $order->setExtensionAttributes($extensionAttributes); + } + /** * Find entities by criteria * @@ -126,6 +210,8 @@ public function getList(\Magento\Framework\Api\SearchCriteriaInterface $searchCr $searchResult->setSearchCriteria($searchCriteria); foreach ($searchResult->getItems() as $order) { $this->setShippingAssignments($order); + $this->setOrderTaxDetails($order); + $this->setPaymentAdditionalInfo($order); } return $searchResult; } @@ -179,6 +265,8 @@ public function save(\Magento\Sales\Api\Data\OrderInterface $entity) } /** + * Set shipping assignments to extension attributes. + * * @param OrderInterface $order * @return void */ diff --git a/app/code/Magento/Sales/Model/ResourceModel/Order/Handler/State.php b/app/code/Magento/Sales/Model/ResourceModel/Order/Handler/State.php index 3b127abbda732..f18118447f95f 100644 --- a/app/code/Magento/Sales/Model/ResourceModel/Order/Handler/State.php +++ b/app/code/Magento/Sales/Model/ResourceModel/Order/Handler/State.php @@ -9,12 +9,12 @@ use Magento\Sales\Model\Order; /** - * Class State + * Class to check order State. */ class State { /** - * Check order status before save + * Check order status and adjust the status before save. * * @param Order $order * @return $this @@ -23,25 +23,23 @@ class State */ public function check(Order $order) { - if (!$order->isCanceled() && !$order->canUnhold() && !$order->canInvoice() && !$order->canShip()) { - if (0 == $order->getBaseGrandTotal() || $order->canCreditmemo()) { - if ($order->getState() !== Order::STATE_COMPLETE) { - $order->setState(Order::STATE_COMPLETE) - ->setStatus($order->getConfig()->getStateDefaultStatus(Order::STATE_COMPLETE)); - } - } elseif ((float)$order->getTotalRefunded() - || !$order->getTotalRefunded() && $order->hasForcedCanCreditmemo() - ) { - if ($order->getState() !== Order::STATE_CLOSED) { - $order->setState(Order::STATE_CLOSED) - ->setStatus($order->getConfig()->getStateDefaultStatus(Order::STATE_CLOSED)); - } - } - } - if ($order->getState() == Order::STATE_NEW && $order->getIsInProcess()) { + $currentState = $order->getState(); + if ($currentState == Order::STATE_NEW && $order->getIsInProcess()) { $order->setState(Order::STATE_PROCESSING) ->setStatus($order->getConfig()->getStateDefaultStatus(Order::STATE_PROCESSING)); + $currentState = Order::STATE_PROCESSING; + } + + if (!$order->isCanceled() && !$order->canUnhold() && !$order->canInvoice()) { + if (in_array($currentState, [Order::STATE_PROCESSING, Order::STATE_COMPLETE]) && !$order->canCreditmemo()) { + $order->setState(Order::STATE_CLOSED) + ->setStatus($order->getConfig()->getStateDefaultStatus(Order::STATE_CLOSED)); + } elseif ($currentState === Order::STATE_PROCESSING && !$order->canShip()) { + $order->setState(Order::STATE_COMPLETE) + ->setStatus($order->getConfig()->getStateDefaultStatus(Order::STATE_COMPLETE)); + } } + return $this; } } diff --git a/app/code/Magento/Sales/Model/Service/InvoiceService.php b/app/code/Magento/Sales/Model/Service/InvoiceService.php index 718f55c3e551c..b66f59d2a2962 100644 --- a/app/code/Magento/Sales/Model/Service/InvoiceService.php +++ b/app/code/Magento/Sales/Model/Service/InvoiceService.php @@ -5,6 +5,7 @@ */ namespace Magento\Sales\Model\Service; +use Magento\Sales\Api\Data\OrderItemInterface; use Magento\Sales\Api\InvoiceManagementInterface; use Magento\Sales\Model\Order; @@ -136,14 +137,14 @@ public function prepareInvoice(Order $order, array $qtys = []) $totalQty = 0; $qtys = $this->prepareItemsQty($order, $qtys); foreach ($order->getAllItems() as $orderItem) { - if (!$this->_canInvoiceItem($orderItem)) { + if (!$this->_canInvoiceItem($orderItem, $qtys)) { continue; } $item = $this->orderConverter->itemToInvoiceItem($orderItem); - if ($orderItem->isDummy()) { - $qty = $orderItem->getQtyOrdered() ? $orderItem->getQtyOrdered() : 1; - } elseif (isset($qtys[$orderItem->getId()])) { + if (isset($qtys[$orderItem->getId()])) { $qty = (double) $qtys[$orderItem->getId()]; + } elseif ($orderItem->isDummy()) { + $qty = $orderItem->getQtyOrdered() ? $orderItem->getQtyOrdered() : 1; } elseif (empty($qtys)) { $qty = $orderItem->getQtyToInvoice(); } else { @@ -170,38 +171,55 @@ private function prepareItemsQty(Order $order, array $qtys = []) { foreach ($order->getAllItems() as $orderItem) { if (empty($qtys[$orderItem->getId()])) { - continue; - } - if ($orderItem->isDummy()) { - if ($orderItem->getHasChildren()) { - foreach ($orderItem->getChildrenItems() as $child) { - if (!isset($qtys[$child->getId()])) { - $qtys[$child->getId()] = $child->getQtyToInvoice(); - } - } - } elseif ($orderItem->getParentItem()) { - $parent = $orderItem->getParentItem(); - if (!isset($qtys[$parent->getId()])) { - $qtys[$parent->getId()] = $parent->getQtyToInvoice(); - } + $parentId = $orderItem->getParentItemId(); + if ($parentId && array_key_exists($parentId, $qtys)) { + $qtys[$orderItem->getId()] = $qtys[$parentId]; + } else { + continue; } } + $this->prepareItemQty($orderItem, $qtys); } return $qtys; } + /** + * Prepare qty to invoice item. + * + * @param OrderItemInterface $orderItem + * @param array $qtys + * @return void + */ + private function prepareItemQty(OrderItemInterface $orderItem, array &$qtys) + { + if ($orderItem->isDummy()) { + if ($orderItem->getHasChildren()) { + foreach ($orderItem->getChildrenItems() as $child) { + if (!isset($qtys[$child->getId()])) { + $qtys[$child->getId()] = $child->getQtyToInvoice(); + } + } + } elseif ($orderItem->getParentItem()) { + $parent = $orderItem->getParentItem(); + if (!isset($qtys[$parent->getId()])) { + $qtys[$parent->getId()] = $parent->getQtyToInvoice(); + } + } + } + } + /** * Check if order item can be invoiced. Dummy item can be invoiced or with his children or * with parent item which is included to invoice * - * @param \Magento\Sales\Api\Data\OrderItemInterface $item + * @param OrderItemInterface $item + * @param array $qtys * @return bool * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ - protected function _canInvoiceItem(\Magento\Sales\Api\Data\OrderItemInterface $item) + protected function _canInvoiceItem(OrderItemInterface $item, array $qtys = []) { - $qtys = []; if ($item->getLockedDoInvoice()) { return false; } diff --git a/app/code/Magento/Sales/Observer/AssignOrderToCustomerObserver.php b/app/code/Magento/Sales/Observer/AssignOrderToCustomerObserver.php index f41ea6888264f..9857fa39fa51a 100644 --- a/app/code/Magento/Sales/Observer/AssignOrderToCustomerObserver.php +++ b/app/code/Magento/Sales/Observer/AssignOrderToCustomerObserver.php @@ -12,6 +12,7 @@ use Magento\Framework\Event\Observer; use Magento\Framework\Event\ObserverInterface; use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order\CustomerAssignment; /** * Assign order to customer created after issuing guest order. @@ -24,11 +25,22 @@ class AssignOrderToCustomerObserver implements ObserverInterface private $orderRepository; /** + * @var CustomerAssignment + */ + private $assignmentService; + + /** + * AssignOrderToCustomerObserver constructor. + * * @param OrderRepositoryInterface $orderRepository + * @param CustomerAssignment $assignmentService */ - public function __construct(OrderRepositoryInterface $orderRepository) - { + public function __construct( + OrderRepositoryInterface $orderRepository, + CustomerAssignment $assignmentService + ) { $this->orderRepository = $orderRepository; + $this->assignmentService = $assignmentService; } /** @@ -44,11 +56,8 @@ public function execute(Observer $observer) if (array_key_exists('__sales_assign_order_id', $delegateData)) { $orderId = $delegateData['__sales_assign_order_id']; $order = $this->orderRepository->get($orderId); - if (!$order->getCustomerId()) { - //if customer ID wasn't already assigned then assigning. - $order->setCustomerId($customer->getId()); - $order->setCustomerIsGuest(0); - $this->orderRepository->save($order); + if (!$order->getCustomerId() && $customer->getId()) { + $this->assignmentService->execute($order, $customer); } } } diff --git a/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminInvoiceActionGroup.xml b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminInvoiceActionGroup.xml new file mode 100644 index 0000000000000..ed63da75df9e1 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminInvoiceActionGroup.xml @@ -0,0 +1,67 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <!--Check customer information is correct in invoice--> + <actionGroup name="VerifyBasicInvoiceInformation"> + <arguments> + <argument name="customer"/> + <argument name="shippingAddress"/> + <argument name="billingAddress"/> + <argument name="customerGroup" defaultValue="GeneralCustomerGroup"/> + </arguments> + <see selector="{{AdminInvoiceOrderAndAccountInformationSection.customerName}}" userInput="{{customer.firstname}}" stepKey="seeCustomerName"/> + <see selector="{{AdminInvoiceOrderAndAccountInformationSection.customerEmail}}" userInput="{{customer.email}}" stepKey="seeCustomerEmail"/> + <see selector="{{AdminInvoiceOrderAndAccountInformationSection.customerGroup}}" userInput="{{customerGroup.code}}" stepKey="seeCustomerGroup"/> + + <see selector="{{AdminInvoiceAddressInformationSection.billingAddress}}" userInput="{{billingAddress.street[0]}}" stepKey="seeBillingAddressStreet"/> + <see selector="{{AdminInvoiceAddressInformationSection.billingAddress}}" userInput="{{billingAddress.city}}" stepKey="seeBillingAddressCity"/> + <see selector="{{AdminInvoiceAddressInformationSection.billingAddress}}" userInput="{{billingAddress.country}}" stepKey="seeBillingAddressCountry"/> + <see selector="{{AdminInvoiceAddressInformationSection.billingAddress}}" userInput="{{billingAddress.postcode}}" stepKey="seeBillingAddressPostcode"/> + + <see selector="{{AdminInvoiceAddressInformationSection.shippingAddress}}" userInput="{{shippingAddress.street[0]}}" stepKey="seeShippingAddressStreet"/> + <see selector="{{AdminInvoiceAddressInformationSection.shippingAddress}}" userInput="{{shippingAddress.city}}" stepKey="seeShippingAddressCity"/> + <see selector="{{AdminInvoiceAddressInformationSection.shippingAddress}}" userInput="{{shippingAddress.country}}" stepKey="seeShippingAddressCountry"/> + <see selector="{{AdminInvoiceAddressInformationSection.shippingAddress}}" userInput="{{shippingAddress.postcode}}" stepKey="seeShippingAddressPostcode"/> + </actionGroup> + + <!--Check that product is in invoice items--> + <actionGroup name="SeeProductInInvoiceItems"> + <arguments> + <argument name="product"/> + </arguments> + <seeElement selector="{{AdminInvoiceItemsSection.productColumn(product.name)}}" stepKey="seeProductInInvoiceItemsGrid"/> + </actionGroup> + + <actionGroup name="StartCreateInvoiceFromOrderPage"> + <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoiceAction"/> + <seeInCurrentUrl url="{{AdminInvoiceNewPage.url}}" stepKey="seeNewInvoiceUrl"/> + <see selector="{{AdminHeaderSection.pageTitle}}" userInput="New Invoice" stepKey="seeNewInvoicePageTitle"/> + </actionGroup> + + <actionGroup name="CreatePartialInvoice"> + <arguments> + <argument name="productSku" type="string"/> + <argument name="qtyToInvoice" type="string" defaultValue="1"/> + </arguments> + <fillField selector="{{AdminInvoiceItemsSection.itemQtyToInvoiceBySku(productSku)}}" userInput="{{qtyToInvoice}}" stepKey="changeQtyToInvoice"/> + <waitForElementVisible selector="{{AdminInvoiceItemsSection.updateQtyEnabled}}" stepKey="waitForUpdateQtyEnabled"/> + <click selector="{{AdminInvoiceItemsSection.updateQty}}" stepKey="updateQty"/> + <waitForLoadingMaskToDisappear stepKey="waitForQtyToUpdate"/> + <waitForElementVisible selector="{{AdminInvoiceMainActionsSection.submitInvoice}}" stepKey="waitForSubmitInvoiceButton"/> + </actionGroup> + + <actionGroup name="SubmitInvoice"> + <click selector="{{AdminInvoiceMainActionsSection.submitInvoice}}" stepKey="clickSubmitInvoice"/> + <waitForElementVisible selector="{{AdminMessagesSection.successMessage}}" stepKey="waitForMessageAppears"/> + <see selector="{{AdminMessagesSection.successMessage}}" userInput="The invoice has been created." stepKey="seeInvoiceCreateSuccess"/> + <grabFromCurrentUrl regex="~/order_id/(\d+)/~" stepKey="grabOrderId"/> + <seeInCurrentUrl url="{{AdminOrderDetailsPage.url('$grabOrderId')}}" stepKey="seeViewOrderPageInvoice"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminOrderActionGroup.xml b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminOrderActionGroup.xml index 508b9015f29ed..730d3d6a5a185 100644 --- a/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminOrderActionGroup.xml +++ b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminOrderActionGroup.xml @@ -18,6 +18,12 @@ <see selector="{{AdminOrderDetailsInformationSection.orderStatus}}" userInput="Canceled" stepKey="seeOrderStatusCanceled"/> </actionGroup> + <!--Cancel order that is in processing status--> + <actionGroup name="CancelProcessingOrder" extends="cancelPendingOrder"> + <remove keyForRemoval="seeOrderStatusCanceled"/> + <see selector="{{AdminOrderDetailsInformationSection.orderStatus}}" after="seeCancelSuccessMessage" userInput="{{CONST.orderStatusComplete}}" stepKey="seeOrderStatusComplete"/> + </actionGroup> + <!--Navigate to create order page (New Order -> Create New Customer)--> <actionGroup name="navigateToNewOrderPageNewCustomerSingleStore"> <arguments> @@ -60,17 +66,24 @@ <actionGroup name="navigateToNewOrderPageExistingCustomer"> <arguments> <argument name="customer"/> + <argument name="storeView" defaultValue="_defaultStore"/> </arguments> <amOnPage url="{{AdminOrdersPage.url}}" stepKey="navigateToOrderIndexPage"/> <waitForPageLoad stepKey="waitForIndexPageLoad"/> <see selector="{{AdminHeaderSection.pageTitle}}" userInput="Orders" stepKey="seeIndexPageTitle"/> <click selector="{{AdminMainActionsSection.add}}" stepKey="clickCreateNewOrder"/> <waitForPageLoad stepKey="waitForCustomerGridLoad"/> + <!--Clear grid filters--> + <conditionalClick selector="{{AdminOrderCustomersGridSection.resetButton}}" dependentSelector="{{AdminOrderCustomersGridSection.resetButton}}" visible="true" stepKey="clearExistingCustomerFilters"/> + <waitForPageLoad stepKey="waitPageLoadAfterFilterReset"/> <fillField userInput="{{customer.email}}" selector="{{AdminOrderCustomersGridSection.emailFilter}}" stepKey="filterEmail"/> <click selector="{{AdminOrderCustomersGridSection.searchButton}}" stepKey="applyFilter"/> <waitForPageLoad stepKey="waitForFilteredCustomerGridLoad"/> <click selector="{{AdminOrderCustomersGridSection.firstRow}}" stepKey="clickOnCustomer"/> - <waitForPageLoad stepKey="waitForCreateOrderPageLoad" /> + <waitForPageLoad stepKey="waitForCreateOrderPageLoad"/> + <!-- Select store view if appears --> + <conditionalClick selector="{{AdminOrderStoreScopeTreeSection.storeOption(storeView.name)}}" dependentSelector="{{AdminOrderStoreScopeTreeSection.storeOption(storeView.name)}}" visible="true" stepKey="selectStoreViewIfAppears"/> + <waitForPageLoad stepKey="waitForCreateOrderPageLoadAfterStoreSelect"/> <see selector="{{AdminHeaderSection.pageTitle}}" userInput="Create New Order" stepKey="seeNewOrderPageTitle"/> </actionGroup> <!--Add a simple product to order--> @@ -123,6 +136,35 @@ <fillField selector="{{AdminOrderFormConfigureProductSection.quantity}}" userInput="{{quantity}}" stepKey="fillQuantity"/> <click selector="{{AdminOrderFormConfigureProductSection.ok}}" stepKey="clickOkConfigurablePopover"/> </actionGroup> + <!--Add bundle product to order --> + <actionGroup name="addBundleProductToOrder"> + <arguments> + <argument name="product"/> + <argument name="quantity" type="string" defaultValue="1"/> + </arguments> + <click selector="{{AdminOrderFormItemsSection.addProducts}}" stepKey="clickAddProducts"/> + <fillField selector="{{AdminOrderFormItemsSection.skuFilter}}" userInput="{{product.sku}}" stepKey="fillSkuFilterBundle"/> + <click selector="{{AdminOrderFormItemsSection.search}}" stepKey="clickSearchBundle"/> + <scrollToTopOfPage stepKey="scrollToTop1"/> + <checkOption selector="{{AdminOrderFormItemsSection.rowCheck('1')}}" stepKey="selectBundleProduct"/> + <waitForLoadingMaskToDisappear stepKey="waitForMask"/> + <waitForElementVisible selector="{{AdminOrderFormBundleProductSection.quantity}}" stepKey="waitForBundleOptionLoad"/> + <fillField selector="{{AdminOrderFormBundleProductSection.quantity}}" userInput="{{quantity}}" stepKey="fillQuantity"/> + <click selector="{{AdminOrderFormConfigureProductSection.ok}}" stepKey="clickOk"/> + <scrollToTopOfPage stepKey="scrollToTop2"/> + <click selector="{{AdminOrderFormItemsSection.addSelected}}" stepKey="clickAddSelectedProducts"/> + </actionGroup> + <!--Add bundle product to order and check product price in the grid--> + <actionGroup name="addBundleProductToOrderAndCheckPriceInGrid" extends="addBundleProductToOrder"> + <arguments> + <argument name="price" type="string"/> + </arguments> + <grabTextFrom selector="{{AdminOrderFormItemsSection.rowPrice('1')}}" stepKey="grabProductPriceFromGrid" after="clickOk"/> + <assertEquals stepKey="assertProductPriceInGrid" message="Bundle product price in grid should be equal {{price}}" after="grabProductPriceFromGrid"> + <expectedResult type="string">{{price}}</expectedResult> + <actualResult type="variable">grabProductPriceFromGrid</actualResult> + </assertEquals> + </actionGroup> <!--Fill customer billing address--> <actionGroup name="fillOrderCustomerInformation"> <arguments> @@ -203,4 +245,10 @@ </arguments> <see selector="{{AdminOrderItemsOrderedSection.productSkuColumn}}" userInput="{{product.sku}}" stepKey="seeSkuInItemsOrdered"/> </actionGroup> + + <!--Select Check Money payment method--> + <actionGroup name="SelectCheckMoneyPaymentMethod"> + <waitForElementVisible selector="{{AdminOrderFormPaymentSection.paymentBlock}}" stepKey="waitForPaymentOptions"/> + <conditionalClick selector="{{AdminOrderFormPaymentSection.checkMoneyOption}}" dependentSelector="{{AdminOrderFormPaymentSection.checkMoneyOption}}" visible="true" stepKey="checkCheckMoneyOption"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminOrderGridActionGroup.xml b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminOrderGridActionGroup.xml index 65a4b512394d8..e5646e0a64621 100644 --- a/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminOrderGridActionGroup.xml +++ b/app/code/Magento/Sales/Test/Mftf/ActionGroup/AdminOrderGridActionGroup.xml @@ -14,7 +14,6 @@ <argument name="orderId" type="string"/> </arguments> <amOnPage url="{{AdminOrdersPage.url}}" stepKey="navigateToOrderGridPage"/> - <waitForPageLoad stepKey="waitForOrderGridLoad"/> <conditionalClick selector="{{AdminOrdersGridSection.clearFilters}}" dependentSelector="{{AdminOrdersGridSection.clearFilters}}" visible="true" stepKey="clearExistingOrderFilters"/> <click selector="{{AdminOrdersGridSection.filters}}" stepKey="openOrderGridFilters"/> <fillField selector="{{AdminOrdersGridSection.idFilter}}" userInput="{{orderId}}" stepKey="fillOrderIdFilter"/> @@ -22,7 +21,10 @@ </actionGroup> <actionGroup name="AdminOrdersGridClearFiltersActionGroup"> <amOnPage url="{{AdminOrdersPage.url}}" stepKey="goToGridOrdersPage"/> - <waitForPageLoad stepKey="waitForPageToLoad"/> <conditionalClick selector="{{AdminOrdersGridSection.clearFilters}}" dependentSelector="{{AdminOrdersGridSection.enabledFilters}}" visible="true" stepKey="clickOnButtonToRemoveFiltersIfPresent"/> </actionGroup> + <actionGroup name="OpenOrderById" extends="filterOrderGridById"> + <click selector="{{AdminDataGridTableSection.firstRow}}" after="clickOrderApplyFilters" stepKey="openOrderViewPage"/> + <waitForPageLoad after="openOrderViewPage" stepKey="waitForOrderViewPageOpened"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/Sales/Test/Mftf/ActionGroup/StorefrontSearchGuestOrderActionGroup.xml b/app/code/Magento/Sales/Test/Mftf/ActionGroup/StorefrontSearchGuestOrderActionGroup.xml new file mode 100644 index 0000000000000..5a5943da91ce6 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/ActionGroup/StorefrontSearchGuestOrderActionGroup.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <!--Fill order information fields and click continue--> + <actionGroup name="StorefrontSearchGuestOrderActionGroup"> + <arguments> + <argument name="orderId" type="string"/> + <argument name="orderLastName" type="string"/> + <argument name="orderEmail" type="string"/> + </arguments> + <amOnPage url="{{StorefrontGuestOrderSearchPage.url}}" stepKey="navigateToOrderAndReturnPage"/> + <fillField selector="{{StorefrontGuestOrderSearchSection.orderId}}" userInput="{{orderId}}" stepKey="fillOrderId"/> + <fillField selector="{{StorefrontGuestOrderSearchSection.billingLastName}}" userInput="{{orderLastName}}" stepKey="fillBillingLastName"/> + <fillField selector="{{StorefrontGuestOrderSearchSection.email}}" userInput="{{orderEmail}}" stepKey="fillEmail"/> + <click selector="{{StorefrontGuestOrderSearchSection.continue}}" stepKey="clickContinue"/> + <waitForPageLoad stepKey="waitForOrderInformationPageLoad"/> + <seeInCurrentUrl url="{{StorefrontGuestOrderViewPage.url}}" stepKey="seeOrderInformationUrl"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Sales/Test/Mftf/Data/ConstData.xml b/app/code/Magento/Sales/Test/Mftf/Data/ConstData.xml new file mode 100644 index 0000000000000..523b13ae99c38 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Data/ConstData.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <entity name="CONST" type="CONST"> + <data key="orderStatusComplete">Complete</data> + <data key="orderStatusClosed">Closed</data> + <data key="orderStatusPending">Pending</data> + <data key="orderStatusProcessing">Processing</data> + </entity> +</entities> + + + diff --git a/app/code/Magento/Sales/Test/Mftf/Data/SalesConfigData.xml b/app/code/Magento/Sales/Test/Mftf/Data/SalesConfigData.xml index 035c3949820f6..15f18c2ad2a6c 100644 --- a/app/code/Magento/Sales/Test/Mftf/Data/SalesConfigData.xml +++ b/app/code/Magento/Sales/Test/Mftf/Data/SalesConfigData.xml @@ -24,4 +24,23 @@ <entity name="DisableMinimumOrderCheck" type="active"> <data key="value">0</data> </entity> + + <entity name="CheckoutShippingTotalsSortOrder" type="checkout_totals_sort_order"> + <requiredEntity type="shipping">ShippingTotalsSortOrder</requiredEntity> + </entity> + <entity name="ShippingTotalsSortOrder" type="shipping"> + <data key="value">27</data> + </entity> + + <entity name="DefaultTotalsSortOrder" type="checkout_totals_sort_order"> + <requiredEntity type="shipping">DefaultShippingTotalSortOrder</requiredEntity> + </entity> + + <entity name="DefaultShippingTotalSortOrder" type="shipping"> + <requiredEntity type="shipping_inherit_value">DefaultTotalFlagDisabled</requiredEntity> + </entity> + + <entity name="DefaultTotalFlagDisabled" type="shipping_inherit_value"> + <data key="value">0</data> + </entity> </entities> diff --git a/app/code/Magento/Sales/Test/Mftf/Metadata/sales_config-meta.xml b/app/code/Magento/Sales/Test/Mftf/Metadata/sales_config-meta.xml index 77c8c8fa1c959..98c9fdb043dd6 100644 --- a/app/code/Magento/Sales/Test/Mftf/Metadata/sales_config-meta.xml +++ b/app/code/Magento/Sales/Test/Mftf/Metadata/sales_config-meta.xml @@ -20,4 +20,48 @@ </object> </object> </operation> + <operation name="SetCheckoutTotalsSortOrder" dataType="checkout_totals_sort_order" type="create" auth="adminFormKey" url="/admin/system_config/save/section/sales/" method="POST" successRegex="/messages-message-success/"> + <object key="groups" dataType="checkout_totals_sort_order"> + <object key="totals_sort" dataType="checkout_totals_sort_order"> + <object key="fields" dataType="checkout_totals_sort_order"> + <object key="subtotal" dataType="subtotal"> + <field key="value">integer</field> + <object key="inherit" dataType="subtotal_inherit_value"> + <field key="value">integer</field> + </object> + </object> + <object key="discount" dataType="discount"> + <field key="value">integer</field> + <object key="inherit" dataType="discount_inherit_value"> + <field key="value">integer</field> + </object> + </object> + <object key="shipping" dataType="shipping"> + <field key="value">integer</field> + <object key="inherit" dataType="shipping_inherit_value"> + <field key="value">integer</field> + </object> + </object> + <object key="tax" dataType="tax"> + <field key="value">integer</field> + <object key="inherit" dataType="tax_inherit_value"> + <field key="value">integer</field> + </object> + </object> + <object key="weee" dataType="weee"> + <field key="value">integer</field> + <object key="inherit" dataType="weee_inherit_value"> + <field key="value">integer</field> + </object> + </object> + <object key="grand_total" dataType="grand_total"> + <field key="value">integer</field> + <object key="inherit" dataType="grand_total_inherit_value"> + <field key="value">integer</field> + </object> + </object> + </object> + </object> + </object> + </operation> </operations> diff --git a/app/code/Magento/Sales/Test/Mftf/Page/AdminInvoiceNewPage.xml b/app/code/Magento/Sales/Test/Mftf/Page/AdminInvoiceNewPage.xml index cfd5c5794a2de..4d01371f1efc5 100644 --- a/app/code/Magento/Sales/Test/Mftf/Page/AdminInvoiceNewPage.xml +++ b/app/code/Magento/Sales/Test/Mftf/Page/AdminInvoiceNewPage.xml @@ -11,6 +11,8 @@ <page name="AdminInvoiceNewPage" url="/sales/order_invoice/new/order_id/" area="admin" module="Magento_Sales"> <section name="AdminInvoiceNewSection"/> <section name="AdminInvoiceMainActionsSection"/> + <section name="AdminInvoiceOrderAndAccountInformationSection"/> + <section name="AdminInvoiceAddressInformationSection"/> <section name="AdminInvoiceTotalSection"/> <section name="AdminInvoiceItemsSection"/> </page> diff --git a/app/code/Magento/Sales/Test/Mftf/Page/AdminOrderAddressEditPage.xml b/app/code/Magento/Sales/Test/Mftf/Page/AdminOrderAddressEditPage.xml new file mode 100644 index 0000000000000..603250e8bac7b --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Page/AdminOrderAddressEditPage.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminOrderAddressEditPage" url="admin/sales/order/address/address_id/{{var1}}/" parameterized="true" + area="admin" module="Magento_Sales"> + <section name="AdminOrderAddressEditSection"/> + </page> +</pages> diff --git a/app/code/Magento/Sales/Test/Mftf/Page/AdminOrderCreatePage.xml b/app/code/Magento/Sales/Test/Mftf/Page/AdminOrderCreatePage.xml index 6522dbf3d25ce..e67a2c3b961d9 100644 --- a/app/code/Magento/Sales/Test/Mftf/Page/AdminOrderCreatePage.xml +++ b/app/code/Magento/Sales/Test/Mftf/Page/AdminOrderCreatePage.xml @@ -18,5 +18,6 @@ <section name="AdminOrderFormTotalSection"/> <section name="AdminOrderCustomersGridSection"/> <section name="AdminOrderFormConfigureProductSection"/> + <section name="AdminOrderStoreScopeTreeSection"/> </page> </pages> diff --git a/app/code/Magento/Sales/Test/Mftf/Page/AdminOrderDetailsPage.xml b/app/code/Magento/Sales/Test/Mftf/Page/AdminOrderDetailsPage.xml index 4280356278a88..c523bd0ed5f52 100644 --- a/app/code/Magento/Sales/Test/Mftf/Page/AdminOrderDetailsPage.xml +++ b/app/code/Magento/Sales/Test/Mftf/Page/AdminOrderDetailsPage.xml @@ -7,8 +7,8 @@ --> <pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/PageObject.xsd"> - <page name="AdminOrderDetailsPage" url="sales/order/view/order_id/" area="admin" module="Magento_Sales"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminOrderDetailsPage" url="sales/order/view/order_id/{{order_id}}/" area="admin" module="Magento_Sales" parameterized="true"> <section name="AdminMessagesSection"/> <section name="OrderDetailsMainActionsSection"/> <section name="OrderDetailsInformationSection"/> diff --git a/app/code/Magento/Sales/Test/Mftf/Page/AdminOrderInvoiceViewPage.xml b/app/code/Magento/Sales/Test/Mftf/Page/AdminOrderInvoiceViewPage.xml new file mode 100644 index 0000000000000..9f21245224748 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Page/AdminOrderInvoiceViewPage.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminOrderInvoiceViewPage" url="/sales/order_invoice/view/invoice_id/{{invoiceId}}" parameterized="true" area="admin" module="Magento_Sales"> + <section name="AdminOrderInvoiceViewMainActionsSection"/> + </page> +</pages> diff --git a/app/code/Magento/Sales/Test/Mftf/Page/StorefrontGuestOrderSearchPage.xml b/app/code/Magento/Sales/Test/Mftf/Page/StorefrontGuestOrderSearchPage.xml new file mode 100644 index 0000000000000..d2a4f4f0459c2 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Page/StorefrontGuestOrderSearchPage.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="StorefrontGuestOrderSearchPage" url="sales/guest/form/" module="Magento_Sales" area="storefront"> + <section name="StorefrontGuestOrderSearchSection"/> + </page> +</pages> diff --git a/app/code/Magento/Sales/Test/Mftf/Page/StorefrontGuestOrderViewPage.xml b/app/code/Magento/Sales/Test/Mftf/Page/StorefrontGuestOrderViewPage.xml new file mode 100644 index 0000000000000..69c7fa76129ea --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Page/StorefrontGuestOrderViewPage.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="StorefrontGuestOrderViewPage" url="sales/guest/view/" module="Magento_Sales" area="storefront"> + <section name="StorefrontGuestOrderViewSection"/> + </page> +</pages> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/AdminCreditMemoItemsSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/AdminCreditMemoItemsSection.xml index d835bfe069683..496fae2b548a7 100644 --- a/app/code/Magento/Sales/Test/Mftf/Section/AdminCreditMemoItemsSection.xml +++ b/app/code/Magento/Sales/Test/Mftf/Section/AdminCreditMemoItemsSection.xml @@ -7,7 +7,7 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminCreditMemoItemsSection"> <element name="itemQtyToRefund" type="input" selector=".order-creditmemo-tables tbody:nth-of-type({{row}}) .col-refund .qty-input" parameterized="true"/> <element name="updateQty" type="button" selector=".order-creditmemo-tables tfoot button[data-ui-id='order-items-update-button']" timeout="30"/> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/AdminCreditMemoTotalSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/AdminCreditMemoTotalSection.xml index 5fa5584fa8824..648f519455b56 100644 --- a/app/code/Magento/Sales/Test/Mftf/Section/AdminCreditMemoTotalSection.xml +++ b/app/code/Magento/Sales/Test/Mftf/Section/AdminCreditMemoTotalSection.xml @@ -7,9 +7,12 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminCreditMemoTotalSection"> <element name="total" type="text" selector="//table[contains(@class,'order-subtotal-table')]/tbody/tr/td[contains(text(), '{{total}}')]/following-sibling::td/span/span[contains(@class, 'price')]" parameterized="true"/> - <element name="submitRefundOffline" type="button" selector=".order-totals-actions button[data-ui-id='order-items-submit-button']" timeout="30"/> + <element name="refundShipping" type="input" selector=".order-subtotal-table tbody input[name='creditmemo[shipping_amount]']"/> + <element name="updateTotals" type="button" selector=".update-totals-button" timeout="30"/> + <element name="submitRefundOffline" type="button" selector=".order-totals-actions button[title='Refund Offline']" timeout="30"/> + <element name="submitRefundOfflineEnabled" type="button" selector=".order-totals-actions button[data-ui-id='order-items-submit-button'][class='action-default scalable save submit-button primary']" timeout="30"/> </section> </sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/AdminInvoiceAddressInformationSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/AdminInvoiceAddressInformationSection.xml new file mode 100644 index 0000000000000..a3fca029096ec --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Section/AdminInvoiceAddressInformationSection.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminInvoiceAddressInformationSection"> + <element name="billingAddress" type="text" selector=".order-billing-address address"/> + <element name="billingAddressEdit" type="button" selector=".order-billing-address .actions a"/> + <element name="shippingAddress" type="text" selector=".order-shipping-address address"/> + <element name="shippingAddressEdit" type="button" selector=".order-shipping-address .actions a"/> + </section> +</sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/AdminInvoiceItemsSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/AdminInvoiceItemsSection.xml index dbae8042b6554..93d1b32f58dc0 100644 --- a/app/code/Magento/Sales/Test/Mftf/Section/AdminInvoiceItemsSection.xml +++ b/app/code/Magento/Sales/Test/Mftf/Section/AdminInvoiceItemsSection.xml @@ -7,10 +7,13 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminInvoiceItemsSection"> <element name="itemQty" type="text" selector=".order-invoice-tables tbody:nth-of-type({{row}}) .col-qty .qty-table" parameterized="true"/> <element name="itemQtyToInvoice" type="input" selector=".order-invoice-tables tbody:nth-of-type({{row}}) .col-qty-invoice .qty-input" parameterized="true"/> <element name="updateQty" type="button" selector=".order-invoice-tables tfoot button[data-ui-id='order-items-update-button']"/> + <element name="updateQtyEnabled" type="button" selector=".order-invoice-tables tfoot button[data-ui-id='order-items-update-button'][class='action-default scalable update-button']"/> + <element name="productColumn" type="text" selector="//*[contains(@class,'order-invoice-tables')]//td[@class = 'col-product']//div[contains(text(),'{{productName}}')]" parameterized="true"/> + <element name="itemQtyToInvoiceBySku" type="input" selector="//div[contains(@class,'product-sku-block') and contains(., '{{productSku}}')]/ancestor::tr//td[contains(@class,'col-qty-invoice')]//input" parameterized="true"/> </section> </sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/AdminInvoiceMainActionsSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/AdminInvoiceMainActionsSection.xml index c1a9718b29b1c..48dc5e5b109fd 100644 --- a/app/code/Magento/Sales/Test/Mftf/Section/AdminInvoiceMainActionsSection.xml +++ b/app/code/Magento/Sales/Test/Mftf/Section/AdminInvoiceMainActionsSection.xml @@ -7,8 +7,8 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminInvoiceMainActionsSection"> - <element name="submitInvoice" type="button" selector=".action-default.scalable.save.submit-button.primary"/> + <element name="submitInvoice" type="button" selector=".action-default.scalable.save.submit-button.primary" timeout="60"/> </section> </sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/AdminInvoiceOrderAndAccountInformationSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/AdminInvoiceOrderAndAccountInformationSection.xml new file mode 100644 index 0000000000000..e549f27155309 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Section/AdminInvoiceOrderAndAccountInformationSection.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminInvoiceOrderAndAccountInformationSection"> + <element name="customerName" type="text" selector=".order-account-information table tr:first-of-type > td span"/> + <element name="customerEmail" type="text" selector=".order-account-information table tr:nth-of-type(2) > td a"/> + <element name="customerGroup" type="text" selector=".order-account-information table tr:nth-of-type(3) > td"/> + </section> +</sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderAddressEditSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderAddressEditSection.xml new file mode 100644 index 0000000000000..5f2fbcbdde784 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderAddressEditSection.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminOrderAddressEditSection"> + <element name="customAttribute" type="input" selector="input#{{arg}}" parameterized="true"/> + </section> +</sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderAddressInformationSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderAddressInformationSection.xml index a9755bbbc1b61..2e9c7ee13c848 100644 --- a/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderAddressInformationSection.xml +++ b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderAddressInformationSection.xml @@ -11,5 +11,6 @@ <section name="AdminOrderAddressInformationSection"> <element name="billingAddress" type="text" selector=".order-billing-address address"/> <element name="shippingAddress" type="text" selector=".order-shipping-address address"/> + <element name="editBillingAddress" type="text" selector="//div[@class='admin__page-section-item order-billing-address']//a[contains(text(),'Edit')]"/> </section> </sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormBundleProductSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormBundleProductSection.xml new file mode 100644 index 0000000000000..44a488204f775 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormBundleProductSection.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminOrderFormBundleProductSection"> + <element name="quantity" type="input" selector="#product_composite_configure_input_qty"/> + <element name="ok" type="button" selector=".modal-header .page-actions button[data-role='action']" timeout="30"/> + </section> +</sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormItemsSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormItemsSection.xml index c4cf5bd05bb6f..3cc09f6fef40f 100644 --- a/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormItemsSection.xml +++ b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormItemsSection.xml @@ -14,6 +14,7 @@ <element name="skuFilter" type="input" selector="#sales_order_create_search_grid_filter_sku"/> <element name="rowCheck" type="checkbox" selector="#sales_order_create_search_grid_table > tbody tr:nth-of-type({{row}}) td.col-select [type=checkbox]" parameterized="true"/> <element name="rowQty" type="input" selector="#sales_order_create_search_grid_table > tbody tr:nth-of-type({{row}}) td.col-qty [name='qty']" parameterized="true"/> + <element name="rowPrice" type="text" selector="#sales_order_create_search_grid_table > tbody tr:nth-of-type({{row}}) td.price" parameterized="true"/> <element name="addSelected" type="button" selector="#order-search .admin__page-section-title .actions button.action-add" timeout="30"/> <element name="configure" type="button" selector=".product-configure-block button.action-default.scalable" timeout="30"/> <element name="selectProduct" type="checkbox" selector="//td[contains(text(), '{{arg}}')]/following-sibling::td[contains(@class, 'col-select col-in_products')]" parameterized="true"/> @@ -26,5 +27,7 @@ <element name="removeItems" type="select" selector="//span[text()='{{arg}}']/parent::td/following-sibling::td/select[@class='admin__control-select']" parameterized="true"/> <element name="applyCoupon" type="input" selector="#coupons:code"/> <element name="customPriceField" type="input" selector="//*[@class='custom-price-block']/following-sibling::input"/> + <element name="productRowBySku" type="block" selector="//*[@id='order-items_grid']//tbody//td[contains(@class,'col-product')]/span[starts-with(@id,'order_item_') and text()='{{productName}}']/ancestor::tr" parameterized="true"/> + <element name="itemsOrderedSummaryText" type="text" selector="#order-items_grid tfoot tr"/> </section> </sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormPaymentSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormPaymentSection.xml index 419c7877246b2..22bff9c286d0f 100644 --- a/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormPaymentSection.xml +++ b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderFormPaymentSection.xml @@ -14,5 +14,7 @@ <element name="flatRateOption" type="radio" selector="#s_method_flatrate_flatrate" timeout="30"/> <element name="shippingError" type="text" selector="#order[has_shipping]-error"/> <element name="freeShippingOption" type="radio" selector="#s_method_freeshipping_freeshipping" timeout="30"/> + <element name="checkMoneyOption" type="radio" selector="#p_method_checkmo" timeout="30"/> + <element name="paymentBlock" type="text" selector="#order-billing_method" /> </section> </sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderInvoiceViewMainActionsSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderInvoiceViewMainActionsSection.xml new file mode 100644 index 0000000000000..56ff0c8182386 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderInvoiceViewMainActionsSection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminOrderInvoiceViewMainActionsSection"> + <element name="creditMemo" type="button" selector=".credit-memo" timeout="30"/> + </section> +</sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderStoreScopeTreeSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderStoreScopeTreeSection.xml new file mode 100644 index 0000000000000..cbe17499319f9 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Section/AdminOrderStoreScopeTreeSection.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminOrderStoreScopeTreeSection"> + <element name="storeTree" type="text" selector="div.tree-store-scope"/> + <element name="storeOption" type="radio" selector="//div[contains(@class, 'tree-store-scope')]//label[contains(text(), '{{name}}')]/preceding-sibling::input" parameterized="true" timeout="30"/> + <element name="storeForOrder" type="radio" selector="//div[contains(@class, 'tree-store-scope')]//label[contains(text(), '{{arg}}')]" parameterized="true" timeout="30"/> + </section> +</sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/StorefrontGuestOrderSearchSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/StorefrontGuestOrderSearchSection.xml new file mode 100644 index 0000000000000..b3fd2eeb05c96 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Section/StorefrontGuestOrderSearchSection.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontGuestOrderSearchSection"> + <element name="orderId" type="input" selector="#oar-order-id"/> + <element name="billingLastName" type="input" selector="#oar-billing-lastname"/> + <element name="findOrderBy" type="select" selector="#quick-search-type-id"/> + <element name="email" type="input" selector="#oar_email"/> + <element name="continue" type="button" selector="//span[contains(text(), 'Continue')]"/> + </section> +</sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Section/StorefrontGuestOrderViewSection.xml b/app/code/Magento/Sales/Test/Mftf/Section/StorefrontGuestOrderViewSection.xml new file mode 100644 index 0000000000000..31c6b4e95796e --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Section/StorefrontGuestOrderViewSection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontGuestOrderViewSection"> + <element name="orderInformationTab" type="text" selector="//*[contains(@class,'nav')]/strong[contains(text(), 'Order Information')]"/> + </section> +</sections> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminAbleToShipPartiallyInvoicedItemsTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminAbleToShipPartiallyInvoicedItemsTest.xml index 66a9709473623..cb761dc358abb 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/AdminAbleToShipPartiallyInvoicedItemsTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminAbleToShipPartiallyInvoicedItemsTest.xml @@ -6,7 +6,7 @@ */ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/testSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> <test name="AdminAbleToShipPartiallyInvoicedItemsTest"> <annotations> <title value="Ship Action is available for remaining of the partially invoiced items "/> @@ -14,6 +14,9 @@ <severity value="CRITICAL"/> <testCaseId value="MAGETWO-95524"/> <group value="sales"/> + <skip> + <issueId value="MAGETWO-97664"/> + </skip> </annotations> <before> <createData entity="_defaultCategory" stepKey="createCategory"/> @@ -56,8 +59,10 @@ <click selector="{{AdminOrderFormActionSection.submitOrder}}" after="seeCorrectGrandTotal" stepKey="clickSubmitOrder"/> <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskTodisappear"/> <waitForPageLoad stepKey="waitForOrderSubmitted"/> - <seeInCurrentUrl url="{{AdminOrderDetailsPage.url}}" after="clickSubmitOrder" stepKey="seeViewOrderPage"/> + <grabFromCurrentUrl regex="~/order_id/(\d+)/~" stepKey="grabOrderId"/> + <seeInCurrentUrl url="{{AdminOrderDetailsPage.url('$grabOrderId')}}" after="grabOrderId" stepKey="seeViewOrderPage"/> <see selector="{{AdminMessagesSection.success}}" userInput="You created the order." after="seeViewOrderPage" stepKey="seeSuccessMessage"/> + <see selector="{{AdminOrderDetailsInformationSection.orderStatus}}" userInput="{{CONST.orderStatusPending}}" stepKey="seeOrderPending"/> <grabTextFrom selector="|Order # (\d+)|" after="seeSuccessMessage" stepKey="getOrderId"/> <scrollTo selector="{{AdminOrderItemsOrderedSection.qtyColumn}}" after="getOrderId" stepKey="scrollToItemsOrdered"/> <see selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" userInput="Ordered 10" after="scrollToItemsOrdered" stepKey="seeQtyOfItemsOrdered"/> @@ -69,14 +74,16 @@ <scrollTo selector="{{AdminInvoiceItemsSection.itemQtyToInvoice('1')}}" stepKey="scrollToItemsInvoiced"/> <!--Change invoiced items count--> <fillField selector="{{AdminInvoiceItemsSection.itemQtyToInvoice('1')}}" userInput="5" stepKey="invoiceHalfTheItems"/> + <waitForElementVisible selector="{{AdminInvoiceItemsSection.updateQtyEnabled}}" stepKey="waitForEnabledQtyToBeInvoicedSubmitButton"/> <click selector="{{AdminInvoiceItemsSection.updateQty}}" stepKey="updateQtyToBeInvoiced"/> <waitForLoadingMaskToDisappear stepKey="waitForQtyToUpdate"/> <waitForElementVisible selector="{{AdminInvoiceMainActionsSection.submitInvoice}}" stepKey="waitforSubmitInvoiceBtn"/> <!--Submit Invoice--> <click selector="{{AdminInvoiceMainActionsSection.submitInvoice}}" stepKey="submitInvoice"/> - <waitForLoadingMaskToDisappear stepKey="waitForInvoiceToSubmit"/> + <waitForPageLoad stepKey="waitForInvoiceToSubmit1"/> <!--Invoice created successfully--> <see selector="{{AdminMessagesSection.success}}" userInput="The invoice has been created." stepKey="seeInvoiceSuccessMessage"/> + <see selector="{{AdminOrderDetailsInformationSection.orderStatus}}" userInput="{{CONST.orderStatusProcessing}}" stepKey="seeOrderProcessing1"/> <scrollTo selector="{{AdminOrderItemsOrderedSection.itemStatus('1')}}" stepKey="scrollToOrderItems"/> <see selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" userInput="Invoiced 5" stepKey="see5itemsInvoiced"/> <scrollTo selector="{{AdminHeaderSection.pageTitle}}" stepKey="scrollToTopOfPage"/> @@ -96,6 +103,7 @@ <waitForLoadingMaskToDisappear stepKey="waitForShipmentToSubmit"/> <!--Verify shipment created successfully--> <see selector="{{AdminMessagesSection.success}}" userInput="The shipment has been created." after="submitShipment" stepKey="successfullShipmentCreation"/> + <see selector="{{AdminOrderDetailsInformationSection.orderStatus}}" userInput="{{CONST.orderStatusProcessing}}" stepKey="seeOrderProcessing2"/> <see selector="{{AdminHeaderSection.pageTitle}}" userInput="$getOrderId" stepKey="seeOrderIdInPageTitleAfterShip"/> <scrollTo selector="{{AdminOrderItemsOrderedSection.itemStatus('1')}}" stepKey="scrollToOrderItems1"/> <see selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" userInput="Shipped 5" stepKey="see5itemsShipped"/> @@ -110,13 +118,16 @@ <see selector="{{AdminHeaderSection.pageTitle}}" userInput="New Memo" stepKey="seeNewMemoInPageTitle"/> <!--Submit refund--> <scrollTo selector="{{AdminCreditMemoItemsSection.itemQtyToRefund('1')}}" stepKey="scrollToItemsToRefund"/> - <fillField selector="{{AdminCreditMemoItemsSection.itemQtyToRefund('1')}}" userInput="5" after="scrollToItemsToRefund" stepKey="fillQtyOfItemsToRefund"/> - <click selector="{{AdminCreditMemoItemsSection.updateQty}}" stepKey="updateRefundQty"/> - <waitForLoadingMaskToDisappear stepKey="waitForRefundQtyToUpdate"/> - <waitForElementVisible selector="{{AdminCreditMemoTotalSection.submitRefundOffline}}" stepKey="seeSubmitRefundBtn"/> + <seeInField selector="{{AdminCreditMemoItemsSection.itemQtyToRefund('1')}}" userInput="5" after="scrollToItemsToRefund" stepKey="checkQtyOfItemsToRefund"/> + <waitForElementVisible selector="{{AdminCreditMemoTotalSection.submitRefundOfflineEnabled}}" stepKey="waitForSubmitRefundOfflineEnabled"/> <click selector="{{AdminCreditMemoTotalSection.submitRefundOffline}}" stepKey="submitRefundOffline"/> <!--Verify Credit Memo created successfully--> + <waitForElementVisible + selector="{{AdminMessagesSection.success}}" + time="20" + stepKey="waitForMessage"/> <see selector="{{AdminMessagesSection.success}}" userInput="You created the credit memo." after="submitRefundOffline" stepKey="seeCreditMemoSuccessMsg"/> + <see selector="{{AdminOrderDetailsInformationSection.orderStatus}}" userInput="{{CONST.orderStatusProcessing}}" stepKey="seeOrderProcessing3"/> <scrollTo selector="{{AdminOrderItemsOrderedSection.itemStatus('1')}}" stepKey="scrollToOrderItems2"/> <see selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" userInput="Refunded 5" stepKey="see5itemsRefunded"/> <scrollTo selector="{{AdminHeaderSection.pageTitle}}" stepKey="scrollToTopOfPage2"/> @@ -135,8 +146,10 @@ <see selector="{{AdminInvoiceItemsSection.itemQty('1')}}" userInput="Refunded 5" stepKey="seeQtyOfItemsRefunded"/> <!--Submit Invoice--> <click selector="{{AdminInvoiceMainActionsSection.submitInvoice}}" stepKey="submitInvoice2" /> + <waitForPageLoad stepKey="waitForInvoiceToSubmit2"/> <!--Invoice created successfully for the rest of the ordered items--> - <see selector="{{AdminMessagesSection.success}}" userInput="The invoice has been created." after="submitInvoice2" stepKey="seeInvoiceSuccessMessage2"/> + <see selector="{{AdminMessagesSection.success}}" userInput="The invoice has been created." stepKey="seeInvoiceSuccessMessage2"/> + <see selector="{{AdminOrderDetailsInformationSection.orderStatus}}" userInput="{{CONST.orderStatusProcessing}}" stepKey="seeOrderProcessing4"/> <scrollTo selector="{{AdminOrderItemsOrderedSection.itemStatus('1')}}" stepKey="scrollToOrderItems3"/> <see selector="{{AdminOrderItemsOrderedSection.itemQty('1')}}" userInput="Invoiced 10" stepKey="see10itemsInvoiced"/> <scrollTo selector="{{AdminHeaderSection.pageTitle}}" stepKey="scrollToTopOfPage3"/> @@ -155,6 +168,7 @@ <!--Submit Shipment--> <click selector="{{AdminShipmentMainActionsSection.submitShipment}}" after="fillRestOfItemsToShip" stepKey="submitShipment2" /> <see selector="{{AdminMessagesSection.success}}" userInput="The shipment has been created." after="submitShipment2" stepKey="successfullyCreatedShipment"/> + <see selector="{{AdminOrderDetailsInformationSection.orderStatus}}" userInput="{{CONST.orderStatusComplete}}" stepKey="seeOrderComplete"/> <!--Verify Items Status and Shipped Qty in the Items Ordered section--> <scrollTo selector="{{AdminOrderItemsOrderedSection.itemStatus('1')}}" stepKey="scrollToItemsOrdered2"/> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminAvailabilityCreditMemoWithNoPaymentTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminAvailabilityCreditMemoWithNoPaymentTest.xml index 379cb67d3e52f..9bd906a8abe08 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/AdminAvailabilityCreditMemoWithNoPaymentTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminAvailabilityCreditMemoWithNoPaymentTest.xml @@ -72,19 +72,14 @@ <click selector="{{AdminOrderFormActionSection.submitOrder}}" after="selectFreeShippingOption" stepKey="clickSubmitOrder"/> <!--Click *Invoice* button--> - <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoiceButton"/> - <see selector="{{AdminHeaderSection.pageTitle}}" userInput="New Invoice" after="clickInvoiceButton" stepKey="seeNewInvoiceInPageTitle"/> - <waitForPageLoad stepKey="waitForInvoicePageOpened"/> - - <click selector="{{AdminInvoiceMainActionsSection.submitInvoice}}" stepKey="clickSubmitInvoice"/> - <waitForPageLoad stepKey="waitForInvoiceSaved"/> - <see userInput="The invoice has been created." stepKey="seeCorrectMessage"/> + <actionGroup ref="StartCreateInvoiceFromOrderPage" stepKey="startCreateInvoice"/> + <actionGroup ref="SubmitInvoice" stepKey="submitInvoice"/> <!--Verify that *Credit Memo* button is displayed--> <seeElement selector="{{AdminOrderDetailsMainActionsSection.creditMemo}}" stepKey="seeCreditMemo"/> <click selector="{{AdminOrderDetailsMainActionsSection.creditMemo}}" stepKey="clickCreditMemoItem"/> <waitForPageLoad stepKey="waitForCreditMemoPageLoaded"/> - <see userInput="New Memo" stepKey="seeNewMemoPage"/> - <seeInCurrentUrl url="{{AdminCreditMemoNewPage.url}}" stepKey="seeUrlOnPage"/> + <see selector="{{AdminHeaderSection.pageTitle}}" userInput="New Memo" stepKey="seeNewMemoPageTitle"/> + <seeInCurrentUrl url="{{AdminCreditMemoNewPage.url}}" stepKey="seeNewMemoUrlOnPage"/> </test> </tests> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminCheckingCreditMemoTotalsTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminCheckingCreditMemoTotalsTest.xml new file mode 100644 index 0000000000000..b73c66d49d690 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminCheckingCreditMemoTotalsTest.xml @@ -0,0 +1,112 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminCheckingCreditMemoTotalsTest"> + <annotations> + <features value="CreditMemo"/> + <stories value="MAGETWO-82400: Credit Memo - Wrong tax calculation! #10982"/> + <title value="Checking Credit Memo Totals"/> + <description value="Checking Credit Memo Totals"/> + <severity value="MAJOR"/> + <testCaseId value="MAGETWO-97140"/> + <group value="sales"/> + <group value="tax"/> + <skip> + <issueId value="MAGETWO-97826"/> + </skip> + </annotations> + <before> + <!--Create category--> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <!--Create simple product--> + <createData entity="_defaultProduct" stepKey="createSimpleProduct"> + <requiredEntity createDataKey="createCategory"/> + <field key="price">100</field> + </createData> + <!--Create Tax Rule--> + <createData entity="SimpleTaxRule" stepKey="createTaxRule"/> + <!--Create customer--> + <createData entity="Simple_US_CA_Customer" stepKey="createCustomer"/> + <!--Configure Tax Class for shipping--> + <createData entity="TaxClassForShippingConfig" stepKey="configureTaxClassForShipping"/> + <!--Configure Braintree--> + <createData entity="SandboxBraintreeConfig" stepKey="configureBraintree"/> + <!--Login to admin page--> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + </before> + <after> + <!--Delete category--> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <!--Delete simple product--> + <deleteData createDataKey="createSimpleProduct" stepKey="deleteSimpleProduct"/> + <!--Delete Tax Rule--> + <deleteData createDataKey="createTaxRule" stepKey="deleteTaxRule"/> + <!--Delete customer--> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <!--Restore default configuration for Tax Class for shipping--> + <createData entity="DefaultTaxClassForShippingConfig" stepKey="restoreTaxClassForShippingConfig"/> + <!--Restore default configuration for Braintree--> + <createData entity="DefaultBraintreeConfig" stepKey="restoreBraintreeConfig"/> + <!--Logout from admin page--> + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <!--Create new order with existing customer--> + <actionGroup ref="navigateToNewOrderPageExistingCustomer" stepKey="navigateToNewOrderPage"> + <argument name="customer" value="$$createCustomer$$"/> + </actionGroup> + <!--Add simple product to order--> + <actionGroup ref="addSimpleProductToOrder" stepKey="addSimpleProductToOrder"> + <argument name="product" value="$$createSimpleProduct$$"/> + </actionGroup> + <!--Select Flat Rate shipping method--> + <actionGroup ref="orderSelectFlatRateShipping" stepKey="selectFlatRateShipping"/> + <!--Fill Braintree credit card for payment method--> + <actionGroup ref="AdminOrderFillBraintreeCreditCardActionGroup" stepKey="fillBraintreeCreditCard"/> + <!--Submit order--> + <click selector="{{AdminOrderFormActionSection.submitOrder}}" stepKey="submitOrder"/> + <waitForPageLoad stepKey="waitForSubmitOrder"/> + <see selector="{{AdminMessagesSection.success}}" userInput="You created the order." + stepKey="seeSuccessMessage"/> + + <!--Create invoice--> + <actionGroup ref="StartCreateInvoiceFromOrderPage" stepKey="startCreateInvoice"/> + <!--Submit invoice--> + <actionGroup ref="SubmitInvoice" stepKey="submitInvoice"/> + + <!--Go to invoice page--> + <click selector="{{AdminOrderViewTabsSection.invoices}}" stepKey="clickInvoicesTab"/> + <waitForPageLoad stepKey="waitForInvoiceGridToLoad"/> + <see selector="{{AdminOrderInvoicesTabSection.gridRow('1')}}" userInput="$113.66" stepKey="seeInvoiceInGrid"/> + <click selector="{{AdminDataGridTableSection.rowViewAction('1')}}" stepKey="clickViewInvoice"/> + + <!--Create Credit Memo--> + <click selector="{{AdminOrderInvoiceViewMainActionsSection.creditMemo}}" stepKey="clickCreditMemo"/> + <see selector="{{AdminHeaderSection.pageTitle}}" userInput="New Memo" stepKey="seeNewMemoPageTitle"/> + <fillField selector="{{AdminCreditMemoTotalSection.refundShipping}}" userInput="0" stepKey="setRefundShipping"/> + <click selector="{{AdminCreditMemoTotalSection.updateTotals}}" stepKey="clickUpdateTotals"/> + <waitForLoadingMaskToDisappear stepKey="waitForUpdateTotals"/> + <click selector="{{AdminCreditMemoTotalSection.submitRefundOffline}}" stepKey="clickRefundOffline"/> + <waitForElementVisible + selector="{{AdminMessagesSection.success}}" + time="10" + stepKey="waitForMessage"/> + <see selector="{{AdminMessagesSection.success}}" userInput="You created the credit memo." + stepKey="seeCreatedCreditMemoSuccessMessage"/> + + <!--Go to Credit Memo tab--> + <click selector="{{AdminOrderViewTabsSection.creditMemos}}" stepKey="clickCreditMemosTab"/> + <waitForPageLoad stepKey="waitForCreditMemosGridToLoad"/> + + <!--Check refunded total --> + <see selector="{{AdminOrderCreditMemosTabSection.gridRow('1')}}" userInput="$108.25" + stepKey="seeCreditMemoInGrid"/> + </test> +</tests> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoWhenCartRuleDeletedTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoWhenCartRuleDeletedTest.xml new file mode 100644 index 0000000000000..5386ade5dffd2 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateCreditMemoWhenCartRuleDeletedTest.xml @@ -0,0 +1,91 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminCreateCreditMemoWhenCartRuleDeletedTest"> + <annotations> + <stories value="MAGETWO-95722: Cannot create credit memo if the used in the order cart rule is deleted"/> + <title value="Checking creating of credit memo"/> + <description value="Verify Credit Memo created if the used in the order cart rule is deleted"/> + <severity value="MAJOR"/> + <testCaseId value="MAGETWO-97232"/> + <group value="sales"/> + <skip> + <issueId value="MAGETWO-97825"/> + </skip> + </annotations> + <before> + <!--Create product with category--> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <createData entity="_defaultProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <!--Login as admin--> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + </before> + <after> + <!--Clear filters--> + <amOnPage url="{{AdminOrdersPage.url}}" stepKey="navigateToOrderGridPage"/> + <actionGroup ref="clearFiltersAdminDataGrid" stepKey="clearOrdersFilters"/> + <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceRulePage"/> + <actionGroup ref="clearFiltersAdminDataGrid" stepKey="clearCartPriceRuleFilters"/> + <!--Delete product and category--> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <!--Log Out--> + <actionGroup ref="logout" stepKey="logOut"/> + </after> + <!--Create Cart Price Rule with a specific coupon--> + <actionGroup ref="AdminCreateCartPriceRuleSpecificCouponActionGroup" stepKey="createCartPriceRule"> + <argument name="rule" value="TestSalesRule"/> + <argument name="userPerCoupon" value="99"/> + </actionGroup> + <!--Go to Storefront. Add product to cart--> + <amOnPage url="{{StorefrontProductPage.url($$createProduct.name$$)}}" stepKey="goToProductPage"/> + <actionGroup ref="ApplyCartRuleOnStorefrontActionGroup" stepKey="addProductToCardAndApplyCartRule"> + <argument name="product" value="$$createProduct$$"/> + <argument name="couponCode" value="{{_defaultCoupon.code}}"/> + </actionGroup> + <!--Proceed to checkout--> + <actionGroup ref="GoToCheckoutFromMinicartActionGroup" stepKey="goToCheckoutPage"/> + <actionGroup ref="GuestCheckoutFillingShippingSectionActionGroup" stepKey="guestCheckoutFillingShippingSection"> + <argument name="customerVar" value="Simple_US_Customer"/> + <argument name="customerAddressVar" value="US_Address_TX"/> + <argument name="shippingMethod" value="Flat Rate"/> + </actionGroup> + <!--Select Check/Money payment--> + <actionGroup ref="CheckoutSelectCheckMoneyOrderPaymentActionGroup" stepKey="selectPaymentMethod"/> + <!--Place order--> + <actionGroup ref="ClickPlaceOrderActionGroup" stepKey="placeOrder"/> + <grabTextFrom selector="{{CheckoutSuccessMainSection.orderNumber}}" stepKey="grabOrderNumber"/> + <!--Open Order--> + <actionGroup ref="filterOrderGridById" stepKey="filterOrderGridById1"> + <argument name="orderId" value="{$grabOrderNumber}"/> + </actionGroup> + <click selector="{{AdminDataGridTableSection.firstRow}}" stepKey="clickOrderRow1"/> + <!--Create and Submit Invoice--> + <actionGroup ref="StartCreateInvoiceFromOrderPage" stepKey="createInvoice"/> + <actionGroup ref="SubmitInvoice" stepKey="submitInvoice"/> + <!--Delete the cart price rule --> + <actionGroup ref="DeleteCartPriceRuleByName" stepKey="deleteCartPriceRule"> + <argument name="ruleName" value="{{TestSalesRule.name}}"/> + </actionGroup> + <!--Open Order--> + <actionGroup ref="filterOrderGridById" stepKey="filterOrderGridById2"> + <argument name="orderId" value="{$grabOrderNumber}"/> + </actionGroup> + <click selector="{{AdminDataGridTableSection.firstRow}}" stepKey="clickOrderRow2"/> + <!--Create Credit Memo--> + <click selector="{{AdminOrderDetailsMainActionsSection.creditMemo}}" stepKey="clickCreateCreditMemo"/> + <see selector="{{AdminHeaderSection.pageTitle}}" userInput="New Memo" stepKey="seeNewMemoInPageTitle"/> + <click selector="{{AdminCreditMemoTotalSection.submitRefundOffline}}" stepKey="clickRefundOffline"/> + <!--Make sure that Credit memo was created successfully--> + <see selector="{{AdminMessagesSection.success}}" userInput="You created the credit memo." stepKey="seeCreditMemoSuccess"/> + </test> +</tests> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateOrderWithBundleProductTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateOrderWithBundleProductTest.xml new file mode 100644 index 0000000000000..2332ea3723a42 --- /dev/null +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateOrderWithBundleProductTest.xml @@ -0,0 +1,111 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminCreateOrderWithBundleProductTest"> + <annotations> + <title value="Create Order in Admin and update bundle product configuration"/> + <stories value="MAGETWO-96394: Wrong price calculation for bundle product on creating order from the admin panel"/> + <description value="Add bundle product with bundle option items with default quantity 2 to order in Admin and check price in product grid"/> + <features value="Sales"/> + <severity value="AVERAGE"/> + <group value="Sales"/> + </annotations> + + <before> + <!--Set default flat rate shipping method settings--> + <createData entity="FlatRateShippingMethodDefault" stepKey="setDefaultFlatRateShippingMethod"/> + + <!--Create simple customer--> + <createData entity="Simple_US_CA_Customer" stepKey="simpleCustomer"/> + + <!--Create simple product 1--> + <createData entity="ApiProductWithDescription" stepKey="simple1" before="simple2"/> + + <!--Create simple product 2--> + <createData entity="ApiProductWithDescription" stepKey="simple2" before="product"/> + + <!--Create bundle product with checkbox bundle option--> + <createData entity="ApiBundleProduct" stepKey="product"/> + <createData entity="CheckboxOption" stepKey="checkboxBundleOption"> + <requiredEntity createDataKey="product"/> + </createData> + + <!--Link simple product 1 to bundle option with default quantity 2--> + <createData entity="ApiBundleLink" stepKey="createBundleLink1"> + <requiredEntity createDataKey="product"/> + <requiredEntity createDataKey="checkboxBundleOption"/> + <requiredEntity createDataKey="simple1"/> + <field key="qty">2</field> + <field key="is_default">1</field> + </createData> + + <!--Link simple product 2 to bundle option with default quantity 2--> + <createData entity="ApiBundleLink" stepKey="createBundleLink2"> + <requiredEntity createDataKey="product"/> + <requiredEntity createDataKey="checkboxBundleOption"/> + <requiredEntity createDataKey="simple2"/> + <field key="qty">2</field> + <field key="is_default">1</field> + </createData> + + <!--Add drop-down bundle option--> + <createData entity="DropDownBundleOption" stepKey="dropDownBundleOption"> + <requiredEntity createDataKey="product"/> + </createData> + + <!--Link simple product 1 to drop-down bundle option with default quantity 2--> + <createData entity="ApiBundleLink" stepKey="createBundleLink3"> + <requiredEntity createDataKey="product"/> + <requiredEntity createDataKey="dropDownBundleOption"/> + <requiredEntity createDataKey="simple1"/> + <field key="qty">2</field> + <field key="is_default">1</field> + </createData> + + <!--Link simple product 2 to drop-down bundle option with default quantity 2--> + <createData entity="ApiBundleLink" stepKey="createBundleLink4"> + <requiredEntity createDataKey="product"/> + <requiredEntity createDataKey="dropDownBundleOption"/> + <requiredEntity createDataKey="simple2"/> + <field key="qty">2</field> + </createData> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + </before> + + <!--Create new customer order--> + <actionGroup ref="navigateToNewOrderPageExistingCustomer" stepKey="navigateToNewOrderWithExistingCustomer"> + <argument name="customer" value="$$simpleCustomer$$"/> + </actionGroup> + + <!--Add bundle product to order and check product price in grid--> + <actionGroup ref="addBundleProductToOrderAndCheckPriceInGrid" stepKey="addBundleProductToOrder"> + <argument name="product" value="$$product$$"/> + <argument name="quantity" value="1"/> + <argument name="price" value="$738.00"/> + </actionGroup> + + <!--Select FlatRate shipping method--> + <actionGroup ref="orderSelectFlatRateShipping" stepKey="orderSelectFlatRateShippingMethod"/> + + <!--Submit order--> + <click selector="{{AdminOrderFormActionSection.submitOrder}}" stepKey="submitOrder"/> + + <!--Verify order information--> + <actionGroup ref="verifyCreatedOrderInformation" stepKey="verifyCreatedOrderInformation"/> + + <after> + <actionGroup ref="logout" stepKey="logout"/> + + <deleteData createDataKey="product" stepKey="delete"/> + <deleteData createDataKey="simpleCustomer" stepKey="deleteSimpleCustomer"/> + <deleteData createDataKey="simple1" stepKey="deleteSimple1" before="deleteSimple2"/> + <deleteData createDataKey="simple2" stepKey="deleteSimple2" before="delete"/> + </after> + </test> +</tests> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateOrderWithMinimumAmountEnabledTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateOrderWithMinimumAmountEnabledTest.xml index e339f8056e96d..70b39a664c0f2 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateOrderWithMinimumAmountEnabledTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreateOrderWithMinimumAmountEnabledTest.xml @@ -61,7 +61,8 @@ <!--Submit Order and verify information--> <click selector="{{AdminOrderFormActionSection.submitOrder}}" stepKey="clickSubmitOrder"/> - <seeInCurrentUrl url="{{AdminOrderDetailsPage.url}}" stepKey="seeViewOrderPage"/> + <grabFromCurrentUrl regex="~/order_id/(\d+)/~" stepKey="grabOrderId"/> + <seeInCurrentUrl url="{{AdminOrderDetailsPage.url('$grabOrderId')}}" stepKey="seeViewOrderPage"/> <see selector="{{AdminMessagesSection.success}}" userInput="You created the order." stepKey="seeSuccessMessage"/> <see selector="{{AdminOrderDetailsInformationSection.orderStatus}}" userInput="Pending" stepKey="seeOrderPendingStatus"/> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/CreditMemoTotalAfterShippingDiscountTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreditMemoTotalAfterShippingDiscountTest.xml similarity index 91% rename from app/code/Magento/Sales/Test/Mftf/Test/CreditMemoTotalAfterShippingDiscountTest.xml rename to app/code/Magento/Sales/Test/Mftf/Test/AdminCreditMemoTotalAfterShippingDiscountTest.xml index a703f5e690cfa..2f4271d56038b 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/CreditMemoTotalAfterShippingDiscountTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminCreditMemoTotalAfterShippingDiscountTest.xml @@ -7,8 +7,8 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/testSchema.xsd"> - <test name="CreditMemoTotalAfterShippingDiscountTest"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminCreditMemoTotalAfterShippingDiscountTest"> <annotations> <features value="Credit memo"/> <title value="Verify credit memo grand total after shipping discount is applied via Cart Price Rule"/> @@ -26,6 +26,8 @@ <actionGroup ref="SetTaxClassForShipping" stepKey="setShippingTaxClass"/> </before> <after> + <!--Clear filter in orders grid--> + <actionGroup ref="AdminOrdersGridClearFiltersActionGroup" stepKey="ordersGridClearFilters"/> <actionGroup ref="ResetTaxClassForShipping" stepKey="resetTaxClassForShipping"/> <actionGroup ref="DeleteCartPriceRuleByName" stepKey="deleteSalesRule"> <argument name="ruleName" value="{{ApiSalesRule.name}}"/> @@ -89,14 +91,15 @@ <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin1"/> <!-- Search for Order in the order grid --> - <amOnPage url="{{AdminOrdersPage.url}}" stepKey="onOrdersPage"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoadingMask3"/> - <fillField selector="{{OrdersGridSection.search}}" userInput="{$grabOrderNumber}" stepKey="searchOrderNum"/> - <click selector="{{OrdersGridSection.submitSearch}}" stepKey="submitSearch"/> + <actionGroup ref="filterOrderGridById" stepKey="filterOrderGridById"> + <argument name="orderId" value="$grabOrderNumber"/> + </actionGroup> <waitForLoadingMaskToDisappear stepKey="waitForLoadingMask4"/> <!-- Create invoice --> - <click selector="{{OrdersGridSection.firstRow}}" stepKey="clickOrderRow"/> + <click selector="{{AdminDataGridTableSection.firstRow}}" stepKey="clickOrderRow"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + <see selector="{{AdminOrderDetailsInformationSection.orderStatus}}" userInput="{{CONST.orderStatusPending}}" stepKey="seeOrderPending"/> <click selector="{{AdminOrderDetailsMainActionsSection.invoice}}" stepKey="clickInvoiceButton"/> <see selector="{{AdminHeaderSection.pageTitle}}" userInput="New Invoice" stepKey="seeNewInvoiceInPageTitle" after="clickInvoiceButton"/> @@ -114,6 +117,7 @@ <grabTextFrom selector="{{AdminInvoiceTotalSection.grandTotal}}" stepKey="grabInvoiceGrandTotal" after="seeCorrectGrandTotal"/> <click selector="{{AdminInvoiceMainActionsSection.submitInvoice}}" stepKey="clickSubmitInvoice"/> <see selector="{{OrderDetailsMessagesSection.successMessage}}" userInput="The invoice has been created." stepKey="seeSuccessMessage1"/> + <see selector="{{AdminOrderDetailsInformationSection.orderStatus}}" userInput="{{CONST.orderStatusProcessing}}" stepKey="seeOrderProcessing"/> <!--Create Credit Memo--> <comment userInput="Admin creates credit memo" stepKey="createCreditMemoComment"/> diff --git a/app/code/Magento/Sales/Test/Mftf/Test/AdminSubmitsOrderWithAndWithoutEmailTest.xml b/app/code/Magento/Sales/Test/Mftf/Test/AdminSubmitsOrderWithAndWithoutEmailTest.xml index fcef43c81bb1d..0bc26674e7dc8 100644 --- a/app/code/Magento/Sales/Test/Mftf/Test/AdminSubmitsOrderWithAndWithoutEmailTest.xml +++ b/app/code/Magento/Sales/Test/Mftf/Test/AdminSubmitsOrderWithAndWithoutEmailTest.xml @@ -6,7 +6,7 @@ */ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/testSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> <test name="AdminSubmitsOrderWithAndWithoutEmailTest"> <annotations> <title value="Email is required to create an order from Admin Panel"/> @@ -69,7 +69,8 @@ <!--Submit Order and verify information--> <click selector="{{AdminOrderFormActionSection.submitOrder}}" stepKey="clickSubmitOrder" after="seeCorrectGrandTotal"/> - <seeInCurrentUrl url="{{AdminOrderDetailsPage.url}}" after="clickSubmitOrder" stepKey="seeViewOrderPage"/> + <grabFromCurrentUrl regex="~/order_id/(\d+)/~" stepKey="grabOrderId"/> + <seeInCurrentUrl url="{{AdminOrderDetailsPage.url('$grabOrderId')}}" after="grabOrderId" stepKey="seeViewOrderPage"/> <see selector="{{AdminMessagesSection.success}}" userInput="You created the order." after="seeViewOrderPage" stepKey="seeSuccessMessage"/> </test> </tests> diff --git a/app/code/Magento/Sales/Test/Unit/Block/Order/Item/Renderer/DefaultRendererTest.php b/app/code/Magento/Sales/Test/Unit/Block/Order/Item/Renderer/DefaultRendererTest.php index 9561baf6bd5f4..7863fc20b7396 100644 --- a/app/code/Magento/Sales/Test/Unit/Block/Order/Item/Renderer/DefaultRendererTest.php +++ b/app/code/Magento/Sales/Test/Unit/Block/Order/Item/Renderer/DefaultRendererTest.php @@ -60,18 +60,7 @@ protected function setUp() ->setMethods(['setItem', 'toHtml']) ->getMock(); - $itemMockMethods = [ - '__wakeup', - 'getRowTotal', - 'getTaxAmount', - 'getDiscountAmount', - 'getDiscountTaxCompensationAmount', - 'getWeeeTaxAppliedRowAmount', - ]; - $this->itemMock = $this->getMockBuilder(\Magento\Sales\Model\Order\Item::class) - ->disableOriginalConstructor() - ->setMethods($itemMockMethods) - ->getMock(); + $this->itemMock = $this->createMock(\Magento\Sales\Model\Order\Item::class); } public function testGetItemPriceHtml() @@ -161,4 +150,20 @@ public function testGetTotalAmount() $this->assertEquals($expectedResult, $this->block->getTotalAmount($this->itemMock)); } + + /** + * @return void + */ + public function testGetBaseTotalAmount() + { + $expectedBaseTotalAmount = 10; + + $this->itemMock->expects($this->once())->method('getBaseRowTotal')->willReturn(8); + $this->itemMock->expects($this->once())->method('getBaseTaxAmount')->willReturn(1); + $this->itemMock->expects($this->once())->method('getBaseDiscountTaxCompensationAmount')->willReturn(1); + $this->itemMock->expects($this->once())->method('getBaseWeeeTaxAppliedAmount')->willReturn(1); + $this->itemMock->expects($this->once())->method('getBaseDiscountAmount')->willReturn(1); + + $this->assertEquals($expectedBaseTotalAmount, $this->block->getBaseTotalAmount($this->itemMock)); + } } diff --git a/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/AddCommentTest.php b/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/AddCommentTest.php new file mode 100644 index 0000000000000..d72121878e350 --- /dev/null +++ b/app/code/Magento/Sales/Test/Unit/Controller/Adminhtml/Order/AddCommentTest.php @@ -0,0 +1,186 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Sales\Test\Unit\Controller\Adminhtml\Order; + +/** + * Test for AddComment. + */ +class AddCommentTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var \Magento\Sales\Controller\Adminhtml\Order\AddComment + */ + private $addCommentController; + + /** + * @var \Magento\Backend\App\Action\Context|\PHPUnit_Framework_MockObject_MockObject + */ + private $contextMock; + + /** + * @var \Magento\Sales\Model\Order|\PHPUnit_Framework_MockObject_MockObject + */ + private $orderMock; + + /** + * @var \Magento\Backend\Model\View\Result\RedirectFactory|\PHPUnit_Framework_MockObject_MockObject + */ + private $resultRedirectFactoryMock; + + /** + * @var \Magento\Backend\Model\View\Result\Redirect|\PHPUnit_Framework_MockObject_MockObject + */ + private $resultRedirectMock; + + /** + * @var \Magento\Framework\App\Request\Http|\PHPUnit_Framework_MockObject_MockObject + */ + private $requestMock; + + /** + * @var \Magento\Sales\Api\OrderRepositoryInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $orderRepositoryMock; + + /** + * @var \Magento\Framework\AuthorizationInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $authorizationMock; + + /** + * @var \Magento\Sales\Model\Order\Status\History|\PHPUnit_Framework_MockObject_MockObject + */ + private $statusHistoryCommentMock; + + /** + * @var \Magento\Framework\ObjectManagerInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $objectManagerMock; + + /** + * @inheritdoc + */ + protected function setUp() + { + $this->contextMock = $this->createMock(\Magento\Backend\App\Action\Context::class); + $this->requestMock = $this->createMock(\Magento\Framework\App\Request\Http::class); + $this->orderRepositoryMock = $this->createMock(\Magento\Sales\Api\OrderRepositoryInterface::class); + $this->orderMock = $this->createMock(\Magento\Sales\Model\Order::class); + $this->resultRedirectFactoryMock = $this->createMock(\Magento\Backend\Model\View\Result\RedirectFactory::class); + $this->resultRedirectMock = $this->createMock(\Magento\Backend\Model\View\Result\Redirect::class); + $this->authorizationMock = $this->createMock(\Magento\Framework\AuthorizationInterface::class); + $this->statusHistoryCommentMock = $this->createMock(\Magento\Sales\Model\Order\Status\History::class); + $this->objectManagerMock = $this->createMock(\Magento\Framework\ObjectManagerInterface::class); + + $this->contextMock->expects($this->once())->method('getRequest')->willReturn($this->requestMock); + + $objectManagerHelper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->addCommentController = $objectManagerHelper->getObject( + \Magento\Sales\Controller\Adminhtml\Order\AddComment::class, + [ + 'context' => $this->contextMock, + 'orderRepository' => $this->orderRepositoryMock, + '_authorization' => $this->authorizationMock, + '_objectManager' => $this->objectManagerMock, + ] + ); + } + + /** + * Test for execute method with different data. + * + * @param array $historyData + * @param bool $userHasResource + * @param bool $expectedNotify + * + * @return void + * @dataProvider executeWillNotifyCustomerDataProvider + */ + public function testExecuteWillNotifyCustomer(array $historyData, bool $userHasResource, bool $expectedNotify) + { + $orderId = 30; + $this->requestMock->expects($this->once())->method('getParam')->with('order_id')->willReturn($orderId); + $this->orderRepositoryMock->expects($this->once()) + ->method('get') + ->willReturn($this->orderMock); + $this->requestMock->expects($this->once())->method('getPost')->with('history')->willReturn($historyData); + $this->authorizationMock->expects($this->any())->method('isAllowed')->willReturn($userHasResource); + $this->orderMock->expects($this->once()) + ->method('addStatusHistoryComment') + ->willReturn($this->statusHistoryCommentMock); + $this->statusHistoryCommentMock->expects($this->once())->method('setIsCustomerNotified')->with($expectedNotify); + $this->objectManagerMock->expects($this->once())->method('create')->willReturn( + $this->createMock(\Magento\Sales\Model\Order\Email\Sender\OrderCommentSender::class) + ); + + $this->addCommentController->execute(); + } + + /** + * Data provider for testExecuteWillNotifyCustomer method. + * + * @return array + */ + public function executeWillNotifyCustomerDataProvider(): array + { + return [ + 'User Has Access - Notify True' => [ + 'postData' => [ + 'comment' => 'Great Product!', + 'is_customer_notified' => true, + 'status' => 'Processing', + ], + 'userHasResource' => true, + 'expectedNotify' => true, + ], + 'User Has Access - Notify False' => [ + 'postData' => [ + 'comment' => 'Great Product!', + 'is_customer_notified' => false, + 'status' => 'Processing', + ], + 'userHasResource' => true, + 'expectedNotify' => false, + ], + 'User Has Access - Notify Unset' => [ + 'postData' => [ + 'comment' => 'Great Product!', + 'status' => 'Processing', + ], + 'userHasResource' => true, + 'expectedNotify' => false, + ], + 'User No Access - Notify True' => [ + 'postData' => [ + 'comment' => 'Great Product!', + 'is_customer_notified' => true, + 'status' => 'Processing', + ], + 'userHasResource' => false, + 'expectedNotify' => false, + ], + 'User No Access - Notify False' => [ + 'postData' => [ + 'comment' => 'Great Product!', + 'is_customer_notified' => false, + 'status' => 'Processing', + ], + 'userHasResource' => false, + 'expectedNotify' => false, + ], + 'User No Access - Notify Unset' => [ + 'postData' => [ + 'comment' => 'Great Product!', + 'status' => 'Processing', + ], + 'userHasResource' => false, + 'expectedNotify' => false, + ], + ]; + } +} diff --git a/app/code/Magento/Sales/Test/Unit/Model/CronJob/CleanExpiredOrdersTest.php b/app/code/Magento/Sales/Test/Unit/Model/CronJob/CleanExpiredOrdersTest.php index 269ce829e64d3..6844b908ea98d 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/CronJob/CleanExpiredOrdersTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/CronJob/CleanExpiredOrdersTest.php @@ -26,6 +26,11 @@ class CleanExpiredOrdersTest extends \PHPUnit\Framework\TestCase */ protected $orderCollectionMock; + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ + private $orderManagementMock; + /** * @var ObjectManager */ @@ -44,10 +49,12 @@ protected function setUp() ['create'] ); $this->orderCollectionMock = $this->createMock(\Magento\Sales\Model\ResourceModel\Order\Collection::class); + $this->orderManagementMock = $this->createMock(\Magento\Sales\Api\OrderManagementInterface::class); $this->model = new CleanExpiredOrders( $this->storesConfigMock, - $this->collectionFactoryMock + $this->collectionFactoryMock, + $this->orderManagementMock ); } @@ -64,8 +71,11 @@ public function testExecute() $this->collectionFactoryMock->expects($this->exactly(2)) ->method('create') ->willReturn($this->orderCollectionMock); + $this->orderCollectionMock->expects($this->exactly(2)) + ->method('getAllIds') + ->willReturn([1, 2]); $this->orderCollectionMock->expects($this->exactly(4))->method('addFieldToFilter'); - $this->orderCollectionMock->expects($this->exactly(4))->method('walk'); + $this->orderManagementMock->expects($this->exactly(4))->method('cancel'); $selectMock = $this->createMock(\Magento\Framework\DB\Select::class); $selectMock->expects($this->exactly(2))->method('where')->willReturnSelf(); @@ -92,14 +102,18 @@ public function testExecuteWithException() $this->collectionFactoryMock->expects($this->once()) ->method('create') ->willReturn($this->orderCollectionMock); + $this->orderCollectionMock->expects($this->once()) + ->method('getAllIds') + ->willReturn([1]); $this->orderCollectionMock->expects($this->exactly(2))->method('addFieldToFilter'); + $this->orderManagementMock->expects($this->once())->method('cancel'); $selectMock = $this->createMock(\Magento\Framework\DB\Select::class); $selectMock->expects($this->once())->method('where')->willReturnSelf(); $this->orderCollectionMock->expects($this->once())->method('getSelect')->willReturn($selectMock); - $this->orderCollectionMock->expects($this->once()) - ->method('walk') + $this->orderManagementMock->expects($this->once()) + ->method('cancel') ->willThrowException(new \Exception($exceptionMessage)); $this->model->execute(); diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/Creditmemo/Total/TaxTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/Creditmemo/Total/TaxTest.php index 01c0565a557a6..f9a5a428bb554 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/Order/Creditmemo/Total/TaxTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/Creditmemo/Total/TaxTest.php @@ -440,10 +440,10 @@ public function collectDataProvider() ], ], 'creditmemo_data' => [ - 'grand_total' => 64.95, - 'base_grand_total' => 64.95, - 'tax_amount' => 4.95, - 'base_tax_amount' => 4.95, + 'grand_total' => 64.94, + 'base_grand_total' => 64.94, + 'tax_amount' => 4.94, + 'base_tax_amount' => 4.94, ], ], ]; diff --git a/app/code/Magento/Sales/Test/Unit/Model/Order/Webapi/ChangeOutputArrayTest.php b/app/code/Magento/Sales/Test/Unit/Model/Order/Webapi/ChangeOutputArrayTest.php new file mode 100644 index 0000000000000..83c40707c0079 --- /dev/null +++ b/app/code/Magento/Sales/Test/Unit/Model/Order/Webapi/ChangeOutputArrayTest.php @@ -0,0 +1,92 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Sales\Test\Unit\Model\Order\Webapi; + +use Magento\Sales\Api\Data\OrderItemInterface; +use Magento\Sales\Block\Adminhtml\Items\Column\DefaultColumn; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Sales\Block\Order\Item\Renderer\DefaultRenderer; +use Magento\Sales\Model\Order\Webapi\ChangeOutputArray; + +/** + * Test for Magento\Sales\Model\Order\Webapi\ChangeOutputArray class. + */ +class ChangeOutputArrayTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var DefaultColumn|\PHPUnit_Framework_MockObject_MockObject + */ + private $priceRendererMock; + + /** + * @var DefaultRenderer|\PHPUnit_Framework_MockObject_MockObject + */ + private $defaultRendererMock; + + /** + * @var ObjectManager + */ + private $objectManager; + + /** + * @var ChangeOutputArray + */ + private $model; + + /** + * @inheritdoc + */ + protected function setUp() + { + $this->objectManager = new ObjectManager($this); + + $this->priceRendererMock = $this->createMock(DefaultColumn::class); + $this->defaultRendererMock = $this->createMock(DefaultRenderer::class); + + $this->model = $this->objectManager->getObject( + ChangeOutputArray::class, + [ + 'priceRenderer' => $this->priceRendererMock, + 'defaultRenderer' => $this->defaultRendererMock, + ] + ); + } + + /** + * @return void + */ + public function testExecute() + { + $expectedResult = [ + OrderItemInterface::ROW_TOTAL => 10, + OrderItemInterface::BASE_ROW_TOTAL => 10, + OrderItemInterface::ROW_TOTAL_INCL_TAX => 11, + OrderItemInterface::BASE_ROW_TOTAL_INCL_TAX => 11, + ]; + $orderItemInterfaceMock = $this->createMock(OrderItemInterface::class); + + $this->priceRendererMock->expects($this->once()) + ->method('getTotalAmount') + ->with($orderItemInterfaceMock) + ->willReturn(10); + $this->priceRendererMock->expects($this->once()) + ->method('getBaseTotalAmount') + ->with($orderItemInterfaceMock) + ->willReturn(10); + $this->defaultRendererMock->expects($this->once()) + ->method('getTotalAmount') + ->with($orderItemInterfaceMock) + ->willReturn(11); + $this->defaultRendererMock->expects($this->once()) + ->method('getBaseTotalAmount') + ->with($orderItemInterfaceMock) + ->willReturn(11); + + $this->assertEquals($expectedResult, $this->model->execute($orderItemInterfaceMock, [])); + } +} diff --git a/app/code/Magento/Sales/Test/Unit/Model/OrderRepositoryTest.php b/app/code/Magento/Sales/Test/Unit/Model/OrderRepositoryTest.php index 3df667094f2a9..04b774c8a74fd 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/OrderRepositoryTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/OrderRepositoryTest.php @@ -9,6 +9,8 @@ use Magento\Sales\Api\Data\OrderInterface; use Magento\Sales\Api\Data\OrderSearchResultInterfaceFactory as SearchResultFactory; use Magento\Sales\Model\ResourceModel\Metadata; +use Magento\Tax\Api\OrderTaxManagementInterface; +use Magento\Payment\Api\Data\PaymentAdditionalInfoInterfaceFactory; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -41,7 +43,17 @@ class OrderRepositoryTest extends \PHPUnit\Framework\TestCase private $collectionProcessor; /** - * Setup the test + * @var OrderTaxManagementInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $orderTaxManagementMock; + + /** + * @var PaymentAdditionalInfoInterfaceFactory|\PHPUnit_Framework_MockObject_MockObject + */ + private $paymentAdditionalInfoFactory; + + /** + * @inheritdoc */ protected function setUp() { @@ -58,34 +70,67 @@ protected function setUp() $orderExtensionFactoryMock = $this->getMockBuilder(\Magento\Sales\Api\Data\OrderExtensionFactory::class) ->disableOriginalConstructor() ->getMock(); + $this->orderTaxManagementMock = $this->getMockBuilder(OrderTaxManagementInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->paymentAdditionalInfoFactory = $this->getMockBuilder(PaymentAdditionalInfoInterfaceFactory::class) + ->disableOriginalConstructor()->setMethods(['create'])->getMockForAbstractClass(); $this->orderRepository = $this->objectManager->getObject( \Magento\Sales\Model\OrderRepository::class, [ 'metadata' => $this->metadata, 'searchResultFactory' => $this->searchResultFactory, 'collectionProcessor' => $this->collectionProcessor, - 'orderExtensionFactory' => $orderExtensionFactoryMock + 'orderExtensionFactory' => $orderExtensionFactoryMock, + 'orderTaxManagement' => $this->orderTaxManagementMock, + 'paymentAdditionalInfoFactory' => $this->paymentAdditionalInfoFactory, ] ); } + /** + * Test for method getList. + * + * @return void + */ public function testGetList() { $searchCriteriaMock = $this->createMock(\Magento\Framework\Api\SearchCriteria::class); $collectionMock = $this->createMock(\Magento\Sales\Model\ResourceModel\Order\Collection::class); - $itemsMock = $this->getMockBuilder(OrderInterface::class)->disableOriginalConstructor()->getMock(); + $itemsMock = $this->getMockBuilder(OrderInterface::class)->disableOriginalConstructor() + ->getMockForAbstractClass(); + $orderTaxDetailsMock = $this->getMockBuilder(\Magento\Tax\Api\Data\OrderTaxDetailsInterface::class) + ->disableOriginalConstructor() + ->setMethods(['getAppliedTaxes', 'getItems'])->getMockForAbstractClass(); + $paymentMock = $this->getMockBuilder(\Magento\Sales\Api\Data\OrderPaymentInterface::class) + ->disableOriginalConstructor()->getMockForAbstractClass(); + $paymentAdditionalInfo = $this->getMockBuilder(\Magento\Payment\Api\Data\PaymentAdditionalInfoInterface::class) + ->disableOriginalConstructor()->setMethods(['setKey', 'setValue'])->getMockForAbstractClass(); $extensionAttributes = $this->createPartialMock( \Magento\Sales\Api\Data\OrderExtension::class, - ['getShippingAssignments'] + [ + 'getShippingAssignments', 'setShippingAssignments', 'setConvertingFromQuote', + 'setAppliedTaxes', 'setItemAppliedTaxes', 'setPaymentAdditionalInfo', + ] ); $shippingAssignmentBuilder = $this->createMock( \Magento\Sales\Model\Order\ShippingAssignmentBuilder::class ); + $itemsMock->expects($this->atLeastOnce())->method('getEntityId')->willReturn(1); $this->collectionProcessor->expects($this->once()) ->method('process') ->with($searchCriteriaMock, $collectionMock); - $itemsMock->expects($this->once())->method('getExtensionAttributes')->willReturn($extensionAttributes); + $itemsMock->expects($this->atLeastOnce())->method('getExtensionAttributes')->willReturn($extensionAttributes); + $itemsMock->expects($this->atleastOnce())->method('getPayment')->willReturn($paymentMock); + $paymentMock->expects($this->atLeastOnce())->method('getAdditionalInformation') + ->willReturn(['method' => 'checkmo']); + $this->paymentAdditionalInfoFactory->expects($this->atLeastOnce())->method('create') + ->willReturn($paymentAdditionalInfo); + $paymentAdditionalInfo->expects($this->atLeastOnce())->method('setKey')->willReturnSelf(); + $paymentAdditionalInfo->expects($this->atLeastOnce())->method('setValue')->willReturnSelf(); + $this->orderTaxManagementMock->expects($this->atLeastOnce())->method('getOrderTaxDetails') + ->willReturn($orderTaxDetailsMock); $extensionAttributes->expects($this->any()) ->method('getShippingAssignments') ->willReturn($shippingAssignmentBuilder); @@ -96,6 +141,11 @@ public function testGetList() $this->assertEquals($collectionMock, $this->orderRepository->getList($searchCriteriaMock)); } + /** + * Test for method save. + * + * @return void + */ public function testSave() { $mapperMock = $this->getMockBuilder(\Magento\Sales\Model\ResourceModel\Order::class) diff --git a/app/code/Magento/Sales/Test/Unit/Model/ResourceModel/Order/Handler/StateTest.php b/app/code/Magento/Sales/Test/Unit/Model/ResourceModel/Order/Handler/StateTest.php index e120d613e323c..48f4a282a2be2 100644 --- a/app/code/Magento/Sales/Test/Unit/Model/ResourceModel/Order/Handler/StateTest.php +++ b/app/code/Magento/Sales/Test/Unit/Model/ResourceModel/Order/Handler/StateTest.php @@ -8,7 +8,7 @@ use Magento\Sales\Model\Order; /** - * Class StateTest + * Tests for State. */ class StateTest extends \PHPUnit\Framework\TestCase { @@ -22,9 +22,14 @@ class StateTest extends \PHPUnit\Framework\TestCase */ protected $orderMock; + /** + * @inheritdoc + */ protected function setUp() { - $this->orderMock = $this->createPartialMock(\Magento\Sales\Model\Order::class, [ + $this->orderMock = $this->createPartialMock( + \Magento\Sales\Model\Order::class, + [ '__wakeup', 'getId', 'hasCustomerNoteNotify', @@ -35,13 +40,12 @@ protected function setUp() 'canShip', 'getBaseGrandTotal', 'canCreditmemo', - 'getState', - 'setState', 'getTotalRefunded', 'hasForcedCanCreditmemo', 'getIsInProcess', 'getConfig', - ]); + ] + ); $this->orderMock->expects($this->any()) ->method('getConfig') ->willReturnSelf(); @@ -53,127 +57,96 @@ protected function setUp() } /** - * test check order - order without id + * Test for check method with different states. + * + * @param bool $isCanceled + * @param bool $canUnhold + * @param bool $canInvoice + * @param bool $canShip + * @param int $callCanSkipNum + * @param bool $canCreditmemo + * @param int $callCanCreditmemoNum + * @param string $currentState + * @param string $expectedState + * @param int $callSetStateNum + * @return void + * @dataProvider stateCheckDataProvider + * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ - public function testCheckOrderEmpty() - { - $this->orderMock->expects($this->once()) - ->method('getBaseGrandTotal') - ->willReturn(100); - $this->orderMock->expects($this->never()) - ->method('setState'); - - $this->state->check($this->orderMock); - } - - /** - * test check order - set state complete - */ - public function testCheckSetStateComplete() - { + public function testCheck( + bool $canCreditmemo, + int $callCanCreditmemoNum, + bool $canShip, + int $callCanSkipNum, + string $currentState, + string $expectedState = '', + bool $isInProcess = false, + int $callGetIsInProcessNum = 0, + bool $isCanceled = false, + bool $canUnhold = false, + bool $canInvoice = false + ) { + $this->orderMock->setState($currentState); $this->orderMock->expects($this->any()) - ->method('getId') - ->will($this->returnValue(1)); - $this->orderMock->expects($this->once()) ->method('isCanceled') - ->will($this->returnValue(false)); - $this->orderMock->expects($this->once()) - ->method('canUnhold') - ->will($this->returnValue(false)); - $this->orderMock->expects($this->once()) - ->method('canInvoice') - ->will($this->returnValue(false)); - $this->orderMock->expects($this->once()) - ->method('canShip') - ->will($this->returnValue(false)); - $this->orderMock->expects($this->once()) - ->method('getBaseGrandTotal') - ->will($this->returnValue(100)); - $this->orderMock->expects($this->once()) - ->method('canCreditmemo') - ->will($this->returnValue(true)); - $this->orderMock->expects($this->exactly(2)) - ->method('getState') - ->will($this->returnValue(Order::STATE_PROCESSING)); - $this->orderMock->expects($this->once()) - ->method('setState') - ->with(Order::STATE_COMPLETE) - ->will($this->returnSelf()); - $this->assertEquals($this->state, $this->state->check($this->orderMock)); - } - - /** - * test check order - set state closed - */ - public function testCheckSetStateClosed() - { + ->willReturn($isCanceled); $this->orderMock->expects($this->any()) - ->method('getId') - ->will($this->returnValue(1)); - $this->orderMock->expects($this->once()) - ->method('isCanceled') - ->will($this->returnValue(false)); - $this->orderMock->expects($this->once()) ->method('canUnhold') - ->will($this->returnValue(false)); - $this->orderMock->expects($this->once()) + ->willReturn($canUnhold); + $this->orderMock->expects($this->any()) ->method('canInvoice') - ->will($this->returnValue(false)); - $this->orderMock->expects($this->once()) + ->willReturn($canInvoice); + $this->orderMock->expects($this->exactly($callCanSkipNum)) ->method('canShip') - ->will($this->returnValue(false)); - $this->orderMock->expects($this->once()) - ->method('getBaseGrandTotal') - ->will($this->returnValue(100)); - $this->orderMock->expects($this->once()) + ->willReturn($canShip); + $this->orderMock->expects($this->exactly($callCanCreditmemoNum)) ->method('canCreditmemo') - ->will($this->returnValue(false)); - $this->orderMock->expects($this->exactly(2)) - ->method('getTotalRefunded') - ->will($this->returnValue(null)); - $this->orderMock->expects($this->once()) - ->method('hasForcedCanCreditmemo') - ->will($this->returnValue(true)); - $this->orderMock->expects($this->exactly(2)) - ->method('getState') - ->will($this->returnValue(Order::STATE_PROCESSING)); - $this->orderMock->expects($this->once()) - ->method('setState') - ->with(Order::STATE_CLOSED) - ->will($this->returnSelf()); - $this->assertEquals($this->state, $this->state->check($this->orderMock)); + ->willReturn($canCreditmemo); + $this->orderMock->expects($this->exactly($callGetIsInProcessNum)) + ->method('getIsInProcess') + ->willReturn($isInProcess); + $this->state->check($this->orderMock); + $this->assertEquals($expectedState, $this->orderMock->getState()); } /** - * test check order - set state processing + * Data provider for testCheck method. + * + * @return array */ - public function testCheckSetStateProcessing() + public function stateCheckDataProvider(): array { - $this->orderMock->expects($this->any()) - ->method('getId') - ->will($this->returnValue(1)); - $this->orderMock->expects($this->once()) - ->method('isCanceled') - ->will($this->returnValue(false)); - $this->orderMock->expects($this->once()) - ->method('canUnhold') - ->will($this->returnValue(false)); - $this->orderMock->expects($this->once()) - ->method('canInvoice') - ->will($this->returnValue(false)); - $this->orderMock->expects($this->once()) - ->method('canShip') - ->will($this->returnValue(true)); - $this->orderMock->expects($this->once()) - ->method('getState') - ->will($this->returnValue(Order::STATE_NEW)); - $this->orderMock->expects($this->once()) - ->method('getIsInProcess') - ->will($this->returnValue(true)); - $this->orderMock->expects($this->once()) - ->method('setState') - ->with(Order::STATE_PROCESSING) - ->will($this->returnSelf()); - $this->assertEquals($this->state, $this->state->check($this->orderMock)); + return [ + 'processing - !canCreditmemo!canShip -> closed' => + [false, 1, false, 0, Order::STATE_PROCESSING, Order::STATE_CLOSED], + 'complete - !canCreditmemo,!canShip -> closed' => + [false, 1, false, 0, Order::STATE_COMPLETE, Order::STATE_CLOSED], + 'processing - !canCreditmemo,canShip -> closed' => + [false, 1, true, 0, Order::STATE_PROCESSING, Order::STATE_CLOSED], + 'complete - !canCreditmemo,canShip -> closed' => + [false, 1, true, 0, Order::STATE_COMPLETE, Order::STATE_CLOSED], + 'processing - canCreditmemo,!canShip -> complete' => + [true, 1, false, 1, Order::STATE_PROCESSING, Order::STATE_COMPLETE], + 'complete - canCreditmemo,!canShip -> complete' => + [true, 1, false, 0, Order::STATE_COMPLETE, Order::STATE_COMPLETE], + 'processing - canCreditmemo, canShip -> processing' => + [true, 1, true, 1, Order::STATE_PROCESSING, Order::STATE_PROCESSING], + 'complete - canCreditmemo, canShip -> complete' => + [true, 1, true, 0, Order::STATE_COMPLETE, Order::STATE_COMPLETE], + 'new - canCreditmemo, canShip, IsInProcess -> processing' => + [true, 1, true, 1, Order::STATE_NEW, Order::STATE_PROCESSING, true, 1], + 'new - canCreditmemo, !canShip, IsInProcess -> processing' => + [true, 1, false, 1, Order::STATE_NEW, Order::STATE_COMPLETE, true, 1], + 'new - canCreditmemo, canShip, !IsInProcess -> new' => + [true, 0, true, 0, Order::STATE_NEW, Order::STATE_NEW, false, 1], + 'hold - canUnhold -> hold' => + [true, 0, true, 0, Order::STATE_HOLDED, Order::STATE_HOLDED, false, 0, false, true], + 'payment_review - canUnhold -> payment_review' => + [true, 0, true, 0, Order::STATE_PAYMENT_REVIEW, Order::STATE_PAYMENT_REVIEW, false, 0, false, true], + 'pending_payment - canUnhold -> pending_payment' => + [true, 0, true, 0, Order::STATE_PENDING_PAYMENT, Order::STATE_PENDING_PAYMENT, false, 0, false, true], + 'cancelled - isCanceled -> cancelled' => + [true, 0, true, 0, Order::STATE_HOLDED, Order::STATE_HOLDED, false, 0, true], + ]; } } diff --git a/app/code/Magento/Sales/Test/Unit/Observer/AssignOrderToCustomerObserverTest.php b/app/code/Magento/Sales/Test/Unit/Observer/AssignOrderToCustomerObserverTest.php index c6e02151b9bc1..18371274049e3 100644 --- a/app/code/Magento/Sales/Test/Unit/Observer/AssignOrderToCustomerObserverTest.php +++ b/app/code/Magento/Sales/Test/Unit/Observer/AssignOrderToCustomerObserverTest.php @@ -12,6 +12,7 @@ use Magento\Framework\Event\Observer; use Magento\Sales\Api\Data\OrderInterface; use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order\CustomerAssignment; use Magento\Sales\Observer\AssignOrderToCustomerObserver; use PHPUnit\Framework\TestCase; use PHPUnit_Framework_MockObject_MockObject; @@ -27,6 +28,9 @@ class AssignOrderToCustomerObserverTest extends TestCase /** @var OrderRepositoryInterface|PHPUnit_Framework_MockObject_MockObject */ protected $orderRepositoryMock; + /** @var CustomerAssignment | PHPUnit_Framework_MockObject_MockObject */ + protected $assignmentMock; + /** * Set Up */ @@ -35,7 +39,12 @@ protected function setUp() $this->orderRepositoryMock = $this->getMockBuilder(OrderRepositoryInterface::class) ->disableOriginalConstructor() ->getMock(); - $this->sut = new AssignOrderToCustomerObserver($this->orderRepositoryMock); + + $this->assignmentMock = $this->getMockBuilder(CustomerAssignment::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->sut = new AssignOrderToCustomerObserver($this->orderRepositoryMock, $this->assignmentMock); } /** @@ -69,13 +78,14 @@ public function testAssignOrderToCustomerAfterGuestOrder($customerId) $orderMock->expects($this->once())->method('getCustomerId')->willReturn($customerId); $this->orderRepositoryMock->expects($this->once())->method('get')->with($orderId) ->willReturn($orderMock); - if (!$customerId) { - $this->orderRepositoryMock->expects($this->once())->method('save')->with($orderMock); + + if ($customerId) { + $this->assignmentMock->expects($this->once())->method('execute')->with($orderMock, $customerMock); $this->sut->execute($observerMock); - return ; + return; } - $this->orderRepositoryMock->expects($this->never())->method('save')->with($orderMock); + $this->assignmentMock->expects($this->never())->method('execute'); $this->sut->execute($observerMock); } diff --git a/app/code/Magento/Sales/ViewModel/Customer/AddressFormatter.php b/app/code/Magento/Sales/ViewModel/Customer/AddressFormatter.php new file mode 100644 index 0000000000000..16de1412a2a45 --- /dev/null +++ b/app/code/Magento/Sales/ViewModel/Customer/AddressFormatter.php @@ -0,0 +1,131 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\Sales\ViewModel\Customer; + +use Magento\Framework\View\Element\Block\ArgumentInterface; + +/** + * Customer address formatter + */ +class AddressFormatter implements ArgumentInterface +{ + /** + * Customer form factory + * + * @var \Magento\Customer\Model\Metadata\FormFactory + */ + private $customerFormFactory; + + /** + * Address format helper + * + * @var \Magento\Customer\Helper\Address + */ + private $addressFormatHelper; + + /** + * Directory helper + * + * @var \Magento\Directory\Helper\Data + */ + private $directoryHelper; + + /** + * Session quote + * + * @var \Magento\Backend\Model\Session\Quote + */ + private $session; + + /** + * Json encoder + * + * @var \Magento\Framework\Serialize\Serializer\Json + */ + private $jsonEncoder; + + /** + * Customer address + * + * @param \Magento\Customer\Model\Metadata\FormFactory $customerFormFactory + * @param \Magento\Customer\Helper\Address $addressFormatHelper + * @param \Magento\Directory\Helper\Data $directoryHelper + * @param \Magento\Backend\Model\Session\Quote $session + * @param \Magento\Framework\Serialize\Serializer\Json $jsonEncoder + */ + public function __construct( + \Magento\Customer\Model\Metadata\FormFactory $customerFormFactory, + \Magento\Customer\Helper\Address $addressFormatHelper, + \Magento\Directory\Helper\Data $directoryHelper, + \Magento\Backend\Model\Session\Quote $session, + \Magento\Framework\Serialize\Serializer\Json $jsonEncoder + ) { + $this->customerFormFactory = $customerFormFactory; + $this->addressFormatHelper = $addressFormatHelper; + $this->directoryHelper = $directoryHelper; + $this->session = $session; + $this->jsonEncoder = $jsonEncoder; + } + + /** + * Return customer address array as JSON + * + * @param array $addressArray + * + * @return string + */ + public function getAddressesJson(array $addressArray) + { + $data = $this->getEmptyAddressForm(); + foreach ($addressArray as $addressId => $address) { + $addressForm = $this->customerFormFactory->create( + 'customer_address', + 'adminhtml_customer_address', + $address + ); + $data[$addressId] = $addressForm->outputData( + \Magento\Eav\Model\AttributeDataFactory::OUTPUT_FORMAT_JSON + ); + } + + return $this->jsonEncoder->serialize($data); + } + + /** + * Represent customer address in 'online' format. + * + * @param array $address + * @return string + */ + public function getAddressAsString(array $address) + { + $formatTypeRenderer = $this->addressFormatHelper->getFormatTypeRenderer('oneline'); + $result = ''; + if ($formatTypeRenderer) { + $result = $formatTypeRenderer->renderArray($address); + } + + return $result; + } + + /** + * Return empty address address form + * + * @return array + */ + private function getEmptyAddressForm() + { + $defaultCountryId = $this->directoryHelper->getDefaultCountry($this->session->getStore()); + $emptyAddressForm = $this->customerFormFactory->create( + 'customer_address', + 'adminhtml_customer_address', + [\Magento\Customer\Api\Data\AddressInterface::COUNTRY_ID => $defaultCountryId] + ); + + return [0 => $emptyAddressForm->outputData(\Magento\Eav\Model\AttributeDataFactory::OUTPUT_FORMAT_JSON)]; + } +} diff --git a/app/code/Magento/Sales/composer.json b/app/code/Magento/Sales/composer.json index a0e40d283da68..ed5f939d81869 100644 --- a/app/code/Magento/Sales/composer.json +++ b/app/code/Magento/Sales/composer.json @@ -33,7 +33,7 @@ "magento/module-sales-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "101.0.5", + "version": "101.0.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Sales/etc/adminhtml/system.xml b/app/code/Magento/Sales/etc/adminhtml/system.xml index 1b2f8b88d7dc3..2dc467d6ca247 100644 --- a/app/code/Magento/Sales/etc/adminhtml/system.xml +++ b/app/code/Magento/Sales/etc/adminhtml/system.xml @@ -89,6 +89,11 @@ <label>Minimum Amount</label> <comment>Subtotal after discount</comment> </field> + <field id="include_discount_amount" translate="label" sortOrder="12" type="select" showInDefault="1" showInWebsite="1" showInStore="0" canRestore="1"> + <label>Include Discount Amount</label> + <source_model>Magento\Config\Model\Config\Source\Yesno</source_model> + <comment>Choosing yes will be used subtotal after discount, otherwise only subtotal will be used</comment> + </field> <field id="tax_including" translate="label" sortOrder="15" type="select" showInDefault="1" showInWebsite="1" showInStore="0" canRestore="1"> <label>Include Tax to Amount</label> <source_model>Magento\Config\Model\Config\Source\Yesno</source_model> diff --git a/app/code/Magento/Sales/etc/config.xml b/app/code/Magento/Sales/etc/config.xml index 5be06fa3836a7..2480da4ad214b 100644 --- a/app/code/Magento/Sales/etc/config.xml +++ b/app/code/Magento/Sales/etc/config.xml @@ -22,6 +22,7 @@ <allow_zero_grandtotal>1</allow_zero_grandtotal> </zerograndtotal_creditmemo> <minimum_order> + <include_discount_amount>1</include_discount_amount> <tax_including>1</tax_including> </minimum_order> <orders> diff --git a/app/code/Magento/Sales/etc/di.xml b/app/code/Magento/Sales/etc/di.xml index ac7b8def08aa8..b4c1e63902121 100644 --- a/app/code/Magento/Sales/etc/di.xml +++ b/app/code/Magento/Sales/etc/di.xml @@ -746,6 +746,10 @@ <virtualType name="ShippingAddressAggregator" type="Magento\Framework\DB\Sql\ConcatExpression"> <arguments> <argument name="columns" xsi:type="array"> + <item name="company" xsi:type="array"> + <item name="tableAlias" xsi:type="string">sales_shipping_address</item> + <item name="columnName" xsi:type="string">company</item> + </item> <item name="street" xsi:type="array"> <item name="tableAlias" xsi:type="string">sales_shipping_address</item> <item name="columnName" xsi:type="string">street</item> @@ -769,6 +773,10 @@ <virtualType name="BillingAddressAggregator" type="Magento\Framework\DB\Sql\ConcatExpression"> <arguments> <argument name="columns" xsi:type="array"> + <item name="company" xsi:type="array"> + <item name="tableAlias" xsi:type="string">sales_billing_address</item> + <item name="columnName" xsi:type="string">company</item> + </item> <item name="street" xsi:type="array"> <item name="tableAlias" xsi:type="string">sales_billing_address</item> <item name="columnName" xsi:type="string">street</item> diff --git a/app/code/Magento/Sales/etc/extension_attributes.xml b/app/code/Magento/Sales/etc/extension_attributes.xml index 7280a1a071548..222f61cdc7324 100644 --- a/app/code/Magento/Sales/etc/extension_attributes.xml +++ b/app/code/Magento/Sales/etc/extension_attributes.xml @@ -10,4 +10,7 @@ <extension_attributes for="Magento\Sales\Api\Data\OrderInterface"> <attribute code="shipping_assignments" type="Magento\Sales\Api\Data\ShippingAssignmentInterface[]" /> </extension_attributes> + <extension_attributes for="Magento\Sales\Api\Data\OrderInterface"> + <attribute code="payment_additional_info" type="Magento\Payment\Api\Data\PaymentAdditionalInfoInterface[]" /> + </extension_attributes> </config> diff --git a/app/code/Magento/Sales/etc/webapi.xml b/app/code/Magento/Sales/etc/webapi.xml index cee245e348393..492dff8057039 100644 --- a/app/code/Magento/Sales/etc/webapi.xml +++ b/app/code/Magento/Sales/etc/webapi.xml @@ -10,271 +10,271 @@ <route url="/V1/orders/:id" method="GET"> <service class="Magento\Sales\Api\OrderRepositoryInterface" method="get"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::actions_view" /> </resources> </route> <route url="/V1/orders" method="GET"> <service class="Magento\Sales\Api\OrderRepositoryInterface" method="getList"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::actions_view" /> </resources> </route> <route url="/V1/orders/:id/statuses" method="GET"> <service class="Magento\Sales\Api\OrderManagementInterface" method="getStatus"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::actions_view" /> </resources> </route> <route url="/V1/orders/:id/cancel" method="POST"> <service class="Magento\Sales\Api\OrderManagementInterface" method="cancel"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::cancel" /> </resources> </route> <route url="/V1/orders/:id/emails" method="POST"> <service class="Magento\Sales\Api\OrderManagementInterface" method="notify"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::emails" /> </resources> </route> <route url="/V1/orders/:id/hold" method="POST"> <service class="Magento\Sales\Api\OrderManagementInterface" method="hold"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::hold" /> </resources> </route> <route url="/V1/orders/:id/unhold" method="POST"> <service class="Magento\Sales\Api\OrderManagementInterface" method="unHold"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::unhold" /> </resources> </route> <route url="/V1/orders/:id/comments" method="POST"> <service class="Magento\Sales\Api\OrderManagementInterface" method="addComment"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::comment" /> </resources> </route> <route url="/V1/orders/:id/comments" method="GET"> <service class="Magento\Sales\Api\OrderManagementInterface" method="getCommentsList"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::actions_view" /> </resources> </route> <route url="/V1/orders/create" method="PUT"> <service class="Magento\Sales\Api\OrderRepositoryInterface" method="save"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::create" /> </resources> </route> <route url="/V1/orders/:parent_id" method="PUT"> <service class="Magento\Sales\Api\OrderAddressRepositoryInterface" method="save"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::create" /> </resources> </route> <route url="/V1/orders/items/:id" method="GET"> <service class="Magento\Sales\Api\OrderItemRepositoryInterface" method="get"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::actions_view" /> </resources> </route> <route url="/V1/orders/items" method="GET"> <service class="Magento\Sales\Api\OrderItemRepositoryInterface" method="getList"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::actions_view" /> </resources> </route> <route url="/V1/invoices/:id" method="GET"> <service class="Magento\Sales\Api\InvoiceRepositoryInterface" method="get"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::sales_invoice" /> </resources> </route> <route url="/V1/invoices" method="GET"> <service class="Magento\Sales\Api\InvoiceRepositoryInterface" method="getList"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::sales_invoice" /> </resources> </route> <route url="/V1/invoices/:id/comments" method="GET"> <service class="Magento\Sales\Api\InvoiceManagementInterface" method="getCommentsList"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::sales_invoice" /> </resources> </route> <route url="/V1/invoices/:id/emails" method="POST"> <service class="Magento\Sales\Api\InvoiceManagementInterface" method="notify"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::sales_invoice" /> </resources> </route> <route url="/V1/invoices/:id/void" method="POST"> <service class="Magento\Sales\Api\InvoiceManagementInterface" method="setVoid"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::sales_invoice" /> </resources> </route> <route url="/V1/invoices/:id/capture" method="POST"> <service class="Magento\Sales\Api\InvoiceManagementInterface" method="setCapture"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::sales_invoice" /> </resources> </route> <route url="/V1/invoices/comments" method="POST"> <service class="Magento\Sales\Api\InvoiceCommentRepositoryInterface" method="save"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::sales_invoice" /> </resources> </route> <route url="/V1/invoices/" method="POST"> <service class="Magento\Sales\Api\InvoiceRepositoryInterface" method="save"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::sales_invoice" /> </resources> </route> <route url="/V1/invoice/:invoiceId/refund" method="POST"> <service class="Magento\Sales\Api\RefundInvoiceInterface" method="execute"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::sales_invoice" /> </resources> </route> <route url="/V1/creditmemo/:id/comments" method="GET"> <service class="Magento\Sales\Api\CreditmemoManagementInterface" method="getCommentsList"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::sales_creditmemo" /> </resources> </route> <route url="/V1/creditmemos" method="GET"> <service class="Magento\Sales\Api\CreditmemoRepositoryInterface" method="getList"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::sales_creditmemo" /> </resources> </route> <route url="/V1/creditmemo/:id" method="GET"> <service class="Magento\Sales\Api\CreditmemoRepositoryInterface" method="get"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::sales_creditmemo" /> </resources> </route> <route url="/V1/creditmemo/:id" method="PUT"> <service class="Magento\Sales\Api\CreditmemoManagementInterface" method="cancel"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::sales_creditmemo" /> </resources> </route> <route url="/V1/creditmemo/:id/emails" method="POST"> <service class="Magento\Sales\Api\CreditmemoManagementInterface" method="notify"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::sales_creditmemo" /> </resources> </route> <route url="/V1/creditmemo/refund" method="POST"> <service class="Magento\Sales\Api\CreditmemoManagementInterface" method="refund"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::sales_creditmemo" /> </resources> </route> <route url="/V1/creditmemo/:id/comments" method="POST"> <service class="Magento\Sales\Api\CreditmemoCommentRepositoryInterface" method="save"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::sales_creditmemo" /> </resources> </route> <route url="/V1/creditmemo" method="POST"> <service class="Magento\Sales\Api\CreditmemoRepositoryInterface" method="save"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::sales_creditmemo" /> </resources> </route> <route url="/V1/order/:orderId/refund" method="POST"> <service class="Magento\Sales\Api\RefundOrderInterface" method="execute"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::creditmemo" /> </resources> </route> <route url="/V1/shipment/:id" method="GET"> <service class="Magento\Sales\Api\ShipmentRepositoryInterface" method="get"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::shipment" /> </resources> </route> <route url="/V1/shipments" method="GET"> <service class="Magento\Sales\Api\ShipmentRepositoryInterface" method="getList"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::shipment" /> </resources> </route> <route url="/V1/shipment/:id/comments" method="GET"> <service class="Magento\Sales\Api\ShipmentManagementInterface" method="getCommentsList"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::shipment" /> </resources> </route> <route url="/V1/shipment/:id/comments" method="POST"> <service class="Magento\Sales\Api\ShipmentCommentRepositoryInterface" method="save"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::shipment" /> </resources> </route> <route url="/V1/shipment/:id/emails" method="POST"> <service class="Magento\Sales\Api\ShipmentManagementInterface" method="notify"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::shipment" /> </resources> </route> <route url="/V1/shipment/track" method="POST"> <service class="Magento\Sales\Api\ShipmentTrackRepositoryInterface" method="save"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::shipment" /> </resources> </route> <route url="/V1/shipment/track/:id" method="DELETE"> <service class="Magento\Sales\Api\ShipmentTrackRepositoryInterface" method="deleteById"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::shipment" /> </resources> </route> <route url="/V1/shipment/" method="POST"> <service class="Magento\Sales\Api\ShipmentRepositoryInterface" method="save"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::shipment" /> </resources> </route> <route url="/V1/shipment/:id/label" method="GET"> <service class="Magento\Sales\Api\ShipmentManagementInterface" method="getLabel"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::shipment" /> </resources> </route> <route url="/V1/order/:orderId/ship" method="POST"> <service class="Magento\Sales\Api\ShipOrderInterface" method="execute"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::ship" /> </resources> </route> <route url="/V1/orders/" method="POST"> <service class="Magento\Sales\Api\OrderRepositoryInterface" method="save"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::create" /> </resources> </route> <route url="/V1/transactions/:id" method="GET"> <service class="Magento\Sales\Api\TransactionRepositoryInterface" method="get"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::transactions_fetch" /> </resources> </route> <route url="/V1/transactions" method="GET"> <service class="Magento\Sales\Api\TransactionRepositoryInterface" method="getList"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::transactions_fetch" /> </resources> </route> <route url="/V1/order/:orderId/invoice" method="POST"> <service class="Magento\Sales\Api\InvoiceOrderInterface" method="execute"/> <resources> - <resource ref="Magento_Sales::sales" /> + <resource ref="Magento_Sales::invoice" /> </resources> </route> </routes> diff --git a/app/code/Magento/Sales/etc/webapi_rest/di.xml b/app/code/Magento/Sales/etc/webapi_rest/di.xml index 47fb3f188513c..6435445e0ef93 100644 --- a/app/code/Magento/Sales/etc/webapi_rest/di.xml +++ b/app/code/Magento/Sales/etc/webapi_rest/di.xml @@ -15,4 +15,11 @@ <type name="Magento\Sales\Api\ShipmentRepositoryInterface"> <plugin name="convert_blob_to_string" type="Magento\Sales\Plugin\ShippingLabelConverter" /> </type> + <type name="Magento\Framework\Reflection\DataObjectProcessor"> + <arguments> + <argument name="processors" xsi:type="array"> + <item name="\Magento\Sales\Model\Order\Item" xsi:type="object">Magento\Sales\Model\Order\Webapi\ChangeOutputArray\Proxy</item> + </argument> + </arguments> + </type> </config> diff --git a/app/code/Magento/Sales/etc/webapi_soap/di.xml b/app/code/Magento/Sales/etc/webapi_soap/di.xml index 47fb3f188513c..6435445e0ef93 100644 --- a/app/code/Magento/Sales/etc/webapi_soap/di.xml +++ b/app/code/Magento/Sales/etc/webapi_soap/di.xml @@ -15,4 +15,11 @@ <type name="Magento\Sales\Api\ShipmentRepositoryInterface"> <plugin name="convert_blob_to_string" type="Magento\Sales\Plugin\ShippingLabelConverter" /> </type> + <type name="Magento\Framework\Reflection\DataObjectProcessor"> + <arguments> + <argument name="processors" xsi:type="array"> + <item name="\Magento\Sales\Model\Order\Item" xsi:type="object">Magento\Sales\Model\Order\Webapi\ChangeOutputArray\Proxy</item> + </argument> + </arguments> + </type> </config> diff --git a/app/code/Magento/Sales/i18n/en_US.csv b/app/code/Magento/Sales/i18n/en_US.csv index cc18de430edc1..62d8e53e62fa0 100644 --- a/app/code/Magento/Sales/i18n/en_US.csv +++ b/app/code/Magento/Sales/i18n/en_US.csv @@ -796,3 +796,5 @@ Created,Created "PDF Creditmemos","PDF Creditmemos" Refunds,Refunds "Shipment with requested ID %1 doesn't correspond with Order with requested ID %2.","Shipment with requested ID %1 doesn't correspond with Order with requested ID %2." +"Allow Zero GrandTotal for Creditmemo","Allow Zero GrandTotal for Creditmemo" +"Allow Zero GrandTotal","Allow Zero GrandTotal" diff --git a/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_customer_block.xml b/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_customer_block.xml index c321bee460e46..0f5a3559f3008 100644 --- a/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_customer_block.xml +++ b/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_customer_block.xml @@ -80,13 +80,13 @@ <argument name="align" xsi:type="string">center</argument> </arguments> </block> - </block> - <block class="Magento\Backend\Block\Widget\Grid\Column" name="adminhtml.customer.grid.columnSet.website_name" as="website_name"> - <arguments> - <argument name="header" xsi:type="string" translate="true">Website</argument> - <argument name="index" xsi:type="string">website_name</argument> - <argument name="align" xsi:type="string">center</argument> - </arguments> + <block class="Magento\Backend\Block\Widget\Grid\Column" name="adminhtml.customer.grid.columnSet.website_name" as="website_name"> + <arguments> + <argument name="header" xsi:type="string" translate="true">Website</argument> + <argument name="index" xsi:type="string">website_name</argument> + <argument name="align" xsi:type="string">center</argument> + </arguments> + </block> </block> </block> </referenceBlock> diff --git a/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_index.xml b/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_index.xml index eb0a7685e5e22..3832476ff6972 100644 --- a/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_index.xml +++ b/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_index.xml @@ -45,8 +45,18 @@ <block class="Magento\Sales\Block\Adminhtml\Order\Create\Sidebar\Pviewed" template="Magento_Sales::order/create/sidebar/items.phtml" name="pviewed"/> </block> <block class="Magento\Sales\Block\Adminhtml\Order\Create\Form\Account" template="Magento_Sales::order/create/form/account.phtml" name="form_account"/> - <block class="Magento\Sales\Block\Adminhtml\Order\Create\Shipping\Address" template="Magento_Sales::order/create/form/address.phtml" name="shipping_address"/> - <block class="Magento\Sales\Block\Adminhtml\Order\Create\Billing\Address" template="Magento_Sales::order/create/form/address.phtml" name="billing_address"/> + <block class="Magento\Sales\Block\Adminhtml\Order\Create\Shipping\Address" template="Magento_Sales::order/create/form/address.phtml" name="shipping_address"> + <arguments> + <argument name="customerAddressFormatter" xsi:type="object">Magento\Sales\ViewModel\Customer\AddressFormatter</argument> + <argument name="customerAddressCollection" xsi:type="object">Magento\Customer\Model\ResourceModel\Address\Collection</argument> + </arguments> + </block> + <block class="Magento\Sales\Block\Adminhtml\Order\Create\Billing\Address" template="Magento_Sales::order/create/form/address.phtml" name="billing_address"> + <arguments> + <argument name="customerAddressFormatter" xsi:type="object">Magento\Sales\ViewModel\Customer\AddressFormatter</argument> + <argument name="customerAddressCollection" xsi:type="object">Magento\Customer\Model\ResourceModel\Address\Collection</argument> + </arguments> + </block> <block class="Magento\Sales\Block\Adminhtml\Order\Create\Shipping\Method" template="Magento_Sales::order/create/abstract.phtml" name="shipping_method"> <block class="Magento\Sales\Block\Adminhtml\Order\Create\Shipping\Method\Form" template="Magento_Sales::order/create/shipping/method/form.phtml" name="order_create_shipping_form" as="form"/> </block> diff --git a/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_load_block_billing_address.xml b/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_load_block_billing_address.xml index 6f0cbdb0cd43f..c52f81d5cb56d 100644 --- a/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_load_block_billing_address.xml +++ b/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_load_block_billing_address.xml @@ -8,7 +8,12 @@ <page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> <body> <referenceContainer name="content"> - <block class="Magento\Sales\Block\Adminhtml\Order\Create\Billing\Address" template="Magento_Sales::order/create/form/address.phtml" name="billing_address"/> + <block class="Magento\Sales\Block\Adminhtml\Order\Create\Billing\Address" template="Magento_Sales::order/create/form/address.phtml" name="billing_address"> + <arguments> + <argument name="customerAddressFormatter" xsi:type="object">Magento\Sales\ViewModel\Customer\AddressFormatter</argument> + <argument name="customerAddressCollection" xsi:type="object">Magento\Customer\Model\ResourceModel\Address\Collection</argument> + </arguments> + </block> </referenceContainer> </body> </page> diff --git a/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_load_block_data.xml b/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_load_block_data.xml index 70b5bfc298274..54348ce961c56 100644 --- a/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_load_block_data.xml +++ b/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_load_block_data.xml @@ -20,8 +20,18 @@ <block class="Magento\Sales\Block\Adminhtml\Order\Create\Sidebar\Pviewed" template="Magento_Sales::order/create/sidebar/items.phtml" name="pviewed"/> </block> <block class="Magento\Sales\Block\Adminhtml\Order\Create\Form\Account" template="Magento_Sales::order/create/form/account.phtml" name="form_account"/> - <block class="Magento\Sales\Block\Adminhtml\Order\Create\Shipping\Address" template="Magento_Sales::order/create/form/address.phtml" name="shipping_address"/> - <block class="Magento\Sales\Block\Adminhtml\Order\Create\Billing\Address" template="Magento_Sales::order/create/form/address.phtml" name="billing_address"/> + <block class="Magento\Sales\Block\Adminhtml\Order\Create\Shipping\Address" template="Magento_Sales::order/create/form/address.phtml" name="shipping_address"> + <arguments> + <argument name="customerAddressFormatter" xsi:type="object">Magento\Sales\ViewModel\Customer\AddressFormatter</argument> + <argument name="customerAddressCollection" xsi:type="object">Magento\Customer\Model\ResourceModel\Address\Collection</argument> + </arguments> + </block> + <block class="Magento\Sales\Block\Adminhtml\Order\Create\Billing\Address" template="Magento_Sales::order/create/form/address.phtml" name="billing_address"> + <arguments> + <argument name="customerAddressFormatter" xsi:type="object">Magento\Sales\ViewModel\Customer\AddressFormatter</argument> + <argument name="customerAddressCollection" xsi:type="object">Magento\Customer\Model\ResourceModel\Address\Collection</argument> + </arguments> + </block> <block class="Magento\Sales\Block\Adminhtml\Order\Create\Shipping\Method" template="Magento_Sales::order/create/abstract.phtml" name="shipping_method"> <block class="Magento\Sales\Block\Adminhtml\Order\Create\Shipping\Method\Form" template="Magento_Sales::order/create/shipping/method/form.phtml" name="order.create.shipping.method.form" as="form"/> </block> diff --git a/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_load_block_shipping_address.xml b/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_load_block_shipping_address.xml index 56f6786397df9..559f56dcb845b 100644 --- a/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_load_block_shipping_address.xml +++ b/app/code/Magento/Sales/view/adminhtml/layout/sales_order_create_load_block_shipping_address.xml @@ -8,7 +8,12 @@ <page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> <body> <referenceContainer name="content"> - <block class="Magento\Sales\Block\Adminhtml\Order\Create\Shipping\Address" template="Magento_Sales::order/create/form/address.phtml" name="shipping_address"/> + <block class="Magento\Sales\Block\Adminhtml\Order\Create\Shipping\Address" template="Magento_Sales::order/create/form/address.phtml" name="shipping_address"> + <arguments> + <argument name="customerAddressFormatter" xsi:type="object">Magento\Sales\ViewModel\Customer\AddressFormatter</argument> + <argument name="customerAddressCollection" xsi:type="object">Magento\Customer\Model\ResourceModel\Address\Collection</argument> + </arguments> + </block> </referenceContainer> </body> </page> diff --git a/app/code/Magento/Sales/view/adminhtml/templates/items/column/name.phtml b/app/code/Magento/Sales/view/adminhtml/templates/items/column/name.phtml index ebdf79fe7f008..98cc0d1d0ad07 100644 --- a/app/code/Magento/Sales/view/adminhtml/templates/items/column/name.phtml +++ b/app/code/Magento/Sales/view/adminhtml/templates/items/column/name.phtml @@ -28,7 +28,7 @@ <dt><?= $block->escapeHtml($_option['label']) ?>:</dt> <dd> <?php if (isset($_option['custom_view']) && $_option['custom_view']): ?> - <?= $block->escapeHtml($block->getCustomizedOptionValue($_option)) ?> + <?= /* @escapeNotVerified */ $block->getCustomizedOptionValue($_option) ?> <?php else: ?> <?php $_option = $block->getFormattedOption($_option['value']); ?> <?= $block->escapeHtml($_option['value']) ?> diff --git a/app/code/Magento/Sales/view/adminhtml/templates/order/create/form/address.phtml b/app/code/Magento/Sales/view/adminhtml/templates/order/create/form/address.phtml index 686e311292ac7..b0a88b8fa37dc 100644 --- a/app/code/Magento/Sales/view/adminhtml/templates/order/create/form/address.phtml +++ b/app/code/Magento/Sales/view/adminhtml/templates/order/create/form/address.phtml @@ -6,6 +6,21 @@ // @codingStandardsIgnoreFile +/** + * @var \Magento\Customer\Model\ResourceModel\Address\Collection $addressCollection + */ +$addressCollection = $block->getData('customerAddressCollection'); + +$addressArray = []; +if ($block->getCustomerId()) { + $addressArray = $addressCollection->setCustomerFilter([$block->getCustomerId()])->toArray(); +} + +/** + * @var \Magento\Sales\ViewModel\Customer\AddressFormatter $customerAddressFormatter + */ +$customerAddressFormatter = $block->getData('customerAddressFormatter'); + /** * @var \Magento\Sales\Block\Adminhtml\Order\Create\Billing\Address|\Magento\Sales\Block\Adminhtml\Order\Create\Shipping\Address $block */ @@ -17,7 +32,7 @@ if ($block->getIsShipping()): require(["Magento_Sales/order/create/form"], function(){ order.shippingAddressContainer = '<?= /* @escapeNotVerified */ $_fieldsContainerId ?>'; - order.setAddresses(<?= /* @escapeNotVerified */ $block->getAddressCollectionJson() ?>); + order.setAddresses(<?= /* @escapeNotVerified */ $customerAddressFormatter->getAddressesJson($addressArray) ?>); }); </script> @@ -59,13 +74,11 @@ endif; ?> onchange="order.selectAddress(this, '<?= /* @escapeNotVerified */ $_fieldsContainerId ?>')" class="admin__control-select"> <option value=""><?= /* @escapeNotVerified */ __('Add New Address') ?></option> - <?php foreach ($block->getAddressCollection() as $_address): ?> - <?php //if($block->getAddressAsString($_address)!=$block->getAddressAsString($block->getAddress())): ?> + <?php foreach ($addressArray as $addressId => $address): ?> <option - value="<?= /* @escapeNotVerified */ $_address->getId() ?>"<?php if ($_address->getId() == $block->getAddressId()): ?> selected="selected"<?php endif; ?>> - <?= /* @escapeNotVerified */ $block->getAddressAsString($_address) ?> + value="<?= /* @escapeNotVerified */ $addressId ?>"<?php if ($addressId == $block->getAddressId()): ?> selected="selected"<?php endif; ?>> + <?= /* @escapeNotVerified */ $block->escapeHtml($customerAddressFormatter->getAddressAsString($address)) ?> </option> - <?php //endif; ?> <?php endforeach; ?> </select> </div> diff --git a/app/code/Magento/Sales/view/adminhtml/templates/order/invoice/create/items.phtml b/app/code/Magento/Sales/view/adminhtml/templates/order/invoice/create/items.phtml index fa5ea0568011b..4a77c3b166de9 100644 --- a/app/code/Magento/Sales/view/adminhtml/templates/order/invoice/create/items.phtml +++ b/app/code/Magento/Sales/view/adminhtml/templates/order/invoice/create/items.phtml @@ -31,9 +31,9 @@ <?php if ($block->canEditQty()): ?> <tfoot> <tr> - <td colspan="2"> </td> - <td colspan="3"><?= $block->getUpdateButtonHtml() ?></td> <td colspan="3"> </td> + <td><?= $block->getUpdateButtonHtml() ?></td> + <td colspan="4"> </td> </tr> </tfoot> <?php endif; ?> diff --git a/app/code/Magento/Sales/view/adminhtml/templates/order/view/info.phtml b/app/code/Magento/Sales/view/adminhtml/templates/order/view/info.phtml index 5384a00dc894d..bbd6394097f9e 100644 --- a/app/code/Magento/Sales/view/adminhtml/templates/order/view/info.phtml +++ b/app/code/Magento/Sales/view/adminhtml/templates/order/view/info.phtml @@ -104,7 +104,7 @@ $customerUrl = $block->getCustomerViewUrl(); <?php if ($order->getBaseCurrencyCode() != $order->getOrderCurrencyCode()): ?> <tr> <th><?= $block->escapeHtml(__('%1 / %2 rate:', $order->getOrderCurrencyCode(), $order->getBaseCurrencyCode())) ?></th> - <th><?= $block->escapeHtml($order->getBaseToOrderRate()) ?></th> + <td><?= $block->escapeHtml($order->getBaseToOrderRate()) ?></td> </tr> <?php endif; ?> </table> diff --git a/app/code/Magento/Sales/view/adminhtml/web/order/create/scripts.js b/app/code/Magento/Sales/view/adminhtml/web/order/create/scripts.js index 05449c478152f..c508a5ecdfa58 100644 --- a/app/code/Magento/Sales/view/adminhtml/web/order/create/scripts.js +++ b/app/code/Magento/Sales/view/adminhtml/web/order/create/scripts.js @@ -14,7 +14,7 @@ define([ 'prototype', 'Magento_Catalog/catalog/product/composite/configure', 'Magento_Ui/js/lib/view/utils/async' -], function(jQuery, confirm, alert, template, shippingTemplate, paymentTemplate){ +], function (jQuery, confirm, alert, template, shippingTemplate, paymentTemplate) { window.AdminOrder = new Class.create(); @@ -42,7 +42,6 @@ define([ this.isOnlyVirtualProduct = false; this.excludedPaymentMethods = []; this.summarizePrice = true; - this.timerId = null; this.shippingTemplate = template(shippingTemplate, { data: { title: jQuery.mage.__('Shipping Method'), @@ -193,35 +192,42 @@ define([ } }, - isShippingField : function(fieldId){ - if(this.shippingAsBilling){ + /** + * Checks if the field belongs to the shipping address. + * + * @param {String} fieldId + * @return {Boolean} + */ + isShippingField: function (fieldId) { + if (this.shippingAsBilling) { return fieldId.include('billing'); } + return fieldId.include('shipping'); }, - isBillingField : function(fieldId){ + /** + * Checks if the field belongs to the billing address. + * + * @param {String} fieldId + * @return {Boolean} + */ + isBillingField: function (fieldId) { return fieldId.include('billing'); }, - bindAddressFields : function(container) { - var fields = $(container).select('input', 'select', 'textarea'); - for(var i=0;i<fields.length;i++){ - Event.observe(fields[i], 'change', this.triggerChangeEvent.bind(this)); - } - }, - /** - * Calls changing address field handler after timeout to prevent multiple simultaneous calls. + * Binds events on container form fields. * - * @param {Event} event + * @param {String} container */ - triggerChangeEvent: function (event) { - if (this.timerId) { - window.clearTimeout(this.timerId); - } + bindAddressFields: function (container) { + var fields = $(container).select('input', 'select', 'textarea'), + i; - this.timerId = window.setTimeout(this.changeAddressField.bind(this), 500, event); + for (i = 0; i < fields.length; i++) { + jQuery(fields[i]).change(this.changeAddressField.bind(this)); + } }, /** @@ -235,7 +241,8 @@ define([ matchRes = field.name.match(re), type, name, - data; + data, + resetShipping = false; if (!matchRes) { return; @@ -251,12 +258,21 @@ define([ } data = data.toObject(); - if (type === 'billing' && this.shippingAsBilling || type === 'shipping' && !this.shippingAsBilling) { + if (type === 'billing' && this.shippingAsBilling) { + this.syncAddressField(this.shippingAddressContainer, field.name, field.value); + resetShipping = true; + } + + if (type === 'shipping' && !this.shippingAsBilling) { + resetShipping = true; + } + + if (resetShipping) { data['reset_shipping'] = true; } data['order[' + type + '_address][customer_address_id]'] = null; - data['shipping_as_billing'] = +this.isShippingAsBilling(); + data['shipping_as_billing'] = +this.shippingAsBilling; if (name === 'customer_address_id') { data['order[' + type + '_address][customer_address_id]'] = @@ -264,8 +280,9 @@ define([ } this.resetPaymentMethod(); + if (data['reset_shipping']) { - this.resetShippingMethod(data); + this.resetShippingMethod(); } else { this.saveData(data); @@ -275,7 +292,28 @@ define([ } }, - fillAddressFields : function(container, data){ + /** + * Set address container form field value. + * + * @param {String} container - container ID + * @param {String} fieldName - form field name + * @param {*} fieldValue - form field value + */ + syncAddressField: function (container, fieldName, fieldValue) { + var syncName; + + if (this.isBillingField(fieldName)) { + syncName = fieldName.replace('billing', 'shipping'); + } + + $(container).select('[name="' + syncName + '"]').each(function (element) { + if (~['input', 'textarea', 'select'].indexOf(element.tagName.toLowerCase())) { + element.value = fieldValue; + } + }); + }, + + fillAddressFields: function(container, data){ var regionIdElem = false; var regionIdElemValue = false; @@ -316,10 +354,15 @@ define([ fields[i].setValue(data[name] ? data[name] : ''); } - if (fields[i].changeUpdater) fields[i].changeUpdater(); + if (fields[i].changeUpdater) { + fields[i].changeUpdater(); + } + if (name == 'region' && data['region_id'] && !data['region']){ fields[i].value = data['region_id']; } + + jQuery(fields[i]).trigger('change'); } }, @@ -350,16 +393,17 @@ define([ } }, - setShippingAsBilling : function(flag){ - var data; - var areasToLoad = ['billing_method', 'shipping_address', 'totals', 'giftmessage']; + /** + * Equals shipping and billing addresses. + * + * @param {Boolean} flag + */ + setShippingAsBilling: function (flag) { + var data, + areasToLoad = ['billing_method', 'shipping_address', 'shipping_method', 'totals', 'giftmessage']; + this.disableShippingAddress(flag); - if(flag){ - data = this.serializeData(this.billingAddressContainer); - } else { - data = this.serializeData(this.shippingAddressContainer); - } - areasToLoad.push('shipping_method'); + data = this.serializeData(flag ? this.billingAddressContainer : this.shippingAddressContainer); data = data.toObject(); data['shipping_as_billing'] = flag ? 1 : 0; data['reset_shipping'] = 1; @@ -367,21 +411,18 @@ define([ }, /** - * Checks if shipping address is corresponds to billing address. - * - * @return {Boolean} + * Replace shipping method area. */ - isShippingAsBilling: function () { - return jQuery('[name="shipping_same_as_billing"]').is(':checked'); - }, - - resetShippingMethod: function() { + resetShippingMethod: function () { if (!this.isOnlyVirtualProduct) { $(this.getAreaId('shipping_method')).update(this.shippingTemplate); } }, - resetPaymentMethod: function() { + /** + * Replace payment method area. + */ + resetPaymentMethod: function () { $(this.getAreaId('billing_method')).update(this.paymentTemplate); }, @@ -391,7 +432,7 @@ define([ * @return {Boolean} */ loadShippingRates: function () { - var addressContainer = this.isShippingAsBilling() ? + var addressContainer = this.shippingAsBilling ? 'billingAddressContainer' : 'shippingAddressContainer', data = this.serializeData(this[addressContainer]).toObject(); @@ -1505,6 +1546,4 @@ define([ return this._label; } }; - }); - diff --git a/app/code/Magento/Sales/view/frontend/templates/email/shipment/track.phtml b/app/code/Magento/Sales/view/frontend/templates/email/shipment/track.phtml index 9f7146ab084df..f1cd5f2b99865 100644 --- a/app/code/Magento/Sales/view/frontend/templates/email/shipment/track.phtml +++ b/app/code/Magento/Sales/view/frontend/templates/email/shipment/track.phtml @@ -9,22 +9,23 @@ ?> <?php $_shipment = $block->getShipment() ?> <?php $_order = $block->getOrder() ?> -<?php if ($_shipment && $_order && $_shipment->getAllTracks()): ?> -<br /> -<table class="shipment-track"> - <thead> +<?php $trackCollection = $_order->getTracksCollection($_shipment->getId()) ?> +<?php if ($_shipment && $_order && $trackCollection): ?> + <br /> + <table class="shipment-track"> + <thead> <tr> <th><?= /* @escapeNotVerified */ __('Shipped By') ?></th> <th><?= /* @escapeNotVerified */ __('Tracking Number') ?></th> </tr> - </thead> - <tbody> - <?php foreach ($_shipment->getAllTracks() as $_item): ?> - <tr> - <td><?= $block->escapeHtml($_item->getTitle()) ?>:</td> - <td><?= $block->escapeHtml($_item->getNumber()) ?></td> - </tr> - <?php endforeach ?> - </tbody> -</table> + </thead> + <tbody> + <?php foreach ($trackCollection as $_item): ?> + <tr> + <td><?= $block->escapeHtml($_item->getTitle()) ?>:</td> + <td><?= $block->escapeHtml($_item->getNumber()) ?></td> + </tr> + <?php endforeach ?> + </tbody> + </table> <?php endif; ?> diff --git a/app/code/Magento/Sales/view/frontend/templates/guest/form.phtml b/app/code/Magento/Sales/view/frontend/templates/guest/form.phtml index 3ebca4d08b349..89be190588677 100644 --- a/app/code/Magento/Sales/view/frontend/templates/guest/form.phtml +++ b/app/code/Magento/Sales/view/frontend/templates/guest/form.phtml @@ -10,7 +10,7 @@ <form class="form form-orders-search" id="oar-widget-orders-and-returns-form" data-mage-init='{"ordersReturns":{}, "validation":{}}' action="<?= /* @escapeNotVerified */ $block->getActionUrl() ?>" method="post" name="guest_post"> <fieldset class="fieldset"> - <legend class="admin__legend"><span><?= /* @escapeNotVerified */ __('Order Information') ?></span></legend> + <legend class="legend"><span><?= /* @escapeNotVerified */ __('Order Information') ?></span></legend> <br> <div class="field id required"> diff --git a/app/code/Magento/SalesAnalytics/composer.json b/app/code/Magento/SalesAnalytics/composer.json index 242e2d811a718..f6f2eaf29a70f 100644 --- a/app/code/Magento/SalesAnalytics/composer.json +++ b/app/code/Magento/SalesAnalytics/composer.json @@ -7,7 +7,7 @@ "magento/module-sales": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/SalesInventory/composer.json b/app/code/Magento/SalesInventory/composer.json index 4dc2c7b9445b5..28064cf97305e 100644 --- a/app/code/Magento/SalesInventory/composer.json +++ b/app/code/Magento/SalesInventory/composer.json @@ -10,7 +10,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/SalesRule/Model/ResourceModel/Coupon/Usage.php b/app/code/Magento/SalesRule/Model/ResourceModel/Coupon/Usage.php index d8fe830718ce1..8680a91a9acc4 100644 --- a/app/code/Magento/SalesRule/Model/ResourceModel/Coupon/Usage.php +++ b/app/code/Magento/SalesRule/Model/ResourceModel/Coupon/Usage.php @@ -74,11 +74,11 @@ public function loadByCustomerCoupon(\Magento\Framework\DataObject $object, $cus $select = $connection->select()->from( $this->getMainTable() )->where( - 'customer_id =:customet_id' + 'customer_id =:customer_id' )->where( 'coupon_id = :coupon_id' ); - $data = $connection->fetchRow($select, [':coupon_id' => $couponId, ':customet_id' => $customerId]); + $data = $connection->fetchRow($select, [':coupon_id' => $couponId, ':customer_id' => $customerId]); if ($data) { $object->setData($data); } diff --git a/app/code/Magento/SalesRule/Model/ResourceModel/Rule/Collection.php b/app/code/Magento/SalesRule/Model/ResourceModel/Rule/Collection.php index 59f24fa8b6e03..5e6f3847c8e31 100644 --- a/app/code/Magento/SalesRule/Model/ResourceModel/Rule/Collection.php +++ b/app/code/Magento/SalesRule/Model/ResourceModel/Rule/Collection.php @@ -80,6 +80,8 @@ protected function _construct() } /** + * Map data for associated entities + * * @param string $entityType * @param string $objectField * @throws \Magento\Framework\Exception\LocalizedException @@ -114,6 +116,8 @@ protected function mapAssociatedEntities($entityType, $objectField) } /** + * Add website ids and customer group ids to rules data + * * @return $this * @throws \Exception * @since 100.1.0 @@ -158,60 +162,15 @@ public function setValidationFilter( $connection = $this->getConnection(); if (strlen($couponCode)) { - $select->joinLeft( - ['rule_coupons' => $this->getTable('salesrule_coupon')], - $connection->quoteInto( - 'main_table.rule_id = rule_coupons.rule_id AND main_table.coupon_type != ?', - \Magento\SalesRule\Model\Rule::COUPON_TYPE_NO_COUPON - ), - ['code'] - ); - $noCouponWhereCondition = $connection->quoteInto( - 'main_table.coupon_type = ? ', + 'main_table.coupon_type = ?', \Magento\SalesRule\Model\Rule::COUPON_TYPE_NO_COUPON ); - - $autoGeneratedCouponCondition = [ - $connection->quoteInto( - "main_table.coupon_type = ?", - \Magento\SalesRule\Model\Rule::COUPON_TYPE_AUTO - ), - $connection->quoteInto( - "rule_coupons.type = ?", - \Magento\SalesRule\Api\Data\CouponInterface::TYPE_GENERATED - ), - ]; - - $orWhereConditions = [ - "(" . implode($autoGeneratedCouponCondition, " AND ") . ")", - $connection->quoteInto( - '(main_table.coupon_type = ? AND main_table.use_auto_generation = 1 AND rule_coupons.type = 1)', - \Magento\SalesRule\Model\Rule::COUPON_TYPE_SPECIFIC - ), - $connection->quoteInto( - '(main_table.coupon_type = ? AND main_table.use_auto_generation = 0 AND rule_coupons.type = 0)', - \Magento\SalesRule\Model\Rule::COUPON_TYPE_SPECIFIC - ), - ]; - - $andWhereConditions = [ - $connection->quoteInto( - 'rule_coupons.code = ?', - $couponCode - ), - $connection->quoteInto( - '(rule_coupons.expiration_date IS NULL OR rule_coupons.expiration_date >= ?)', - $this->_date->date()->format('Y-m-d') - ), - ]; - - $orWhereCondition = implode(' OR ', $orWhereConditions); - $andWhereCondition = implode(' AND ', $andWhereConditions); + $relatedRulesIds = $this->getCouponRelatedRuleIds($couponCode); $select->where( - $noCouponWhereCondition . ' OR ((' . $orWhereCondition . ') AND ' . $andWhereCondition . ')', - null, + $noCouponWhereCondition . ' OR main_table.rule_id IN (?)', + $relatedRulesIds, Select::TYPE_CONDITION ); } else { @@ -227,6 +186,75 @@ public function setValidationFilter( return $this; } + /** + * Get rules ids related to coupon code + * + * @param string $couponCode + * @return array + */ + private function getCouponRelatedRuleIds(string $couponCode): array + { + $connection = $this->getConnection(); + $select = $connection->select()->from( + ['main_table' => $this->getTable('salesrule')], + 'rule_id' + ); + $select->joinLeft( + ['rule_coupons' => $this->getTable('salesrule_coupon')], + $connection->quoteInto( + 'main_table.rule_id = rule_coupons.rule_id AND main_table.coupon_type != ?', + \Magento\SalesRule\Model\Rule::COUPON_TYPE_NO_COUPON, + null + ) + ); + + $autoGeneratedCouponCondition = [ + $connection->quoteInto( + "main_table.coupon_type = ?", + \Magento\SalesRule\Model\Rule::COUPON_TYPE_AUTO + ), + $connection->quoteInto( + "rule_coupons.type = ?", + \Magento\SalesRule\Api\Data\CouponInterface::TYPE_GENERATED + ), + ]; + + $orWhereConditions = [ + "(" . implode($autoGeneratedCouponCondition, " AND ") . ")", + $connection->quoteInto( + '(main_table.coupon_type = ? AND main_table.use_auto_generation = 1 AND rule_coupons.type = 1)', + \Magento\SalesRule\Model\Rule::COUPON_TYPE_SPECIFIC + ), + $connection->quoteInto( + '(main_table.coupon_type = ? AND main_table.use_auto_generation = 0 AND rule_coupons.type = 0)', + \Magento\SalesRule\Model\Rule::COUPON_TYPE_SPECIFIC + ), + ]; + + $andWhereConditions = [ + $connection->quoteInto( + 'rule_coupons.code = ?', + $couponCode + ), + $connection->quoteInto( + '(rule_coupons.expiration_date IS NULL OR rule_coupons.expiration_date >= ?)', + $this->_date->date()->format('Y-m-d') + ), + ]; + + $orWhereCondition = implode(' OR ', $orWhereConditions); + $andWhereCondition = implode(' AND ', $andWhereConditions); + + $select->where( + '(' . $orWhereCondition . ') AND ' . $andWhereCondition, + null, + Select::TYPE_CONDITION + ); + $select->group('main_table.rule_id'); + + return $connection->fetchCol($select); + } + /** * Filter collection by website(s), customer group(s) and date. * Filter collection to only active rules. @@ -366,6 +394,8 @@ public function addCustomerGroupFilter($customerGroupId) } /** + * Getter for _associatedEntitiesMap property + * * @return array * @deprecated 100.1.0 */ @@ -380,6 +410,8 @@ private function getAssociatedEntitiesMap() } /** + * Getter for dateApplier property + * * @return DateApplier * @deprecated 100.1.0 */ diff --git a/app/code/Magento/SalesRule/Model/Rule/Condition/Product/Subselect.php b/app/code/Magento/SalesRule/Model/Rule/Condition/Product/Subselect.php index 1e8fbf43ec3bc..b3a44fcc56011 100644 --- a/app/code/Magento/SalesRule/Model/Rule/Condition/Product/Subselect.php +++ b/app/code/Magento/SalesRule/Model/Rule/Condition/Product/Subselect.php @@ -5,6 +5,9 @@ */ namespace Magento\SalesRule\Model\Rule\Condition\Product; +/** + * Subselect conditions for product. + */ class Subselect extends \Magento\SalesRule\Model\Rule\Condition\Product\Combine { /** @@ -161,9 +164,12 @@ public function validate(\Magento\Framework\Model\AbstractModel $model) } } if ($hasValidChild || parent::validate($item)) { - $total += (($hasValidChild && $useChildrenTotal) ? $childrenAttrTotal : $item->getData($attr)); + $total += ($hasValidChild && $useChildrenTotal) + ? $childrenAttrTotal * $item->getQty() + : $item->getData($attr); } } + return $this->validateAttribute($total); } } diff --git a/app/code/Magento/SalesRule/Observer/AssignCouponDataAfterOrderCustomerAssignObserver.php b/app/code/Magento/SalesRule/Observer/AssignCouponDataAfterOrderCustomerAssignObserver.php new file mode 100644 index 0000000000000..d9699d334ff6a --- /dev/null +++ b/app/code/Magento/SalesRule/Observer/AssignCouponDataAfterOrderCustomerAssignObserver.php @@ -0,0 +1,52 @@ +<?php + +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\SalesRule\Observer; + +use Magento\Framework\Event\Observer; +use Magento\SalesRule\Model\Coupon\UpdateCouponUsages; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Framework\Event\ObserverInterface; + +class AssignCouponDataAfterOrderCustomerAssignObserver implements ObserverInterface +{ + const EVENT_KEY_CUSTOMER = 'customer'; + + const EVENT_KEY_ORDER = 'order'; + + /** + * @var UpdateCouponUsages + */ + private $updateCouponUsages; + + /** + * AssignCouponDataAfterOrderCustomerAssign constructor. + * + * @param UpdateCouponUsages $updateCouponUsages + */ + public function __construct( + UpdateCouponUsages $updateCouponUsages + ) { + $this->updateCouponUsages = $updateCouponUsages; + } + + /** + * @inheritDoc + */ + public function execute(Observer $observer) + { + $event = $observer->getEvent(); + /** @var OrderInterface $order */ + $order = $event->getData(self::EVENT_KEY_ORDER); + + if ($order->getCustomerId()) { + $this->updateCouponUsages->execute($order, true); + } + } +} diff --git a/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/AdminCartPriceRuleActionGroup.xml b/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/AdminCartPriceRuleActionGroup.xml index b9efe4e51a51c..fe91f75e0448e 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/AdminCartPriceRuleActionGroup.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/AdminCartPriceRuleActionGroup.xml @@ -6,13 +6,13 @@ */ --> <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/actionGroupSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <actionGroup name="DeleteCartPriceRuleByName"> <arguments> <argument name="ruleName" type="string"/> </arguments> <amOnPage url="{{AdminCartPriceRulesPage.url}}" stepKey="amOnCartPriceList"/> - <waitForPageLoad stepKey="waitForPriceList"/> + <conditionalClick selector="{{AdminDataGridHeaderSection.clearFilters}}" dependentSelector="{{AdminDataGridHeaderSection.clearFilters}}" visible="true" stepKey="clearFilters"/> <fillField selector="{{AdminCartPriceRulesSection.filterByNameInput}}" userInput="{{ruleName}}" stepKey="filterByName"/> <click selector="{{AdminCartPriceRulesSection.searchButton}}" stepKey="doFilter"/> <click selector="{{AdminCartPriceRulesSection.rowByIndex('1')}}" stepKey="goToEditRulePage"/> @@ -23,4 +23,49 @@ <!-- This actionGroup was created to be merged from B2B because B2B has a very different form control here --> <selectOption selector="{{AdminCartPriceRulesFormSection.customerGroups}}" userInput="NOT LOGGED IN" stepKey="selectCustomerGroup"/> </actionGroup> + + <!--Set Subtotal condition for Customer Segment--> + <actionGroup name="SetCartAttributeConditionForCartPriceRuleActionGroup"> + <arguments> + <argument name="attributeName" type="string"/> + <argument name="operatorType" defaultValue="is" type="string"/> + <argument name="value" type="string"/> + </arguments> + <scrollTo selector="{{AdminCartPriceRulesFormSection.conditionsHeader}}" stepKey="scrollToActionTab"/> + <conditionalClick selector="{{AdminCartPriceRulesFormSection.conditionsHeader}}" dependentSelector="{{AdminCartPriceRulesFormSection.conditionsHeaderOpen}}" + visible="false" stepKey="openActionTab"/> + <click selector="{{AdminCartPriceRulesFormSection.conditions}}" stepKey="applyRuleForConditions"/> + <waitForPageLoad stepKey="waitForDropDownOpened"/> + <selectOption selector="{{AdminCartPriceRulesFormSection.childAttribute}}" userInput="{{attributeName}}" stepKey="selectAttribute"/> + <waitForPageLoad stepKey="waitForOperatorOpened"/> + <click selector="{{AdminCartPriceRulesFormSection.condition('is')}}" stepKey="clickToChooseOption"/> + <selectOption userInput="{{operatorType}}" selector="{{AdminCartPriceRulesFormSection.conditionsOperator}}" stepKey="setOperatorType"/> + <click selector="{{AdminCartPriceRulesFormSection.condition('...')}}" stepKey="clickToChooseOption1"/> + <fillField userInput="{{value}}" selector="{{AdminCartPriceRulesFormSection.conditionsValue}}" stepKey="fillActionValue"/> + <click selector="{{AdminMainActionsSection.saveAndContinue}}" stepKey="clickSaveButton"/> + <see selector="{{AdminCartPriceRulesSection.messages}}" userInput="You saved the rule." stepKey="seeSuccessMessage"/> + </actionGroup> + + <actionGroup name="SetConditionForActionsInCartPriceRuleActionGroup"> + <arguments> + <argument name="actionsAggregator" type="string" defaultValue="ANY"/> + <argument name="actionsValue" type="string" defaultValue="FALSE"/> + <argument name="childAttribute" type="string" defaultValue="Category"/> + <argument name="actionValue" type="string"/> + </arguments> + <click selector="{{AdminCartPriceRulesFormSection.actionsHeader}}" stepKey="clickOnActionTab"/> + <click selector="{{AdminCartPriceRulesFormSection.condition('ALL')}}" stepKey="clickToChooseOption"/> + <selectOption selector="{{AdminCartPriceRulesFormSection.actionsAggregator}}" userInput="{{actionsAggregator}}" stepKey="selectCondition"/> + <click selector="{{AdminCartPriceRulesFormSection.condition('TRUE')}}" stepKey="clickToChooseOption2"/> + <selectOption selector="{{AdminCartPriceRulesFormSection.actionsValue}}" userInput="{{actionsValue}}" stepKey="selectCondition2"/> + <click selector="{{AdminCartPriceRulesFormSection.conditions}}" stepKey="selectActionConditions"/> + <waitForPageLoad stepKey="waitForDropDownOpened"/> + <selectOption selector="{{AdminCartPriceRulesFormSection.childAttribute}}" userInput="{{childAttribute}}" stepKey="selectAttribute"/> + <waitForPageLoad stepKey="waitForOperatorOpened"/> + <click selector="{{AdminCartPriceRulesFormSection.condition('...')}}" stepKey="clickToChooseOption3"/> + <fillField selector="{{AdminCartPriceRulesFormSection.actionValue}}" userInput="{{actionValue}}" stepKey="fillActionValue"/> + <click selector="{{AdminCartPriceRulesFormSection.applyAction}}" stepKey="applyAction"/> + <click selector="{{AdminMainActionsSection.save}}" stepKey="clickSaveButton"/> + <see selector="{{AdminMessagesSection.success}}" userInput="You saved the rule." stepKey="seeSuccessMessage"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/AdminCreateCartPriceRuleActionGroup.xml b/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/AdminCreateCartPriceRuleActionGroup.xml index 26a71e4167ffa..c5f35aef6f480 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/AdminCreateCartPriceRuleActionGroup.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/AdminCreateCartPriceRuleActionGroup.xml @@ -23,4 +23,31 @@ <click selector="{{AdminCartPriceRulesFormSection.save}}" stepKey="clickSaveButton"/> <see selector="{{AdminMessagesSection.success}}" userInput="You saved the rule." stepKey="seeSuccessMessage"/> </actionGroup> + + <actionGroup name="AdminCreateCartPriceRuleWithProductSubselectionCondition" extends="AdminCreateCartPriceRuleActionGroup"> + <arguments> + <argument name="condition" type="string" defaultValue="Category" /> + <argument name="operation" type="string" defaultValue="equals or greater than" /> + <argument name="totalQuantity" type="string" defaultValue="2" /> + <argument name="value" type="string" defaultValue="_defaultCategory.name" /> + </arguments> + <!--Go to Conditions section--> + <click selector="{{AdminCartPriceRulesFormSection.conditionsHeader}}" after="selectActionType" stepKey="openConditionsSection"/> + <click selector="{{AdminCartPriceRulesFormSection.addCondition('1')}}" after="openConditionsSection" stepKey="addFirstCondition"/> + <selectOption selector="{{AdminCartPriceRulesFormSection.ruleCondition('1')}}" userInput="Products subselection" after="addFirstCondition" stepKey="selectRule"/> + <waitForElementVisible selector="{{AdminCartPriceRulesFormSection.ruleParameter('is')}}" after="selectRule" stepKey="waitForFirstRuleElement"/> + <click selector="{{AdminCartPriceRulesFormSection.ruleParameter('is')}}" after="waitForFirstRuleElement" stepKey="clickToChangeRule"/> + <selectOption selector="{{AdminCartPriceRulesFormSection.ruleOperatorSelect('1--1')}}" userInput="{{operation}}" after="clickToChangeRule" stepKey="selectRule1"/> + <waitForElementVisible selector="{{AdminCartPriceRulesFormSection.ruleParameter('...')}}" after="selectRule1" stepKey="waitForSecondRuleElement"/> + <click selector="{{AdminCartPriceRulesFormSection.ruleParameter('...')}}" after="waitForSecondRuleElement" stepKey="clickToChangeRule1"/> + <fillField selector="{{AdminCartPriceRulesFormSection.ruleValueInput('1--1')}}" userInput="{{totalQuantity}}" after="clickToChangeRule1" stepKey="fillRule"/> + <click selector="{{AdminCartPriceRulesFormSection.addCondition('1--1')}}" after="fillRule" stepKey="addSecondCondition"/> + <selectOption selector="{{AdminCartPriceRulesFormSection.ruleCondition('1--1')}}" userInput="{{condition}}" after="addSecondCondition" stepKey="selectSecondCondition"/> + <waitForElementVisible selector="{{AdminCartPriceRulesFormSection.ruleParameter('...')}}" after="selectSecondCondition" stepKey="waitForThirdRuleElement"/> + <click selector="{{AdminCartPriceRulesFormSection.ruleParameter('...')}}" after="waitForThirdRuleElement" stepKey="addThirdCondition"/> + <waitForElementVisible selector="{{AdminCartPriceRulesFormSection.chooseValue('1--1--1')}}" after="addThirdCondition" stepKey="waitForForthRuleElement"/> + <click selector="{{AdminCartPriceRulesFormSection.chooseValue('1--1--1')}}" after="waitForForthRuleElement" stepKey="chooseValue"/> + <waitForElementVisible selector="{{AdminCartPriceRulesFormSection.categoryCheckbox(value)}}" after="chooseValue" stepKey="waitForCategoryVisible"/> + <checkOption selector="{{AdminCartPriceRulesFormSection.categoryCheckbox(value)}}" after="waitForCategoryVisible" stepKey="checkCategoryName"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/AdminEditCartPriceRuleActionGroup.xml b/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/AdminEditCartPriceRuleActionGroup.xml new file mode 100644 index 0000000000000..0f18819e3ed1e --- /dev/null +++ b/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/AdminEditCartPriceRuleActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="ChangeCartPriceRuleWebsiteActionGroup"> + <arguments> + <argument name="websiteLabel" type="string"/> + </arguments> + <waitForPageLoad stepKey="waitForPriceList"/> + <selectOption selector="{{AdminCartPriceRulesFormSection.websites}}" userInput="{{websiteLabel}}" stepKey="selectWebsites"/> + <click selector="{{AdminCartPriceRulesFormSection.save}}" stepKey="clickSaveButton"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/AdminFilterCartPriceRuleActionGroup.xml b/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/AdminFilterCartPriceRuleActionGroup.xml new file mode 100644 index 0000000000000..2c44fdf3e900f --- /dev/null +++ b/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/AdminFilterCartPriceRuleActionGroup.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <!--Search grid with keyword search--> + <actionGroup name="AdminFilterCartPriceRuleActionGroup"> + <arguments> + <argument name="ruleName"/> + </arguments> + <conditionalClick selector="{{AdminDataGridHeaderSection.clearFilters}}" dependentSelector="{{AdminDataGridHeaderSection.clearFilters}}" visible="true" stepKey="clickClearFilters"/> + <fillField selector="{{AdminCartPriceRulesSection.filterByNameInput}}" userInput="{{ruleName}}" stepKey="filterByName"/> + <click selector="{{AdminCartPriceRulesSection.searchButton}}" stepKey="doFilter"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/ApplyCartRuleOnStorefrontActionGroup.xml b/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/ApplyCartRuleOnStorefrontActionGroup.xml index d3930a2767b0d..0ea7ec06ca869 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/ApplyCartRuleOnStorefrontActionGroup.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/ApplyCartRuleOnStorefrontActionGroup.xml @@ -22,4 +22,34 @@ <click selector="{{AdminCartPriceRuleDiscountSection.applyCodeBtn}}" stepKey="applyCode"/> <waitForPageLoad stepKey="waitForApplyCode"/> </actionGroup> + + <!-- Apply Sales Rule Coupon to the cart --> + <actionGroup name="StorefrontApplyCouponActionGroup"> + <arguments> + <argument name="couponCode" type="string"/> + </arguments> + <waitForElement selector="{{AdminCartPriceRuleDiscountSection.discountTab}}" time="30" stepKey="waitForCouponHeader" /> + <conditionalClick selector="{{AdminCartPriceRuleDiscountSection.discountTab}}" dependentSelector="{{AdminCartPriceRuleDiscountSection.discountBlockActive}}" visible="false" stepKey="clickCouponHeader" /> + <waitForElementVisible selector="{{AdminCartPriceRuleDiscountSection.couponInput}}" stepKey="waitForCouponField" /> + <fillField userInput="{{couponCode}}" selector="{{AdminCartPriceRuleDiscountSection.couponInput}}" stepKey="fillCouponField"/> + <click selector="{{AdminCartPriceRuleDiscountSection.applyCodeBtn}}" stepKey="clickApplyButton"/> + <see userInput='You used coupon code "{{couponCode}}".' selector="{{StorefrontMessagesSection.successMessage}}" stepKey="seeSuccessMessage"/> + </actionGroup> + + <!-- Apply Sales Rule Coupon to the cart --> + <actionGroup name="StorefrontTryingToApplyCouponActionGroup" extends="StorefrontApplyCouponActionGroup"> + <remove keyForRemoval="seeSuccessMessage"/> + <waitForElementVisible selector="{{StorefrontMessagesSection.error}}" stepKey="waitError"/> + <see selector="{{StorefrontMessagesSection.error}}" userInput='The coupon code "{{couponCode}}" is not valid.' + stepKey="seeErrorMessages"/> + </actionGroup> + + <!-- Cancel Sales Rule Coupon applied to the cart --> + <actionGroup name="StorefrontCancelCouponActionGroup"> + <waitForElement selector="{{AdminCartPriceRuleDiscountSection.discountTab}}" time="30" stepKey="waitForCouponHeader" /> + <conditionalClick selector="{{AdminCartPriceRuleDiscountSection.discountTab}}" dependentSelector="{{AdminCartPriceRuleDiscountSection.discountBlockActive}}" visible="false" stepKey="clickCouponHeader" /> + <waitForElementVisible selector="{{AdminCartPriceRuleDiscountSection.couponInput}}" stepKey="waitForCouponField" /> + <click selector="{{AdminCartPriceRuleDiscountSection.cancelButton}}" stepKey="clickCancelButton"/> + <see userInput="You canceled the coupon code." selector="{{StorefrontMessagesSection.successMessage}}" stepKey="seeSuccessMessage"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/StorefrontSalesRuleActionGroup.xml b/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/StorefrontSalesRuleActionGroup.xml new file mode 100644 index 0000000000000..35feabc8d9fbe --- /dev/null +++ b/app/code/Magento/SalesRule/Test/Mftf/ActionGroup/StorefrontSalesRuleActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="VerifyDiscountAmount"> + <arguments> + <argument name="expectedDiscount" type="string"/> + </arguments> + <amOnPage url="{{CheckoutCartPage.url}}" stepKey="goToCartPage"/> + <waitForElementVisible selector="{{StorefrontCheckoutCartSummarySection.discountAmount}}" stepKey="waitForDiscountElement"/> + <see selector="{{StorefrontCheckoutCartSummarySection.discountAmount}}" userInput="{{expectedDiscount}}" stepKey="seeDiscountTotal"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Data/CouponData.xml b/app/code/Magento/SalesRule/Test/Mftf/Data/CouponData.xml index d06903d5fd5d9..8810157092d97 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Data/CouponData.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Data/CouponData.xml @@ -15,4 +15,11 @@ <data key="times_used">0</data> <data key="is_primary">false</data> </entity> + + <entity name="SimpleSalesRuleCoupon" type="coupon"> + <var key="rule_id" entityKey="rule_id" entityType="SalesRule"/> + <data key="code" unique="suffix">couponCode</data> + <data key="is_primary">1</data> + <data key="times_used">0</data> + </entity> </entities> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Data/SalesCouponData.xml b/app/code/Magento/SalesRule/Test/Mftf/Data/SalesCouponData.xml new file mode 100644 index 0000000000000..99d02998fac23 --- /dev/null +++ b/app/code/Magento/SalesRule/Test/Mftf/Data/SalesCouponData.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <entity name="ApiSalesRuleCoupon" type="SalesRuleCoupon"> + <data key="code" unique="suffix">salesCoupon</data> + <data key="times_used">0</data> + <data key="is_primary">1</data> + <data key="type">0</data> + <var key="rule_id" entityType="SalesRule" entityKey="rule_id"/> + </entity> +</entities> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Data/SalesRuleAddressConditionsData.xml b/app/code/Magento/SalesRule/Test/Mftf/Data/SalesRuleAddressConditionsData.xml new file mode 100644 index 0000000000000..cc695b347c4fb --- /dev/null +++ b/app/code/Magento/SalesRule/Test/Mftf/Data/SalesRuleAddressConditionsData.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <entity name="SalesRuleAddressConditions" type="SalesRuleConditionAttribute"> + <data key="subtotal">Magento\SalesRule\Model\Rule\Condition\Address|base_subtotal</data> + <data key="totalItemsQty">Magento\SalesRule\Model\Rule\Condition\Address|total_qty</data> + <data key="totalWeight">Magento\SalesRule\Model\Rule\Condition\Address|weight</data> + <data key="shippingMethod">Magento\SalesRule\Model\Rule\Condition\Address|shipping_method</data> + <data key="shippingPostCode">Magento\SalesRule\Model\Rule\Condition\Address|postcode</data> + <data key="shippingRegion">Magento\SalesRule\Model\Rule\Condition\Address|region</data> + <data key="shippingState">Magento\SalesRule\Model\Rule\Condition\Address|region_id</data> + <data key="shippingCountry">Magento\SalesRule\Model\Rule\Condition\Address|country_id</data> + </entity> +</entities> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Data/SalesRuleData.xml b/app/code/Magento/SalesRule/Test/Mftf/Data/SalesRuleData.xml index 28d61e34339ef..c0589f3acfda6 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Data/SalesRuleData.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Data/SalesRuleData.xml @@ -7,7 +7,7 @@ --> <entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/DataGenerator/etc/dataProfileSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/dataProfileSchema.xsd"> <entity name="ApiSalesRule" type="SalesRule"> <data key="name" unique="suffix">salesRule</data> <data key="description">Sales Rule Descritpion</data> @@ -56,7 +56,7 @@ </entity> <entity name="SalesRuleSpecificCoupon" type="SalesRule"> <data key="name" unique="suffix">SimpleSalesRule</data> - <data key="description">Sales Rule Descritpion</data> + <data key="description">Sales Rule Description</data> <array key="website_ids"> <item>1</item> </array> @@ -85,4 +85,126 @@ <entity name="SalesRule100PercentDiscount" extends="TestSalesRule" type="SalesRule"> <data key="discountAmount">100</data> </entity> + <entity name="SaleRule50PercentDiscountNoCoupon" type="SalesRule"> + <data key="name" unique="suffix">salesRule</data> + <data key="description">Sales Rule Descritpion</data> + <array key="website_ids"> + <item>1</item> + </array> + <array key="customer_group_ids"> + <item>0</item> + <item>1</item> + <item>3</item> + </array> + <data key="uses_per_customer">0</data> + <data key="is_active">true</data> + <data key="stop_rules_processing">true</data> + <data key="is_advanced">true</data> + <data key="sort_order">0</data> + <data key="simple_action">by_percent</data> + <data key="discount_amount">50</data> + <data key="discount_qty">0</data> + <data key="discount_step">0</data> + <data key="apply_to_shipping">false</data> + <data key="times_used">0</data> + <data key="is_rss">true</data> + <data key="coupon_type">NO_COUPON</data> + <data key="use_auto_generation">false</data> + <data key="uses_per_coupon">0</data> + <data key="simple_free_shipping">0</data> + </entity> + <entity name="ApiCartRule" type="SalesRule"> + <data key="name" unique="suffix">salesRule</data> + <data key="description">Sales Rule Descritpion</data> + <array key="website_ids"> + <item>1</item> + </array> + <array key="customer_group_ids"> + <item>0</item> + <item>1</item> + <item>3</item> + </array> + <data key="uses_per_customer">0</data> + <data key="is_active">true</data> + <data key="stop_rules_processing">true</data> + <data key="is_advanced">true</data> + <data key="sort_order">0</data> + <data key="simple_action">by_percent</data> + <data key="discount_amount">50</data> + <data key="discount_qty">0</data> + <data key="discount_step">0</data> + <data key="apply_to_shipping">false</data> + <data key="times_used">0</data> + <data key="is_rss">true</data> + <data key="coupon_type">NO_COUPON</data> + <data key="use_auto_generation">false</data> + <data key="uses_per_coupon">0</data> + <data key="simple_free_shipping">0</data> + </entity> + + <entity name="SalesRuleSpecificCouponWithFixedDiscount" type="SalesRule"> + <data key="name" unique="suffix">SimpleSalesRule</data> + <data key="description">Sales Rule Description</data> + <array key="website_ids"> + <item>1</item> + </array> + <array key="customer_group_ids"> + <item>1</item> + </array> + <data key="uses_per_customer">10</data> + <data key="is_active">true</data> + <data key="stop_rules_processing">false</data> + <data key="is_advanced">true</data> + <data key="sort_order">1</data> + <data key="simple_action">cart_fixed</data> + <data key="discount_amount">10</data> + <data key="discount_qty">10</data> + <data key="discount_step">0</data> + <data key="apply_to_shipping">false</data> + <data key="times_used">0</data> + <data key="is_rss">false</data> + <data key="coupon_type">SPECIFIC_COUPON</data> + <data key="use_auto_generation">false</data> + <data key="uses_per_coupon">10</data> + <data key="simple_free_shipping">1</data> + </entity> + + <entity name="PriceRuleWithCondition" type="SalesRule"> + <data key="name" unique="suffix">SalesRule</data> + <data key="websites">Main Website</data> + <data key="customerGroups">'NOT LOGGED IN', 'General', 'Wholesale', 'Retailer'</data> + <data key="apply">Fixed amount discount for whole cart</data> + <data key="discountAmount">0</data> + </entity> + + <entity name="SalesRuleNoCouponWithFixedDiscount" extends="ApiCartRule"> + <data key="simple_action">by_fixed</data> + </entity> + + <entity name="SalesRuleSpecificCouponWithPercentDiscount" type="SalesRule"> + <data key="name" unique="suffix">SimpleSalesRule</data> + <data key="description">Sales Rule Description</data> + <array key="website_ids"> + <item>1</item> + </array> + <array key="customer_group_ids"> + <item>1</item> + </array> + <data key="uses_per_customer">10</data> + <data key="is_active">true</data> + <data key="stop_rules_processing">false</data> + <data key="is_advanced">true</data> + <data key="sort_order">1</data> + <data key="simple_action">by_percent</data> + <data key="discount_amount">10</data> + <data key="discount_qty">10</data> + <data key="discount_step">0</data> + <data key="apply_to_shipping">false</data> + <data key="times_used">0</data> + <data key="is_rss">false</data> + <data key="coupon_type">SPECIFIC_COUPON</data> + <data key="use_auto_generation">false</data> + <data key="uses_per_coupon">10</data> + <data key="simple_free_shipping">1</data> + </entity> </entities> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Data/SalesRuleProductConditionsData.xml b/app/code/Magento/SalesRule/Test/Mftf/Data/SalesRuleProductConditionsData.xml new file mode 100644 index 0000000000000..8af7ac0fdd99a --- /dev/null +++ b/app/code/Magento/SalesRule/Test/Mftf/Data/SalesRuleProductConditionsData.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <entity name="SalesRuleProductConditions" type="SalesRuleConditionAttribute"> + <data key="priceInCart" >Magento\SalesRule\Model\Rule\Condition\Product|quote_item_price</data> + <data key="quantityInCart">Magento\SalesRule\Model\Rule\Condition\Product|quote_item_qty</data> + <data key="rowTotalInCart">Magento\SalesRule\Model\Rule\Condition\Product|quote_item_row_total</data> + </entity> +</entities> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Metadata/coupon-meta.xml b/app/code/Magento/SalesRule/Test/Mftf/Metadata/coupon-meta.xml index dda402ed6436d..9bec2ecd006cf 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Metadata/coupon-meta.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Metadata/coupon-meta.xml @@ -9,7 +9,7 @@ <operations xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/DataGenerator/etc/dataOperation.xsd"> - <operation name="CreateCoupon" dataType="coupon" type="create" auth="adminOauth" url="/rest/V1/coupons" method="POST"> + <operation name="CreateCoupon" dataType="coupon" type="create" auth="adminOauth" url="/V1/coupons" method="POST"> <contentType>application/json</contentType> <object key="coupon" dataType="coupon"> <field key="rule_id" required="true">integer</field> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Page/AdminCartPriceRuleEditPage.xml b/app/code/Magento/SalesRule/Test/Mftf/Page/AdminCartPriceRuleEditPage.xml new file mode 100644 index 0000000000000..faed9d42bcdec --- /dev/null +++ b/app/code/Magento/SalesRule/Test/Mftf/Page/AdminCartPriceRuleEditPage.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminCartPriceRuleEditPage" area="admin" url="sales_rule/promo_quote/edit/id/{{salesRuleId}}" module="Magento_SalesRule" parameterized="true"> + <section name="AdminCartPriceRulesFormSection"/> + </page> +</pages> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Page/AdminCartPriceRulesLimitPage.xml b/app/code/Magento/SalesRule/Test/Mftf/Page/AdminCartPriceRulesLimitPage.xml new file mode 100644 index 0000000000000..3cd2563ddf183 --- /dev/null +++ b/app/code/Magento/SalesRule/Test/Mftf/Page/AdminCartPriceRulesLimitPage.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/PageObject.xsd"> + <page name="AdminCartPriceRulesLimitPage" url="sales_rule/promo_quote/index/limit/{{count}}" area="admin" module="Magento_SalesRule" parameterized="true"/> +</pages> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Section/AdminCartPriceRuleDiscountSection.xml b/app/code/Magento/SalesRule/Test/Mftf/Section/AdminCartPriceRuleDiscountSection.xml index d0aaa8e15f9ca..257452d6cd27a 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Section/AdminCartPriceRuleDiscountSection.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Section/AdminCartPriceRuleDiscountSection.xml @@ -6,10 +6,12 @@ */ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminCartPriceRuleDiscountSection"> - <element name="discountTab" type="button" selector="//strong[text()='Apply Discount Code']"/> + <element name="discountTab" type="button" selector="#block-discount-heading"/> <element name="couponInput" type="input" selector="#coupon_code"/> - <element name="applyCodeBtn" type="button" selector="//span[text()='Apply Discount']"/> + <element name="applyCodeBtn" type="button" selector="#discount-coupon-form button[class*='apply']" timeout="30"/> + <element name="discountBlockActive" type="text" selector=".block.discount.active"/> + <element name="cancelButton" type="text" selector="#discount-coupon-form button[class*='cancel']" timeout="30"/> </section> </sections> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Section/AdminCartPriceRulesFormSection.xml b/app/code/Magento/SalesRule/Test/Mftf/Section/AdminCartPriceRulesFormSection.xml index 14584acd03376..e1dd048e4a9e4 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Section/AdminCartPriceRulesFormSection.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Section/AdminCartPriceRulesFormSection.xml @@ -24,8 +24,22 @@ <element name="userPerCustomer" type="input" selector="//input[@name='uses_per_customer']"/> <element name="priority" type="input" selector="//*[@name='sort_order']"/> + <!-- Conditions sub-form --> + <element name="conditionsHeader" type="button" selector="div[data-index='conditions']" timeout="30"/> + <element name="conditionsHeaderOpen" type="button" selector="div[data-index='conditions'] div[data-state-collapsible='open']" timeout="30"/> + <element name="addCondition" type="button" selector="//*[@id='conditions__{{arg}}__children']//span" parameterized="true"/> + <element name="ruleCondition" type="select" selector="rule[conditions][{{arg}}][new_child]" parameterized="true"/> + <element name="ruleParameter" type="text" selector="//span[@class='rule-param']/a[contains(text(), '{{arg}}')]" parameterized="true"/> + <element name="ruleOperatorSelect" type="select" selector="rule[conditions][{{arg}}][operator]" parameterized="true"/> + <element name="ruleValueInput" type="input" selector="rule[conditions][{{arg}}][value]" parameterized="true"/> + <element name="chooseValue" type="button" selector="label[for='conditions__{{arg}}__value']" parameterized="true"/> + <element name="categoryCheckbox" type="checkbox" selector="//span[contains(text(), '{{arg}}')]/parent::a/preceding-sibling::input[@type='checkbox']" parameterized="true"/> + <element name="conditionsValue" type="input" selector=".rule-param-edit input"/> + <element name="conditionsOperator" type="select" selector=".rule-param-edit select"/> + <!-- Actions sub-form --> <element name="actionsHeader" type="button" selector="div[data-index='actions']" timeout="30"/> + <element name="actionsHeaderOpen" type="button" selector="div[data-index='actions'] div[data-state-collapsible='open']" timeout="30"/> <element name="apply" type="select" selector="select[name='simple_action']"/> <element name="applyDiscountToShipping" type="checkbox" selector="input[name='apply_to_shipping']"/> <element name="applyDiscountToShippingLabel" type="checkbox" selector="input[name='apply_to_shipping']+label"/> @@ -34,9 +48,14 @@ <element name="freeShipping" type="select" selector="select[name='simple_free_shipping']"/> <element name="conditions" type="button" selector=".rule-param.rule-param-new-child > a"/> <element name="condition" type="text" selector="//span[@class='rule-param']/a[text()='{{arg}}']" parameterized="true"/> + <element name="actionsAggregator" type="select" selector="#actions__1__aggregator"/> + <element name="actionsValue" type="select" selector="#actions__1__value"/> <element name="operator" type="select" selector="select[name*='[operator]']"/> <element name="childAttribute" type="select" selector="select[name*='new_child']"/> <element name="optionInput" type="input" selector="ul[class*='rule-param-children'] input[name*='[value]']"/> + <element name="actionValue" type="input" selector=".rule-param-edit input"/> + <element name="applyAction" type="text" selector=".rule-param-apply" timeout="30"/> + <element name="actionOperator" type="select" selector=".rule-param-edit select"/> <!-- Manage Coupon Codes sub-form --> <element name="manageCouponCodesHeader" type="button" selector="div[data-index='manage_coupon_codes']" timeout="30"/> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Section/AdminCartPriceRulesSection.xml b/app/code/Magento/SalesRule/Test/Mftf/Section/AdminCartPriceRulesSection.xml index a341c4a3bf769..0aa01e06c44c7 100644 --- a/app/code/Magento/SalesRule/Test/Mftf/Section/AdminCartPriceRulesSection.xml +++ b/app/code/Magento/SalesRule/Test/Mftf/Section/AdminCartPriceRulesSection.xml @@ -6,7 +6,7 @@ */ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/SectionObject.xsd"> <section name="AdminCartPriceRulesSection"> <element name="addNewRuleButton" type="button" selector="#add" timeout="30"/> <element name="messages" type="text" selector=".messages"/> @@ -15,5 +15,9 @@ <element name="rowByIndex" type="text" selector="tr[data-role='row']:nth-of-type({{var1}})" parameterized="true" timeout="30"/> <element name="nameColumns" type="text" selector="td[data-column='name']"/> <element name="rowContainingText" type="text" selector="//*[@id='promo_quote_grid_table']/tbody/tr[td//text()[contains(., '{{var1}}')]]" parameterized="true" timeout="30"/> + <element name="rulesRow" type="text" selector="//tr[@data-role='row']"/> + <element name="pageCurrent" type="text" selector="//label[@for='promo_quote_grid_page-current']"/> + <element name="countPages" type="text" selector="//label[@for='promo_quote_grid_page-current']//span"/> + <element name="totalCount" type="text" selector="span[data-ui-id*='grid-total-count']"/> </section> </sections> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Section/StorefrontCheckoutCartSummarySection.xml b/app/code/Magento/SalesRule/Test/Mftf/Section/StorefrontCheckoutCartSummarySection.xml new file mode 100644 index 0000000000000..26b52d4610c37 --- /dev/null +++ b/app/code/Magento/SalesRule/Test/Mftf/Section/StorefrontCheckoutCartSummarySection.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontCheckoutCartSummarySection"> + <element name="discountLabel" type="text" selector="//*[@id='cart-totals']//tr[.//th//span[contains(@class, 'discount coupon')]]"/> + <element name="discountTotal" type="text" selector="//*[@id='cart-totals']//tr[.//th//span[contains(@class, 'discount coupon')]]//td//span//span[@class='price']"/> + </section> +</sections> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCartRulesAppliedForProductInCartTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCartRulesAppliedForProductInCartTest.xml new file mode 100644 index 0000000000000..327a126b8dc78 --- /dev/null +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/AdminCartRulesAppliedForProductInCartTest.xml @@ -0,0 +1,108 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminCartRulesAppliedForProductInCartTest"> + <annotations> + <features value="SalesRule"/> + <stories value="Create cart price rule"/> + <title value="Check that cart rules applied for product in cart"/> + <description value="Check that cart rules applied for product in cart"/> + <severity value="MAJOR"/> + <testCaseId value="MC-13629"/> + <useCaseId value="MAGETWO-94348"/> + <group value="salesRule"/> + </annotations> + + <before> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + + <!--Create category and product--> + <createData entity="_defaultCategory" stepKey="createPreReqCategory"/> + <createData entity="SimpleProduct2" stepKey="simpleProduct"> + <field key="price">200</field> + <field key="quantity">500</field> + <requiredEntity createDataKey="createPreReqCategory"/> + </createData> + </before> + <after> + <!--Delete created data--> + <deleteData createDataKey="createPreReqCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="simpleProduct" stepKey="deleteSimpleProduct"/> + + <actionGroup ref="DeleteProductOnProductsGridPageByName" stepKey="deleteProductOnProductsGridPageByName"> + <argument name="product" value="BundleProduct"/> + </actionGroup> + <actionGroup ref="clearFiltersAdminDataGrid" stepKey="clearFilters"/> + + <actionGroup ref="DeleteCartPriceRuleByName" stepKey="deleteCartPriceRule"> + <argument name="ruleName" value="{{PriceRuleWithCondition.name}}"/> + </actionGroup> + <actionGroup ref="clearFiltersAdminDataGrid" stepKey="clearFilters1"/> + + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <!--Start creating a bundle product--> + <amOnPage url="{{AdminProductIndexPage.url}}" stepKey="goToProductList"/> + <actionGroup ref="goToCreateProductPage" stepKey="goToCreateProduct"> + <argument name="product" value="BundleProduct"/> + </actionGroup> + + <!--Off dynamic price and set value--> + <scrollToTopOfPage stepKey="scrollToTopOfThePageToSeePriceTypeElement"/> + <click selector="{{AdminProductFormBundleSection.priceTypeSwitcher}}" stepKey="offDynamicPrice"/> + <fillField selector="{{AdminProductFormSection.productPrice}}" userInput="0" stepKey="setProductPrice"/> + + <!-- Add option, a "Radio Buttons" type option, with one product and set fixed price 200--> + <actionGroup ref="CreateBundleProductForOneSimpleProductsWithRadioTypeOption" stepKey="createBundleProductWithRadioTypeOption"> + <argument name="bundleProduct" value="BundleProduct"/> + <argument name="simpleProductFirst" value="$$simpleProduct$$"/> + <argument name="simpleProductSecond"/> + </actionGroup> + <selectOption selector="{{AdminProductFormBundleSection.bundleSelectionPriceType}}" userInput="Fixed" stepKey="selectPriceType"/> + <fillField selector="{{AdminProductFormBundleSection.bundleSelectionPriceValue}}" userInput="200" stepKey="fillPriceValue"/> + <actionGroup ref="saveProductForm" stepKey="saveProduct"/> + + <!--Create cart price rule--> + <actionGroup ref="AdminCreateCartPriceRuleWithProductSubselectionCondition" stepKey="createRule"> + <argument name="rule" value="PriceRuleWithCondition"/> + <argument name="condition" value="Category"/> + <argument name="operation" value="equals or greater than"/> + <argument name="totalQuantity" value="2"/> + <argument name="value" value="{{_defaultCategory.name}}"/> + </actionGroup> + + <!--Go to Storefront and add product to cart and checkout from cart--> + <amOnPage url="{{StorefrontProductPage.url($$simpleProduct.name$$)}}" stepKey="goToProduct"/> + <actionGroup ref="StorefrontAddProductToCartQuantityActionGroup" stepKey="addToCart"> + <argument name="productName" value="$$simpleProduct.name$$"/> + <argument name="quantity" value="2"/> + </actionGroup> + <actionGroup ref="GoToCheckoutFromMinicartActionGroup" stepKey="goToCheckoutFromMinicart"/> + <actionGroup ref="GuestCheckoutFillingShippingSectionActionGroup" stepKey="guestCheckoutFillingShipping"/> + + <!--Check totals--> + <grabTextFrom selector="{{CheckoutPaymentSection.orderSummarySubtotal}}" stepKey="grabSubtotal"/> + <grabTextFrom selector="{{CheckoutPaymentSection.orderSummaryShippingTotal}}" stepKey="grabShippingTotal"/> + <grabTextFrom selector="{{CheckoutPaymentSection.orderSummaryTotal}}" stepKey="grabTotal"/> + <assertEquals stepKey="assertSubtotal"> + <expectedResult type="string">$400.00</expectedResult> + <actualResult type="variable">$grabSubtotal</actualResult> + </assertEquals> + <assertEquals stepKey="assertShippingTotal"> + <expectedResult type="string">$10.00</expectedResult> + <actualResult type="variable">$grabShippingTotal</actualResult> + </assertEquals> + <assertEquals stepKey="assertTotal"> + <expectedResult type="string">$410.00</expectedResult> + <actualResult type="variable">$grabTotal</actualResult> + </assertEquals> + </test> +</tests> diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCategoryRulesShouldApplyToComplexProductsTest.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCategoryRulesShouldApplyToComplexProductsTest.xml new file mode 100644 index 0000000000000..047b64c68e7e3 --- /dev/null +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/StorefrontCategoryRulesShouldApplyToComplexProductsTest.xml @@ -0,0 +1,122 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontCategoryRulesShouldApplyToComplexProductsTest"> + <annotations> + <features value="CatalogRule"/> + <stories value="Create cart price rule"/> + <title value="Category rules should apply to complex products"/> + <description value="Sales rules filtering on category should apply to all products, including complex products."/> + <severity value="CRITICAL"/> + <testCaseId value="MAGETWO-76029"/> + <group value="catalogRule"/> + </annotations> + <before> + <!-- Create two Categories: CAT1 and CAT2 --> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <createData entity="SimpleSubCategory" stepKey="createCategory2"/> + <!--Create config1 and config2--> + <actionGroup ref="AdminCreateApiConfigurableProductWithHiddenChildActionGroup" stepKey="createConfigurableProduct1"> + <argument name="productName" value="config1"/> + </actionGroup> + <actionGroup ref="AdminCreateApiConfigurableProductWithHiddenChildActionGroup" stepKey="createConfigurableProduct2"> + <argument name="productName" value="config2"/> + </actionGroup> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <!-- Assign config1 and the associated child products to CAT1 --> + <actionGroup ref="AdminAssignProductToCategory" stepKey="assignConfigurableProduct1ToCategory"> + <argument name="productId" value="$$createConfigProductCreateConfigurableProduct1.id$$"/> + <argument name="categoryName" value="$$createCategory.name$$"/> + </actionGroup> + <actionGroup ref="AdminAssignProductToCategory" stepKey="assignConfig1ChildProduct1ToCategory"> + <argument name="productId" value="$$createConfigChildProduct1CreateConfigurableProduct1.id$$"/> + <argument name="categoryName" value="$$createCategory.name$$"/> + </actionGroup> + <actionGroup ref="AdminAssignProductToCategory" stepKey="assignConfig1ChildProduct2ToCategory"> + <argument name="productId" value="$$createConfigChildProduct2CreateConfigurableProduct1.id$$"/> + <argument name="categoryName" value="$$createCategory.name$$"/> + </actionGroup> + <!-- Assign config12 and the associated child products to CAT2 --> + <actionGroup ref="AdminAssignProductToCategory" stepKey="assignConfigurableProduct2ToCategory2"> + <argument name="productId" value="$$createConfigProductCreateConfigurableProduct2.id$$"/> + <argument name="categoryName" value="$$createCategory2.name$$"/> + </actionGroup> + <actionGroup ref="AdminAssignProductToCategory" stepKey="assignConfig2ChildProduct1ToCategory2"> + <argument name="productId" value="$$createConfigChildProduct1CreateConfigurableProduct2.id$$"/> + <argument name="categoryName" value="$$createCategory2.name$$"/> + </actionGroup> + <actionGroup ref="AdminAssignProductToCategory" stepKey="assignConfig2ChildProduct2ToCategory2"> + <argument name="productId" value="$$createConfigChildProduct2CreateConfigurableProduct2.id$$"/> + <argument name="categoryName" value="$$createCategory2.name$$"/> + </actionGroup> + </before> + <after> + <!--Delete configurable product 1--> + <deleteData createDataKey="createConfigProductCreateConfigurableProduct1" stepKey="deleteConfigProduct1"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory1"/> + <deleteData createDataKey="createConfigChildProduct1CreateConfigurableProduct1" stepKey="deleteConfigChildProduct1"/> + <deleteData createDataKey="createConfigChildProduct2CreateConfigurableProduct1" stepKey="deleteConfigChildProduct2"/> + <deleteData createDataKey="createConfigProductAttributeCreateConfigurableProduct1" stepKey="deleteConfigProductAttribute1"/> + <!--Delete configurable product 2--> + <deleteData createDataKey="createConfigProductCreateConfigurableProduct2" stepKey="deleteConfigProduct2"/> + <deleteData createDataKey="createCategory2" stepKey="deleteCategory2"/> + <deleteData createDataKey="createConfigChildProduct1CreateConfigurableProduct2" stepKey="deleteConfigChildProduct3"/> + <deleteData createDataKey="createConfigChildProduct2CreateConfigurableProduct2" stepKey="deleteConfigChildProduct4"/> + <deleteData createDataKey="createConfigProductAttributeCreateConfigurableProduct2" stepKey="deleteConfigProductAttribute2"/> + <!--Delete Cart Price Rule --> + <deleteData createDataKey="createCartPriceRule" stepKey="deleteCartPriceRule"/> + <actionGroup ref="logout" stepKey="logout"/> + </after> + <!-- 1: Create a cart price rule applying to CAT1 with discount --> + <createData entity="SalesRuleNoCouponWithFixedDiscount" stepKey="createCartPriceRule"/> + <amOnPage url="{{AdminCartPriceRuleEditPage.url($$createCartPriceRule.rule_id$$)}}" stepKey="goToCartPriceRuleEditPage"/> + <actionGroup ref="SetConditionForActionsInCartPriceRuleActionGroup" stepKey="setConditionForActionsInCartPriceRuleActionGroup"> + <argument name="actionValue" value="$$createCategory.id$$"/> + </actionGroup> + <!-- 2: Go to frontend and add an item from both CAT1 and CAT2 to your cart --> + <amOnPage url="{{StorefrontHomePage.url}}" stepKey="goToFrontend"/> + <!-- 3: Open configurable product 1 and add all his child products to cart --> + <amOnPage url="{{StorefrontProductPage.url($$createConfigProductCreateConfigurableProduct1.custom_attributes[url_key]$$)}}" stepKey="amOnConfigurableProductPage"/> + <selectOption selector="{{StorefrontProductInfoMainSection.productOptionSelect('$$createConfigProductAttributeCreateConfigurableProduct1.attribute[frontend_labels][0][label]$$')}}" userInput="$$createConfigProductAttributeOption1CreateConfigurableProduct1.option[store_labels][0][label]$$" stepKey="selectOption"/> + <waitForPageLoad stepKey="waitForProductDataChange"/> + <actionGroup ref="StorefrontAddProductToCartActionGroup" stepKey="cartAddConfigurableProductToCart"> + <argument name="product" value="$$createConfigProductCreateConfigurableProduct1$$"/> + <argument name="productCount" value="1"/> + </actionGroup> + <selectOption selector="{{StorefrontProductInfoMainSection.productOptionSelect('$$createConfigProductAttributeCreateConfigurableProduct1.attribute[frontend_labels][0][label]$$')}}" userInput="$$createConfigProductAttributeOption2CreateConfigurableProduct1.option[store_labels][0][label]$$" stepKey="selectOption2"/> + <waitForPageLoad stepKey="waitForProductDataChange2"/> + <actionGroup ref="StorefrontAddProductToCartActionGroup" stepKey="cartAddConfigurableProductToCart2"> + <argument name="product" value="$$createConfigProductCreateConfigurableProduct1$$"/> + <argument name="productCount" value="2"/> + </actionGroup> + <actionGroup ref="clickViewAndEditCartFromMiniCart" stepKey="goToCart"/> + <waitForElementVisible selector="{{StorefrontCheckoutCartSummarySection.tableTotals}}" stepKey="waitForCartTotalsBlockLoad"/> + <!-- Discount amount is not applied --> + <dontSee selector="{{StorefrontCheckoutCartSummarySection.discountLabel}}" stepKey="discountIsNotApply"/> + <!-- 3: Open configurable product 2 and add all his child products to cart --> + <amOnPage url="{{StorefrontProductPage.url($$createConfigProductCreateConfigurableProduct2.custom_attributes[url_key]$$)}}" stepKey="amOnConfigurableProductPage2"/> + <selectOption selector="{{StorefrontProductInfoMainSection.productOptionSelect('$$createConfigProductAttributeCreateConfigurableProduct2.attribute[frontend_labels][0][label]$$')}}" userInput="$$createConfigProductAttributeOption1CreateConfigurableProduct2.option[store_labels][0][label]$$" stepKey="selectOption3"/> + <waitForPageLoad stepKey="waitForProductDataChange3"/> + <actionGroup ref="StorefrontAddProductToCartActionGroup" stepKey="cartAddConfigurableProductToCart3"> + <argument name="product" value="$$createConfigProductCreateConfigurableProduct2$$"/> + <argument name="productCount" value="3"/> + </actionGroup> + <selectOption selector="{{StorefrontProductInfoMainSection.productOptionSelect('$$createConfigProductAttributeCreateConfigurableProduct2.attribute[frontend_labels][0][label]$$')}}" userInput="$$createConfigProductAttributeOption2CreateConfigurableProduct2.option[store_labels][0][label]$$" stepKey="selectOption4"/> + <waitForPageLoad stepKey="waitForProductDataChange4"/> + <actionGroup ref="StorefrontAddProductToCartActionGroup" stepKey="cartAddConfigurableProductToCart4"> + <argument name="product" value="$$createConfigProductCreateConfigurableProduct2$$"/> + <argument name="productCount" value="4"/> + </actionGroup> + <!-- Discount amount is applied --> + <actionGroup ref="clickViewAndEditCartFromMiniCart" stepKey="goToCart2"/> + <waitForElementVisible selector="{{StorefrontCheckoutCartSummarySection.tableTotals}}" stepKey="waitForCartTotalsBlockLoad2"/> + <see selector="{{StorefrontCheckoutCartSummarySection.discountTotal}}" userInput="-$100.00" stepKey="discountIsApply"/> + </test> +</tests> diff --git a/app/code/Magento/SalesRule/composer.json b/app/code/Magento/SalesRule/composer.json index 3751aca28aef2..752c711ff4c3a 100644 --- a/app/code/Magento/SalesRule/composer.json +++ b/app/code/Magento/SalesRule/composer.json @@ -25,7 +25,7 @@ "magento/module-sales-rule-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "101.0.4", + "version": "101.0.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/SalesRule/etc/events.xml b/app/code/Magento/SalesRule/etc/events.xml index 8261860bbb7ce..eec0da74f619e 100644 --- a/app/code/Magento/SalesRule/etc/events.xml +++ b/app/code/Magento/SalesRule/etc/events.xml @@ -24,4 +24,7 @@ <event name="magento_salesrule_api_data_ruleinterface_load_after"> <observer name="legacy_model_load" instance="Magento\Framework\EntityManager\Observer\AfterEntityLoad" /> </event> + <event name="sales_order_customer_assign_after"> + <observer name="sales_order_assign_customer_after" instance="Magento\SalesRule\Observer\AssignCouponDataAfterOrderCustomerAssignObserver" /> + </event> </config> diff --git a/app/code/Magento/SalesRule/view/frontend/layout/checkout_cart_index.xml b/app/code/Magento/SalesRule/view/frontend/layout/checkout_cart_index.xml index 022403579b237..375324ed4cde6 100644 --- a/app/code/Magento/SalesRule/view/frontend/layout/checkout_cart_index.xml +++ b/app/code/Magento/SalesRule/view/frontend/layout/checkout_cart_index.xml @@ -13,14 +13,10 @@ <item name="components" xsi:type="array"> <item name="block-totals" xsi:type="array"> <item name="children" xsi:type="array"> - <item name="before_grandtotal" xsi:type="array"> - <item name="children" xsi:type="array"> - <item name="discount" xsi:type="array"> - <item name="component" xsi:type="string">Magento_SalesRule/js/view/cart/totals/discount</item> - <item name="config" xsi:type="array"> - <item name="title" xsi:type="string" translate="true">Discount</item> - </item> - </item> + <item name="discount" xsi:type="array"> + <item name="component" xsi:type="string">Magento_SalesRule/js/view/cart/totals/discount</item> + <item name="config" xsi:type="array"> + <item name="title" xsi:type="string" translate="true">Discount</item> </item> </item> </item> diff --git a/app/code/Magento/SalesSequence/composer.json b/app/code/Magento/SalesSequence/composer.json index 8c4993966d4bb..a0c93b6310910 100644 --- a/app/code/Magento/SalesSequence/composer.json +++ b/app/code/Magento/SalesSequence/composer.json @@ -6,7 +6,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/SampleData/composer.json b/app/code/Magento/SampleData/composer.json index 7d9e64740f240..31358c933acd3 100644 --- a/app/code/Magento/SampleData/composer.json +++ b/app/code/Magento/SampleData/composer.json @@ -9,7 +9,7 @@ "magento/sample-data-media": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Search/composer.json b/app/code/Magento/Search/composer.json index 2b474fdba93dc..3515ce33a4ee5 100644 --- a/app/code/Magento/Search/composer.json +++ b/app/code/Magento/Search/composer.json @@ -11,7 +11,7 @@ "magento/module-ui": "101.0.*" }, "type": "magento2-module", - "version": "100.2.5", + "version": "100.2.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Security/composer.json b/app/code/Magento/Security/composer.json index 5422b6d606551..4edfa9c55e2ee 100644 --- a/app/code/Magento/Security/composer.json +++ b/app/code/Magento/Security/composer.json @@ -11,7 +11,7 @@ "magento/module-customer": "101.0.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/SendFriend/composer.json b/app/code/Magento/SendFriend/composer.json index 324465885f82f..e6af6d9dcfbef 100644 --- a/app/code/Magento/SendFriend/composer.json +++ b/app/code/Magento/SendFriend/composer.json @@ -9,7 +9,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Shipping/Block/Adminhtml/Order/Packaging.php b/app/code/Magento/Shipping/Block/Adminhtml/Order/Packaging.php index b4ff445c63f4e..e5e419328eea4 100644 --- a/app/code/Magento/Shipping/Block/Adminhtml/Order/Packaging.php +++ b/app/code/Magento/Shipping/Block/Adminhtml/Order/Packaging.php @@ -74,6 +74,7 @@ public function getShipment() * Configuration for popup window for packaging * * @return string + * @SuppressWarnings(PHPMD.RequestAwareBlockMethod) */ public function getConfigDataJson() { @@ -86,7 +87,7 @@ public function getConfigDataJson() $itemsName = []; $itemsWeight = []; $itemsProductId = []; - + $itemsOrderItemId = []; if ($shipmentId) { $urlParams['shipment_id'] = $shipmentId; $createLabelUrl = $this->getUrl('adminhtml/order_shipment/createLabel', $urlParams); diff --git a/app/code/Magento/Shipping/Model/Carrier/AbstractCarrierOnline.php b/app/code/Magento/Shipping/Model/Carrier/AbstractCarrierOnline.php index 762ffed75fc34..019baeef062c5 100644 --- a/app/code/Magento/Shipping/Model/Carrier/AbstractCarrierOnline.php +++ b/app/code/Magento/Shipping/Model/Carrier/AbstractCarrierOnline.php @@ -28,6 +28,14 @@ abstract class AbstractCarrierOnline extends AbstractCarrier const GUAM_REGION_CODE = 'GU'; + const SPAIN_COUNTRY_ID = 'ES'; + + const CANARY_ISLANDS_COUNTRY_ID = 'IC'; + + const SANTA_CRUZ_DE_TENERIFE_REGION_ID = 'Santa Cruz de Tenerife'; + + const LAS_PALMAS_REGION_ID = 'Las Palmas'; + /** * Array of quotes * diff --git a/app/code/Magento/Shipping/Test/Mftf/ActionGroup/AdminShipmentActionGroup.xml b/app/code/Magento/Shipping/Test/Mftf/ActionGroup/AdminShipmentActionGroup.xml new file mode 100644 index 0000000000000..1d90867b110dd --- /dev/null +++ b/app/code/Magento/Shipping/Test/Mftf/ActionGroup/AdminShipmentActionGroup.xml @@ -0,0 +1,50 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="VerifyBasicShipmentInformation"> + <arguments> + <argument name="customer"/> + <argument name="shippingAddress"/> + <argument name="billingAddress"/> + <argument name="customerGroup" defaultValue="GeneralCustomerGroup"/> + </arguments> + <see selector="{{AdminShipmentOrderAndAccountInformationSection.customerName}}" userInput="{{customer.firstname}}" stepKey="seeCustomerName"/> + <see selector="{{AdminShipmentOrderAndAccountInformationSection.customerEmail}}" userInput="{{customer.email}}" stepKey="seeCustomerEmail"/> + <see selector="{{AdminShipmentOrderAndAccountInformationSection.customerGroup}}" userInput="{{customerGroup.code}}" stepKey="seeCustomerGroup"/> + <see selector="{{AdminShipmentAddressInformationSection.billingAddress}}" userInput="{{billingAddress.street[0]}}" stepKey="seeBillingAddressStreet"/> + <see selector="{{AdminShipmentAddressInformationSection.billingAddress}}" userInput="{{billingAddress.city}}" stepKey="seeBillingAddressCity"/> + <see selector="{{AdminShipmentAddressInformationSection.billingAddress}}" userInput="{{billingAddress.country}}" stepKey="seeBillingAddressCountry"/> + <see selector="{{AdminShipmentAddressInformationSection.billingAddress}}" userInput="{{billingAddress.postcode}}" stepKey="seeBillingAddressPostcode"/> + <see selector="{{AdminShipmentAddressInformationSection.shippingAddress}}" userInput="{{shippingAddress.street[0]}}" stepKey="seeShippingAddressStreet"/> + <see selector="{{AdminShipmentAddressInformationSection.shippingAddress}}" userInput="{{shippingAddress.city}}" stepKey="seeShippingAddressCity"/> + <see selector="{{AdminShipmentAddressInformationSection.shippingAddress}}" userInput="{{shippingAddress.country}}" stepKey="seeShippingAddressCountry"/> + <see selector="{{AdminShipmentAddressInformationSection.shippingAddress}}" userInput="{{shippingAddress.postcode}}" stepKey="seeShippingAddressPostcode"/> + </actionGroup> + + <actionGroup name="SeeProductInShipmentItems"> + <arguments> + <argument name="product"/> + </arguments> + <seeElement selector="{{AdminShipmentItemsSection.productColumn(product.name)}}" stepKey="seeProductInShipmentItemsGrid"/> + </actionGroup> + + <actionGroup name="StartCreateShipmentFromOrderPage"> + <click selector="{{AdminOrderDetailsMainActionsSection.ship}}" stepKey="clickShipAction"/> + <seeInCurrentUrl url="{{AdminShipmentNewPage.url}}" stepKey="seeNewShipmentUrl"/> + <see selector="{{AdminHeaderSection.pageTitle}}" userInput="New Shipment" stepKey="seeNewShipmentPageTitle"/> + </actionGroup> + + <actionGroup name="SubmitShipment"> + <click selector="{{AdminShipmentMainActionsSection.submitShipment}}" stepKey="clickSubmitShipment"/> + <grabFromCurrentUrl regex="~/order_id/(\d+)/~" stepKey="grabOrderId"/> + <seeInCurrentUrl url="{{AdminOrderDetailsPage.url('$grabOrderId')}}" stepKey="seeViewOrderPageShipping"/> + <see selector="{{AdminMessagesSection.successMessage}}" userInput="The shipment has been created." stepKey="seeShipmentCreateSuccess"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Shipping/Test/Mftf/Page/AdminShipmentNewPage.xml b/app/code/Magento/Shipping/Test/Mftf/Page/AdminShipmentNewPage.xml index d7b91f68d21a2..bd311d3390043 100644 --- a/app/code/Magento/Shipping/Test/Mftf/Page/AdminShipmentNewPage.xml +++ b/app/code/Magento/Shipping/Test/Mftf/Page/AdminShipmentNewPage.xml @@ -10,6 +10,8 @@ xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/PageObject.xsd"> <page name="AdminShipmentNewPage" url="order_shipment/new/order_id/" area="admin" module="Magento_Shipping"> <section name="AdminShipmentMainActionsSection"/> + <section name="AdminShipmentOrderAndAccountInformationSection"/> + <section name="AdminShipmentAddressInformationSection"/> <section name="AdminShipmentItemsSection"/> </page> </pages> diff --git a/app/code/Magento/Shipping/Test/Mftf/Section/AdminShipmentAddressInformationSection.xml b/app/code/Magento/Shipping/Test/Mftf/Section/AdminShipmentAddressInformationSection.xml new file mode 100644 index 0000000000000..39035868c4b65 --- /dev/null +++ b/app/code/Magento/Shipping/Test/Mftf/Section/AdminShipmentAddressInformationSection.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminShipmentAddressInformationSection"> + <element name="billingAddress" type="text" selector=".order-billing-address address"/> + <element name="billingAddressEdit" type="button" selector=".order-billing-address .actions a"/> + <element name="shippingAddress" type="text" selector=".order-shipping-address address"/> + <element name="shippingAddressEdit" type="button" selector=".order-shipping-address .actions a"/> + </section> +</sections> diff --git a/app/code/Magento/Shipping/Test/Mftf/Section/AdminShipmentItemsSection.xml b/app/code/Magento/Shipping/Test/Mftf/Section/AdminShipmentItemsSection.xml index 740cae14f8bc5..55d26d729090c 100644 --- a/app/code/Magento/Shipping/Test/Mftf/Section/AdminShipmentItemsSection.xml +++ b/app/code/Magento/Shipping/Test/Mftf/Section/AdminShipmentItemsSection.xml @@ -7,9 +7,10 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminShipmentItemsSection"> <element name="itemQty" type="text" selector=".order-shipment-table tbody:nth-of-type({{var1}}) .col-ordered-qty .qty-table" parameterized="true"/> <element name="itemQtyToShip" type="input" selector=".order-shipment-table tbody:nth-of-type({{row}}) .col-qty input.qty-item" parameterized="true"/> + <element name="productColumn" type="text" selector="//*[contains(@class,'order-shipment-table')]//td[@class = 'col-product']//div[contains(text(),'{{productName}}')]" parameterized="true"/> </section> </sections> diff --git a/app/code/Magento/Shipping/Test/Mftf/Section/AdminShipmentMainActionsSection.xml b/app/code/Magento/Shipping/Test/Mftf/Section/AdminShipmentMainActionsSection.xml index d79748d1e20cc..a21c3229e3549 100644 --- a/app/code/Magento/Shipping/Test/Mftf/Section/AdminShipmentMainActionsSection.xml +++ b/app/code/Magento/Shipping/Test/Mftf/Section/AdminShipmentMainActionsSection.xml @@ -7,8 +7,8 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminShipmentMainActionsSection"> - <element name="submitShipment" type="button" selector="button.action-default.save.submit-button"/> + <element name="submitShipment" type="button" selector="button.action-default.save.submit-button" timeout="30"/> </section> </sections> diff --git a/app/code/Magento/Shipping/Test/Mftf/Section/AdminShipmentOrderAndAccountInformationSection.xml b/app/code/Magento/Shipping/Test/Mftf/Section/AdminShipmentOrderAndAccountInformationSection.xml new file mode 100644 index 0000000000000..2a83995cf3bbc --- /dev/null +++ b/app/code/Magento/Shipping/Test/Mftf/Section/AdminShipmentOrderAndAccountInformationSection.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminShipmentOrderAndAccountInformationSection"> + <element name="customerName" type="text" selector=".order-account-information table tr:first-of-type > td span"/> + <element name="customerEmail" type="text" selector=".order-account-information table tr:nth-of-type(2) > td a"/> + <element name="customerGroup" type="text" selector=".order-account-information table tr:nth-of-type(3) > td"/> + </section> +</sections> diff --git a/app/code/Magento/Shipping/composer.json b/app/code/Magento/Shipping/composer.json index b4e77dfdeb2d9..d887d9c984eb7 100644 --- a/app/code/Magento/Shipping/composer.json +++ b/app/code/Magento/Shipping/composer.json @@ -25,7 +25,7 @@ "magento/module-config": "101.0.*" }, "type": "magento2-module", - "version": "100.2.6", + "version": "100.2.7", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Signifyd/composer.json b/app/code/Magento/Signifyd/composer.json index 51e290957ec9a..7140bf36518a3 100644 --- a/app/code/Magento/Signifyd/composer.json +++ b/app/code/Magento/Signifyd/composer.json @@ -17,7 +17,7 @@ "magento/module-config": "101.0.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "proprietary" ], diff --git a/app/code/Magento/Sitemap/Controller/Adminhtml/Sitemap/Delete.php b/app/code/Magento/Sitemap/Controller/Adminhtml/Sitemap/Delete.php index a885aa30dae5e..12d89d899fa67 100644 --- a/app/code/Magento/Sitemap/Controller/Adminhtml/Sitemap/Delete.php +++ b/app/code/Magento/Sitemap/Controller/Adminhtml/Sitemap/Delete.php @@ -53,6 +53,9 @@ public function execute() $sitemap->load($id); // delete file $sitemapPath = $sitemap->getSitemapPath(); + if ($sitemapPath && $sitemapPath[0] === DIRECTORY_SEPARATOR) { + $sitemapPath = mb_substr($sitemapPath, 1); + } $sitemapFilename = $sitemap->getSitemapFilename(); $path = $directory->getRelativePath($sitemapPath . $sitemapFilename); diff --git a/app/code/Magento/Sitemap/Test/Unit/Controller/Adminhtml/Sitemap/DeleteTest.php b/app/code/Magento/Sitemap/Test/Unit/Controller/Adminhtml/Sitemap/DeleteTest.php index ed004fe88b318..b56ed39ba16fc 100644 --- a/app/code/Magento/Sitemap/Test/Unit/Controller/Adminhtml/Sitemap/DeleteTest.php +++ b/app/code/Magento/Sitemap/Test/Unit/Controller/Adminhtml/Sitemap/DeleteTest.php @@ -135,7 +135,7 @@ public function testExecute() $this->sitemapFactoryMock->expects($this->once())->method('create')->willReturn($sitemapMock); $writeDirectoryMock->expects($this->any()) ->method('getRelativePath') - ->with($sitemapPath . $sitemapFilename) + ->with($sitemapFilename) ->willReturn($relativePath); $writeDirectoryMock->expects($this->once())->method('isFile')->with($relativePath)->willReturn(true); $writeDirectoryMock->expects($this->once())->method('delete')->with($relativePath)->willReturn(true); diff --git a/app/code/Magento/Sitemap/composer.json b/app/code/Magento/Sitemap/composer.json index cda61e63e35cf..40f2c153f33be 100644 --- a/app/code/Magento/Sitemap/composer.json +++ b/app/code/Magento/Sitemap/composer.json @@ -18,7 +18,7 @@ "magento/module-config": "101.0.*" }, "type": "magento2-module", - "version": "100.2.5", + "version": "100.2.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Store/Api/Data/WebsiteInterface.php b/app/code/Magento/Store/Api/Data/WebsiteInterface.php index 176e82f5905b5..fae9fa368d3d1 100644 --- a/app/code/Magento/Store/Api/Data/WebsiteInterface.php +++ b/app/code/Magento/Store/Api/Data/WebsiteInterface.php @@ -13,6 +13,11 @@ */ interface WebsiteInterface extends \Magento\Framework\Api\ExtensibleDataInterface { + /** + * Contains code of admin website + */ + const ADMIN_CODE = 'admin'; + /** * @return int */ diff --git a/app/code/Magento/Store/Model/Store.php b/app/code/Magento/Store/Model/Store.php index 1203f748c0615..6807ca8311822 100644 --- a/app/code/Magento/Store/Model/Store.php +++ b/app/code/Magento/Store/Model/Store.php @@ -888,7 +888,10 @@ public function setCurrentCurrencyCode($code) if (in_array($code, $this->getAvailableCurrencyCodes())) { $this->_getSession()->setCurrencyCode($code); - $defaultCode = $this->_storeManager->getWebsite()->getDefaultStore()->getDefaultCurrency()->getCode(); + $defaultCode = ($this->_storeManager->getStore() !== null) + ? $this->_storeManager->getStore()->getDefaultCurrency()->getCode() + : $this->_storeManager->getWebsite()->getDefaultStore()->getDefaultCurrency()->getCode(); + $this->_httpContext->setValue(Context::CONTEXT_CURRENCY, $code, $defaultCode); } return $this; @@ -1328,12 +1331,14 @@ public function getIdentities() } /** + * Return Store Path + * * @return string */ public function getStorePath() { $parsedUrl = parse_url($this->getBaseUrl()); - return isset($parsedUrl['path']) ? $parsedUrl['path'] : '/'; + return $parsedUrl['path'] ?? '/'; } /** diff --git a/app/code/Magento/Store/Model/StoreResolver/Website.php b/app/code/Magento/Store/Model/StoreResolver/Website.php index 29f85716fea29..d4bb990307f1e 100644 --- a/app/code/Magento/Store/Model/StoreResolver/Website.php +++ b/app/code/Magento/Store/Model/StoreResolver/Website.php @@ -45,8 +45,10 @@ public function getAllowedStoreIds($scopeCode) $stores = []; $website = $scopeCode ? $this->websiteRepository->get($scopeCode) : $this->websiteRepository->getDefault(); foreach ($this->storeRepository->getList() as $store) { - if ($store->isActive() && $store->getWebsiteId() == $website->getId()) { - $stores[] = $store->getId(); + if ($store->isActive()) { + if (!$scopeCode || ($store->getWebsiteId() === $website->getId())) { + $stores[] = $store->getId(); + } } } return $stores; diff --git a/app/code/Magento/Store/Model/WebsiteRepository.php b/app/code/Magento/Store/Model/WebsiteRepository.php index 94fd59c7634df..1b12164e42cee 100644 --- a/app/code/Magento/Store/Model/WebsiteRepository.php +++ b/app/code/Magento/Store/Model/WebsiteRepository.php @@ -77,7 +77,14 @@ public function get($code) ]); if ($website->getId() === null) { - throw new NoSuchEntityException(); + throw new NoSuchEntityException( + __( + sprintf( + "The website with code %s that was requested wasn't found. Verify the website and try again.", + $code + ) + ) + ); } $this->entities[$code] = $website; $this->entitiesById[$website->getId()] = $website; @@ -99,7 +106,14 @@ public function getById($id) ]); if ($website->getId() === null) { - throw new NoSuchEntityException(); + throw new NoSuchEntityException( + __( + sprintf( + "The website with id %s that was requested wasn't found. Verify the website and try again.", + $id + ) + ) + ); } $this->entities[$website->getCode()] = $website; $this->entitiesById[$id] = $website; diff --git a/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminCreateStoreViewActionGroup.xml b/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminCreateStoreViewActionGroup.xml index 6fdfb2566fee9..f1c6f4d87e0d6 100644 --- a/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminCreateStoreViewActionGroup.xml +++ b/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminCreateStoreViewActionGroup.xml @@ -7,7 +7,7 @@ --> <!-- Test XML Example --> <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/actionGroupSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <actionGroup name="AdminCreateStoreViewActionGroup"> <arguments> <argument name="storeGroup" defaultValue="_defaultStoreGroup"/> @@ -27,4 +27,14 @@ <waitForElementVisible selector="{{AdminStoresGridSection.storeFilterTextField}}" stepKey="waitForPageReolad"/> <see userInput="You saved the store view." stepKey="seeSavedMessage" /> </actionGroup> + <actionGroup name="AdminCreateStoreViewUseStringArgumentsActionGroup" extends="AdminCreateStoreViewActionGroup"> + <arguments> + <argument name="storeGroupName" type="string"/> + <argument name="customStoreName" type="string"/> + <argument name="customStoreCode" type="string"/> + </arguments> + <selectOption selector="{{AdminNewStoreSection.storeGrpDropdown}}" userInput="{{storeGroupName}}" stepKey="selectStore" /> + <fillField selector="{{AdminNewStoreSection.storeNameTextField}}" userInput="{{customStoreName}}" stepKey="enterStoreViewName" /> + <fillField selector="{{AdminNewStoreSection.storeCodeTextField}}" userInput="{{customStoreCode}}" stepKey="enterStoreViewCode" /> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminCreateWebsiteActionGroup.xml b/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminCreateWebsiteActionGroup.xml index 9361c7d942c67..9d7c538d3a3c4 100644 --- a/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminCreateWebsiteActionGroup.xml +++ b/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminCreateWebsiteActionGroup.xml @@ -23,4 +23,18 @@ <waitForElementVisible selector="{{AdminStoresGridSection.websiteFilterTextField}}" stepKey="waitForStoreGridToReload"/> <see userInput="You saved the website." stepKey="seeSavedMessage" /> </actionGroup> + + <!--Get Website_id--> + <actionGroup name="AdminGetWebsiteIdActionGroup"> + <arguments> + <argument name="website"/> + </arguments> + <amOnPage url="{{AdminSystemStorePage.url}}" stepKey="amOnTheStorePage"/> + <click selector="{{AdminStoresGridSection.resetButton}}" stepKey="clickOnResetButton"/> + <fillField selector="{{AdminStoresGridSection.websiteFilterTextField}}" userInput="{{website.name}}" stepKey="fillSearchWebsiteField"/> + <click selector="{{AdminStoresGridSection.searchButton}}" stepKey="clickSearchButton" /> + <see selector="{{AdminStoresGridSection.websiteNameInFirstRow}}" userInput="{{website.name}}" stepKey="verifyThatCorrectWebsiteFound"/> + <click selector="{{AdminStoresGridSection.websiteNameInFirstRow}}" stepKey="clickEditExistingWebsite"/> + <grabFromCurrentUrl regex="~(\d+)/~" stepKey="grabFromCurrentUrl"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminDeleteStoreViewActionGroup.xml b/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminDeleteStoreViewActionGroup.xml index c32953540a77a..8b059ac164c0f 100644 --- a/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminDeleteStoreViewActionGroup.xml +++ b/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminDeleteStoreViewActionGroup.xml @@ -7,7 +7,7 @@ --> <!-- Test XML Example --> <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/actionGroupSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> <actionGroup name="AdminDeleteStoreViewActionGroup"> <arguments> <argument name="customStore" defaultValue="customStore"/> @@ -25,4 +25,10 @@ <click selector="{{AdminConfirmationModalSection.ok}}" stepKey="confirmStoreDelete"/> <see userInput="You deleted the store view." stepKey="seeDeleteMessage"/> </actionGroup> + <actionGroup name="AdminDeleteStoreViewUseStringArgumentsActionGroup" extends="AdminDeleteStoreViewActionGroup"> + <arguments> + <argument name="customStoreName" type="string"/> + </arguments> + <fillField selector="{{AdminStoresGridSection.storeFilterTextField}}" userInput="{{customStoreName}}" stepKey="fillStoreViewFilterField"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminDeleteWebsiteActionGroup.xml b/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminDeleteWebsiteActionGroup.xml index 558205bbc8071..395ba02d5a9de 100644 --- a/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminDeleteWebsiteActionGroup.xml +++ b/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminDeleteWebsiteActionGroup.xml @@ -23,5 +23,6 @@ <click selector="{{AdminStoresDeleteStoreGroupSection.deleteStoreGroupButton}}" stepKey="clickDeleteWebsiteButton"/> <waitForElementVisible selector="{{AdminStoresGridSection.websiteFilterTextField}}" stepKey="waitForStoreGridToReload"/> <see userInput="You deleted the website." stepKey="seeSavedMessage"/> + <conditionalClick selector="{{AdminDataGridHeaderSection.clearFilters}}" dependentSelector="{{AdminDataGridHeaderSection.clearFilters}}" visible="true" stepKey="clearExistingFilters"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminSwitchStoreViewActionGroup.xml b/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminSwitchStoreViewActionGroup.xml new file mode 100644 index 0000000000000..d540a0000655f --- /dev/null +++ b/app/code/Magento/Store/Test/Mftf/ActionGroup/AdminSwitchStoreViewActionGroup.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminSwitchBaseActionGroup"> + <arguments> + <argument name="scopeName" defaultValue="customStore.name"/> + </arguments> + <scrollToTopOfPage stepKey="scrollToTop"/> + <click selector="{{AdminMainActionsSection.storeViewDropdown}}" stepKey="clickScopeSwitchDropdown"/> + <waitForElementVisible selector="{{AdminConfirmationModalSection.ok}}" stepKey="waitingForInformationModal"/> + <click selector="{{AdminConfirmationModalSection.ok}}" stepKey="confirmScopeSwitch"/> + <waitForPageLoad stepKey="waitForScopeSwitched"/> + <see userInput="{{scopeName}}" selector="{{AdminMainActionsSection.storeSwitcher}}" stepKey="seeNewScopeName"/> + </actionGroup> + + <actionGroup name="AdminSwitchStoreViewActionGroup" extends="AdminSwitchBaseActionGroup"> + <waitForElementVisible selector="{{AdminMainActionsSection.storeViewByName(scopeName)}}" after="clickScopeSwitchDropdown" stepKey="waitForStoreViewNameIsVisible"/> + <click selector="{{AdminMainActionsSection.storeViewByName(scopeName)}}" after="waitForStoreViewNameIsVisible" stepKey="clickStoreViewByName"/> + </actionGroup> + + <actionGroup name="AdminSwitchWebsiteActionGroup" extends="AdminSwitchBaseActionGroup"> + <waitForElementVisible selector="{{AdminMainActionsSection.websiteByName(scopeName)}}" after="clickScopeSwitchDropdown" stepKey="waitForWebsiteNameIsVisible"/> + <click selector="{{AdminMainActionsSection.websiteByName(scopeName)}}" after="waitForWebsiteNameIsVisible" stepKey="clickStoreViewByName"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Store/Test/Mftf/ActionGroup/StorefrontSwitchStoreViewActionGroup.xml b/app/code/Magento/Store/Test/Mftf/ActionGroup/StorefrontSwitchStoreViewActionGroup.xml new file mode 100644 index 0000000000000..e6ebd229e4683 --- /dev/null +++ b/app/code/Magento/Store/Test/Mftf/ActionGroup/StorefrontSwitchStoreViewActionGroup.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="StorefrontSwitchStoreViewActionGroup"> + <arguments> + <argument name="storeView" defaultValue="customStore"/> + </arguments> + <click selector="{{StorefrontHeaderSection.storeViewSwitcher}}" stepKey="clickStoreViewSwitcher"/> + <waitForElementVisible selector="{{StorefrontHeaderSection.storeViewDropdown}}" stepKey="waitForStoreViewDropdown"/> + <click selector="{{StorefrontHeaderSection.storeViewOption(storeView.name)}}" stepKey="clickSelectStoreView"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Store/Test/Mftf/Data/ProductWebsiteLinkData.xml b/app/code/Magento/Store/Test/Mftf/Data/ProductWebsiteLinkData.xml new file mode 100644 index 0000000000000..8e84b84c8aa49 --- /dev/null +++ b/app/code/Magento/Store/Test/Mftf/Data/ProductWebsiteLinkData.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <entity name="ProductAssignToWebsite" type="product_website_link"> + <var key="sku" entityKey="sku" entityType="product"/> + </entity> +</entities> \ No newline at end of file diff --git a/app/code/Magento/Store/Test/Mftf/Data/StoreData.xml b/app/code/Magento/Store/Test/Mftf/Data/StoreData.xml index f85125bcf3291..5877ed383ae16 100644 --- a/app/code/Magento/Store/Test/Mftf/Data/StoreData.xml +++ b/app/code/Magento/Store/Test/Mftf/Data/StoreData.xml @@ -5,7 +5,7 @@ * See COPYING.txt for license details. */ --> -<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/DataGenerator/etc/dataProfileSchema.xsd"> +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> <entity name="_defaultStore" type="store"> <data key="name">Default Store View</data> <data key="code">default</data> @@ -39,6 +39,42 @@ <data key="store_type">store</data> <requiredEntity type="storeGroup">customStoreGroup</requiredEntity> </entity> + <entity name="CustomStoreENNotUnique" type="store"> + <data key="name">EN</data> + <data key="code">en</data> + <data key="is_active">1</data> + <data key="store_id">null</data> + <data key="store_action">add</data> + <data key="store_type">store</data> + <requiredEntity type="storeGroup">customStoreGroup</requiredEntity> + </entity> + <entity name="CustomStoreNLNotUnique" type="store"> + <data key="name">NL</data> + <data key="code">nl</data> + <data key="is_active">1</data> + <data key="store_id">null</data> + <data key="store_action">add</data> + <data key="store_type">store</data> + <requiredEntity type="storeGroup">customStoreGroup</requiredEntity> + </entity> + <entity name="CustomStoreENNotUnique" type="store"> + <data key="name">EN</data> + <data key="code">en</data> + <data key="is_active">1</data> + <data key="store_id">null</data> + <data key="store_action">add</data> + <data key="store_type">store</data> + <requiredEntity type="storeGroup">customStoreGroup</requiredEntity> + </entity> + <entity name="CustomStoreNLNotUnique" type="store"> + <data key="name">NL</data> + <data key="code">nl</data> + <data key="is_active">1</data> + <data key="store_id">null</data> + <data key="store_action">add</data> + <data key="store_type">store</data> + <requiredEntity type="storeGroup">customStoreGroup</requiredEntity> + </entity> <entity name="secondStore" type="store"> <data key="name">Second Store View</data> <data key="code">second_store_view</data> diff --git a/app/code/Magento/Store/Test/Mftf/Data/WebsiteData.xml b/app/code/Magento/Store/Test/Mftf/Data/WebsiteData.xml index 22c5d747748cd..1403175dcb7f1 100644 --- a/app/code/Magento/Store/Test/Mftf/Data/WebsiteData.xml +++ b/app/code/Magento/Store/Test/Mftf/Data/WebsiteData.xml @@ -16,6 +16,9 @@ <data key="name" unique="suffix">website</data> <data key="code" unique="suffix">website</data> <data key="sort_order">2</data> + <data key="store_action">add</data> + <data key="store_type">website</data> + <data key="website_id">null</data> </entity> <entity name="SecondWebsite" type="website"> <data key="name" unique="suffix">Second Website </data> diff --git a/app/code/Magento/Store/Test/Mftf/Metadata/product_website_link-meta.xml b/app/code/Magento/Store/Test/Mftf/Metadata/product_website_link-meta.xml new file mode 100644 index 0000000000000..ca0725f86c289 --- /dev/null +++ b/app/code/Magento/Store/Test/Mftf/Metadata/product_website_link-meta.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<operations xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataOperation.xsd"> + <operation name="ProductWebsiteLink" dataType="product_website_link" type="create" auth="adminOauth" url="/V1/products/{sku}/websites" method="POST"> + <contentType>application/json</contentType> + <object dataType="product_website_link" key="productWebsiteLink"> + <field key="sku">string</field> + <field key="websiteId">integer</field> + </object> + </operation> +</operations> diff --git a/app/code/Magento/Store/Test/Mftf/Page/StorefrontStoreHomePage.xml b/app/code/Magento/Store/Test/Mftf/Page/StorefrontStoreHomePage.xml new file mode 100644 index 0000000000000..0cf1cffceac71 --- /dev/null +++ b/app/code/Magento/Store/Test/Mftf/Page/StorefrontStoreHomePage.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="StorefrontStoreHomePage" url="/{{store_view}}/" area="storefront" module="Magento_Store" parameterized="true"> + <section name="StorefrontHeaderSection"/> + </page> +</pages> \ No newline at end of file diff --git a/app/code/Magento/Store/Test/Mftf/Section/AdminMainActionsSection.xml b/app/code/Magento/Store/Test/Mftf/Section/AdminMainActionsSection.xml index 14429c298b5e5..1a95d88d454e4 100644 --- a/app/code/Magento/Store/Test/Mftf/Section/AdminMainActionsSection.xml +++ b/app/code/Magento/Store/Test/Mftf/Section/AdminMainActionsSection.xml @@ -7,10 +7,11 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminMainActionsSection"> <element name="storeSwitcher" type="text" selector=".store-switcher"/> <element name="storeViewDropdown" type="button" selector="#store-change-button"/> <element name="storeViewByName" type="button" selector="//*[@class='store-switcher-store-view ']/a[contains(text(), '{{storeViewName}}')]" timeout="30" parameterized="true"/> + <element name="websiteByName" type="button" selector="//*[@class='store-switcher-website ']/a[contains(text(), '{{websiteName}}')]" timeout="30" parameterized="true"/> </section> </sections> diff --git a/app/code/Magento/Store/Test/Mftf/Section/AdminNewStoreViewActionsSection.xml b/app/code/Magento/Store/Test/Mftf/Section/AdminNewStoreViewActionsSection.xml index a3b5d1e616319..faffc69dc6975 100644 --- a/app/code/Magento/Store/Test/Mftf/Section/AdminNewStoreViewActionsSection.xml +++ b/app/code/Magento/Store/Test/Mftf/Section/AdminNewStoreViewActionsSection.xml @@ -5,7 +5,7 @@ * See COPYING.txt for license details. */ --> -<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminNewStoreViewActionsSection"> <element name="backButton" type="button" selector="#back" timeout="30"/> <element name="delete" type="button" selector="#delete" timeout="30"/> diff --git a/app/code/Magento/Store/Test/Unit/Url/Plugin/RouteParamsResolverTest.php b/app/code/Magento/Store/Test/Unit/Url/Plugin/RouteParamsResolverTest.php index f31acd40a2e69..9b83714166b12 100644 --- a/app/code/Magento/Store/Test/Unit/Url/Plugin/RouteParamsResolverTest.php +++ b/app/code/Magento/Store/Test/Unit/Url/Plugin/RouteParamsResolverTest.php @@ -80,7 +80,7 @@ public function testBeforeSetRouteParamsScopeInParams() $routeParamsResolverMock->expects($this->once())->method('setScope')->with($storeCode); $routeParamsResolverMock->expects($this->once())->method('getScope')->willReturn($storeCode); - $this->queryParamsResolverMock->expects($this->never())->method('setQueryParam'); + $this->queryParamsResolverMock->expects($this->any())->method('setQueryParam'); $this->model->beforeSetRouteParams( $routeParamsResolverMock, @@ -113,7 +113,7 @@ public function testBeforeSetRouteParamsScopeUseStoreInUrl() $routeParamsResolverMock->expects($this->once())->method('setScope')->with($storeCode); $routeParamsResolverMock->expects($this->once())->method('getScope')->willReturn($storeCode); - $this->queryParamsResolverMock->expects($this->once())->method('setQueryParam')->with('___store', $storeCode); + $this->queryParamsResolverMock->expects($this->never())->method('setQueryParam')->with('___store', $storeCode); $this->model->beforeSetRouteParams( $routeParamsResolverMock, @@ -178,7 +178,7 @@ public function testBeforeSetRouteParamsNoScopeInParams() $routeParamsResolverMock->expects($this->never())->method('setScope'); $routeParamsResolverMock->expects($this->once())->method('getScope')->willReturn(false); - $this->queryParamsResolverMock->expects($this->once())->method('setQueryParam')->with('___store', $storeCode); + $this->queryParamsResolverMock->expects($this->never())->method('setQueryParam')->with('___store', $storeCode); $this->model->beforeSetRouteParams( $routeParamsResolverMock, diff --git a/app/code/Magento/Store/Url/Plugin/RouteParamsResolver.php b/app/code/Magento/Store/Url/Plugin/RouteParamsResolver.php index 468352af78cbc..9c9d1e6023af0 100644 --- a/app/code/Magento/Store/Url/Plugin/RouteParamsResolver.php +++ b/app/code/Magento/Store/Url/Plugin/RouteParamsResolver.php @@ -78,7 +78,7 @@ public function beforeSetRouteParams( $storeCode ); - if ($useStoreInUrl && !$this->storeManager->hasSingleStore()) { + if (!$useStoreInUrl && !$this->storeManager->hasSingleStore()) { $this->queryParamsResolver->setQueryParam('___store', $storeCode); } } diff --git a/app/code/Magento/Store/composer.json b/app/code/Magento/Store/composer.json index 6f2d88d6f6fcb..8465d5118a45e 100644 --- a/app/code/Magento/Store/composer.json +++ b/app/code/Magento/Store/composer.json @@ -14,7 +14,7 @@ "magento/module-deploy": "100.2.*" }, "type": "magento2-module", - "version": "100.2.5", + "version": "100.2.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Store/etc/config.xml b/app/code/Magento/Store/etc/config.xml index bb5b23620df4b..42ca893f5bd71 100644 --- a/app/code/Magento/Store/etc/config.xml +++ b/app/code/Magento/Store/etc/config.xml @@ -130,6 +130,8 @@ <html>html</html> <phtml>phtml</phtml> <shtml>shtml</shtml> + <phpt>phpt</phpt> + <pht>pht</pht> </protected_extensions> <public_files_valid_paths> <protected> diff --git a/app/code/Magento/Store/etc/di.xml b/app/code/Magento/Store/etc/di.xml index 3d740bfee2093..83f891d06647d 100644 --- a/app/code/Magento/Store/etc/di.xml +++ b/app/code/Magento/Store/etc/di.xml @@ -232,6 +232,7 @@ <type name="Magento\Framework\App\ScopeResolverPool"> <arguments> <argument name="scopeResolvers" xsi:type="array"> + <item name="default" xsi:type="object">Magento\Framework\App\ScopeResolver</item> <item name="store" xsi:type="object">Magento\Store\Model\Resolver\Store</item> <item name="stores" xsi:type="object">Magento\Store\Model\Resolver\Store</item> <item name="group" xsi:type="object">Magento\Store\Model\Resolver\Group</item> diff --git a/app/code/Magento/Store/etc/frontend/di.xml b/app/code/Magento/Store/etc/frontend/di.xml index c39d5df863939..917aedad3d960 100644 --- a/app/code/Magento/Store/etc/frontend/di.xml +++ b/app/code/Magento/Store/etc/frontend/di.xml @@ -9,7 +9,7 @@ <type name="Magento\Framework\App\FrontController"> <plugin name="requestPreprocessor" type="Magento\Store\App\FrontController\Plugin\RequestPreprocessor" sortOrder="50"/> </type> - <type name="Magento\Framework\App\Action\Action"> + <type name="Magento\Framework\App\Action\AbstractAction"> <plugin name="contextPlugin" type="Magento\Store\App\Action\Plugin\Context" sortOrder="10"/> </type> <type name="Magento\Framework\App\RouterList" shared="true"> diff --git a/app/code/Magento/Swagger/composer.json b/app/code/Magento/Swagger/composer.json index 0cdf3dadb2b8a..67278bfd2b4a9 100644 --- a/app/code/Magento/Swagger/composer.json +++ b/app/code/Magento/Swagger/composer.json @@ -6,7 +6,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Swagger/view/frontend/templates/swagger-ui/index.phtml b/app/code/Magento/Swagger/view/frontend/templates/swagger-ui/index.phtml index b20da68734579..26ef4847a1267 100644 --- a/app/code/Magento/Swagger/view/frontend/templates/swagger-ui/index.phtml +++ b/app/code/Magento/Swagger/view/frontend/templates/swagger-ui/index.phtml @@ -58,7 +58,7 @@ $schemaUrl = $block->getSchemaUrl(); <div class="swagger-ui-wrap"> <a id="logo" href="http://swagger.io">swagger</a> <form id='api_selector'> - <input id="input_baseUrl" type="hidden" value="<?= /* @escapeNotVerified */ $schemaUrl ?>"/> + <input id="input_baseUrl" type="hidden" value="<?= $block->escapeUrl($schemaUrl) ?>"/> <div class='input'><input placeholder="api_key" id="input_apiKey" name="apiKey" type="text"/></div> <div class='input'><a id="explore" href="#" data-sw-translate>apply</a></div> </form> diff --git a/app/code/Magento/SwaggerWebapi/composer.json b/app/code/Magento/SwaggerWebapi/composer.json index 5a72cb88c4f3a..f2b5cb08948a4 100644 --- a/app/code/Magento/SwaggerWebapi/composer.json +++ b/app/code/Magento/SwaggerWebapi/composer.json @@ -7,7 +7,7 @@ "magento/module-swagger": "100.2.*" }, "type": "magento2-module", - "version": "100.2.0", + "version": "100.2.1", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Swatches/Controller/Adminhtml/Product/Attribute/Plugin/Save.php b/app/code/Magento/Swatches/Controller/Adminhtml/Product/Attribute/Plugin/Save.php index 383c97a166d34..72d27152d639a 100644 --- a/app/code/Magento/Swatches/Controller/Adminhtml/Product/Attribute/Plugin/Save.php +++ b/app/code/Magento/Swatches/Controller/Adminhtml/Product/Attribute/Plugin/Save.php @@ -16,6 +16,8 @@ class Save { /** + * Performs the conversion of the frontend input value. + * * @param Attribute\Save $subject * @param RequestInterface $request * @return array @@ -26,15 +28,6 @@ public function beforeDispatch(Attribute\Save $subject, RequestInterface $reques $data = $request->getPostValue(); if (isset($data['frontend_input'])) { - //Data is serialized to overcome issues caused by max_input_vars value if it's modification is unavailable. - //See subject controller code and comments for more info. - if (isset($data['serialized_swatch_values']) - && in_array($data['frontend_input'], ['swatch_visual', 'swatch_text']) - ) { - $data['serialized_options'] = $data['serialized_swatch_values']; - unset($data['serialized_swatch_values']); - } - switch ($data['frontend_input']) { case 'swatch_visual': $data[Swatch::SWATCH_INPUT_TYPE_KEY] = Swatch::SWATCH_INPUT_TYPE_VISUAL; diff --git a/app/code/Magento/Swatches/Controller/Ajax/Media.php b/app/code/Magento/Swatches/Controller/Ajax/Media.php index 079ba8f897127..2aaf158064947 100644 --- a/app/code/Magento/Swatches/Controller/Ajax/Media.php +++ b/app/code/Magento/Swatches/Controller/Ajax/Media.php @@ -24,18 +24,26 @@ class Media extends \Magento\Framework\App\Action\Action */ private $swatchHelper; + /** + * @var \Magento\PageCache\Model\Config + */ + protected $config; + /** * @param Context $context * @param \Magento\Catalog\Model\ProductFactory $productModelFactory * @param \Magento\Swatches\Helper\Data $swatchHelper + * @param \Magento\PageCache\Model\Config $config */ public function __construct( Context $context, \Magento\Catalog\Model\ProductFactory $productModelFactory, - \Magento\Swatches\Helper\Data $swatchHelper + \Magento\Swatches\Helper\Data $swatchHelper, + \Magento\PageCache\Model\Config $config ) { $this->productModelFactory = $productModelFactory; $this->swatchHelper = $swatchHelper; + $this->config = $config; parent::__construct($context); } @@ -44,18 +52,28 @@ public function __construct( * Get product media for specified configurable product variation * * @return string + * @throws \Magento\Framework\Exception\LocalizedException */ public function execute() { $productMedia = []; + + /** @var \Magento\Framework\Controller\Result\Json $resultJson */ + $resultJson = $this->resultFactory->create(ResultFactory::TYPE_JSON); + + /** @var \Magento\Framework\App\ResponseInterface $response */ + $response = $this->getResponse(); + if ($productId = (int)$this->getRequest()->getParam('product_id')) { + $product = $this->productModelFactory->create()->load($productId); $productMedia = $this->swatchHelper->getProductMediaGallery( - $this->productModelFactory->create()->load($productId) + $product ); + $resultJson->setHeader('X-Magento-Tags', implode(',', $product->getIdentities())); + + $response->setPublicHeaders($this->config->getTtl()); } - /** @var \Magento\Framework\Controller\Result\Json $resultJson */ - $resultJson = $this->resultFactory->create(ResultFactory::TYPE_JSON); $resultJson->setData($productMedia); return $resultJson; } diff --git a/app/code/Magento/Swatches/Helper/Data.php b/app/code/Magento/Swatches/Helper/Data.php index 6f751068d543a..ae35f5203dd73 100644 --- a/app/code/Magento/Swatches/Helper/Data.php +++ b/app/code/Magento/Swatches/Helper/Data.php @@ -6,6 +6,7 @@ namespace Magento\Swatches\Helper; +use Magento\Catalog\Api\Data\ProductAttributeMediaGalleryEntryInterface; use Magento\Catalog\Api\Data\ProductInterface as Product; use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Catalog\Helper\Image; @@ -311,37 +312,67 @@ private function addFilterByParent(ProductCollection $productCollection, $parent * ] * @param ModelProduct $product * @return array + * @throws \Magento\Framework\Exception\LocalizedException */ - public function getProductMediaGallery(ModelProduct $product) + public function getProductMediaGallery(ModelProduct $product): array { $baseImage = null; $gallery = []; $mediaGallery = $product->getMediaGalleryEntries(); + /** @var ProductAttributeMediaGalleryEntryInterface $mediaEntry */ foreach ($mediaGallery as $mediaEntry) { if ($mediaEntry->isDisabled()) { continue; } - if (in_array('image', $mediaEntry->getTypes(), true)) { - $baseImage = $mediaEntry->getFile(); + if (!$baseImage || $this->isMainImage($mediaEntry)) { + $baseImage = $mediaEntry; } elseif (!$baseImage) { $baseImage = $mediaEntry->getFile(); } - $gallery[$mediaEntry->getId()] = $this->getAllSizeImages($product, $mediaEntry->getFile()); + $gallery[$mediaEntry->getId()] = $this->collectImageData($product, $mediaEntry); } if (!$baseImage) { return []; } - $resultGallery = $this->getAllSizeImages($product, $baseImage); + $resultGallery = $this->collectImageData($product, $baseImage); $resultGallery['gallery'] = $gallery; return $resultGallery; } + /** + * Checks if image is main image in gallery + * + * @param ProductAttributeMediaGalleryEntryInterface $mediaEntry + * @return bool + */ + private function isMainImage(ProductAttributeMediaGalleryEntryInterface $mediaEntry): bool + { + return in_array('image', $mediaEntry->getTypes(), true); + } + + /** + * Returns image data for swatches + * + * @param ModelProduct $product + * @param ProductAttributeMediaGalleryEntryInterface $mediaEntry + * @return array + */ + private function collectImageData( + ModelProduct $product, + ProductAttributeMediaGalleryEntryInterface $mediaEntry + ): array { + $image = $this->getAllSizeImages($product, $mediaEntry->getFile()); + $image[ProductAttributeMediaGalleryEntryInterface::POSITION] = $mediaEntry->getPosition(); + $image['isMain'] = $this->isMainImage($mediaEntry); + return $image; + } + /** * @param ModelProduct $product * @param string $imageFile diff --git a/app/code/Magento/Swatches/Model/ResourceModel/Swatch.php b/app/code/Magento/Swatches/Model/ResourceModel/Swatch.php index 8ca694725511d..39245941df948 100644 --- a/app/code/Magento/Swatches/Model/ResourceModel/Swatch.php +++ b/app/code/Magento/Swatches/Model/ResourceModel/Swatch.php @@ -7,8 +7,9 @@ namespace Magento\Swatches\Model\ResourceModel; /** - * @codeCoverageIgnore * Swatch Resource Model + * + * @codeCoverageIgnore * @api * @since 100.0.2 */ @@ -25,8 +26,10 @@ protected function _construct() } /** - * @param string $defaultValue + * Update default swatch option value. + * * @param integer $id + * @param string $defaultValue * @return void */ public function saveDefaultSwatchOption($id, $defaultValue) @@ -49,7 +52,7 @@ public function clearSwatchOptionByOptionIdAndType($optionIDs, $type = null) { if (count($optionIDs)) { foreach ($optionIDs as $optionId) { - $where = ['option_id' => $optionId]; + $where = ['option_id = ?' => $optionId]; if ($type !== null) { $where['type = ?'] = $type; } diff --git a/app/code/Magento/Swatches/Test/Mftf/ActionGroup/ColorPickerActionGroup.xml b/app/code/Magento/Swatches/Test/Mftf/ActionGroup/ColorPickerActionGroup.xml new file mode 100644 index 0000000000000..bde93ea0ebcd7 --- /dev/null +++ b/app/code/Magento/Swatches/Test/Mftf/ActionGroup/ColorPickerActionGroup.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/actionGroupSchema.xsd"> + <actionGroup name="setColorPickerValueByHex"> + <arguments> + <argument name="nthColorPicker" type="string" defaultValue="1"/> + <argument name="hexColor" type="string" defaultValue="e74c3c"/> + </arguments> + <!-- This 6x backspace stuff is some magic that is necessary to interact with this field correctly --> + <pressKey selector="{{AdminColorPickerSection.hexByIndex(nthColorPicker)}}" + parameterArray="[\Facebook\WebDriver\WebDriverKeys::BACKSPACE,\Facebook\WebDriver\WebDriverKeys::BACKSPACE, + \Facebook\WebDriver\WebDriverKeys::BACKSPACE,\Facebook\WebDriver\WebDriverKeys::BACKSPACE, + \Facebook\WebDriver\WebDriverKeys::BACKSPACE,\Facebook\WebDriver\WebDriverKeys::BACKSPACE,'{{hexColor}}']" stepKey="fillHex"/> + <click selector="{{AdminColorPickerSection.submitByIndex(nthColorPicker)}}" stepKey="submitColor"/> + </actionGroup> + <actionGroup name="openSwatchMenuByIndex"> + <arguments> + <argument name="index" type="string" defaultValue="0"/> + </arguments> + <!-- I had to use executeJS to perform the click to get around the use of CSS ::before and ::after --> + <executeJS function="jQuery('#swatch_window_option_option_{{index}}').click()" stepKey="clickSwatch"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/Swatches/Test/Mftf/Data/SwatchAttributeData.xml b/app/code/Magento/Swatches/Test/Mftf/Data/SwatchAttributeData.xml index 7822120337e1d..c2e0cce712889 100644 --- a/app/code/Magento/Swatches/Test/Mftf/Data/SwatchAttributeData.xml +++ b/app/code/Magento/Swatches/Test/Mftf/Data/SwatchAttributeData.xml @@ -11,5 +11,6 @@ <entity name="VisualSwatchAttribute" type="SwatchAttribute"> <data key="default_label" unique="suffix">VisualSwatchAttr</data> <data key="input_type">Visual Swatch</data> + <data key="attribute_code" unique="suffix">visual_swatch</data> </entity> </entities> diff --git a/app/code/Magento/Swatches/Test/Mftf/Page/AdminProductAttributeFormPage.xml b/app/code/Magento/Swatches/Test/Mftf/Page/AdminProductAttributeFormPage.xml new file mode 100644 index 0000000000000..efad7e7b3578b --- /dev/null +++ b/app/code/Magento/Swatches/Test/Mftf/Page/AdminProductAttributeFormPage.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/PageObject.xsd"> + <page name="ProductAttributePage" url="catalog/product_attribute/new/" area="admin" module="Magento_Catalog"> + <section name="AdminColorPickerSection"/> + <section name="AdminManageSwatchImageSection"/> + </page> +</pages> diff --git a/app/code/Magento/Swatches/Test/Mftf/Section/AdminColorPickerSection.xml b/app/code/Magento/Swatches/Test/Mftf/Section/AdminColorPickerSection.xml new file mode 100644 index 0000000000000..772b724b6648d --- /dev/null +++ b/app/code/Magento/Swatches/Test/Mftf/Section/AdminColorPickerSection.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + <section name="AdminColorPickerSection"> + <element name="hexByIndex" type="input" selector="//div[@class='colorpicker'][{{var}}]/div[@class='colorpicker_hex']/input" parameterized="true"/> + <element name="submitByIndex" type="button" selector="//div[@class='colorpicker'][{{var}}]/div[@class='colorpicker_submit']" parameterized="true"/> + </section> +</sections> diff --git a/app/code/Magento/Swatches/Test/Mftf/Section/AdminManageSwatchSection.xml b/app/code/Magento/Swatches/Test/Mftf/Section/AdminManageSwatchSection.xml new file mode 100644 index 0000000000000..65eb32aee103a --- /dev/null +++ b/app/code/Magento/Swatches/Test/Mftf/Section/AdminManageSwatchSection.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + <section name="AdminManageSwatchSection"> + <element name="adminInputByIndex" type="input" selector="optionvisual[value][option_{{var}}][0]" parameterized="true"/> + <element name="addSwatch" type="button" selector="#add_new_swatch_visual_option_button" timeout="30"/> + <element name="chooseColorRow" type="button" selector="#swatch-visual-options-panel table tbody tr:nth-of-type({{var}}) .swatch_row_name.colorpicker_handler" parameterized="true"/> + <element name="nthIsDefault" type="input" selector="(//input[@name='defaultvisual[]'])[{{var}}]" parameterized="true"/> + </section> +</sections> diff --git a/app/code/Magento/Swatches/Test/Mftf/Test/AdminAttributeTextSwatchesCanBeFiledTest.xml b/app/code/Magento/Swatches/Test/Mftf/Test/AdminAttributeTextSwatchesCanBeFiledTest.xml new file mode 100644 index 0000000000000..c64ba0e89de18 --- /dev/null +++ b/app/code/Magento/Swatches/Test/Mftf/Test/AdminAttributeTextSwatchesCanBeFiledTest.xml @@ -0,0 +1,136 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminAttributeTextSwatchesCanBeFiledTest"> + <annotations> + <features value="Backend"/> + <stories value="Create/configure swatches product attribute"/> + <title value="Check that attribute text swatches can be filed"/> + <description value="Check that attribute text swatches can be filed"/> + <severity value="MAJOR"/> + <testCaseId value="MC-13771"/> + <group value="swatches"/> + </annotations> + <before> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <!-- Create 10 store views --> + <actionGroup ref="AdminCreateStoreViewUseStringArgumentsActionGroup" stepKey="createStoreView"> + <argument name="storeGroupName" value="{{_defaultStoreGroup.name}}" /> + <argument name="customStoreName" value="{{customStore.name}}" /> + <argument name="customStoreCode" value="{{customStore.code}}" /> + </actionGroup> + <actionGroup ref="AdminCreateStoreViewUseStringArgumentsActionGroup" stepKey="createStoreView1"> + <argument name="storeGroupName" value="{{_defaultStoreGroup.name}}" /> + <argument name="customStoreName" value="{{customStore.name}}1" /> + <argument name="customStoreCode" value="{{customStore.code}}1" /> + </actionGroup> + <actionGroup ref="AdminCreateStoreViewUseStringArgumentsActionGroup" stepKey="createStoreView2"> + <argument name="storeGroupName" value="{{_defaultStoreGroup.name}}" /> + <argument name="customStoreName" value="{{customStore.name}}2" /> + <argument name="customStoreCode" value="{{customStore.code}}2" /> + </actionGroup> + <actionGroup ref="AdminCreateStoreViewUseStringArgumentsActionGroup" stepKey="createStoreView3"> + <argument name="storeGroupName" value="{{_defaultStoreGroup.name}}" /> + <argument name="customStoreName" value="{{customStore.name}}3" /> + <argument name="customStoreCode" value="{{customStore.code}}3" /> + </actionGroup> + <actionGroup ref="AdminCreateStoreViewUseStringArgumentsActionGroup" stepKey="createStoreView4"> + <argument name="storeGroupName" value="{{_defaultStoreGroup.name}}" /> + <argument name="customStoreName" value="{{customStore.name}}4" /> + <argument name="customStoreCode" value="{{customStore.code}}4" /> + </actionGroup> + <actionGroup ref="AdminCreateStoreViewUseStringArgumentsActionGroup" stepKey="createStoreView5"> + <argument name="storeGroupName" value="{{_defaultStoreGroup.name}}" /> + <argument name="customStoreName" value="{{customStore.name}}5" /> + <argument name="customStoreCode" value="{{customStore.code}}5" /> + </actionGroup> + <actionGroup ref="AdminCreateStoreViewUseStringArgumentsActionGroup" stepKey="createStoreView6"> + <argument name="storeGroupName" value="{{_defaultStoreGroup.name}}" /> + <argument name="customStoreName" value="{{customStore.name}}6" /> + <argument name="customStoreCode" value="{{customStore.code}}6" /> + </actionGroup> + <actionGroup ref="AdminCreateStoreViewUseStringArgumentsActionGroup" stepKey="createStoreView7"> + <argument name="storeGroupName" value="{{_defaultStoreGroup.name}}" /> + <argument name="customStoreName" value="{{customStore.name}}7" /> + <argument name="customStoreCode" value="{{customStore.code}}7" /> + </actionGroup> + <actionGroup ref="AdminCreateStoreViewUseStringArgumentsActionGroup" stepKey="createStoreView8"> + <argument name="storeGroupName" value="{{_defaultStoreGroup.name}}" /> + <argument name="customStoreName" value="{{customStore.name}}8" /> + <argument name="customStoreCode" value="{{customStore.code}}8" /> + </actionGroup> + <actionGroup ref="AdminCreateStoreViewUseStringArgumentsActionGroup" stepKey="createStoreView9"> + <argument name="storeGroupName" value="{{_defaultStoreGroup.name}}" /> + <argument name="customStoreName" value="{{customStore.name}}9" /> + <argument name="customStoreCode" value="{{customStore.code}}9" /> + </actionGroup> + </before> + <after> + <!-- Delete all 10 store views --> + <actionGroup ref="AdminDeleteStoreViewUseStringArgumentsActionGroup" stepKey="deleteStoreView"> + <argument name="customStoreName" value="{{customStore.name}}"/> + </actionGroup> + <actionGroup ref="AdminDeleteStoreViewUseStringArgumentsActionGroup" stepKey="deleteStoreView1"> + <argument name="customStoreName" value="{{customStore.name}}1"/> + </actionGroup> + <actionGroup ref="AdminDeleteStoreViewUseStringArgumentsActionGroup" stepKey="deleteStoreView2"> + <argument name="customStoreName" value="{{customStore.name}}2"/> + </actionGroup> + <actionGroup ref="AdminDeleteStoreViewUseStringArgumentsActionGroup" stepKey="deleteStoreView3"> + <argument name="customStoreName" value="{{customStore.name}}3"/> + </actionGroup> + <actionGroup ref="AdminDeleteStoreViewUseStringArgumentsActionGroup" stepKey="deleteStoreView4"> + <argument name="customStoreName" value="{{customStore.name}}4"/> + </actionGroup> + <actionGroup ref="AdminDeleteStoreViewUseStringArgumentsActionGroup" stepKey="deleteStoreView5"> + <argument name="customStoreName" value="{{customStore.name}}5"/> + </actionGroup> + <actionGroup ref="AdminDeleteStoreViewUseStringArgumentsActionGroup" stepKey="deleteStoreView6"> + <argument name="customStoreName" value="{{customStore.name}}6"/> + </actionGroup> + <actionGroup ref="AdminDeleteStoreViewUseStringArgumentsActionGroup" stepKey="deleteStoreView7"> + <argument name="customStoreName" value="{{customStore.name}}7"/> + </actionGroup> + <actionGroup ref="AdminDeleteStoreViewUseStringArgumentsActionGroup" stepKey="deleteStoreView8"> + <argument name="customStoreName" value="{{customStore.name}}8"/> + </actionGroup> + <actionGroup ref="AdminDeleteStoreViewUseStringArgumentsActionGroup" stepKey="deleteStoreView9"> + <argument name="customStoreName" value="{{customStore.name}}9"/> + </actionGroup> + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <!--Navigate to Product attribute page--> + <amOnPage url="{{ProductAttributePage.url}}" stepKey="navigateToNewProductAttributePage"/> + <fillField userInput="test_label" selector="{{AttributePropertiesSection.defaultLabel}}" stepKey="fillDefaultLabel"/> + <selectOption selector="{{AttributePropertiesSection.inputType}}" userInput="Text Swatch" stepKey="selectInputType"/> + <click selector="{{AdvancedAttributePropertiesSection.addSwatch}}" stepKey="clickAddSwatch"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + + <!-- Get field outer width --> + <executeJS function="return jQuery("{{AttributeManageSwatchSection.descriptionField('0','0')}}").outerWidth();" stepKey="getElementWidth"/> + <assertGreaterThanOrEqual stepKey="assertElementsWidthIsGreaterOrEqual"> + <expectedResult type="int">30</expectedResult> + <actualResult type="variable">getElementWidth</actualResult> + </assertGreaterThanOrEqual> + + <!-- Fill Swatch and Description fields for Admin --> + <fillField selector="{{AttributeManageSwatchSection.swatchField('0','0')}}" userInput="test" stepKey="fillSwatchForAdmin"/> + <fillField selector="{{AttributeManageSwatchSection.descriptionField('0','0')}}" userInput="test" stepKey="fillDescriptionForAdmin"/> + + <!-- Grab value Swatch and Description fields for Admin --> + <grabValueFrom selector="{{AttributeManageSwatchSection.swatchField('0','0')}}" stepKey="grabSwatchForAdmin"/> + <grabValueFrom selector="{{AttributeManageSwatchSection.descriptionField('0','0'')}}" stepKey="grabDescriptionForAdmin"/> + + <!-- Check that Swatch and Description fields for Admin are not empty--> + <assertNotEmpty actual="$grabSwatchForAdmin" stepKey="checkSwatchFieldForAdmin"/> + <assertNotEmpty actual="$grabDescriptionForAdmin" stepKey="checkDescriptionFieldForAdmin"/> + </test> +</tests> diff --git a/app/code/Magento/Swatches/Test/Mftf/Test/AdminCreateVisualSwatchWithNonValidOptionsTest.xml b/app/code/Magento/Swatches/Test/Mftf/Test/AdminCreateVisualSwatchWithNonValidOptionsTest.xml new file mode 100644 index 0000000000000..c36369bab424f --- /dev/null +++ b/app/code/Magento/Swatches/Test/Mftf/Test/AdminCreateVisualSwatchWithNonValidOptionsTest.xml @@ -0,0 +1,109 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/testSchema.xsd"> + <test name="AdminCreateVisualSwatchWithNonValidOptionsTest"> + <annotations> + <features value="Swatches"/> + <stories value="Create/configure swatches product attribute"/> + <title value="Admin should be able to create swatch product attribute"/> + <description value="Admin should be able to create swatch product attribute"/> + <severity value="BLOCKER"/> + <testCaseId value="MAGETWO-95487"/> + <group value="swatches"/> + </annotations> + <before> + <actionGroup ref="LoginAsAdmin" stepKey="login"/> + </before> + <after> + <!-- Remove attribute --> + <actionGroup ref="deleteProductAttribute" stepKey="deleteProductAttribute"> + <argument name="ProductAttribute" value="VisualSwatchAttribute"/> + </actionGroup> + + <actionGroup ref="logout" stepKey="logout"/> + </after> + + <amOnPage url="{{ProductAttributePage.url}}" stepKey="navigateToNewProductAttributePage"/> + <waitForPageLoad stepKey="waitForPageLoad"/> + + <!-- Set attribute properties --> + <fillField selector="{{AttributePropertiesSection.defaultLabel}}" + userInput="{{VisualSwatchAttribute.default_label}}" stepKey="fillDefaultLabel"/> + <selectOption selector="{{AttributePropertiesSection.inputType}}" + userInput="{{VisualSwatchAttribute.input_type}}" stepKey="fillInputType"/> + + <!-- Set advanced attribute properties --> + <click selector="{{AdvancedAttributePropertiesSection.advancedAttributePropertiesSectionToggle}}" + stepKey="showAdvancedAttributePropertiesSection"/> + <waitForElementVisible selector="{{AdvancedAttributePropertiesSection.attributeCode}}" + stepKey="waitForSlideOut"/> + <fillField selector="{{AdvancedAttributePropertiesSection.attributeCode}}" + userInput="{{VisualSwatchAttribute.attribute_code}}" + stepKey="fillAttributeCode"/> + + <!-- Add new swatch option without label --> + <click selector="{{AdminManageSwatchSection.addSwatch}}" stepKey="clickAddSwatch"/> + <actionGroup ref="openSwatchMenuByIndex" stepKey="clickSwatch"> + <argument name="index" value="0"/> + </actionGroup> + <click selector="{{AdminManageSwatchSection.chooseColorRow('1')}}" stepKey="clickChooseColor"/> + <actionGroup ref="setColorPickerValueByHex" stepKey="fillHex"> + <argument name="hexColor" value="ff0000"/> + </actionGroup> + + <!-- Scroll to top of the page --> + <scrollToTopOfPage stepKey="scrollToTop"/> + + <!-- Save the new product attribute --> + <click selector="{{AdminMainActionsSection.save}}" stepKey="clickSave1"/> + <waitForElementVisible selector="{{AdminMessagesSection.error}}" stepKey="waitForError"/> + + <!-- Fill options data --> + <fillField selector="{{AdminManageSwatchSection.adminInputByIndex('0')}}" + userInput="red" stepKey="fillAdmin"/> + + <!-- Add 2 additional new swatch options --> + <click selector="{{AdminManageSwatchSection.addSwatch}}" stepKey="clickAddSwatch1"/> + <actionGroup ref="openSwatchMenuByIndex" stepKey="clickSwatch1"> + <argument name="index" value="1"/> + </actionGroup> + <click selector="{{AdminManageSwatchSection.chooseColorRow('2')}}" stepKey="clickChooseColor1"/> + <actionGroup ref="setColorPickerValueByHex" stepKey="fillHex1"> + <argument name="nthColorPicker" value="2"/> + <argument name="hexColor" value="00ff00"/> + </actionGroup> + <fillField selector="{{AdminManageSwatchSection.adminInputByIndex('1')}}" + userInput="green" stepKey="fillAdmin1"/> + + <click selector="{{AdminManageSwatchSection.addSwatch}}" stepKey="clickAddSwatch2"/> + <actionGroup ref="openSwatchMenuByIndex" stepKey="clickSwatch2"> + <argument name="index" value="2"/> + </actionGroup> + <click selector="{{AdminManageSwatchSection.chooseColorRow('3')}}" stepKey="clickChooseColor2"/> + <actionGroup ref="setColorPickerValueByHex" stepKey="fillHex2"> + <argument name="nthColorPicker" value="3"/> + <argument name="hexColor" value="0000ff"/> + </actionGroup> + <fillField selector="{{AdminManageSwatchSection.adminInputByIndex('2')}}" + userInput="blue" stepKey="fillAdmin2"/> + + <!-- Mark second option as default --> + <click selector="{{AdminManageSwatchSection.nthIsDefault('2')}}" stepKey="setSecondOptionAsDefault"/> + + <!-- Save the new product attribute --> + <click selector="{{AdminMainActionsSection.save}}" stepKey="clickSave2"/> + <waitForElementVisible selector="{{AdminMessagesSection.successMessage}}" + stepKey="waitForSuccessMessage"/> + + <actionGroup ref="navigateToCreatedProductAttribute" stepKey="navigateToAttribute"> + <argument name="productAttribute" value="VisualSwatchAttribute"/> + </actionGroup> + <!-- Check attribute data --> + <seeCheckboxIsChecked selector="{{AdminManageSwatchSection.nthIsDefault('2')}}" stepKey="checkDefaultOption"/> + </test> +</tests> diff --git a/app/code/Magento/Swatches/Test/Mftf/Test/StorefrontSwatchProductWithFileCustomOptionTest.xml b/app/code/Magento/Swatches/Test/Mftf/Test/StorefrontSwatchProductWithFileCustomOptionTest.xml index 1786b9bad236d..8f13860f75ad1 100644 --- a/app/code/Magento/Swatches/Test/Mftf/Test/StorefrontSwatchProductWithFileCustomOptionTest.xml +++ b/app/code/Magento/Swatches/Test/Mftf/Test/StorefrontSwatchProductWithFileCustomOptionTest.xml @@ -70,7 +70,6 @@ <!--Try invalid file--> <attachFile selector="{{StorefrontProductInfoMainSection.addLinkFileUploadFile(ProductOptionFile.title)}}" userInput="lorem_ipsum.docx" stepKey="attachInvalidFile"/> <click selector="{{StorefrontProductPageSection.addToCartBtn}}" stepKey="addToCartInvalidFile"/> - <waitForPageLoad time="30" stepKey="waitForAddToCartWithError"/> <waitForElementVisible selector="{{StorefrontProductPageSection.alertMessage}}" stepKey="waitForErrorMessageInvalidFile"/> <see selector="{{StorefrontProductPageSection.messagesBlock}}" userInput="The file 'lorem_ipsum.docx' for '{{ProductOptionFile.title}}' has an invalid extension." stepKey="seeMessageInvalidFile"/> <!--Swatch remains selected--> diff --git a/app/code/Magento/Swatches/Test/Unit/Controller/Ajax/MediaTest.php b/app/code/Magento/Swatches/Test/Unit/Controller/Ajax/MediaTest.php index 7a110c63da79e..5a11e2787bc69 100644 --- a/app/code/Magento/Swatches/Test/Unit/Controller/Ajax/MediaTest.php +++ b/app/code/Magento/Swatches/Test/Unit/Controller/Ajax/MediaTest.php @@ -20,6 +20,9 @@ class MediaTest extends \PHPUnit\Framework\TestCase /** @var \Magento\Catalog\Model\ProductFactory|\PHPUnit_Framework_MockObject_MockObject */ private $productModelFactoryMock; + /** @var \Magento\PageCache\Model\Config|\PHPUnit_Framework_MockObject_MockObject */ + private $config; + /** @var \Magento\Catalog\Model\Product|\PHPUnit_Framework_MockObject_MockObject */ private $productMock; @@ -29,6 +32,9 @@ class MediaTest extends \PHPUnit\Framework\TestCase /** @var \Magento\Framework\App\RequestInterface|\PHPUnit_Framework_MockObject_MockObject */ private $requestMock; + /** @var \Magento\Framework\App\ResponseInterface|\PHPUnit_Framework_MockObject_MockObject */ + private $responseMock; + /** @var \Magento\Framework\Controller\ResultFactory|\PHPUnit_Framework_MockObject_MockObject */ private $resultFactory; @@ -57,11 +63,20 @@ protected function setUp() \Magento\Catalog\Model\ProductFactory::class, ['create'] ); + $this->config = $this->createMock(\Magento\PageCache\Model\Config::class); + $this->config->method('getTtl')->willReturn(1); + $this->productMock = $this->createMock(\Magento\Catalog\Model\Product::class); $this->contextMock = $this->createMock(\Magento\Framework\App\Action\Context::class); $this->requestMock = $this->createMock(\Magento\Framework\App\RequestInterface::class); $this->contextMock->method('getRequest')->willReturn($this->requestMock); + $this->responseMock = $this->getMockBuilder(\Magento\Framework\App\ResponseInterface::class) + ->disableOriginalConstructor() + ->setMethods(['setPublicHeaders']) + ->getMockForAbstractClass(); + $this->responseMock->method('setPublicHeaders')->willReturnSelf(); + $this->contextMock->method('getResponse')->willReturn($this->responseMock); $this->resultFactory = $this->createPartialMock(\Magento\Framework\Controller\ResultFactory::class, ['create']); $this->contextMock->method('getResultFactory')->willReturn($this->resultFactory); @@ -73,7 +88,8 @@ protected function setUp() [ 'context' => $this->contextMock, 'swatchHelper' => $this->swatchHelperMock, - 'productModelFactory' => $this->productModelFactoryMock + 'productModelFactory' => $this->productModelFactoryMock, + 'config' => $this->config ] ); } @@ -86,6 +102,10 @@ public function testExecute() ->method('load') ->with(59) ->willReturn($this->productMock); + $this->productMock + ->expects($this->once()) + ->method('getIdentities') + ->willReturn(['tags']); $this->productModelFactoryMock ->expects($this->once()) diff --git a/app/code/Magento/Swatches/composer.json b/app/code/Magento/Swatches/composer.json index 26d6325447b2e..f46f6720d30d8 100644 --- a/app/code/Magento/Swatches/composer.json +++ b/app/code/Magento/Swatches/composer.json @@ -12,6 +12,7 @@ "magento/module-media-storage": "100.2.*", "magento/module-config": "101.0.*", "magento/module-theme": "100.2.*", + "magento/module-page-cache": "100.2.*", "magento/framework": "101.0.*" }, "suggest": { @@ -19,7 +20,7 @@ "magento/module-swatches-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "proprietary" ], diff --git a/app/code/Magento/Swatches/view/adminhtml/templates/catalog/product/attribute/text.phtml b/app/code/Magento/Swatches/view/adminhtml/templates/catalog/product/attribute/text.phtml index 8d4400b3d0477..e00c41d371c9e 100644 --- a/app/code/Magento/Swatches/view/adminhtml/templates/catalog/product/attribute/text.phtml +++ b/app/code/Magento/Swatches/view/adminhtml/templates/catalog/product/attribute/text.phtml @@ -21,7 +21,7 @@ $stores = $block->getStoresSortedBySortOrder(); <th class="col-draggable"></th> <th class="col-default"><span><?= $block->escapeHtml(__('Is Default')) ?></span></th> <?php foreach ($stores as $_store): ?> - <th class="col-swatch col-<%- data.id %> + <th class="col-swatch col-swatch-min-width col-<%- data.id %> <?php if ($_store->getId() == \Magento\Store\Model\Store::DEFAULT_STORE_ID): ?> _required<?php endif; ?>" colspan="2"> <span><?= $block->escapeHtml($_store->getName()) ?></span> @@ -75,7 +75,7 @@ $stores = $block->getStoresSortedBySortOrder(); </td> <?php foreach ($stores as $_store): ?> <?php $storeId = (int)$_store->getId(); ?> - <td class="col-swatch col-<%- data.id %>"> + <td class="col-swatch col-swatch-min-width col-<%- data.id %>"> <input class="input-text swatch-text-field-<?= /* @noEscape */ $storeId ?> <?php if ($storeId == \Magento\Store\Model\Store::DEFAULT_STORE_ID): ?> required-option required-unique<?php endif; ?>" @@ -83,7 +83,7 @@ $stores = $block->getStoresSortedBySortOrder(); type="text" value="<%- data.swatch<?= /* @noEscape */ $storeId ?> %>" placeholder="<?= $block->escapeHtml(__("Swatch")) ?>"/> </td> - <td class="swatch-col-<%- data.id %>"> + <td class="col-swatch-min-width swatch-col-<%- data.id %>"> <input name="optiontext[value][<%- data.id %>][<?= /* @noEscape */ $storeId ?>]" value="<%- data.store<?= /* @noEscape */ $storeId ?> %>" class="input-text<?php if ($storeId == \Magento\Store\Model\Store::DEFAULT_STORE_ID): ?> required-option<?php endif; ?>" diff --git a/app/code/Magento/Swatches/view/adminhtml/web/css/swatches.css b/app/code/Magento/Swatches/view/adminhtml/web/css/swatches.css index 495b234edf40e..02a3f0324d955 100644 --- a/app/code/Magento/Swatches/view/adminhtml/web/css/swatches.css +++ b/app/code/Magento/Swatches/view/adminhtml/web/css/swatches.css @@ -149,6 +149,10 @@ width: 50px; } +.col-swatch-min-width { + min-width: 30px; +} + .swatches-visual-col.unavailable:after { position: absolute; width: 35px; diff --git a/app/code/Magento/Swatches/view/adminhtml/web/js/product-attributes.js b/app/code/Magento/Swatches/view/adminhtml/web/js/product-attributes.js index 9534e039380d4..02760ecd02f57 100644 --- a/app/code/Magento/Swatches/view/adminhtml/web/js/product-attributes.js +++ b/app/code/Magento/Swatches/view/adminhtml/web/js/product-attributes.js @@ -16,7 +16,8 @@ define([ 'use strict'; return function (optionConfig) { - var swatchProductAttributes = { + var activePanelClass = 'selected-type-options', + swatchProductAttributes = { frontendInput: $('#frontend_input'), isFilterable: $('#is_filterable'), isFilterableInSearch: $('#is_filterable_in_search'), @@ -337,6 +338,7 @@ define([ */ _showPanel: function (el) { el.closest('.fieldset').show(); + el.addClass(activePanelClass); this._render(el.attr('id')); }, @@ -346,6 +348,7 @@ define([ */ _hidePanel: function (el) { el.closest('.fieldset').hide(); + el.removeClass(activePanelClass); }, /** @@ -413,7 +416,11 @@ define([ }; $(function () { - var editForm = $('#edit_form'); + var editForm = $('#edit_form'), + swatchVisualPanel = $('#swatch-visual-options-panel'), + swatchTextPanel = $('#swatch-text-options-panel'), + tableBody = $(), + activePanel = $(); $('#frontend_input').bind('change', function () { swatchProductAttributes.bindAttributeInputType(); @@ -429,33 +436,35 @@ define([ .collapsable() .collapse('hide'); - editForm.on('submit', function () { - var activePanel, - swatchValues = [], - swatchVisualPanel = $('#swatch-visual-options-panel'), - swatchTextPanel = $('#swatch-text-options-panel'); + editForm.on('beforeSubmit', function () { + var optionContainer, optionsValues; - activePanel = swatchTextPanel.is(':visible') ? swatchTextPanel : swatchVisualPanel; + activePanel = swatchTextPanel.hasClass(activePanelClass) ? swatchTextPanel : swatchVisualPanel; + optionContainer = activePanel.find('table tbody'); - activePanel.find('table input') - .each(function () { - if ($(this).is(':radio') && !$(this).prop('checked')) { - return; + if (activePanel.hasClass(activePanelClass)) { + optionsValues = $.map( + optionContainer.find('tr'), + function (row) { + return $(row).find('input, select, textarea').serialize(); } - swatchValues.push(this.name + '=' + $(this).val()); - }); + ); + $('<input>') + .attr({ + type: 'hidden', + name: 'serialized_options' + }) + .val(JSON.stringify(optionsValues)) + .prependTo(editForm); + } - $('<input>').attr({ - type: 'hidden', - name: 'serialized_swatch_values' - }) - .val(JSON.stringify(swatchValues)) - .prependTo(editForm); - - [swatchVisualPanel, swatchTextPanel].forEach(function (el) { - $(el).find('table') - .replaceWith($('<div>').text($.mage.__('Sending swatch values as package.'))); - }); + tableBody = optionContainer.detach(); + }); + editForm.on('afterValidate.error highlight.validate', function () { + if (activePanel.hasClass(activePanelClass)) { + activePanel.find('table').append(tableBody); + $('input[name="serialized_options"]').remove(); + } }); }); diff --git a/app/code/Magento/Swatches/view/frontend/web/js/swatch-renderer.js b/app/code/Magento/Swatches/view/frontend/web/js/swatch-renderer.js index 5e4c0a2c84f14..3e28982ad44d3 100644 --- a/app/code/Magento/Swatches/view/frontend/web/js/swatch-renderer.js +++ b/app/code/Magento/Swatches/view/frontend/web/js/swatch-renderer.js @@ -667,10 +667,9 @@ define([ /** * Load media gallery using ajax or json config. * - * @param {String|undefined} eventName * @private */ - _loadMedia: function (eventName) { + _loadMedia: function () { var $main = this.inProductList ? this.element.parents('.product-item-info') : this.element.parents('.column.main'), @@ -685,10 +684,21 @@ define([ images = this.options.mediaGalleryInitial; } - this.updateBaseImage(images, $main, !this.inProductList, eventName); + this.updateBaseImage(this._sortImages(images), $main, !this.inProductList); } }, + /** + * Sorting images array + * + * @private + */ + _sortImages: function (images) { + return _.sortBy(images, function (image) { + return image.position; + }); + }, + /** * Event for swatch options * @@ -1020,14 +1030,10 @@ define([ _.each(allowedProducts, function (allowedProduct) { optionFinalPrice = parseFloat(optionPrices[allowedProduct].finalPrice.amount); - if (_.isEmpty(product)) { + if (_.isEmpty(product) || optionFinalPrice < optionMinPrice) { optionMinPrice = optionFinalPrice; product = allowedProduct; } - - if (optionFinalPrice < optionMinPrice) { - product = allowedProduct; - } }, this); return product; @@ -1079,12 +1085,14 @@ define([ mediaCallData.isAjax = true; $widget._XhrKiller(); $widget._EnableProductMediaLoader($this); - $widget.xhr = $.get( - $widget.options.mediaCallback, - mediaCallData, - mediaSuccessCallback, - 'json' - ).done(function () { + $widget.xhr = $.ajax({ + url: $widget.options.mediaCallback, + cache: true, + type: 'GET', + dataType: 'json', + data: mediaCallData, + success: mediaSuccessCallback + }).done(function () { $widget._XhrKiller(); }); } @@ -1242,7 +1250,10 @@ define([ } imagesToUpdate = this._setImageIndex(imagesToUpdate); - gallery.updateData(imagesToUpdate); + + if (!_.isUndefined(gallery)) { + gallery.updateData(imagesToUpdate); + } if (isInitial) { $(this.options.mediaGallerySelector).AddFotoramaVideoEvents(); @@ -1252,9 +1263,6 @@ define([ dataMergeStrategy: this.options.gallerySwitchStrategy }); } - - gallery.first(); - } else if (justAnImage && justAnImage.img) { context.find('.product-image-photo').attr('src', justAnImage.img); } diff --git a/app/code/Magento/SwatchesLayeredNavigation/composer.json b/app/code/Magento/SwatchesLayeredNavigation/composer.json index d3d4cba7af291..61fee9b5279a6 100644 --- a/app/code/Magento/SwatchesLayeredNavigation/composer.json +++ b/app/code/Magento/SwatchesLayeredNavigation/composer.json @@ -7,7 +7,7 @@ "magento/magento-composer-installer": "*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Tax/Model/ResourceModel/Report/Tax/Createdat.php b/app/code/Magento/Tax/Model/ResourceModel/Report/Tax/Createdat.php index ebf699db552d6..67477d7056bae 100644 --- a/app/code/Magento/Tax/Model/ResourceModel/Report/Tax/Createdat.php +++ b/app/code/Magento/Tax/Model/ResourceModel/Report/Tax/Createdat.php @@ -11,6 +11,9 @@ */ namespace Magento\Tax\Model\ResourceModel\Report\Tax; +/** + * Class for tax report resource model with aggregation by created at. + */ class Createdat extends \Magento\Reports\Model\ResourceModel\Report\AbstractReport { /** @@ -84,7 +87,7 @@ protected function _aggregateByOrder($aggregationField, $from, $to) 'order_status' => 'e.status', 'percent' => 'MAX(tax.' . $connection->quoteIdentifier('percent') . ')', 'orders_count' => 'COUNT(DISTINCT e.entity_id)', - 'tax_base_amount_sum' => 'SUM(tax.base_amount * e.base_to_global_rate)', + 'tax_base_amount_sum' => 'SUM(tax.base_real_amount * e.base_to_global_rate)', ]; $select = $connection->select()->from( diff --git a/app/code/Magento/Tax/Test/Mftf/ActionGroup/AdminTaxActionGroup.xml b/app/code/Magento/Tax/Test/Mftf/ActionGroup/AdminTaxActionGroup.xml new file mode 100644 index 0000000000000..adb120c78126b --- /dev/null +++ b/app/code/Magento/Tax/Test/Mftf/ActionGroup/AdminTaxActionGroup.xml @@ -0,0 +1,45 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <!-- Add new Tax Rule with custom tax Rate and product Tax Class --> + <actionGroup name="AddTaxRule"> + <arguments> + <argument name="taxRuleName" type="string"/> + <argument name="taxRate"/> + <argument name="productTaxClass"/> + </arguments> + <amOnPage url="{{AdminTaxRuleNewPage.url}}" stepKey="goToTaxRulePage"/> + <fillField selector="{{AdminTaxRuleEditFormSection.ruleName}}" userInput="{{taxRuleName}}" stepKey="fillTaxRuleName"/> + <click selector="{{AdminTaxRuleEditFormSection.selectTaxRate(taxRate.code)}}" stepKey="selectTaxRate"/> + <conditionalClick selector="{{AdminTaxRuleEditFormSection.additionalSettings}}" dependentSelector="{{AdminTaxRuleEditFormSection.additionalSettings}}" visible="true" stepKey="clickAdditionalSettings"/> + <scrollTo selector="{{AdminTaxRuleEditFormSection.selectProductTaxClass(productTaxClass.class_name)}}" stepKey="scrollToProductTaxClass"/> + <click selector="{{AdminTaxRuleEditFormSection.selectProductTaxClass(productTaxClass.class_name)}}" stepKey="selectProductTaxClass"/> + <click selector="{{AdminMainActionsSection.save}}" stepKey="saveTaxRule"/> + <see selector="{{AdminMessagesSection.success}}" userInput="You saved the tax rule." stepKey="seeSuccessMessage"/> + </actionGroup> + + <actionGroup name="DeleteTaxRule"> + <arguments> + <argument name="taxRuleName" type="string"/> + </arguments> + <amOnPage url="{{AdminTaxRulesGridPage.url}}" stepKey="loadTaxRulePage"/> + <click selector="{{AdminDataGridHeaderSection.clearFilters}}" stepKey="clearFilterBefore"/> + <fillField selector="{{AdminTaxRulesGridHeaderSection.taxRuleCodeFilter}}" userInput="{{taxRuleName}}" stepKey="fillTaxRuleName"/> + <click selector="{{AdminDataGridHeaderSection.applyFilters}}" stepKey="clickSearchButton"/> + <click selector="{{AdminGridTableSection.row('1')}}" stepKey="openTaxRule"/> + <waitForPageLoad stepKey="waitForTaxRulePage"/> + <click selector="{{AdminMainActionsSection.delete}}" stepKey="clickDeleteTaxRule"/> + <waitForElementVisible selector="{{AdminConfirmationModalSection.ok}}" stepKey="waitForPageLoadAfterClickingDelete"/> + <click selector="{{AdminConfirmationModalSection.ok}}" stepKey="acceptDelete"/> + <see selector="{{AdminMessagesSection.success}}" userInput="The tax rule has been deleted." stepKey="seeDeleteSuccessMessage"/> + <click selector="{{AdminDataGridHeaderSection.clearFilters}}" stepKey="clearFilterAfter"/> + </actionGroup> + +</actionGroups> diff --git a/app/code/Magento/Tax/Test/Mftf/Data/TaxClassData.xml b/app/code/Magento/Tax/Test/Mftf/Data/TaxClassData.xml new file mode 100644 index 0000000000000..b588b4176b6e8 --- /dev/null +++ b/app/code/Magento/Tax/Test/Mftf/Data/TaxClassData.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <entity name="ProductTaxClass" type="taxClass"> + <data key="class_name" unique="suffix">TaxClass</data> + <data key="class_type">PRODUCT</data> + </entity> + <entity name="ProductTaxClassGetter" type="taxClass"> + <var key="class_id" entityKey="return" entityType="taxClass"/> + </entity> +</entities> diff --git a/app/code/Magento/Tax/Test/Mftf/Data/TaxConfigData.xml b/app/code/Magento/Tax/Test/Mftf/Data/TaxConfigData.xml index 5edabc0826ddc..534c575f90e1a 100644 --- a/app/code/Magento/Tax/Test/Mftf/Data/TaxConfigData.xml +++ b/app/code/Magento/Tax/Test/Mftf/Data/TaxConfigData.xml @@ -142,4 +142,17 @@ <entity name="EmptyField" type="taxPostCodeEmpty"> <data key="value"/> </entity> + <!--Tax Class for Shipping--> + <entity name="TaxClassForShippingConfig" type="tax_config_state"> + <requiredEntity type="shipping_tax_class">ShippingTaxClassTaxableGoods</requiredEntity> + </entity> + <entity name="DefaultTaxClassForShippingConfig" type="tax_config_state"> + <requiredEntity type="shipping_tax_class">ShippingTaxClassNone</requiredEntity> + </entity> + <entity name="ShippingTaxClassNone" type="shipping_tax_class"> + <data key="value">0</data> + </entity> + <entity name="ShippingTaxClassTaxableGoods" type="shipping_tax_class"> + <data key="value">2</data> + </entity> </entities> diff --git a/app/code/Magento/Tax/Test/Mftf/Data/TaxRateData.xml b/app/code/Magento/Tax/Test/Mftf/Data/TaxRateData.xml new file mode 100644 index 0000000000000..a99c51ea6e5f1 --- /dev/null +++ b/app/code/Magento/Tax/Test/Mftf/Data/TaxRateData.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> + <entity name="TexasTaxRate" type="taxRate"> + <data key="code" unique="suffix">USTEXAS</data> + <data key="tax_country_id">US</data> + <data key="tax_region_id">57</data> + <data key="region_name">TX</data> + <data key="tax_postcode">*</data> + <data key="rate">1</data> + </entity> + <entity name="AustinTaxRate" type="taxRate"> + <data key="code" unique="suffix">USTEXASAUSTIN</data> + <data key="tax_country_id">US</data> + <data key="tax_region_id">57</data> + <data key="region_name">TX</data> + <data key="tax_postcode">78729</data> + <data key="rate">5</data> + </entity> +</entities> diff --git a/app/code/Magento/Tax/Test/Mftf/Metadata/tax_class-meta.xml b/app/code/Magento/Tax/Test/Mftf/Metadata/tax_class-meta.xml new file mode 100644 index 0000000000000..b514c3feb6fe3 --- /dev/null +++ b/app/code/Magento/Tax/Test/Mftf/Metadata/tax_class-meta.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<operations xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataOperation.xsd"> + <operation name="CreateTaxClass" dataType="taxClass" type="create" auth="adminOauth" url="/V1/taxClasses" method="POST"> + <contentType>application/json</contentType> + <object key="taxClass" dataType="taxClass"> + <field key="class_name" required="true">string</field> + <field key="class_type" required="true">string</field> + </object> + </operation> + <operation name="GetTaxClass" dataType="taxClass" type="get" auth="adminOauth" url="/V1/taxClasses/{return}" method="GET"> + <contentType>application/json</contentType> + </operation> + <operation name="DeleteTaxClass" dataType="taxClass" type="delete" auth="adminOauth" url="/V1/taxClasses/{return}" method="DELETE"> + <contentType>application/json</contentType> + </operation> +</operations> diff --git a/app/code/Magento/Tax/Test/Mftf/Metadata/tax_config-meta.xml b/app/code/Magento/Tax/Test/Mftf/Metadata/tax_config-meta.xml index af7c0dc5e16a7..ebe4790543fdc 100644 --- a/app/code/Magento/Tax/Test/Mftf/Metadata/tax_config-meta.xml +++ b/app/code/Magento/Tax/Test/Mftf/Metadata/tax_config-meta.xml @@ -7,8 +7,15 @@ --> <operations xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/DataGenerator/etc/dataOperation.xsd"> - <operation name="CreateTaxConfigDefaultsTaxDestination" dataType="tax_config_state" type="create" auth="adminFormKey" url="/admin/system_config/save/section/tax/" method="POST"> + <operation name="CreateTaxConfigDefaultsTaxDestination" dataType="tax_config_state" type="create" auth="adminFormKey" url="/admin/system_config/save/section/tax/" method="POST" successRegex="/messages-message-success/"> <object key="groups" dataType="tax_config_state"> + <object key="classes" dataType="tax_config_state"> + <object key="fields" dataType="tax_config_state"> + <object key="shipping_tax_class" dataType="shipping_tax_class"> + <field key="value">integer</field> + </object> + </object> + </object> <object key="calculation" dataType="tax_config_state"> <object key="fields" dataType="tax_config_state"> <object key="algorithm" dataType="algorithm"> diff --git a/app/code/Magento/Tax/Test/Mftf/Metadata/tax_rate-meta.xml b/app/code/Magento/Tax/Test/Mftf/Metadata/tax_rate-meta.xml new file mode 100644 index 0000000000000..1357aa9c831d1 --- /dev/null +++ b/app/code/Magento/Tax/Test/Mftf/Metadata/tax_rate-meta.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<operations xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataOperation.xsd"> + <operation name="CreateTaxRate" dataType="taxRate" type="create" auth="adminOauth" url="/V1/taxRates" method="POST"> + <contentType>application/json</contentType> + <object key="taxRate" dataType="taxRate"> + <field key="code" required="true">string</field> + <field key="tax_country_id" required="true">string</field> + <field key="tax_region_id" required="true">integer</field> + <field key="region_name" required="true">string</field> + <field key="tax_postcode" required="true">string</field> + <field key="rate" required="true">number</field> + </object> + </operation> + <operation name="DeleteTaxRate" dataType="taxRate" type="delete" auth="adminOauth" url="/V1/taxRates/{id}" method="DELETE"> + <contentType>application/json</contentType> + </operation> +</operations> diff --git a/app/code/Magento/Tax/Test/Mftf/Page/AdminTaxRuleNewPage.xml b/app/code/Magento/Tax/Test/Mftf/Page/AdminTaxRuleNewPage.xml new file mode 100644 index 0000000000000..a97567a438108 --- /dev/null +++ b/app/code/Magento/Tax/Test/Mftf/Page/AdminTaxRuleNewPage.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminTaxRuleNewPage" url="tax/rule/new/" area="admin" module="Magento_Tax"> + <section name="AdminTaxRuleEditFormSection"/> + </page> +</pages> + diff --git a/app/code/Magento/Tax/Test/Mftf/Page/AdminTaxRulesGridPage.xml b/app/code/Magento/Tax/Test/Mftf/Page/AdminTaxRulesGridPage.xml new file mode 100644 index 0000000000000..0672c74c3d358 --- /dev/null +++ b/app/code/Magento/Tax/Test/Mftf/Page/AdminTaxRulesGridPage.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminTaxRulesGridPage" url="tax/rule/" area="admin" module="Magento_Tax"> + <section name="AdminTaxRulesGridHeaderSection"/> + </page> +</pages> + diff --git a/app/code/Magento/Tax/Test/Mftf/Section/AdminTaxRuleEditFormSection.xml b/app/code/Magento/Tax/Test/Mftf/Section/AdminTaxRuleEditFormSection.xml new file mode 100644 index 0000000000000..de93d74e31111 --- /dev/null +++ b/app/code/Magento/Tax/Test/Mftf/Section/AdminTaxRuleEditFormSection.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminTaxRuleEditFormSection"> + <element name="ruleName" type="input" selector="#edit_form #code"/> + <element name="selectTaxRate" type="select" selector="//*[@id='tax_rate']/following-sibling::section[contains(@class, 'mselect-list')]//span[text()='{{taxRate}}']" parameterized="true" timeout="30"/> + <element name="additionalSettings" type="button" selector="#details-summarybase_fieldset"/> + <element name="selectProductTaxClass" type="select" selector="//*[@id='tax_product_class']/following-sibling::section[contains(@class, 'mselect-list')]//span[text()='{{taxClass}}']" parameterized="true" timeout="30"/> + </section> +</sections> diff --git a/app/code/Magento/Tax/Test/Mftf/Section/AdminTaxRulesGridSection.xml b/app/code/Magento/Tax/Test/Mftf/Section/AdminTaxRulesGridSection.xml new file mode 100644 index 0000000000000..5988965b64cad --- /dev/null +++ b/app/code/Magento/Tax/Test/Mftf/Section/AdminTaxRulesGridSection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="AdminTaxRulesGridHeaderSection"> + <element name="taxRuleCodeFilter" type="input" selector="#taxRuleGrid_filter_code"/> + </section> +</sections> diff --git a/app/code/Magento/Tax/Test/Mftf/Test/StorefrontTaxInformationInShoppingCartForCustomerPhysicalQuoteTest.xml b/app/code/Magento/Tax/Test/Mftf/Test/StorefrontTaxInformationInShoppingCartForCustomerPhysicalQuoteTest.xml index 3c6953f08e8d7..3fd06016624d1 100644 --- a/app/code/Magento/Tax/Test/Mftf/Test/StorefrontTaxInformationInShoppingCartForCustomerPhysicalQuoteTest.xml +++ b/app/code/Magento/Tax/Test/Mftf/Test/StorefrontTaxInformationInShoppingCartForCustomerPhysicalQuoteTest.xml @@ -7,7 +7,7 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/testSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> <test name="StorefrontTaxInformationInShoppingCartForCustomerPhysicalQuoteTest"> <annotations> <features value="Tax information in shopping cart for Customer with default addresses (physical quote)"/> @@ -62,6 +62,7 @@ <actionGroup ref="AdminResetProductGridToDefaultViewActionGroup" stepKey="resetGridToDefaultKeywordSearch"/> </before> <after> + <actionGroup ref="CustomerLogoutStorefrontActionGroup" stepKey="customerLogout"/> <deleteData createDataKey="createTaxRule" stepKey="deleteTaxRule"/> <deleteData createDataKey="createProductFPTAttribute" stepKey="deleteProductFPTAttribute"/> <createData entity="DefaultTaxConfig" stepKey="defaultTaxConfiguration"/> @@ -87,9 +88,13 @@ <actionGroup ref="StorefrontViewAndEditCartFromMiniCartActionGroup" stepKey="goToShoppingCartFromMinicart"/> <!-- Step 4: Open Estimate Shipping and Tax section --> <conditionalClick selector="{{StorefrontCheckoutCartSummarySection.estimateShippingAndTax}}" dependentSelector="{{StorefrontCheckoutCartSummarySection.country}}" visible="false" stepKey="expandEstimateShippingandTax" /> - <see selector="{{StorefrontCheckoutCartSummarySection.country}}" userInput="$$createCustomer.country_id$$" stepKey="checkCustomerCountry" /> - <see selector="{{StorefrontCheckoutCartSummarySection.region}}" userInput="$$createCustomer.state$$" stepKey="checkCustomerRegion" /> - <see selector="{{StorefrontCheckoutCartSummarySection.postcode}}" userInput="$$createCustomer.postcode$$" stepKey="checkCustomerPostcode" /> + <seeOptionIsSelected selector="{{StorefrontCheckoutCartSummarySection.country}}" userInput="{{US_Address_CA.country}}" stepKey="checkCustomerCountry" /> + <seeOptionIsSelected selector="{{StorefrontCheckoutCartSummarySection.region}}" userInput="{{US_Address_CA.state}}" stepKey="checkCustomerRegion" /> + <grabValueFrom selector="{{StorefrontCheckoutCartSummarySection.postcode}}" stepKey="grabTextPostCode"/> + <assertEquals message="Customer postcode is invalid" stepKey="checkCustomerPostcode"> + <expectedResult type="string">{{US_Address_CA.postcode}}</expectedResult> + <actualResult type="variable">grabTextPostCode</actualResult> + </assertEquals> <see selector="{{StorefrontCheckoutCartSummarySection.amountFPT}}" userInput="$10" stepKey="checkAmountFPTCA" /> <see selector="{{StorefrontCheckoutCartSummarySection.taxAmount}}" userInput="$0.83" stepKey="checkTaxAmountCA" /> <scrollTo selector="{{StorefrontCheckoutCartSummarySection.taxSummary}}" stepKey="scrollToTaxSummary" /> diff --git a/app/code/Magento/Tax/Test/Mftf/Test/StorefrontTaxInformationInShoppingCartForCustomerVirtualQuoteTest.xml b/app/code/Magento/Tax/Test/Mftf/Test/StorefrontTaxInformationInShoppingCartForCustomerVirtualQuoteTest.xml index fbb44807c8b14..f26b6d2747e09 100644 --- a/app/code/Magento/Tax/Test/Mftf/Test/StorefrontTaxInformationInShoppingCartForCustomerVirtualQuoteTest.xml +++ b/app/code/Magento/Tax/Test/Mftf/Test/StorefrontTaxInformationInShoppingCartForCustomerVirtualQuoteTest.xml @@ -7,7 +7,7 @@ --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/testSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> <test name="StorefrontTaxInformationInShoppingCartForCustomerVirtualQuoteTest"> <annotations> <features value="Tax information in shopping cart for Customer with default addresses (virtual quote)"/> @@ -37,6 +37,7 @@ <createData entity="Simple_US_NY_Customer" stepKey="createCustomer"/> </before> <after> + <actionGroup ref="CustomerLogoutStorefrontActionGroup" stepKey="customerLogout"/> <deleteData createDataKey="createTaxRule" stepKey="deleteTaxRule"/> <deleteData createDataKey="createProductFPTAttribute" stepKey="deleteProductFPTAttribute"/> <createData entity="DefaultTaxConfig" stepKey="defaultTaxConfiguration"/> @@ -59,9 +60,13 @@ <actionGroup ref="StorefrontViewAndEditCartFromMiniCartActionGroup" stepKey="goToShoppingCartFromMinicart"/> <!-- Step 4: Open Estimate Shipping and Tax section --> <conditionalClick selector="{{StorefrontCheckoutCartSummarySection.estimateShippingAndTax}}" dependentSelector="{{StorefrontCheckoutCartSummarySection.country}}" visible="false" stepKey="expandEstimateShippingandTax" /> - <see selector="{{StorefrontCheckoutCartSummarySection.country}}" userInput="$$createCustomer.country_id$$" stepKey="checkCustomerCountry" /> - <see selector="{{StorefrontCheckoutCartSummarySection.region}}" userInput="$$createCustomer.state$$" stepKey="checkCustomerRegion" /> - <see selector="{{StorefrontCheckoutCartSummarySection.postcode}}" userInput="$$createCustomer.postcode$$" stepKey="checkCustomerPostcode" /> + <seeOptionIsSelected selector="{{StorefrontCheckoutCartSummarySection.country}}" userInput="{{US_Address_NY.country}}" stepKey="checkCustomerCountry" /> + <seeOptionIsSelected selector="{{StorefrontCheckoutCartSummarySection.region}}" userInput="{{US_Address_NY.state}}" stepKey="checkCustomerRegion" /> + <grabValueFrom selector="{{StorefrontCheckoutCartSummarySection.postcode}}" stepKey="grabTextPostCode"/> + <assertEquals message="Customer postcode is invalid" stepKey="checkCustomerPostcode"> + <expectedResult type="string">{{US_Address_NY.postcode}}</expectedResult> + <actualResult type="variable">grabTextPostCode</actualResult> + </assertEquals> <scrollTo selector="{{StorefrontCheckoutCartSummarySection.taxSummary}}" stepKey="scrollToTaxSummary" /> <click selector="{{StorefrontCheckoutCartSummarySection.taxSummary}}" stepKey="expandTaxSummary"/> <see selector="{{StorefrontCheckoutCartSummarySection.rate}}" userInput="US-NY-*-Rate 1 (8.375%)" stepKey="checkRateNY" /> diff --git a/app/code/Magento/Tax/composer.json b/app/code/Magento/Tax/composer.json index 34c3ec567467a..52f636d5db077 100644 --- a/app/code/Magento/Tax/composer.json +++ b/app/code/Magento/Tax/composer.json @@ -22,7 +22,7 @@ "magento/module-tax-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "100.2.6", + "version": "100.2.7", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Tax/view/base/web/js/price/adjustment.js b/app/code/Magento/Tax/view/base/web/js/price/adjustment.js index 9af15f84562f4..a17d130d9282a 100644 --- a/app/code/Magento/Tax/view/base/web/js/price/adjustment.js +++ b/app/code/Magento/Tax/view/base/web/js/price/adjustment.js @@ -62,7 +62,7 @@ define([ }, /** - * Set price taax type. + * Set price tax type. * * @param {String} priceType * @return {Object} diff --git a/app/code/Magento/TaxImportExport/composer.json b/app/code/Magento/TaxImportExport/composer.json index 927aac1e59501..72a442e29caca 100644 --- a/app/code/Magento/TaxImportExport/composer.json +++ b/app/code/Magento/TaxImportExport/composer.json @@ -10,7 +10,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Theme/Block/Html/Header/Logo.php b/app/code/Magento/Theme/Block/Html/Header/Logo.php index 0a0e71f44ba32..b51f624c20339 100644 --- a/app/code/Magento/Theme/Block/Html/Header/Logo.php +++ b/app/code/Magento/Theme/Block/Html/Header/Logo.php @@ -43,6 +43,10 @@ public function __construct( /** * Check if current url is url for home page * + * @deprecated This function is no longer used. It was previously used by + * Magento/Theme/view/frontend/templates/html/header/logo.phtml + * to check if the logo should be clickable on the homepage. + * * @return bool */ public function isHomePage() diff --git a/app/code/Magento/Theme/Block/Html/Topmenu.php b/app/code/Magento/Theme/Block/Html/Topmenu.php index 47f88c65acb80..baf2f3878ad9b 100644 --- a/app/code/Magento/Theme/Block/Html/Topmenu.php +++ b/app/code/Magento/Theme/Block/Html/Topmenu.php @@ -78,6 +78,7 @@ protected function getCacheLifetime() * @param string $childrenWrapClass * @param int $limit * @return string + * @SuppressWarnings(PHPMD.RequestAwareBlockMethod) */ public function getHtml($outermostClass = '', $childrenWrapClass = '', $limit = 0) { @@ -361,19 +362,6 @@ public function getIdentities() return $this->identities; } - /** - * Get cache key informative items - * - * @return array - * @since 100.1.0 - */ - public function getCacheKeyInfo() - { - $keyInfo = parent::getCacheKeyInfo(); - $keyInfo[] = $this->getUrl('*/*/*', ['_current' => true, '_query' => '']); - return $keyInfo; - } - /** * Get tags array for saving cache * diff --git a/app/code/Magento/Theme/Model/PageLayout/Config/Builder.php b/app/code/Magento/Theme/Model/PageLayout/Config/Builder.php index 98fa12ab987b6..13b8aa23073ce 100644 --- a/app/code/Magento/Theme/Model/PageLayout/Config/Builder.php +++ b/app/code/Magento/Theme/Model/PageLayout/Config/Builder.php @@ -27,6 +27,11 @@ class Builder implements \Magento\Framework\View\Model\PageLayout\Config\Builder */ protected $themeCollection; + /** + * @var array + */ + private $configFiles = []; + /** * @param \Magento\Framework\View\PageLayout\ConfigFactory $configFactory * @param \Magento\Framework\View\PageLayout\File\Collector\Aggregated $fileCollector @@ -44,7 +49,7 @@ public function __construct( } /** - * @return \Magento\Framework\View\PageLayout\Config + * @inheritdoc */ public function getPageLayoutsConfig() { @@ -52,15 +57,20 @@ public function getPageLayoutsConfig() } /** + * Retrieve configuration files. + * * @return array */ protected function getConfigFiles() { - $configFiles = []; - foreach ($this->themeCollection->loadRegisteredThemes() as $theme) { - $configFiles = array_merge($configFiles, $this->fileCollector->getFilesContent($theme, 'layouts.xml')); + if (!$this->configFiles) { + $configFiles = []; + foreach ($this->themeCollection->loadRegisteredThemes() as $theme) { + $configFiles[] = $this->fileCollector->getFilesContent($theme, 'layouts.xml'); + } + $this->configFiles = array_merge(...$configFiles); } - return $configFiles; + return $this->configFiles; } } diff --git a/app/code/Magento/Theme/Test/Mftf/Section/StorefrontHeaderSection.xml b/app/code/Magento/Theme/Test/Mftf/Section/StorefrontHeaderSection.xml new file mode 100644 index 0000000000000..a4088c7a4a0b7 --- /dev/null +++ b/app/code/Magento/Theme/Test/Mftf/Section/StorefrontHeaderSection.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontHeaderSection"> + <element name="welcomeMessage" type="text" selector=".greet.welcome"/> + </section> +</sections> diff --git a/app/code/Magento/Theme/Test/Unit/Block/Html/TopmenuTest.php b/app/code/Magento/Theme/Test/Unit/Block/Html/TopmenuTest.php index 91c3ce47fc8b8..023c741492752 100644 --- a/app/code/Magento/Theme/Test/Unit/Block/Html/TopmenuTest.php +++ b/app/code/Magento/Theme/Test/Unit/Block/Html/TopmenuTest.php @@ -189,7 +189,6 @@ public function testGetCacheKeyInfo() $treeFactory = $this->createMock(\Magento\Framework\Data\TreeFactory::class); $topmenu = new Topmenu($this->context, $nodeFactory, $treeFactory); - $this->urlBuilder->expects($this->once())->method('getUrl')->with('*/*/*')->willReturn('123'); $this->urlBuilder->expects($this->once())->method('getBaseUrl')->willReturn('baseUrl'); $store = $this->getMockBuilder(\Magento\Store\Model\Store::class) ->disableOriginalConstructor() @@ -199,7 +198,7 @@ public function testGetCacheKeyInfo() $this->storeManager->expects($this->once())->method('getStore')->willReturn($store); $this->assertEquals( - ['BLOCK_TPL', '321', null, 'base_url' => 'baseUrl', 'template' => null, '123'], + ['BLOCK_TPL', '321', null, 'base_url' => 'baseUrl', 'template' => null], $topmenu->getCacheKeyInfo() ); } diff --git a/app/code/Magento/Theme/Test/Unit/Model/PageLayout/Config/BuilderTest.php b/app/code/Magento/Theme/Test/Unit/Model/PageLayout/Config/BuilderTest.php index e5d69cbc820a1..8429be84cae44 100644 --- a/app/code/Magento/Theme/Test/Unit/Model/PageLayout/Config/BuilderTest.php +++ b/app/code/Magento/Theme/Test/Unit/Model/PageLayout/Config/BuilderTest.php @@ -83,7 +83,7 @@ public function testGetPageLayoutsConfig() ->disableOriginalConstructor() ->getMock(); - $this->themeCollection->expects($this->any()) + $this->themeCollection->expects($this->once()) ->method('loadRegisteredThemes') ->willReturn([$theme1, $theme2]); diff --git a/app/code/Magento/Theme/composer.json b/app/code/Magento/Theme/composer.json index d2ac3a95d923d..a89fb10d9769d 100644 --- a/app/code/Magento/Theme/composer.json +++ b/app/code/Magento/Theme/composer.json @@ -22,7 +22,7 @@ "magento/module-directory": "100.2.*" }, "type": "magento2-module", - "version": "100.2.6", + "version": "100.2.7", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Theme/view/base/requirejs-config.js b/app/code/Magento/Theme/view/base/requirejs-config.js index 3b5be208ffc4a..3fab589c8a44f 100644 --- a/app/code/Magento/Theme/view/base/requirejs-config.js +++ b/app/code/Magento/Theme/view/base/requirejs-config.js @@ -56,6 +56,9 @@ var config = { 'mixins': { 'jquery/jstree/jquery.jstree': { 'mage/backend/jstree-mixin': true + }, + 'jquery': { + 'jquery/patches/jquery': true } }, 'text': { @@ -65,9 +68,3 @@ var config = { } } }; - -require(['jquery'], function ($) { - 'use strict'; - - $.noConflict(); -}); diff --git a/app/code/Magento/Theme/view/frontend/requirejs-config.js b/app/code/Magento/Theme/view/frontend/requirejs-config.js index bf38d3cbaae00..ef46f4bfed825 100644 --- a/app/code/Magento/Theme/view/frontend/requirejs-config.js +++ b/app/code/Magento/Theme/view/frontend/requirejs-config.js @@ -44,6 +44,9 @@ var config = { mixins: { 'Magento_Theme/js/view/breadcrumbs': { 'Magento_Theme/js/view/add-home-breadcrumb': true + }, + 'jquery/jquery-ui': { + 'jquery/patches/jquery-ui': true } } } diff --git a/app/code/Magento/Theme/view/frontend/templates/html/header.phtml b/app/code/Magento/Theme/view/frontend/templates/html/header.phtml index 4395cab651de1..1103ae28741c6 100644 --- a/app/code/Magento/Theme/view/frontend/templates/html/header.phtml +++ b/app/code/Magento/Theme/view/frontend/templates/html/header.phtml @@ -15,11 +15,11 @@ $welcomeMessage = $block->getWelcome(); case 'welcome': ?> <li class="greet welcome" data-bind="scope: 'customer'"> <!-- ko if: customer().fullname --> - <span data-bind="text: new String('<?= $block->escapeHtml(__('Welcome, %1!', '%1')) ?>').replace('%1', customer().fullname)"> + <span class="logged-in" data-bind="text: new String('<?= $block->escapeHtml(__('Welcome, %1!', '%1')) ?>').replace('%1', customer().fullname)"> </span> <!-- /ko --> <!-- ko ifnot: customer().fullname --> - <span data-bind='html:"<?= $block->escapeHtml($welcomeMessage) ?>"'></span> + <span class="not-logged-in" data-bind='html:"<?= $block->escapeHtml($welcomeMessage) ?>"'></span> <?= $block->getBlockHtml('header.additional') ?> <!-- /ko --> </li> diff --git a/app/code/Magento/Theme/view/frontend/templates/html/header/logo.phtml b/app/code/Magento/Theme/view/frontend/templates/html/header/logo.phtml index 17f8d7c70f574..79b891b7e55e6 100644 --- a/app/code/Magento/Theme/view/frontend/templates/html/header/logo.phtml +++ b/app/code/Magento/Theme/view/frontend/templates/html/header/logo.phtml @@ -12,19 +12,11 @@ ?> <?php $storeName = $block->getThemeName() ? $block->getThemeName() : $block->getLogoAlt();?> <span data-action="toggle-nav" class="action nav-toggle"><span><?= /* @escapeNotVerified */ __('Toggle Nav') ?></span></span> -<?php if ($block->isHomePage()):?> - <strong class="logo"> -<?php else: ?> - <a class="logo" href="<?= $block->getUrl('') ?>" title="<?= /* @escapeNotVerified */ $storeName ?>"> -<?php endif ?> - <img src="<?= /* @escapeNotVerified */ $block->getLogoSrc() ?>" - title="<?= /* @escapeNotVerified */ $block->getLogoAlt() ?>" - alt="<?= /* @escapeNotVerified */ $block->getLogoAlt() ?>" - <?= $block->getLogoWidth() ? 'width="' . $block->getLogoWidth() . '"' : '' ?> - <?= $block->getLogoHeight() ? 'height="' . $block->getLogoHeight() . '"' : '' ?> - /> -<?php if ($block->isHomePage()):?> - </strong> -<?php else:?> - </a> -<?php endif?> +<a class="logo" href="<?= $block->getUrl('') ?>" title="<?= /* @escapeNotVerified */ $storeName ?>"> + <img src="<?= /* @escapeNotVerified */ $block->getLogoSrc() ?>" + title="<?= /* @escapeNotVerified */ $block->getLogoAlt() ?>" + alt="<?= /* @escapeNotVerified */ $block->getLogoAlt() ?>" + <?= $block->getLogoWidth() ? 'width="' . $block->getLogoWidth() . '"' : '' ?> + <?= $block->getLogoHeight() ? 'height="' . $block->getLogoHeight() . '"' : '' ?> + /> +</a> diff --git a/app/code/Magento/Translation/composer.json b/app/code/Magento/Translation/composer.json index 3a39f30a03e50..dd9e78d16614d 100644 --- a/app/code/Magento/Translation/composer.json +++ b/app/code/Magento/Translation/composer.json @@ -12,7 +12,7 @@ "magento/module-deploy": "100.2.*" }, "type": "magento2-module", - "version": "100.2.5", + "version": "100.2.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Ui/Component/Listing/Columns/Date.php b/app/code/Magento/Ui/Component/Listing/Columns/Date.php index 75cb9dfbc52f7..acdce73572173 100644 --- a/app/code/Magento/Ui/Component/Listing/Columns/Date.php +++ b/app/code/Magento/Ui/Component/Listing/Columns/Date.php @@ -11,6 +11,8 @@ use Magento\Framework\Stdlib\DateTime\TimezoneInterface; /** + * Date format column. + * * @api * @since 100.0.2 */ @@ -47,6 +49,28 @@ public function __construct( parent::__construct($context, $uiComponentFactory, $components, $data); } + /** + * @inheritdoc + */ + public function prepare() + { + $config = $this->getData('config'); + $config['filter'] = [ + 'filterType' => 'dateRange', + 'templates' => [ + 'date' => [ + 'options' => [ + 'dateFormat' => $this->timezone->getDateFormatWithLongYear(), + ], + ], + ], + ]; + + $this->setData('config', $config); + + parent::prepare(); + } + /** * @inheritdoc */ diff --git a/app/code/Magento/Ui/Controller/Adminhtml/Index/Render/Handle.php b/app/code/Magento/Ui/Controller/Adminhtml/Index/Render/Handle.php index cc028a455456d..a7615c5996c10 100644 --- a/app/code/Magento/Ui/Controller/Adminhtml/Index/Render/Handle.php +++ b/app/code/Magento/Ui/Controller/Adminhtml/Index/Render/Handle.php @@ -9,12 +9,36 @@ use Magento\Ui\Component\Control\ActionPool; use Magento\Ui\Component\Wrapper\UiComponent; use Magento\Ui\Controller\Adminhtml\AbstractAction; +use Magento\Backend\App\Action\Context; +use Magento\Framework\View\Element\UiComponentFactory; +use Magento\Framework\View\Element\UiComponent\ContextFactory; +use Magento\Framework\App\ObjectManager; /** * Class Handle */ class Handle extends AbstractAction { + /** + * @var ContextFactory + */ + private $contextFactory; + + /** + * @param Context $context + * @param UiComponentFactory $factory + * @param ContextFactory|null $contextFactory + */ + public function __construct( + Context $context, + UiComponentFactory $factory, + ContextFactory $contextFactory = null + ) { + parent::__construct($context, $factory); + $this->contextFactory = $contextFactory + ?: ObjectManager::getInstance()->get(ContextFactory::class); + } + /** * Render UI component by namespace in handle context * @@ -22,20 +46,51 @@ class Handle extends AbstractAction */ public function execute() { + $response = ''; $handle = $this->_request->getParam('handle'); $namespace = $this->_request->getParam('namespace'); $buttons = $this->_request->getParam('buttons', false); - $this->_view->loadLayout(['default', $handle], true, true, false); + $layout = $this->_view->getLayout(); + $context = $this->contextFactory->create( + [ + 'namespace' => $namespace, + 'pageLayout' => $layout, + ] + ); - $uiComponent = $this->_view->getLayout()->getBlock($namespace); - $response = $uiComponent instanceof UiComponent ? $uiComponent->toHtml() : ''; + $component = $this->factory->create($namespace, null, ['context' => $context]); + if ($this->validateAclResource($component->getContext()->getDataProvider()->getConfigData())) { + $uiComponent = $layout->getBlock($namespace); + $response = $uiComponent instanceof UiComponent ? $uiComponent->toHtml() : ''; + } if ($buttons) { - $actionsToolbar = $this->_view->getLayout()->getBlock(ActionPool::ACTIONS_PAGE_TOOLBAR); + $actionsToolbar = $layout->getBlock(ActionPool::ACTIONS_PAGE_TOOLBAR); $response .= $actionsToolbar instanceof Template ? $actionsToolbar->toHtml() : ''; } $this->_response->appendBody($response); } + + /** + * Optionally validate ACL resource of components with a DataSource/DataProvider + * + * @param mixed $dataProviderConfigData + * @return bool + */ + private function validateAclResource($dataProviderConfigData): bool + { + if (isset($dataProviderConfigData['aclResource']) + && !$this->_authorization->isAllowed($dataProviderConfigData['aclResource']) + ) { + if (!$this->_request->isAjax()) { + $this->_redirect('admin/denied'); + } + + return false; + } + + return true; + } } diff --git a/app/code/Magento/Ui/TemplateEngine/Xhtml/Result.php b/app/code/Magento/Ui/TemplateEngine/Xhtml/Result.php index 066b4494e51d0..14283a8899e50 100644 --- a/app/code/Magento/Ui/TemplateEngine/Xhtml/Result.php +++ b/app/code/Magento/Ui/TemplateEngine/Xhtml/Result.php @@ -5,6 +5,8 @@ */ namespace Magento\Ui\TemplateEngine\Xhtml; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Serialize\Serializer\JsonHexTag; use Magento\Framework\View\Layout\Generator\Structure; use Magento\Framework\View\Element\UiComponentInterface; use Magento\Framework\View\TemplateEngine\Xhtml\Template; @@ -42,25 +44,33 @@ class Result implements ResultInterface */ protected $logger; + /** + * @var JsonHexTag + */ + private $jsonSerializer; + /** * @param Template $template * @param CompilerInterface $compiler * @param UiComponentInterface $component * @param Structure $structure * @param LoggerInterface $logger + * @param JsonHexTag $jsonSerializer */ public function __construct( Template $template, CompilerInterface $compiler, UiComponentInterface $component, Structure $structure, - LoggerInterface $logger + LoggerInterface $logger, + JsonHexTag $jsonSerializer = null ) { $this->template = $template; $this->compiler = $compiler; $this->component = $component; $this->structure = $structure; $this->logger = $logger; + $this->jsonSerializer = $jsonSerializer ?? ObjectManager::getInstance()->get(JsonHexTag::class); } /** @@ -81,7 +91,7 @@ public function getDocumentElement() public function appendLayoutConfiguration() { $layoutConfiguration = $this->wrapContent( - json_encode($this->structure->generate($this->component), JSON_HEX_TAG) + $this->jsonSerializer->serialize($this->structure->generate($this->component)) ); $this->template->append($layoutConfiguration); } diff --git a/app/code/Magento/Ui/Test/Mftf/ActionGroup/AdminDataGridFilterActionGroup.xml b/app/code/Magento/Ui/Test/Mftf/ActionGroup/AdminDataGridFilterActionGroup.xml index 8fbd5342b24c4..c1379b9fce575 100644 --- a/app/code/Magento/Ui/Test/Mftf/ActionGroup/AdminDataGridFilterActionGroup.xml +++ b/app/code/Magento/Ui/Test/Mftf/ActionGroup/AdminDataGridFilterActionGroup.xml @@ -30,7 +30,7 @@ <!--Clear all filters in grid--> <actionGroup name="clearFiltersAdminDataGrid"> <waitForPageLoad stepKey="waitForPageLoad"/> - <conditionalClick selector="{{AdminDataGridHeaderSection.clearFilters}}" dependentSelector="{{AdminDataGridHeaderSection.clearFilters}}" visible="true" stepKey="clearExistingOrderFilters"/> + <conditionalClick selector="{{AdminDataGridHeaderSection.clearFilters}}" dependentSelector="{{AdminDataGridHeaderSection.clearFilters}}" visible="true" stepKey="clearExistingFilters"/> </actionGroup> <actionGroup name="AdminGridFilterSearchResultsByInput"> diff --git a/app/code/Magento/Ui/Test/Mftf/Section/AdminDataGridTableSection.xml b/app/code/Magento/Ui/Test/Mftf/Section/AdminDataGridTableSection.xml index ea0f7e64a8448..28ee9efc9f0a2 100644 --- a/app/code/Magento/Ui/Test/Mftf/Section/AdminDataGridTableSection.xml +++ b/app/code/Magento/Ui/Test/Mftf/Section/AdminDataGridTableSection.xml @@ -7,7 +7,7 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminDataGridTableSection"> <element name="firstRow" type="button" selector="tr.data-row:nth-of-type(1)"/> <element name="columnHeader" type="button" selector="//div[@data-role='grid-wrapper']//table[contains(@class, 'data-grid')]/thead/tr/th[contains(@class, 'data-grid-th')]/span[text() = '{{label}}']" parameterized="true" timeout="30"/> @@ -18,5 +18,8 @@ <!--Specific cell e.g. {{Section.gridCell('1', 'Name')}}--> <element name="gridCell" type="text" selector="//tr[{{row}}]//td[count(//div[@data-role='grid-wrapper']//tr//th[contains(., '{{column}}')]/preceding-sibling::th) +1 ]" parameterized="true"/> <element name="rowViewAction" type="button" selector=".data-grid tbody > tr:nth-of-type({{row}}) .action-menu-item" parameterized="true" timeout="30"/> + <element name="rowActionSelect" type="button" selector="[data-role='grid'] tbody tr .action-select-wrap"/> + <element name="rowEditAction" type="button" selector="[data-role='grid'] tbody tr .action-select-wrap._active [data-action='item-edit']" timeout="30"/> + <element name="dataGridEmpty" type="block" selector=".data-grid-tr-no-data td"/> </section> </sections> diff --git a/app/code/Magento/Ui/Test/Mftf/Section/AdminMessagesSection.xml b/app/code/Magento/Ui/Test/Mftf/Section/AdminMessagesSection.xml index d3e94eb24dfd2..8880f7c3e1cc7 100644 --- a/app/code/Magento/Ui/Test/Mftf/Section/AdminMessagesSection.xml +++ b/app/code/Magento/Ui/Test/Mftf/Section/AdminMessagesSection.xml @@ -11,5 +11,6 @@ <section name="AdminMessagesSection"> <element name="successMessage" type="text" selector=".message-success"/> <element name="errorMessage" type="text" selector=".message.message-error.error"/> + <element name="warningMessage" type="text" selector=".message-warning"/> </section> </sections> diff --git a/app/code/Magento/Ui/Test/Mftf/Section/StorefrontMessagesSection.xml b/app/code/Magento/Ui/Test/Mftf/Section/StorefrontMessagesSection.xml new file mode 100644 index 0000000000000..b07f2d356b9ea --- /dev/null +++ b/app/code/Magento/Ui/Test/Mftf/Section/StorefrontMessagesSection.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> + <section name="StorefrontMessagesSection"> + <element name="successMessage" type="text" selector=".message-success"/> + </section> +</sections> diff --git a/app/code/Magento/Ui/Test/Unit/Component/Listing/Columns/DateTest.php b/app/code/Magento/Ui/Test/Unit/Component/Listing/Columns/DateTest.php index 0bfb2a7dc0f34..414290efdd637 100644 --- a/app/code/Magento/Ui/Test/Unit/Component/Listing/Columns/DateTest.php +++ b/app/code/Magento/Ui/Test/Unit/Component/Listing/Columns/DateTest.php @@ -6,6 +6,7 @@ namespace Magento\Ui\Test\Unit\Component\Listing\Columns; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use PHPUnit_Framework_MockObject_MockObject as MockObject; /** * Class DateTest @@ -14,18 +15,33 @@ class DateTest extends \PHPUnit\Framework\TestCase { const TEST_TIME = '2000-04-12 16:34:12'; + private $data = [ + 'js_config' => [ + 'extends' => 'test_config_extends', + ], + 'config' => [ + 'dataType' => 'testType', + ], + 'name' => 'field_name', + ]; + /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Framework\View\Element\UiComponent\ContextInterface|MockObject */ protected $contextMock; + /** + * @var \Magento\Framework\View\Element\UiComponentFactory|MockObject + */ + private $uiComponentFactoryMock; + /** * @var \Magento\Ui\Component\Listing\Columns\Date */ protected $model; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var \Magento\Framework\Stdlib\DateTime\TimezoneInterface|MockObject */ protected $timezoneMock; @@ -50,11 +66,7 @@ protected function setUp() true, [] ); - $processor = $this->getMockBuilder(\Magento\Framework\View\Element\UiComponent\Processor::class) - ->disableOriginalConstructor() - ->getMock(); - $this->contextMock->expects($this->never())->method('getProcessor')->willReturn($processor); - + $this->uiComponentFactoryMock = $this->createMock(\Magento\Framework\View\Element\UiComponentFactory::class); $this->timezoneMock = $this->getMockBuilder(\Magento\Framework\Stdlib\DateTime\TimezoneInterface::class) ->disableOriginalConstructor() ->getMock(); @@ -63,18 +75,63 @@ protected function setUp() \Magento\Ui\Component\Listing\Columns\Date::class, [ 'context' => $this->contextMock, - 'data' => [ - 'js_config' => [ - 'extends' => 'test_config_extends' - ], - 'config' => [ - 'dataType' => 'testType' + 'uiComponentFactory' => $this->uiComponentFactoryMock, + 'data' => $this->data, + 'timezone' => $this->timezoneMock, + ] + ); + } + + /** + * @return void + */ + public function testPrepare() + { + $dateFormat = 'M/d/Y'; + + $this->data['config']['filter'] = [ + 'filterType' => 'dateRange', + 'templates' => [ + 'date' => [ + 'options' => [ + 'dateFormat' => $dateFormat, ], - 'name' => 'field_name', ], - 'timezone' => $this->timezoneMock - ] + ], + ]; + + $this->timezoneMock->expects($this->once()) + ->method('getDateFormatWithLongYear') + ->willReturn($dateFormat); + + /** @var \Magento\Framework\View\Element\UiComponent\Processor|MockObject $processor */ + $processor = $this->getMockBuilder(\Magento\Framework\View\Element\UiComponent\Processor::class) + ->disableOriginalConstructor() + ->getMock(); + $this->contextMock->expects($this->atLeastOnce())->method('getProcessor')->willReturn($processor); + + /** @var \Magento\Framework\View\Element\UiComponentInterface|MockObject $wrappedComponentMock */ + $wrappedComponentMock = $this->getMockForAbstractClass( + \Magento\Framework\View\Element\UiComponentInterface::class, + [], + '', + false ); + + $wrappedComponentMock->expects($this->once()) + ->method('getContext') + ->willReturn($this->contextMock); + + $this->uiComponentFactoryMock->expects($this->once()) + ->method('create') + ->with( + $this->data['name'], + $this->data['config']['dataType'], + array_merge(['context' => $this->contextMock], $this->data) + ) + ->willReturn($wrappedComponentMock); + + $this->model->prepare(); } public function testPrepareDataSource() diff --git a/app/code/Magento/Ui/Test/Unit/Controller/Adminhtml/Index/Render/HandleTest.php b/app/code/Magento/Ui/Test/Unit/Controller/Adminhtml/Index/Render/HandleTest.php index 31d5b421013f6..8f7641e117cdd 100644 --- a/app/code/Magento/Ui/Test/Unit/Controller/Adminhtml/Index/Render/HandleTest.php +++ b/app/code/Magento/Ui/Test/Unit/Controller/Adminhtml/Index/Render/HandleTest.php @@ -6,7 +6,13 @@ namespace Magento\Ui\Test\Unit\Controller\Adminhtml\Index\Render; use Magento\Ui\Controller\Adminhtml\Index\Render\Handle; +use Magento\Framework\View\Element\UiComponent\ContextInterface; +/** + * Tests \Magento\Ui\Controller\Adminhtml\Index\Render\Handle. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class HandleTest extends \PHPUnit\Framework\TestCase { /** @@ -27,22 +33,42 @@ class HandleTest extends \PHPUnit\Framework\TestCase /** * @var \PHPUnit_Framework_MockObject_MockObject */ - protected $componentFactoryMock; + protected $viewMock; + + /** + * @var Handle + */ + protected $controller; + + /** + * @var \Magento\Framework\AuthorizationInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $authorizationMock; /** * @var \PHPUnit_Framework_MockObject_MockObject */ - protected $viewMock; + private $uiComponentContextMock; /** - * @var Handle + * @var \Magento\Framework\View\Element\UiComponentInterface|\PHPUnit_Framework_MockObject_MockObject */ - protected $controller; + private $uiComponentMock; + + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ + private $uiFactoryMock; + + /** + * @var \Magento\Framework\View\Element\UiComponent\DataProvider\DataProviderInterface| + * \PHPUnit_Framework_MockObject_MockObject + */ + private $dataProviderMock; public function setUp() { $this->contextMock = $this->createMock(\Magento\Backend\App\Action\Context::class); - $this->componentFactoryMock = $this->createMock(\Magento\Framework\View\Element\UiComponentFactory::class); $this->requestMock = $this->createMock(\Magento\Framework\App\RequestInterface::class); $this->contextMock->expects($this->atLeastOnce())->method('getRequest')->willReturn($this->requestMock); @@ -52,8 +78,34 @@ public function setUp() $this->viewMock = $this->createMock(\Magento\Framework\App\ViewInterface::class); $this->contextMock->expects($this->atLeastOnce())->method('getView')->willReturn($this->viewMock); - - $this->controller = new Handle($this->contextMock, $this->componentFactoryMock); + $this->authorizationMock = $this->getMockForAbstractClass(\Magento\Framework\AuthorizationInterface::class); + $this->authorizationMock->expects($this->any()) + ->method('isAllowed') + ->willReturn(true); + $this->uiComponentContextMock = $this->getMockForAbstractClass(ContextInterface::class); + $this->uiComponentMock = $this->getMockForAbstractClass( + \Magento\Framework\View\Element\UiComponentInterface::class + ); + $this->dataProviderMock = $this->getMockForAbstractClass( + \Magento\Framework\View\Element\UiComponent\DataProvider\DataProviderInterface::class + ); + $this->uiComponentContextMock->expects($this->once()) + ->method('getDataProvider') + ->willReturn($this->dataProviderMock); + $this->uiFactoryMock = $this->getMockBuilder(\Magento\Framework\View\Element\UiComponentFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->uiComponentMock->expects($this->any()) + ->method('getContext') + ->willReturn($this->uiComponentContextMock); + $this->uiFactoryMock->expects($this->any()) + ->method('create') + ->willReturn($this->uiComponentMock); + $this->dataProviderMock->expects($this->once()) + ->method('getConfigData') + ->willReturn([]); + $contextMock = $this->createMock(\Magento\Framework\View\Element\UiComponent\ContextFactory::class); + $this->controller = new Handle($this->contextMock, $this->uiFactoryMock, $contextMock); } public function testExecuteNoButtons() @@ -83,7 +135,7 @@ public function testExecute() ->with(['default', $result], true, true, false); $layoutMock = $this->createMock(\Magento\Framework\View\LayoutInterface::class); - $this->viewMock->expects($this->exactly(2))->method('getLayout')->willReturn($layoutMock); + $this->viewMock->expects($this->once())->method('getLayout')->willReturn($layoutMock); $layoutMock->expects($this->exactly(2))->method('getBlock'); diff --git a/app/code/Magento/Ui/Test/Unit/TemplateEngine/Xhtml/ResultTest.php b/app/code/Magento/Ui/Test/Unit/TemplateEngine/Xhtml/ResultTest.php index 30a8e8005bbe8..9babfefd87f76 100644 --- a/app/code/Magento/Ui/Test/Unit/TemplateEngine/Xhtml/ResultTest.php +++ b/app/code/Magento/Ui/Test/Unit/TemplateEngine/Xhtml/ResultTest.php @@ -6,6 +6,7 @@ namespace Magento\Ui\Test\Unit\TemplateEngine\Xhtml; +use Magento\Framework\Serialize\Serializer\JsonHexTag; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Framework\View\Layout\Generator\Structure; use Magento\Framework\View\Element\UiComponentInterface; @@ -58,6 +59,11 @@ class ResultTest extends \PHPUnit\Framework\TestCase */ private $objectManager; + /** + * @var JsonHexTag|\PHPUnit_Framework_MockObject_MockObject + */ + private $serializer; + protected function setUp() { $this->template = $this->createPartialMock(Template::class, ['append']); @@ -65,6 +71,9 @@ protected function setUp() $this->component = $this->createMock(UiComponentInterface::class); $this->structure = $this->createPartialMock(Structure::class, ['generate']); $this->logger = $this->createMock(LoggerInterface::class); + $this->serializer = $this->getMockBuilder(JsonHexTag::class) + ->disableOriginalConstructor() + ->getMock(); $this->objectManager = new ObjectManager($this); $this->testModel = $this->objectManager->getObject(Result::class, [ @@ -73,6 +82,7 @@ protected function setUp() 'component' => $this->component, 'structure' => $this->structure, 'logger' => $this->logger, + 'jsonSerializer' => $this->serializer ]); } @@ -82,6 +92,10 @@ protected function setUp() public function testAppendLayoutConfiguration() { $configWithCdata = 'text before <![CDATA[cdata text]]>'; + $this->serializer->expects($this->once()) + ->method('serialize') + ->with([$configWithCdata]) + ->willReturn('["text before \u003C![CDATA[cdata text]]\u003E"]'); $this->structure->expects($this->once()) ->method('generate') ->with($this->component) diff --git a/app/code/Magento/Ui/composer.json b/app/code/Magento/Ui/composer.json index e259c7d8b0639..effe010a79fe9 100644 --- a/app/code/Magento/Ui/composer.json +++ b/app/code/Magento/Ui/composer.json @@ -13,7 +13,7 @@ "magento/module-config": "101.0.*" }, "type": "magento2-module", - "version": "101.0.6", + "version": "101.0.7", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Ui/i18n/en_US.csv b/app/code/Magento/Ui/i18n/en_US.csv index 981d444b31d53..040ec32cf0b74 100644 --- a/app/code/Magento/Ui/i18n/en_US.csv +++ b/app/code/Magento/Ui/i18n/en_US.csv @@ -202,3 +202,4 @@ CSV,CSV "Please enter at least {0} characters.","Please enter at least {0} characters." "Please enter a value between {0} and {1} characters long.","Please enter a value between {0} and {1} characters long." "Please enter a value between {0} and {1}.","Please enter a value between {0} and {1}." +"The file upload field is disabled.","The file upload field is disabled." diff --git a/app/code/Magento/Ui/view/base/web/js/dynamic-rows/dnd.js b/app/code/Magento/Ui/view/base/web/js/dynamic-rows/dnd.js index dee9ba7acc172..583e97b7e9449 100644 --- a/app/code/Magento/Ui/view/base/web/js/dynamic-rows/dnd.js +++ b/app/code/Magento/Ui/view/base/web/js/dynamic-rows/dnd.js @@ -15,8 +15,7 @@ define([ ], function (ko, $, _, Element) { 'use strict'; - var transformProp, - isTouchDevice = typeof document.ontouchstart !== 'undefined'; + var transformProp; /** * Get element context @@ -110,11 +109,7 @@ define([ * @param {Object} data - element data */ initListeners: function (elem, data) { - if (isTouchDevice) { - $(elem).on('touchstart', this.mousedownHandler.bind(this, data, elem)); - } else { - $(elem).on('mousedown', this.mousedownHandler.bind(this, data, elem)); - } + $(elem).on('mousedown touchstart', this.mousedownHandler.bind(this, data, elem)); }, /** @@ -131,26 +126,20 @@ define([ $table = $(elem).parents('table').eq(0), $tableWrapper = $table.parent(); + this.disableScroll(); $(recordNode).addClass(this.draggableElementClass); $(originRecord).addClass(this.draggableElementClass); this.step = this.step === 'auto' ? originRecord.height() / 2 : this.step; drEl.originRow = originRecord; drEl.instance = recordNode = this.processingStyles(recordNode, elem); drEl.instanceCtx = this.getRecord(originRecord[0]); - drEl.eventMousedownY = isTouchDevice ? event.originalEvent.touches[0].pageY : event.pageY; + drEl.eventMousedownY = this.getPageY(event); drEl.minYpos = $table.offset().top - originRecord.offset().top + $table.children('thead').outerHeight(); drEl.maxYpos = drEl.minYpos + $table.children('tbody').outerHeight() - originRecord.outerHeight(); $tableWrapper.append(recordNode); - - if (isTouchDevice) { - this.body.bind('touchmove', this.mousemoveHandler); - this.body.bind('touchend', this.mouseupHandler); - } else { - this.body.bind('mousemove', this.mousemoveHandler); - this.body.bind('mouseup', this.mouseupHandler); - } - + this.body.bind('mousemove touchmove', this.mousemoveHandler); + this.body.bind('mouseup touchend', this.mouseupHandler); }, /** @@ -160,16 +149,13 @@ define([ */ mousemoveHandler: function (event) { var depEl = this.draggableElement, - pageY = isTouchDevice ? event.originalEvent.touches[0].pageY : event.pageY, + pageY = this.getPageY(event), positionY = pageY - depEl.eventMousedownY, processingPositionY = positionY + 'px', processingMaxYpos = depEl.maxYpos + 'px', processingMinYpos = depEl.minYpos + 'px', depElement = this.getDepElement(depEl.instance, positionY, depEl.originRow); - event.stopPropagation(); - event.preventDefault(); - if (depElement) { depEl.depElement ? depEl.depElement.elem.removeClass(depEl.depElement.className) : false; depEl.depElement = depElement; @@ -194,9 +180,10 @@ define([ mouseupHandler: function (event) { var depElementCtx, drEl = this.draggableElement, - pageY = isTouchDevice ? event.originalEvent.touches[0].pageY : event.pageY, + pageY = this.getPageY(event), positionY = pageY - drEl.eventMousedownY; + this.enableScroll(); drEl.depElement = this.getDepElement(drEl.instance, positionY, this.draggableElement.originRow); drEl.instance.remove(); @@ -212,13 +199,8 @@ define([ drEl.originRow.removeClass(this.draggableElementClass); - if (isTouchDevice) { - this.body.unbind('touchmove', this.mousemoveHandler); - this.body.unbind('touchend', this.mouseupHandler); - } else { - this.body.unbind('mousemove', this.mousemoveHandler); - this.body.unbind('mouseup', this.mouseupHandler); - } + this.body.unbind('mousemove touchmove', this.mousemoveHandler); + this.body.unbind('mouseup touchend', this.mouseupHandler); this.draggableElement = {}; }, @@ -402,6 +384,55 @@ define([ index = _.isFunction(ctx.$index) ? ctx.$index() : ctx.$index; return this.recordsCache()[index]; + }, + + /** + * Get correct page Y + * + * @param {Object} event - current event + * @returns {integer} + */ + getPageY: function (event) { + var pageY; + + if (event.type.indexOf('touch') >= 0) { + if (event.originalEvent.touches[0]) { + pageY = event.originalEvent.touches[0].pageY; + } else { + pageY = event.originalEvent.changedTouches[0].pageY; + } + } else { + pageY = event.pageY; + } + + return pageY; + }, + + /** + * Disable page scrolling + */ + disableScroll: function () { + document.body.addEventListener('touchmove', this.preventDefault, { + passive: false + }); + }, + + /** + * Enable page scrolling + */ + enableScroll: function () { + document.body.removeEventListener('touchmove', this.preventDefault, { + passive: false + }); + }, + + /** + * Prevent default function + * + * @param {Object} event - event object + */ + preventDefault: function (event) { + event.preventDefault(); } }); diff --git a/app/code/Magento/Ui/view/base/web/js/dynamic-rows/record.js b/app/code/Magento/Ui/view/base/web/js/dynamic-rows/record.js index 54309ca068513..3987507ece54f 100644 --- a/app/code/Magento/Ui/view/base/web/js/dynamic-rows/record.js +++ b/app/code/Magento/Ui/view/base/web/js/dynamic-rows/record.js @@ -25,7 +25,7 @@ define([ }, listens: { position: 'initPosition', - elems: 'setColumnVisibileListener' + elems: 'setColumnVisibleListener' }, links: { position: '${ $.name }.${ $.positionProvider }:value' @@ -123,7 +123,7 @@ define([ /** * Set column visibility listener */ - setColumnVisibileListener: function () { + setColumnVisibleListener: function () { var elem = _.find(this.elems(), function (curElem) { return !curElem.hasOwnProperty('visibleListener'); }); diff --git a/app/code/Magento/Ui/view/base/web/js/form/components/fieldset.js b/app/code/Magento/Ui/view/base/web/js/form/components/fieldset.js index 6d33386fa1f1c..5f2fda830f5ba 100644 --- a/app/code/Magento/Ui/view/base/web/js/form/components/fieldset.js +++ b/app/code/Magento/Ui/view/base/web/js/form/components/fieldset.js @@ -162,6 +162,10 @@ define([ } this.error(hasErrors || message); + + if (hasErrors || message) { + this.open(); + } }, /** diff --git a/app/code/Magento/Ui/view/base/web/js/form/element/abstract.js b/app/code/Magento/Ui/view/base/web/js/form/element/abstract.js index 3b1026e1d7fd4..5617110590e50 100755 --- a/app/code/Magento/Ui/view/base/web/js/form/element/abstract.js +++ b/app/code/Magento/Ui/view/base/web/js/form/element/abstract.js @@ -57,6 +57,9 @@ define([ '${ $.provider }:${ $.customScope ? $.customScope + "." : ""}data.validate': 'validate', 'isUseDefault': 'toggleUseDefault' }, + ignoreTmpls: { + value: true + }, links: { value: '${ $.provider }:${ $.dataScope }' @@ -405,6 +408,7 @@ define([ isValid = this.disabled() || !this.visible() || result.passed; this.error(message); + this.error.valueHasMutated(); this.bubble('error', message); //TODO: Implement proper result propagation for form diff --git a/app/code/Magento/Ui/view/base/web/js/form/element/file-uploader.js b/app/code/Magento/Ui/view/base/web/js/form/element/file-uploader.js index b81119f2bd5f3..4b178622c28cf 100644 --- a/app/code/Magento/Ui/view/base/web/js/form/element/file-uploader.js +++ b/app/code/Magento/Ui/view/base/web/js/form/element/file-uploader.js @@ -13,8 +13,9 @@ define([ 'Magento_Ui/js/modal/alert', 'Magento_Ui/js/lib/validation/validator', 'Magento_Ui/js/form/element/abstract', + 'mage/translate', 'jquery/file-uploader' -], function ($, _, utils, uiAlert, validator, Element) { +], function ($, _, utils, uiAlert, validator, Element, $t) { 'use strict'; return Element.extend({ @@ -328,6 +329,12 @@ define([ allowed = this.isFileAllowed(file), target = $(e.target); + if (this.disabled()) { + this.notifyError($t('The file upload field is disabled.')); + + return; + } + if (allowed.passed) { target.on('fileuploadsend', function (event, postData) { postData.data.append('param_name', this.paramName); diff --git a/app/code/Magento/Ui/view/base/web/js/form/element/post-code.js b/app/code/Magento/Ui/view/base/web/js/form/element/post-code.js index 911574a0fb438..1b6dd9f1c57ec 100644 --- a/app/code/Magento/Ui/view/base/web/js/form/element/post-code.js +++ b/app/code/Magento/Ui/view/base/web/js/form/element/post-code.js @@ -26,7 +26,7 @@ define([ update: function (value) { var country = registry.get(this.parentName + '.' + 'country_id'), options = country.indexedOptions, - option; + option = null; if (!value) { return; @@ -34,6 +34,10 @@ define([ option = options[value]; + if (!option) { + return; + } + if (option['is_zipcode_optional']) { this.error(false); this.validation = _.omit(this.validation, 'required-entry'); diff --git a/app/code/Magento/Ui/view/base/web/js/form/element/wysiwyg.js b/app/code/Magento/Ui/view/base/web/js/form/element/wysiwyg.js index b8cd4a0f2c892..547e6cde59839 100644 --- a/app/code/Magento/Ui/view/base/web/js/form/element/wysiwyg.js +++ b/app/code/Magento/Ui/view/base/web/js/form/element/wysiwyg.js @@ -18,7 +18,7 @@ define([ return Abstract.extend({ defaults: { elementSelector: 'textarea', - value: '', + suffixRegExpPattern: '\\${ \\$.wysiwygUniqueSuffix }', $wysiwygEditorButton: '', links: { value: '${ $.provider }:${ $.dataScope }' @@ -52,6 +52,25 @@ define([ return this; }, + /** @inheritdoc */ + initConfig: function (config) { + var pattern = config.suffixRegExpPattern || this.constructor.defaults.suffixRegExpPattern; + + config.content = config.content.replace(new RegExp(pattern, 'g'), this.getUniqueSuffix(config)); + this._super(); + + return this; + }, + + /** + * Build unique id based on name, underscore separated. + * + * @param {Object} config + */ + getUniqueSuffix: function (config) { + return config.name.replace(/(\.|-)/g, '_'); + }, + /** * * @returns {exports} diff --git a/app/code/Magento/Ui/view/base/web/js/grid/editing/record.js b/app/code/Magento/Ui/view/base/web/js/grid/editing/record.js index 18b3836113141..390aedf193b91 100644 --- a/app/code/Magento/Ui/view/base/web/js/grid/editing/record.js +++ b/app/code/Magento/Ui/view/base/web/js/grid/editing/record.js @@ -50,6 +50,9 @@ define([ } } }, + ignoreTmpls: { + data: true + }, listens: { elems: 'updateFields', data: 'updateState' diff --git a/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/bootstrap.js b/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/bootstrap.js index b29d10a143117..a266e282eac66 100644 --- a/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/bootstrap.js +++ b/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/bootstrap.js @@ -26,7 +26,7 @@ define(function (require) { mageInit: require('./mage-init'), keyboard: require('./keyboard'), optgroup: require('./optgroup'), - aferRender: require('./after-render'), + afterRender: require('./after-render'), autoselect: require('./autoselect'), datepicker: require('./datepicker'), outerClick: require('./outer_click'), diff --git a/app/code/Magento/Ui/view/base/web/js/lib/knockout/extender/bound-nodes.js b/app/code/Magento/Ui/view/base/web/js/lib/knockout/extender/bound-nodes.js index a332b595bdf3c..6b3c437b90508 100644 --- a/app/code/Magento/Ui/view/base/web/js/lib/knockout/extender/bound-nodes.js +++ b/app/code/Magento/Ui/view/base/web/js/lib/knockout/extender/bound-nodes.js @@ -109,7 +109,7 @@ define([ wrapper.extend(ko, { /** - * Extends kncokouts' 'applyBindings' + * Extends knockouts' 'applyBindings' * to track nodes associated with model. * * @param {Function} orig - Original 'applyBindings' method. @@ -136,7 +136,7 @@ define([ }, /** - * Extends kncokouts' cleanNode + * Extends knockouts' cleanNode * to track nodes associated with model. * * @param {Function} orig - Original 'cleanNode' method. diff --git a/app/code/Magento/Ui/view/base/web/js/lib/validation/rules.js b/app/code/Magento/Ui/view/base/web/js/lib/validation/rules.js index 8e59f9d290906..cbfc0dae90dda 100644 --- a/app/code/Magento/Ui/view/base/web/js/lib/validation/rules.js +++ b/app/code/Magento/Ui/view/base/web/js/lib/validation/rules.js @@ -691,6 +691,24 @@ define([ }, $.mage.__('The value is not within the specified range.') ], + 'validate-positive-percent-decimal': [ + function (value) { + var numValue; + + if (utils.isEmptyNoTrim(value) || !/^\s*-?\d*(\.\d*)?\s*$/.test(value)) { + return false; + } + + numValue = utils.parseNumber(value); + + if (isNaN(numValue)) { + return false; + } + + return utils.isBetween(numValue, 0.01, 100); + }, + $.mage.__('Please enter a valid percentage discount value greater than 0.') + ], 'validate-digits': [ function (value) { return utils.isEmptyNoTrim(value) || !/[^\d]/.test(value); diff --git a/app/code/Magento/Ui/view/base/web/templates/grid/filters/elements/ui-select.html b/app/code/Magento/Ui/view/base/web/templates/grid/filters/elements/ui-select.html index 4038f65738041..a96a4163caf7e 100644 --- a/app/code/Magento/Ui/view/base/web/templates/grid/filters/elements/ui-select.html +++ b/app/code/Magento/Ui/view/base/web/templates/grid/filters/elements/ui-select.html @@ -36,7 +36,7 @@ class="action-select admin__action-multiselect" data-role="advanced-select" data-bind=" - css: {_active: multiselectFocus}, + css: {_active: listVisible}, click: function(data, event) { toggleListVisible(data, event) } @@ -52,7 +52,7 @@ class="action-select admin__action-multiselect" data-role="advanced-select" data-bind=" - css: {_active: multiselectFocus}, + css: {_active: listVisible}, click: function(data, event) { toggleListVisible(data, event) } diff --git a/app/code/Magento/Ups/Model/Carrier.php b/app/code/Magento/Ups/Model/Carrier.php index 018302bd51fc2..a9e289d57300b 100644 --- a/app/code/Magento/Ups/Model/Carrier.php +++ b/app/code/Magento/Ups/Model/Carrier.php @@ -334,6 +334,14 @@ public function setRequest(RateRequest $request) $destCountry = self::GUAM_COUNTRY_ID; } + // For UPS, Las Palmas and Santa Cruz de Tenerife will be represented by Canary Islands country + if ( + $destCountry == self::SPAIN_COUNTRY_ID && + ($request->getDestRegionCode() == self::LAS_PALMAS_REGION_ID || $request->getDestRegionCode() == self::SANTA_CRUZ_DE_TENERIFE_REGION_ID) + ) { + $destCountry = self::CANARY_ISLANDS_COUNTRY_ID; + } + $country = $this->_countryFactory->create()->load($destCountry); $rowRequest->setDestCountry($country->getData('iso2_code') ?: $destCountry); @@ -632,7 +640,7 @@ protected function _getXmlQuotes() $serviceCode = null; } else { $params['10_action'] = 'Rate'; - $serviceCode = $rowRequest->getProduct() ? $rowRequest->getProduct() : ''; + $serviceCode = $rowRequest->getProduct() ? $rowRequest->getProduct() : null; } $serviceDescription = $serviceCode ? $this->getShipmentByCode($serviceCode) : ''; @@ -666,8 +674,8 @@ protected function _getXmlQuotes() <Shipper> XMLRequest; - if ($this->getConfigFlag('negotiated_active') && ($shipper = $this->getConfigData('shipper_number'))) { - $xmlParams .= "<ShipperNumber>{$shipper}</ShipperNumber>"; + if ($this->getConfigFlag('negotiated_active') && ($shipperNumber = $this->getConfigData('shipper_number'))) { + $xmlParams .= "<ShipperNumber>{$shipperNumber}</ShipperNumber>"; } if ($rowRequest->getIsReturn()) { @@ -690,6 +698,7 @@ protected function _getXmlQuotes() <StateProvinceCode>{$shipperStateProvince}</StateProvinceCode> </Address> </Shipper> + <ShipTo> <Address> <PostalCode>{$params['19_destPostal']}</PostalCode> @@ -705,8 +714,7 @@ protected function _getXmlQuotes() $xmlParams .= <<<XMLRequest </Address> </ShipTo> - - + <ShipFrom> <Address> <PostalCode>{$params['15_origPostal']}</PostalCode> @@ -716,9 +724,13 @@ protected function _getXmlQuotes() </ShipFrom> <Package> - <PackagingType><Code>{$params['48_container']}</Code></PackagingType> + <PackagingType> + <Code>{$params['48_container']}</Code> + </PackagingType> <PackageWeight> - <UnitOfMeasurement><Code>{$rowRequest->getUnitMeasure()}</Code></UnitOfMeasurement> + <UnitOfMeasurement> + <Code>{$rowRequest->getUnitMeasure()}</Code> + </UnitOfMeasurement> <Weight>{$params['23_weight']}</Weight> </PackageWeight> </Package> @@ -732,8 +744,8 @@ protected function _getXmlQuotes() } $xmlParams .= <<<XMLRequest - </Shipment> -</RatingServiceSelectionRequest> + </Shipment> + </RatingServiceSelectionRequest> XMLRequest; $xmlRequest .= $xmlParams; @@ -904,10 +916,13 @@ protected function _parseXmlResponse($xmlResponse) $error = $this->_rateErrorFactory->create(); $error->setCarrier('ups'); $error->setCarrierTitle($this->getConfigData('title')); + if ($this->getConfigData('specificerrmsg') !== '') { + $errorTitle = $this->getConfigData('specificerrmsg'); + } if (!isset($errorTitle)) { $errorTitle = __('Cannot retrieve shipping rates'); } - $error->setErrorMessage($this->getConfigData('specificerrmsg')); + $error->setErrorMessage($errorTitle); $result->append($error); } else { foreach ($priceArr as $method => $price) { @@ -1012,14 +1027,14 @@ protected function _getXmlTracking($trackings) foreach ($trackings as $tracking) { /** - * RequestOption==>'activity' or '1' to request all activities + * RequestOption==>'1' to request all activities */ $xmlRequest = <<<XMLAuth <?xml version="1.0" ?> <TrackRequest xml:lang="en-US"> <Request> <RequestAction>Track</RequestAction> - <RequestOption>activity</RequestOption> + <RequestOption>1</RequestOption> </Request> <TrackingNumber>$tracking</TrackingNumber> <IncludeFreight>01</IncludeFreight> @@ -1083,15 +1098,15 @@ protected function _parseXmlTrackingResponse($trackingValue, $xmlResponse) if ($activityTags) { $index = 1; foreach ($activityTags as $activityTag) { - $addArr = []; + $addressArr = []; if (isset($activityTag->ActivityLocation->Address->City)) { - $addArr[] = (string)$activityTag->ActivityLocation->Address->City; + $addressArr[] = (string)$activityTag->ActivityLocation->Address->City; } if (isset($activityTag->ActivityLocation->Address->StateProvinceCode)) { - $addArr[] = (string)$activityTag->ActivityLocation->Address->StateProvinceCode; + $addressArr[] = (string)$activityTag->ActivityLocation->Address->StateProvinceCode; } if (isset($activityTag->ActivityLocation->Address->CountryCode)) { - $addArr[] = (string)$activityTag->ActivityLocation->Address->CountryCode; + $addressArr[] = (string)$activityTag->ActivityLocation->Address->CountryCode; } $dateArr = []; $date = (string)$activityTag->Date; @@ -1115,8 +1130,8 @@ protected function _parseXmlTrackingResponse($trackingValue, $xmlResponse) //HH:MM:SS $resultArr['deliverylocation'] = (string)$activityTag->ActivityLocation->Description; $resultArr['signedby'] = (string)$activityTag->ActivityLocation->SignedForByName; - if ($addArr) { - $resultArr['deliveryto'] = implode(', ', $addArr); + if ($addressArr) { + $resultArr['deliveryto'] = implode(', ', $addressArr); } } else { $tempArr = []; @@ -1125,8 +1140,8 @@ protected function _parseXmlTrackingResponse($trackingValue, $xmlResponse) //YYYY-MM-DD $tempArr['deliverytime'] = implode(':', $timeArr); //HH:MM:SS - if ($addArr) { - $tempArr['deliverylocation'] = implode(', ', $addArr); + if ($addressArr) { + $tempArr['deliverylocation'] = implode(', ', $addressArr); } $packageProgress[] = $tempArr; } diff --git a/app/code/Magento/Ups/composer.json b/app/code/Magento/Ups/composer.json index 8e262c2a82d09..9173ccbc3393b 100644 --- a/app/code/Magento/Ups/composer.json +++ b/app/code/Magento/Ups/composer.json @@ -16,7 +16,7 @@ "magento/module-config": "101.0.*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Ups/etc/config.xml b/app/code/Magento/Ups/etc/config.xml index 0f92ae4dad195..e2ac1c6d6c443 100644 --- a/app/code/Magento/Ups/etc/config.xml +++ b/app/code/Magento/Ups/etc/config.xml @@ -25,7 +25,7 @@ <model>Magento\Ups\Model\Carrier</model> <pickup>CC</pickup> <title>United Parcel Service - https://www.ups.com/ups.app/xml/Track + https://onlinetools.ups.com/ups.app/xml/Track LBS diff --git a/app/code/Magento/UrlRewrite/Model/Storage/DbStorage.php b/app/code/Magento/UrlRewrite/Model/Storage/DbStorage.php index 499fb9925a54a..60b845f95e5cc 100644 --- a/app/code/Magento/UrlRewrite/Model/Storage/DbStorage.php +++ b/app/code/Magento/UrlRewrite/Model/Storage/DbStorage.php @@ -79,7 +79,7 @@ protected function prepareSelect(array $data) } /** - * {@inheritdoc} + * @inheritdoc */ protected function doFindAllByData(array $data) { @@ -87,7 +87,7 @@ protected function doFindAllByData(array $data) } /** - * {@inheritdoc} + * @inheritdoc */ protected function doFindOneByData(array $data) { @@ -152,26 +152,22 @@ private function deleteOldUrls(array $urls) $oldUrlsSelect->from( $this->resource->getTableName(self::TABLE_NAME) ); - /** @var UrlRewrite $url */ - foreach ($urls as $url) { - $oldUrlsSelect->orWhere( - $this->connection->quoteIdentifier( - UrlRewrite::ENTITY_TYPE - ) . ' = ?', - $url->getEntityType() - ); - $oldUrlsSelect->where( - $this->connection->quoteIdentifier( - UrlRewrite::ENTITY_ID - ) . ' = ?', - $url->getEntityId() - ); - $oldUrlsSelect->where( - $this->connection->quoteIdentifier( - UrlRewrite::STORE_ID - ) . ' = ?', - $url->getStoreId() - ); + + $uniqueEntities = $this->prepareUniqueEntities($urls); + foreach ($uniqueEntities as $storeId => $entityTypes) { + foreach ($entityTypes as $entityType => $entities) { + $oldUrlsSelect->orWhere( + $this->connection->quoteIdentifier( + UrlRewrite::STORE_ID + ) . ' = ' . $this->connection->quote($storeId, 'INTEGER') . + ' AND ' . $this->connection->quoteIdentifier( + UrlRewrite::ENTITY_ID + ) . ' IN (' . $this->connection->quote($entities, 'INTEGER') . ')' . + ' AND ' . $this->connection->quoteIdentifier( + UrlRewrite::ENTITY_TYPE + ) . ' = ' . $this->connection->quote($entityType) + ); + } } // prevent query locking in a case when nothing to delete @@ -189,6 +185,28 @@ private function deleteOldUrls(array $urls) } } + /** + * Prepare array with unique entities + * + * @param UrlRewrite[] $urls + * @return array + */ + private function prepareUniqueEntities(array $urls): array + { + $uniqueEntities = []; + /** @var UrlRewrite $url */ + foreach ($urls as $url) { + $entityIds = (!empty($uniqueEntities[$url->getStoreId()][$url->getEntityType()])) ? + $uniqueEntities[$url->getStoreId()][$url->getEntityType()] : []; + + if (!\in_array($url->getEntityId(), $entityIds)) { + $entityIds[] = $url->getEntityId(); + } + $uniqueEntities[$url->getStoreId()][$url->getEntityType()] = $entityIds; + } + return $uniqueEntities; + } + /** * @inheritDoc */ @@ -279,7 +297,7 @@ protected function createFilterDataBasedOnUrls($urls) } /** - * {@inheritdoc} + * @inheritdoc */ public function deleteByData(array $data) { diff --git a/app/code/Magento/UrlRewrite/Model/StoreSwitcher/RewriteUrl.php b/app/code/Magento/UrlRewrite/Model/StoreSwitcher/RewriteUrl.php index 2b6f9e87e3de2..a6cb4081965dd 100644 --- a/app/code/Magento/UrlRewrite/Model/StoreSwitcher/RewriteUrl.php +++ b/app/code/Magento/UrlRewrite/Model/StoreSwitcher/RewriteUrl.php @@ -65,17 +65,16 @@ public function switch(StoreInterface $fromStore, StoreInterface $targetStore, s UrlRewrite::STORE_ID => $oldStoreId, ]); if ($oldRewrite) { + $targetUrl = $targetStore->getBaseUrl(); // look for url rewrite match on the target store $currentRewrite = $this->urlFinder->findOneByData([ - UrlRewrite::REQUEST_PATH => $urlPath, + UrlRewrite::TARGET_PATH => $oldRewrite->getTargetPath(), UrlRewrite::STORE_ID => $targetStore->getId(), ]); - if (null === $currentRewrite) { - /** @var \Magento\Framework\App\Response\Http $response */ - $targetUrl = $targetStore->getBaseUrl(); + if ($currentRewrite) { + $targetUrl .= $currentRewrite->getRequestPath(); } } - return $targetUrl; } } diff --git a/app/code/Magento/UrlRewrite/Test/Mftf/Section/AdminUrlRewriteIndexSection.xml b/app/code/Magento/UrlRewrite/Test/Mftf/Section/AdminUrlRewriteIndexSection.xml index 455748a0da534..ab847f924b5cf 100644 --- a/app/code/Magento/UrlRewrite/Test/Mftf/Section/AdminUrlRewriteIndexSection.xml +++ b/app/code/Magento/UrlRewrite/Test/Mftf/Section/AdminUrlRewriteIndexSection.xml @@ -7,9 +7,10 @@ --> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd">
+
diff --git a/app/code/Magento/UrlRewrite/Test/Mftf/Test/AdminCheckUrlRewritesCorrectlyGeneratedForMultipleStoreviewsDuringProductImportTest.xml b/app/code/Magento/UrlRewrite/Test/Mftf/Test/AdminCheckUrlRewritesCorrectlyGeneratedForMultipleStoreviewsDuringProductImportTest.xml new file mode 100644 index 0000000000000..cfe96fc1c33f7 --- /dev/null +++ b/app/code/Magento/UrlRewrite/Test/Mftf/Test/AdminCheckUrlRewritesCorrectlyGeneratedForMultipleStoreviewsDuringProductImportTest.xml @@ -0,0 +1,114 @@ + + + + + + + + + + <description value="Check Url Rewrites Correctly Generated for Multiple Storeviews During Product Import."/> + <severity value="CRITICAL"/> + <testCaseId value="MAGETWO-76287"/> + <group value="urlRewrite"/> + </annotations> + <before> + <actionGroup ref="LoginAsAdmin" stepKey="loginAsAdmin"/> + <!-- Create Store View EN --> + <actionGroup ref="AdminCreateStoreViewActionGroup" stepKey="createStoreViewEn"> + <argument name="customStore" value="CustomStoreENNotUnique"/> + </actionGroup> + <!-- Create Store View NL --> + <actionGroup ref="AdminCreateStoreViewActionGroup" stepKey="createStoreViewNl"> + <argument name="customStore" value="CustomStoreNLNotUnique"/> + </actionGroup> + <createData entity="ApiCategory" stepKey="createCategory"> + <field key="name">category-admin</field> + </createData> + </before> + <after> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <actionGroup ref="DeleteProductByName" stepKey="deleteImportedProduct"> + <argument name="product" value="productformagetwo76287"/> + </actionGroup> + <actionGroup ref="AdminDeleteStoreViewActionGroup" stepKey="deleteStoreViewEn"> + <argument name="customStore" value="CustomStoreENNotUnique"/> + </actionGroup> + <actionGroup ref="AdminDeleteStoreViewActionGroup" stepKey="deleteStoreViewNl"> + <argument name="customStore" value="CustomStoreNLNotUnique"/> + </actionGroup> + <actionGroup ref="logout" stepKey="logout"/> + </after> + <actionGroup ref="switchCategoryStoreView" stepKey="switchToStoreViewEn"> + <argument name="store" value="CustomStoreENNotUnique.name"/> + <argument name="catName" value="$$createCategory.name$$"/> + </actionGroup> + <uncheckOption selector="{{AdminCategoryBasicFieldSection.categoryNameUseDefault}}" stepKey="uncheckUseDefaultValueENStoreView"/> + <fillField selector="{{AdminCategoryBasicFieldSection.CategoryNameInput}}" userInput="category-english" stepKey="changeNameField"/> + <click selector="{{AdminCategorySEOSection.SectionHeader}}" stepKey="clickOnSectionHeader"/> + <actionGroup ref="ChangeSeoUrlKeyForSubCategory" stepKey="changeSeoUrlKeyENStoreView"> + <argument name="value" value="category-english"/> + </actionGroup> + <actionGroup ref="switchCategoryStoreView" stepKey="switchToStoreViewNl"> + <argument name="store" value="CustomStoreNLNotUnique.name"/> + <argument name="catName" value="$$createCategory.name$$"/> + </actionGroup> + <uncheckOption selector="{{AdminCategoryBasicFieldSection.categoryNameUseDefault}}" stepKey="uncheckUseDefaultValue1"/> + <fillField selector="{{AdminCategoryBasicFieldSection.CategoryNameInput}}" userInput="category-dutch" stepKey="changeNameFieldNLStoreView"/> + <click selector="{{AdminCategorySEOSection.SectionHeader}}" stepKey="clickOnSectionHeader2"/> + <actionGroup ref="ChangeSeoUrlKeyForSubCategory" stepKey="changeSeoUrlKeyNLStoreView"> + <argument name="value" value="category-dutch"/> + </actionGroup> + <amOnPage url="{{AdminImportIndexPage.url}}" stepKey="navigateToSystemImport"/> + <selectOption selector="{{AdminImportMainSection.entityType}}" userInput="Products" stepKey="selectProductsOption"/> + <waitForElementVisible selector="{{AdminImportMainSection.importBehavior}}" stepKey="waitForImportBehaviorElementVisible"/> + <selectOption selector="{{AdminImportMainSection.importBehavior}}" userInput="Add/Update" stepKey="selectAddUpdateOption"/> + <attachFile selector="{{AdminImportMainSection.selectFileToImport}}" userInput="import_updated.csv" stepKey="attachFileForImport"/> + <click selector="{{AdminImportHeaderSection.checkDataButton}}" stepKey="clickCheckDataButton"/> + <see selector="{{AdminMessagesSection.notice}}" userInput="Checked rows: 3, checked entities: 1, invalid rows: 0, total errors: 0" stepKey="assertNotice"/> + <see selector="{{AdminMessagesSection.successMessage}}" userInput="File is valid! To start import process press "Import" button" stepKey="assertSuccessMessage"/> + <click selector="{{AdminImportMainSection.importButton}}" stepKey="clickImportButton"/> + <see selector="{{AdminMessagesSection.successMessage}}" userInput="Import successfully done" stepKey="assertSuccessMessage1"/> + <actionGroup ref="SearchForProductOnBackendByNameActionGroup" stepKey="searchForProductOnBackend"> + <argument name="productName" value="productformagetwo76287"/> + </actionGroup> + <click selector="{{AdminProductGridSection.productRowBySku('productformagetwo76287')}}" stepKey="clickOnProductRow"/> + <grabFromCurrentUrl regex="~/id/(\d+)/~" stepKey="grabProductIdFromUrl"/> + <amOnPage url="{{AdminUrlRewriteIndexPage.url}}" stepKey="goToUrlRewritesIndexPage"/> + + <fillField selector="{{AdminUrlRewriteIndexSection.requestPathFilter}}" userInput="category-english.html" stepKey="inputCategoryUrlForENStoreView"/> + <click selector="{{AdminDataGridHeaderSection.applyFilters}}" stepKey="clickSearchButton"/> + <seeElement selector="{{AdminUrlRewriteIndexSection.requestPathColumnValue('category-english.html')}}" stepKey="seeUrlInRequestPathColumn"/> + <seeElement selector="{{AdminUrlRewriteIndexSection.targetPathColumnValue(catalog/category/view/id/$$createCategory.id$$)}}" stepKey="seeUrlInTargetPathColumn"/> + + <fillField selector="{{AdminUrlRewriteIndexSection.requestPathFilter}}" userInput="category-dutch.html" stepKey="inputCategoryUrlForNLStoreView"/> + <click selector="{{AdminDataGridHeaderSection.applyFilters}}" stepKey="clickSearchButton1"/> + <seeElement selector="{{AdminUrlRewriteIndexSection.requestPathColumnValue('category-dutch.html')}}" stepKey="seeUrlInRequestPathColumn1"/> + <seeElement selector="{{AdminUrlRewriteIndexSection.targetPathColumnValue(catalog/category/view/id/$$createCategory.id$$)}}" stepKey="seeUrlInTargetPathColumn1"/> + + <fillField selector="{{AdminUrlRewriteIndexSection.requestPathFilter}}" userInput="productformagetwo76287-english.html" stepKey="inputProductUrlForENStoreView"/> + <click selector="{{AdminDataGridHeaderSection.applyFilters}}" stepKey="clickSearchButton2"/> + <seeElement selector="{{AdminUrlRewriteIndexSection.requestPathColumnValue('productformagetwo76287-english.html')}}" stepKey="seeUrlInRequestPathColumn2"/> + <seeElement selector="{{AdminUrlRewriteIndexSection.targetPathColumnValue('catalog/product/view/id/$grabProductIdFromUrl')}}" stepKey="seeUrlInTargetPathColumn2"/> + + <fillField selector="{{AdminUrlRewriteIndexSection.requestPathFilter}}" userInput="productformagetwo76287-dutch.html" stepKey="inputProductUrlForENStoreView1"/> + <click selector="{{AdminDataGridHeaderSection.applyFilters}}" stepKey="clickSearchButton3"/> + <seeElement selector="{{AdminUrlRewriteIndexSection.requestPathColumnValue('productformagetwo76287-dutch.html')}}" stepKey="seeUrlInRequestPathColumn3"/> + <seeElement selector="{{AdminUrlRewriteIndexSection.targetPathColumnValue('catalog/product/view/id/$grabProductIdFromUrl')}}" stepKey="seeUrlInTargetPathColumn3"/> + + <fillField selector="{{AdminUrlRewriteIndexSection.requestPathFilter}}" userInput="category-english/productformagetwo76287-english.html" stepKey="inputProductUrlForENStoreView2"/> + <click selector="{{AdminDataGridHeaderSection.applyFilters}}" stepKey="clickSearchButton4"/> + <seeElement selector="{{AdminUrlRewriteIndexSection.requestPathColumnValue('category-english/productformagetwo76287-english.html')}}" stepKey="seeUrlInRequestPathColumn4"/> + <seeElement selector="{{AdminUrlRewriteIndexSection.targetPathColumnValue(catalog/product/view/id/$grabProductIdFromUrl/category/$$createCategory.id$$)}}" stepKey="seeUrlInTargetPathColumn4"/> + + <fillField selector="{{AdminUrlRewriteIndexSection.requestPathFilter}}" userInput="category-dutch/productformagetwo76287-dutch.html" stepKey="inputProductUrlForENStoreView3"/> + <click selector="{{AdminDataGridHeaderSection.applyFilters}}" stepKey="clickSearchButton5"/> + <seeElement selector="{{AdminUrlRewriteIndexSection.requestPathColumnValue('category-dutch/productformagetwo76287-dutch.html')}}" stepKey="seeUrlInRequestPathColumn5"/> + <seeElement selector="{{AdminUrlRewriteIndexSection.targetPathColumnValue(catalog/product/view/id/$grabProductIdFromUrl/category/$$createCategory.id$$)}}" stepKey="seeUrlInTargetPathColumn5"/> + </test> +</tests> diff --git a/app/code/Magento/UrlRewrite/Test/Unit/Model/Storage/DbStorageTest.php b/app/code/Magento/UrlRewrite/Test/Unit/Model/Storage/DbStorageTest.php index 408bcaf7a489e..08fe9a6375b24 100644 --- a/app/code/Magento/UrlRewrite/Test/Unit/Model/Storage/DbStorageTest.php +++ b/app/code/Magento/UrlRewrite/Test/Unit/Model/Storage/DbStorageTest.php @@ -478,10 +478,6 @@ public function testReplace() ->with(DbStorage::TABLE_NAME) ->will($this->returnValue('table_name')); - $this->connectionMock->expects($this->any()) - ->method('query') - ->with('sql delete query'); - // insert $urlFirst->expects($this->any()) @@ -496,10 +492,6 @@ public function testReplace() ->with(DbStorage::TABLE_NAME) ->will($this->returnValue('table_name')); - $this->connectionMock->expects($this->once()) - ->method('insertMultiple') - ->with('table_name', [['row1'], ['row2']]); - $this->storage->replace([$urlFirst, $urlSecond]); } diff --git a/app/code/Magento/UrlRewrite/composer.json b/app/code/Magento/UrlRewrite/composer.json index 8f8b819d1a62a..6247c47182804 100644 --- a/app/code/Magento/UrlRewrite/composer.json +++ b/app/code/Magento/UrlRewrite/composer.json @@ -12,7 +12,7 @@ "magento/module-cms-url-rewrite": "100.2.*" }, "type": "magento2-module", - "version": "101.0.5", + "version": "101.0.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/User/Test/Mftf/ActionGroup/AdminAssignUserRoleActionGroup.xml b/app/code/Magento/User/Test/Mftf/ActionGroup/AdminAssignUserRoleActionGroup.xml index 3da939b5fa2c6..c154bc89bb893 100644 --- a/app/code/Magento/User/Test/Mftf/ActionGroup/AdminAssignUserRoleActionGroup.xml +++ b/app/code/Magento/User/Test/Mftf/ActionGroup/AdminAssignUserRoleActionGroup.xml @@ -20,7 +20,7 @@ <see selector="{{AdminUserGridSection.usernameInFirstRow}}" userInput="{{user_restricted.username}}" stepKey="seeFoundUsername"/> <click selector="{{AdminUserGridSection.searchResultFirstRow}}" stepKey="clickFoundUsername"/> <waitForPageLoad time="30" stepKey="waitUserEditPage"/> - <seeInField selector="{{AdminEditUserSection.usernameTextField}}" userInput="{{user_restricted.username}}" stepKey="seeUsernameInField"/> + <seeInField selector="{{AdminEditUserSection.username}}" userInput="{{user_restricted.username}}" stepKey="seeUsernameInField"/> <fillField selector="{{AdminEditUserSection.currentPasswordField}}" userInput="{{_ENV.MAGENTO_ADMIN_PASSWORD}}" stepKey="fillCurrentPassword"/> <scrollToTopOfPage stepKey="scrollToTopOfPage"/> <click selector="{{AdminEditUserSection.userRoleTab}}" stepKey="clickUserRoleTab"/> @@ -30,8 +30,7 @@ <waitForPageLoad time="10" stepKey="waitForRoleGrid"/> <see selector="{{AdminEditUserSection.roleNameInFirstRow}}" userInput="{{roleName}}" stepKey="seeFoundRoleName"/> <click selector="{{AdminEditUserSection.searchResultFirstRow}}" stepKey="clickFoundRoleName"/> - <click selector="{{AdminEditUserSection.saveButton}}" stepKey="clickSaveButton"/> - <waitForPageLoad time="10" stepKey="waitUserSaved"/> + <click selector="{{AdminMainActionsSection.save}}" stepKey="clickSaveButton"/> <see selector="{{AdminUserGridSection.successMessage}}" userInput="You saved the user." stepKey="saveUserSuccessMessage"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/User/Test/Mftf/ActionGroup/AdminCreateUserActionGroup.xml b/app/code/Magento/User/Test/Mftf/ActionGroup/AdminCreateUserActionGroup.xml new file mode 100644 index 0000000000000..00634c34dd080 --- /dev/null +++ b/app/code/Magento/User/Test/Mftf/ActionGroup/AdminCreateUserActionGroup.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminCreateUserActionGroup"> + <arguments> + <argument name="role"/> + <argument name="user" defaultValue="restrictedWebUser"/> + </arguments> + <amOnPage url="{{AdminNewUserPage.url}}" stepKey="navigateToNewUser"/> + <fillField selector="{{AdminEditUserSection.username}}" userInput="{{user.username}}" stepKey="enterUserName" /> + <fillField selector="{{AdminEditUserSection.firstName}}" userInput="{{user.firstName}}" stepKey="enterFirstName" /> + <fillField selector="{{AdminEditUserSection.lastName}}" userInput="{{user.lastName}}" stepKey="enterLastName" /> + <fillField selector="{{AdminEditUserSection.email}}" userInput="{{user.username}}@magento.com" stepKey="enterEmail" /> + <fillField selector="{{AdminEditUserSection.password}}" userInput="{{user.password}}" stepKey="enterPassword" /> + <fillField selector="{{AdminEditUserSection.confirmation}}" userInput="{{user.password}}" stepKey="confirmPassword" /> + <fillField selector="{{AdminEditUserSection.currentPasswordField}}" userInput="{{_ENV.MAGENTO_ADMIN_PASSWORD}}" stepKey="enterCurrentPassword" /> + <scrollToTopOfPage stepKey="scrollToTopOfPage" /> + <click selector="{{AdminEditUserSection.userRoleTab}}" stepKey="clickUserRole" /> + <fillField selector="{{AdminEditUserSection.roleNameFilterTextField}}" userInput="{{role.name}}" stepKey="filterRole" /> + <click selector="{{AdminDataGridHeaderSection.applyFilters}}" stepKey="clickSearch" /> + <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskToDisappear1"/> + <click selector="{{AdminRoleGridSection.searchResultFirstRow}}" stepKey="selectRole" /> + <click selector="{{AdminMainActionsSection.save}}" stepKey="clickSaveUser" /> + <see userInput="You saved the user." stepKey="seeSuccessMessage" /> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/User/Test/Mftf/ActionGroup/AdminDeleteUserActionGroup.xml b/app/code/Magento/User/Test/Mftf/ActionGroup/AdminDeleteUserActionGroup.xml index 492bad166b61a..30a9985436c0e 100644 --- a/app/code/Magento/User/Test/Mftf/ActionGroup/AdminDeleteUserActionGroup.xml +++ b/app/code/Magento/User/Test/Mftf/ActionGroup/AdminDeleteUserActionGroup.xml @@ -20,10 +20,9 @@ <click selector="{{AdminUserGridSection.searchResultFirstRow}}" stepKey="openUserEdit"/> <waitForPageLoad stepKey="waitForUserEditPageLoad"/> <fillField selector="{{AdminEditUserSection.currentPasswordField}}" userInput="{{_ENV.MAGENTO_ADMIN_PASSWORD}}" stepKey="enterThePassword" /> - <click selector="{{AdminEditUserSection.deleteButton}}" stepKey="deleteUser"/> + <click selector="{{AdminMainActionsSection.delete}}" stepKey="deleteUser"/> <waitForElementVisible selector="{{AdminConfirmationModalSection.message}}" stepKey="waitForConfirmModal"/> <click selector="{{AdminConfirmationModalSection.ok}}" stepKey="confirmDelete"/> - <waitForPageLoad stepKey="waitForSave" /> <see userInput="You deleted the user." stepKey="seeUserDeleteMessage"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/User/Test/Mftf/Data/UserData.xml b/app/code/Magento/User/Test/Mftf/Data/UserData.xml index 751378da6c15d..fc8652442900b 100644 --- a/app/code/Magento/User/Test/Mftf/Data/UserData.xml +++ b/app/code/Magento/User/Test/Mftf/Data/UserData.xml @@ -7,7 +7,7 @@ --> <entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/DataGenerator/etc/dataProfileSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> <entity name="admin" type="user"> <data key="email">admin@magento.com</data> <data key="password">admin123</data> @@ -23,4 +23,18 @@ <data key="is_active">true</data> <data key="current_password">123123q</data> </entity> + <entity name="Admin3" type="user"> + <data key="username" unique="suffix">admin3</data> + <data key="firstname">admin3</data> + <data key="lastname">admin3</data> + <data key="email" unique="prefix">admin3WebUser@example.com</data> + <data key="password">123123q</data> + <data key="password_confirmation">123123q</data> + <data key="interface_local">en_US</data> + <data key="is_active">true</data> + <data key="current_password">123123q</data> + <array key="roles"> + <item>1</item> + </array> + </entity> </entities> diff --git a/app/code/Magento/User/Test/Mftf/Data/UserRoleData.xml b/app/code/Magento/User/Test/Mftf/Data/UserRoleData.xml index 64f64aeb5a1b2..104096df5813b 100644 --- a/app/code/Magento/User/Test/Mftf/Data/UserRoleData.xml +++ b/app/code/Magento/User/Test/Mftf/Data/UserRoleData.xml @@ -7,7 +7,7 @@ --> <entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/DataGenerator/etc/dataProfileSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/dataProfileSchema.xsd"> <entity name="adminProductInWebsiteRole" type="user_role"> <data key="rolename" unique="suffix">restrictedWebsiteRole</data> <data key="current_password">123123q</data> @@ -34,4 +34,16 @@ <data key="rolename" unique="suffix">RoleTest</data> <data key="current_password">123123q</data> </entity> + <entity name="adminMarketingInWebsiteRole" type="user_role"> + <data key="rolename" unique="suffix">restrictedWebsiteRole</data> + <data key="current_password">123123q</data> + <data key="gws_is_all">0</data> + <array key="gws_websites"> + <item>1</item> + </array> + <array key="resource"> + <item>Magento_Backend::marketing</item> + <item>Magento_SalesRule::quote</item> + </array> + </entity> </entities> diff --git a/app/code/Magento/User/Test/Mftf/Metadata/user-meta.xml b/app/code/Magento/User/Test/Mftf/Metadata/user-meta.xml index 9f8050983b215..1ee29be6b3d76 100644 --- a/app/code/Magento/User/Test/Mftf/Metadata/user-meta.xml +++ b/app/code/Magento/User/Test/Mftf/Metadata/user-meta.xml @@ -6,7 +6,7 @@ */ --> <operations xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/DataGenerator/etc/dataOperation.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataOperation.xsd"> <operation name="CreateUser" dataType="user" type="create" auth="adminFormKey" url="/admin/user/save/" method="POST" successRegex="/messages-message-success/" returnRegex="" > <contentType>application/x-www-form-urlencoded</contentType> @@ -19,5 +19,8 @@ <field key="interface_locale">string</field> <field key="is_active">boolean</field> <field key="current_password">string</field> + <array key="roles"> + <value>string</value> + </array> </operation> </operations> diff --git a/app/code/Magento/User/Test/Mftf/Page/AdminNewUserPage.xml b/app/code/Magento/User/Test/Mftf/Page/AdminNewUserPage.xml new file mode 100644 index 0000000000000..ad4e3edc7cfe6 --- /dev/null +++ b/app/code/Magento/User/Test/Mftf/Page/AdminNewUserPage.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminNewUserPage" url="admin/user/new" area="admin" module="Magento_User"> + <section name="AdminEditUserSection"/> + </page> +</pages> diff --git a/app/code/Magento/User/Test/Mftf/Section/AdminEditUserSection.xml b/app/code/Magento/User/Test/Mftf/Section/AdminEditUserSection.xml index 2ba9e96ab2d29..d7bd2034fb0d0 100644 --- a/app/code/Magento/User/Test/Mftf/Section/AdminEditUserSection.xml +++ b/app/code/Magento/User/Test/Mftf/Section/AdminEditUserSection.xml @@ -5,9 +5,14 @@ * See COPYING.txt for license details. */ --> -<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> +<sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminEditUserSection"> - <element name="usernameTextField" type="input" selector="#user_username"/> + <element name="username" type="input" selector="#user_username"/> + <element name="firstName" type="input" selector="#user_firstname"/> + <element name="lastName" type="input" selector="#user_lastname"/> + <element name="email" type="input" selector="#user_email"/> + <element name="password" type="input" selector="#user_password"/> + <element name="confirmation" type="input" selector="#user_confirmation"/> <element name="currentPasswordField" type="input" selector="#user_current_password"/> <element name="userRoleTab" type="button" selector="#page_tabs_roles_section"/> <element name="roleNameFilterTextField" type="input" selector="#permissionsUserRolesGrid_filter_role_name"/> @@ -15,7 +20,5 @@ <element name="resetButton" type="button" selector="button[title='Reset Filter']"/> <element name="roleNameInFirstRow" type="text" selector=".col-role_name"/> <element name="searchResultFirstRow" type="text" selector=".data-grid>tbody>tr"/> - <element name="saveButton" type="button" selector="#save"/> - <element name="deleteButton" type="button" selector="#delete"/> </section> </sections> diff --git a/app/code/Magento/User/composer.json b/app/code/Magento/User/composer.json index 4ff87f2a704a3..431f33014d1a1 100644 --- a/app/code/Magento/User/composer.json +++ b/app/code/Magento/User/composer.json @@ -12,7 +12,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "101.0.4", + "version": "101.0.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Usps/Model/Carrier.php b/app/code/Magento/Usps/Model/Carrier.php index 1e8faf3cc9614..afe34cac575ac 100644 --- a/app/code/Magento/Usps/Model/Carrier.php +++ b/app/code/Magento/Usps/Model/Carrier.php @@ -1246,7 +1246,7 @@ protected function _getCountryName($countryId) 'FO' => 'Faroe Islands', 'FR' => 'France', 'GA' => 'Gabon', - 'GB' => 'Great Britain and Northern Ireland', + 'GB' => 'United Kingdom of Great Britain and Northern Ireland', 'GD' => 'Grenada', 'GE' => 'Georgia, Republic of', 'GF' => 'French Guiana', @@ -1364,7 +1364,7 @@ protected function _getCountryName($countryId) 'ST' => 'Sao Tome and Principe', 'SV' => 'El Salvador', 'SY' => 'Syrian Arab Republic', - 'SZ' => 'Swaziland', + 'SZ' => 'Eswatini', 'TC' => 'Turks and Caicos Islands', 'TD' => 'Chad', 'TG' => 'Togo', diff --git a/app/code/Magento/Usps/composer.json b/app/code/Magento/Usps/composer.json index dd4231457a774..8cbc56b96943a 100644 --- a/app/code/Magento/Usps/composer.json +++ b/app/code/Magento/Usps/composer.json @@ -15,7 +15,7 @@ "lib-libxml": "*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Variable/composer.json b/app/code/Magento/Variable/composer.json index 473df42feae8b..bfa87d1d5c935 100644 --- a/app/code/Magento/Variable/composer.json +++ b/app/code/Magento/Variable/composer.json @@ -9,7 +9,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.5", + "version": "100.2.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Vault/composer.json b/app/code/Magento/Vault/composer.json index b285718b22b50..fa6ffe8f1d884 100644 --- a/app/code/Magento/Vault/composer.json +++ b/app/code/Magento/Vault/composer.json @@ -12,7 +12,7 @@ "magento/module-quote": "101.0.*" }, "type": "magento2-module", - "version": "101.0.4", + "version": "101.0.5", "license": [ "proprietary" ], diff --git a/app/code/Magento/Version/composer.json b/app/code/Magento/Version/composer.json index 7f015cacb728c..d8bfd8061aad1 100644 --- a/app/code/Magento/Version/composer.json +++ b/app/code/Magento/Version/composer.json @@ -6,7 +6,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Webapi/composer.json b/app/code/Magento/Webapi/composer.json index 8a0dd448c22e0..51c0302b5feab 100644 --- a/app/code/Magento/Webapi/composer.json +++ b/app/code/Magento/Webapi/composer.json @@ -14,7 +14,7 @@ "magento/module-customer": "101.0.*" }, "type": "magento2-module", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/WebapiSecurity/composer.json b/app/code/Magento/WebapiSecurity/composer.json index 24647ea1f980f..6a2c3469f670a 100644 --- a/app/code/Magento/WebapiSecurity/composer.json +++ b/app/code/Magento/WebapiSecurity/composer.json @@ -7,7 +7,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Weee/composer.json b/app/code/Magento/Weee/composer.json index 206af1f6d150a..cdcf68fb5422a 100644 --- a/app/code/Magento/Weee/composer.json +++ b/app/code/Magento/Weee/composer.json @@ -21,7 +21,7 @@ "magento/module-bundle": "100.2.*" }, "type": "magento2-module", - "version": "100.2.3", + "version": "100.2.4", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Widget/Block/Adminhtml/Widget.php b/app/code/Magento/Widget/Block/Adminhtml/Widget.php index 975d206766b0c..56f97f58c3800 100644 --- a/app/code/Magento/Widget/Block/Adminhtml/Widget.php +++ b/app/code/Magento/Widget/Block/Adminhtml/Widget.php @@ -12,11 +12,12 @@ * * @api * @since 100.0.2 + * @SuppressWarnings(PHPMD.RequestAwareBlockMethod) */ class Widget extends \Magento\Backend\Block\Widget\Form\Container { /** - * @return void + * @inheritdoc */ protected function _construct() { @@ -36,12 +37,16 @@ protected function _construct() $this->buttonList->update('save', 'region', 'footer'); $this->buttonList->update('save', 'data_attribute', []); - $this->_formScripts[] = 'require(["mage/adminhtml/wysiwyg/widget"], function(){wWidget = new WysiwygWidget.Widget(' . - '"widget_options_form", "select_widget_type", "widget_options", "' . - $this->getUrl( - 'adminhtml/*/loadOptions' - ) . '", "' . $this->getRequest()->getParam( - 'widget_target_id' - ) . '");});'; + $this->_formScripts[] = <<<EOJS + require(['mage/adminhtml/wysiwyg/widget'], function() { + wWidget = new WysiwygWidget.Widget( + 'widget_options_form', + 'select_widget_type', + 'widget_options', + '{$this->getUrl('adminhtml/*/loadOptions')}', + '{$this->escapeJs($this->getRequest()->getParam('widget_target_id'))}' + ); + }); +EOJS; } } diff --git a/app/code/Magento/Widget/Model/Widget.php b/app/code/Magento/Widget/Model/Widget.php index f83fadf694784..8f0b31372b7b5 100644 --- a/app/code/Magento/Widget/Model/Widget.php +++ b/app/code/Magento/Widget/Model/Widget.php @@ -315,7 +315,7 @@ public function getWidgetDeclaration($type, $params = [], $asIs = true) } } if (isset($value)) { - $directive .= sprintf(' %s="%s"', $name, $this->escaper->escapeQuote($value)); + $directive .= sprintf(' %s="%s"', $name, $this->escaper->escapeHtmlAttr($value, false)); } } diff --git a/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminCreateWidgetActionGroup.xml b/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminCreateWidgetActionGroup.xml index b6dfc00c0ff9e..dfb33b197d74f 100644 --- a/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminCreateWidgetActionGroup.xml +++ b/app/code/Magento/Widget/Test/Mftf/ActionGroup/AdminCreateWidgetActionGroup.xml @@ -7,58 +7,60 @@ --> <actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Test/etc/actionGroupSchema.xsd"> - <actionGroup name="AdminCreateProductsListWidgetActionGroup"> - <arguments> - <argument name="widget"/> - </arguments> - <amOnPage url="{{AdminDashboardPage.url}}" stepKey="amOnAdminDashboard"/> - <click selector="{{AdminMenuSection.content}}" stepKey="clickContent"/> - <waitForLoadingMaskToDisappear stepKey="waitForWidgets" /> - <click selector="{{AdminMenuSection.widgets}}" stepKey="clickWidgets"/> - <waitForPageLoad stepKey="waitForWidgetsLoad"/> - <click selector="{{AdminMainActionsSection.add}}" stepKey="addNewWidget"/> - <selectOption selector="{{AdminNewWidgetSection.widgetType}}" userInput="{{widget.type}}" stepKey="setWidgetType"/> - <selectOption selector="{{AdminNewWidgetSection.widgetDesignTheme}}" userInput="{{widget.design_theme}}" stepKey="setWidgetDesignTheme"/> - <click selector="{{AdminNewWidgetSection.continue}}" stepKey="clickContinue"/> - <fillField selector="{{AdminNewWidgetSection.widgetTitle}}" userInput="{{widget.name}}" stepKey="fillTitle"/> - <selectOption selector="{{AdminNewWidgetSection.widgetStoreIds}}" userInput="{{widget.store_ids[0]}}" stepKey="setWidgetStoreIds"/> - <click selector="{{AdminNewWidgetSection.addLayoutUpdate}}" stepKey="clickAddLayoutUpdate"/> - <selectOption selector="{{AdminNewWidgetSection.selectDisplayOn}}" userInput="{{widget.display_on}}" stepKey="setDisplayOn"/> - <waitForAjaxLoad stepKey="waitForLoad"/> - <selectOption selector="{{AdminNewWidgetSection.selectContainer}}" userInput="{{widget.container}}" stepKey="setContainer"/> - <waitForAjaxLoad stepKey="waitForPageLoad"/> - <scrollToTopOfPage stepKey="scrollToTopOfPage"/> - <click selector="{{AdminNewWidgetSection.widgetOptions}}" stepKey="clickWidgetOptions"/> - <click selector="{{AdminNewWidgetSection.addNewCondition}}" stepKey="clickAddNewCondition"/> - <selectOption selector="{{AdminNewWidgetSection.selectCondition}}" userInput="{{widget.condition}}" stepKey="selectCondition"/> - <waitForElement selector="{{AdminNewWidgetSection.ruleParameter}}" stepKey="waitRuleParameter"/> - <click selector="{{AdminNewWidgetSection.ruleParameter}}" stepKey="clickRuleParameter"/> - <click selector="{{AdminNewWidgetSection.openChooser}}" stepKey="clickChooser"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoadChooser"/> - <click selector="{{AdminNewWidgetSection.sortById}}" stepKey="clickSortById"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoadingMaskDisappear"/> - <click selector="{{AdminNewWidgetSection.sortByIdAscend}}" stepKey="clickSortByIdAscend"/> - <waitForLoadingMaskToDisappear stepKey="waitForLoadingMask"/> - <click selector="{{AdminNewWidgetSection.selectAll}}" stepKey="clickSelectAll"/> - <click selector="{{AdminNewWidgetSection.applyParameter}}" stepKey="clickApplyRuleParameter"/> - <click selector="{{AdminMainActionsSection.save}}" stepKey="clickSaveWidget"/> - <waitForPageLoad stepKey="waitForSaveLoad"/> - <see selector="{{AdminMessagesSection.successMessage}}" userInput="The widget instance has been saved" stepKey="seeSuccess"/> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminCreateWidgetActionGroup"> + <arguments> + <argument name="widget"/> + </arguments> + <amOnPage url="{{AdminNewWidgetPage.url}}" stepKey="amOnAdminNewWidgetPage"/> + <selectOption selector="{{AdminNewWidgetSection.widgetType}}" userInput="{{widget.type}}" stepKey="setWidgetType"/> + <selectOption selector="{{AdminNewWidgetSection.widgetDesignTheme}}" userInput="{{widget.design_theme}}" stepKey="setWidgetDesignTheme"/> + <click selector="{{AdminNewWidgetSection.continue}}" stepKey="clickContinue"/> + <fillField selector="{{AdminNewWidgetSection.widgetTitle}}" userInput="{{widget.name}}" stepKey="fillTitle"/> + <selectOption selector="{{AdminNewWidgetSection.widgetStoreIds}}" userInput="{{widget.store_ids[0]}}" stepKey="setWidgetStoreIds"/> + <click selector="{{AdminNewWidgetSection.addLayoutUpdate}}" stepKey="clickAddLayoutUpdate"/> + <selectOption selector="{{AdminNewWidgetSection.selectDisplayOn}}" userInput="{{widget.display_on}}" stepKey="setDisplayOn"/> + <waitForAjaxLoad stepKey="waitForLoad"/> + <selectOption selector="{{AdminNewWidgetSection.selectContainer}}" userInput="{{widget.container}}" stepKey="setContainer"/> + <waitForAjaxLoad stepKey="waitForPageLoad"/> + <scrollToTopOfPage stepKey="scrollToTopOfPage"/> + <click selector="{{AdminNewWidgetSection.widgetOptions}}" stepKey="clickWidgetOptions"/> </actionGroup> + + <!--Create Product List Widget--> + <actionGroup name="AdminCreateProductsListWidgetActionGroup" extends="AdminCreateWidgetActionGroup"> + <click selector="{{AdminNewWidgetSection.addNewCondition}}" stepKey="clickAddNewCondition"/> + <selectOption selector="{{AdminNewWidgetSection.selectCondition}}" userInput="{{widget.condition}}" stepKey="selectCondition"/> + <waitForElement selector="{{AdminNewWidgetSection.ruleParameter}}" stepKey="waitRuleParameter"/> + <click selector="{{AdminNewWidgetSection.ruleParameter}}" stepKey="clickRuleParameter"/> + <click selector="{{AdminNewWidgetSection.openChooser}}" stepKey="clickChooser"/> + <waitForPageLoad stepKey="waitForAjaxLoad"/> + <click selector="{{AdminNewWidgetSection.selectAll}}" stepKey="clickSelectAll"/> + <click selector="{{AdminNewWidgetSection.applyParameter}}" stepKey="clickApplyRuleParameter"/> + <click selector="{{AdminMainActionsSection.save}}" stepKey="clickSaveWidget"/> + <see selector="{{AdminMessagesSection.successMessage}}" userInput="The widget instance has been saved" stepKey="seeSuccess"/> + </actionGroup> + + <!--Create Dynamic Block Rotate Widget--> + <actionGroup name="AdminCreateDynamicBlocksRotatorWidgetActionGroup" extends="AdminCreateWidgetActionGroup"> + <selectOption selector="{{AdminNewWidgetSection.displayMode}}" userInput="{{widget.display_mode}}" stepKey="selectDisplayMode"/> + <selectOption selector="{{AdminNewWidgetSection.restrictTypes}}" userInput="{{widget.restrict_type}}" stepKey="selectRestrictType"/> + <click selector="{{AdminMainActionsSection.saveAndContinue}}" stepKey="clickSaveWidget"/> + <see selector="{{AdminMessagesSection.successMessage}}" userInput="The widget instance has been saved" stepKey="seeSuccess"/> + </actionGroup> + <actionGroup name="AdminDeleteWidgetActionGroup"> - <arguments> - <argument name="widget"/> - </arguments> - <amOnPage url="{{AdminWidgetsPage.url}}" stepKey="amOnAdmin"/> - <waitForPageLoad stepKey="waitWidgetsLoad"/> - <fillField selector="{{AdminWidgetsSection.widgetTitleSearch}}" userInput="{{widget.name}}" stepKey="fillTitle"/> - <click selector="{{AdminWidgetsSection.searchButton}}" stepKey="clickContinue"/> - <click selector="{{AdminWidgetsSection.searchResult}}" stepKey="clickSearchResult"/> - <waitForPageLoad stepKey="waitForResultLoad"/> - <click selector="{{AdminMainActionsSection.delete}}" stepKey="clickDelete"/> - <waitForAjaxLoad stepKey="waitForAjaxLoad"/> - <click selector="{{AdminConfirmationModalSection.ok}}" stepKey="confirmDelete"/> - <see selector="{{AdminMessagesSection.successMessage}}" userInput="The widget instance has been deleted" stepKey="seeSuccess"/> + <arguments> + <argument name="widget"/> + </arguments> + <amOnPage url="{{AdminWidgetsPage.url}}" stepKey="amOnAdmin"/> + <fillField selector="{{AdminWidgetsSection.widgetTitleSearch}}" userInput="{{widget.name}}" stepKey="fillTitle"/> + <click selector="{{AdminWidgetsSection.searchButton}}" stepKey="clickContinue"/> + <click selector="{{AdminWidgetsSection.searchResult}}" stepKey="clickSearchResult"/> + <waitForPageLoad stepKey="waitForResultLoad"/> + <click selector="{{AdminMainActionsSection.delete}}" stepKey="clickDelete"/> + <waitForElementVisible selector="{{AdminConfirmationModalSection.ok}}" stepKey="waitForAjaxLoad"/> + <click selector="{{AdminConfirmationModalSection.ok}}" stepKey="confirmDelete"/> + <see selector="{{AdminMessagesSection.successMessage}}" userInput="The widget instance has been deleted" stepKey="seeSuccess"/> </actionGroup> </actionGroups> diff --git a/app/code/Magento/Widget/Test/Mftf/Data/WidgetsData.xml b/app/code/Magento/Widget/Test/Mftf/Data/WidgetsData.xml index 26864c60b6494..d7db8fe50cb7f 100644 --- a/app/code/Magento/Widget/Test/Mftf/Data/WidgetsData.xml +++ b/app/code/Magento/Widget/Test/Mftf/Data/WidgetsData.xml @@ -7,7 +7,7 @@ --> <entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/DataGenerator/etc/dataProfileSchema.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:DataGenerator/etc/dataProfileSchema.xsd"> <entity name="ProductsListWidget" type="widget"> <data key="type">Catalog Products List</data> <data key="design_theme">Magento Luma</data> @@ -19,4 +19,17 @@ <data key="display_on">All Pages</data> <data key="container">Main Content Area</data> </entity> + <entity name="DynamicBlocksRotatorWidget" type="widget"> + <data key="type">Banner Rotator</data> + <data key="design_theme">Magento Luma</data> + <data key="name" unique="suffix">TestBannerWidget</data> + <array key="store_ids"> + <item>All Store Views</item> + </array> + <data key="condition">SKU</data> + <data key="display_on">All Pages</data> + <data key="container">Main Content Area</data> + <data key="display_mode">salesrule</data> + <data key="restrict_type">header</data> + </entity> </entities> diff --git a/app/code/Magento/Widget/Test/Mftf/Page/AdminNewWidgetPage.xml b/app/code/Magento/Widget/Test/Mftf/Page/AdminNewWidgetPage.xml index 8eb0a5f65318e..d495a36f68d0a 100644 --- a/app/code/Magento/Widget/Test/Mftf/Page/AdminNewWidgetPage.xml +++ b/app/code/Magento/Widget/Test/Mftf/Page/AdminNewWidgetPage.xml @@ -7,8 +7,8 @@ --> <pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/PageObject.xsd"> - <page name="AdminNewWidgetPage" url="admin/admin/widget_instance/new/" area="admin" module="Magento_Widget"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminNewWidgetPage" url="admin/widget_instance/new/" area="admin" module="Magento_Widget"> <section name="AdminNewWidgetSection"/> </page> </pages> diff --git a/app/code/Magento/Widget/Test/Mftf/Section/AdminNewWidgetSection.xml b/app/code/Magento/Widget/Test/Mftf/Section/AdminNewWidgetSection.xml index b96d3865a6661..37ad425114d5c 100644 --- a/app/code/Magento/Widget/Test/Mftf/Section/AdminNewWidgetSection.xml +++ b/app/code/Magento/Widget/Test/Mftf/Section/AdminNewWidgetSection.xml @@ -7,7 +7,7 @@ --> <sections xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="../../../../../../../dev/tests/acceptance/vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Page/etc/SectionObject.xsd"> + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/SectionObject.xsd"> <section name="AdminNewWidgetSection"> <element name="widgetType" type="select" selector="#code"/> <element name="widgetDesignTheme" type="select" selector="#theme_id"/> @@ -27,5 +27,11 @@ <element name="selectAll" type="checkbox" selector=".admin__control-checkbox"/> <element name="sortById" type="button" selector="th.data-grid-th._sortable.not-sort.col-entity_id"/> <element name="sortByIdAscend" type="button" selector="th.data-grid-th._sortable._ascend.col-entity_id"/> + <element name="insertWidget" type="button" selector="#insert_button" timeout="30"/> + <element name="widgetTypeDropDown" type="select" selector="#select_widget_type"/> + <element name="conditionOperator" type="text" selector="//*[@id='conditions__1--1__attribute']/following-sibling::span[1]"/> + <element name="conditionOperatorSelect" type="select" selector="#conditions__1--{{arg1}}__operator" parameterized="true"/> + <element name="displayMode" type="select" selector="select[id*='display_mode']"/> + <element name="restrictTypes" type="select" selector="select[id*='types']"/> </section> </sections> diff --git a/app/code/Magento/Widget/Test/Mftf/Section/StorefrontWidgetsSection.xml b/app/code/Magento/Widget/Test/Mftf/Section/StorefrontWidgetsSection.xml index 23908626389f9..9d1944cc126b8 100644 --- a/app/code/Magento/Widget/Test/Mftf/Section/StorefrontWidgetsSection.xml +++ b/app/code/Magento/Widget/Test/Mftf/Section/StorefrontWidgetsSection.xml @@ -11,5 +11,6 @@ <section name="StorefrontWidgetsSection"> <element name="widgetProductsGrid" type="block" selector=".block.widget.block-products-list.grid"/> <element name="widgetProductName" type="text" selector=".product-item-name"/> + <element name="checkElementStorefrontByPrice" type="text" selector="//*[@class='product-items widget-product-grid']//*[contains(text(),'${{arg2}}.00')]" parameterized="true"/> </section> </sections> diff --git a/app/code/Magento/Widget/Test/Unit/Model/WidgetTest.php b/app/code/Magento/Widget/Test/Unit/Model/WidgetTest.php index 9f06b954cad5d..652d5e030bee6 100644 --- a/app/code/Magento/Widget/Test/Unit/Model/WidgetTest.php +++ b/app/code/Magento/Widget/Test/Unit/Model/WidgetTest.php @@ -175,7 +175,7 @@ public function testGetWidgetDeclaration() $this->conditionsHelper->expects($this->once())->method('encode')->with($conditions) ->willReturn('encoded-conditions-string'); $this->escaperMock->expects($this->atLeastOnce()) - ->method('escapeQuote') + ->method('escapeHtmlAttr') ->willReturnMap([ ['my "widget"', false, 'my "widget"'], ['1', false, '1'], diff --git a/app/code/Magento/Widget/composer.json b/app/code/Magento/Widget/composer.json index 23ee88072fbd5..eb3e0af2b2bc6 100644 --- a/app/code/Magento/Widget/composer.json +++ b/app/code/Magento/Widget/composer.json @@ -16,7 +16,7 @@ "magento/module-widget-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "101.0.4", + "version": "101.0.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Wishlist/Block/Customer/Wishlist/Item/Column/Cart.php b/app/code/Magento/Wishlist/Block/Customer/Wishlist/Item/Column/Cart.php index b043a8d4b684c..1ba31b26df46e 100644 --- a/app/code/Magento/Wishlist/Block/Customer/Wishlist/Item/Column/Cart.php +++ b/app/code/Magento/Wishlist/Block/Customer/Wishlist/Item/Column/Cart.php @@ -6,6 +6,8 @@ namespace Magento\Wishlist\Block\Customer\Wishlist\Item\Column; +use Magento\Catalog\Controller\Adminhtml\Product\Initialization\StockDataFilter; + /** * Wishlist block customer item cart column * @@ -35,4 +37,28 @@ public function getProductItem() { return $this->getItem()->getProduct(); } + + /** + * Get min and max qty for wishlist form. + * + * @return array + */ + public function getMinMaxQty(): array + { + $stockItem = $this->stockRegistry->getStockItem( + $this->getItem()->getProduct()->getId(), + $this->getItem()->getProduct()->getStore()->getWebsiteId() + ); + + $params = []; + + $params['minAllowed'] = (float)$stockItem->getMinSaleQty(); + if ($stockItem->getMaxSaleQty()) { + $params['maxAllowed'] = (float)$stockItem->getMaxSaleQty(); + } else { + $params['maxAllowed'] = (float)StockDataFilter::MAX_QTY_VALUE; + } + + return $params; + } } diff --git a/app/code/Magento/Wishlist/Test/Mftf/ActionGroup/StorefrontCustomerWishlistActionGroup.xml b/app/code/Magento/Wishlist/Test/Mftf/ActionGroup/StorefrontCustomerWishlistActionGroup.xml index 87a77526e0af5..920b387441ada 100644 --- a/app/code/Magento/Wishlist/Test/Mftf/ActionGroup/StorefrontCustomerWishlistActionGroup.xml +++ b/app/code/Magento/Wishlist/Test/Mftf/ActionGroup/StorefrontCustomerWishlistActionGroup.xml @@ -87,4 +87,26 @@ <click selector="{{StorefrontCustomerWishlistProductSection.productUpdateWishList}}" stepKey="submitUpdateWishlist"/> <see selector="{{StorefrontMessagesSection.success}}" userInput="{{product.name}} has been updated in your Wish List." stepKey="successMessage"/> </actionGroup> + + <actionGroup name="StorefrontCustomerEditProductInWishlistMakeQuantityValidationError"> + <arguments> + <argument name="product"/> + <argument name="description" type="string"/> + <argument name="quantity" type="string"/> + <argument name="errorNum" type="string"/> + </arguments> + <scrollToTopOfPage stepKey="scrollToTop1"/> + <moveMouseOver selector="{{StorefrontCustomerWishlistProductSection.productInfoByName(product.name)}}" stepKey="mouseOverOnProduct" /> + <fillField selector="{{StorefrontCustomerWishlistProductSection.productDescription(product.name)}}" userInput="{{description}}" stepKey="fillDescription"/> + <fillField selector="{{StorefrontCustomerWishlistProductSection.productQuantity(product.name)}}" userInput="{{quantity}}" stepKey="fillQuantity"/> + <moveMouseOver selector="{{StorefrontCustomerWishlistProductSection.productAddAllToCart}}" stepKey="mouseOver1"/> + <click selector="{{StorefrontCustomerWishlistProductSection.productUpdateWishList}}" stepKey="clickAddToWishlistButton"/> + <waitForElement selector="{{StorefrontCustomerWishlistProductSection.productQtyError(product.name)}}" stepKey="waitForErrorMessage"/> + <scrollToTopOfPage stepKey="scrollToTop2"/> + + <!--Check error message--> + <moveMouseOver selector="{{StorefrontCustomerWishlistProductSection.productInfoByName(product.name)}}" stepKey="wishlistMoveMouseOverProduct" /> + <see selector="{{StorefrontCustomerWishlistProductSection.productQtyError(product.name)}}" userInput="The maximum you may purchase is {{errorNum}}." stepKey="checkQtyError"/> + <moveMouseOver selector="{{StorefrontCustomerWishlistProductSection.productAddAllToCart}}" stepKey="mouseOver2"/> + </actionGroup> </actionGroups> diff --git a/app/code/Magento/Wishlist/Test/Mftf/Section/StorefrontCustomerWishlistProductSection.xml b/app/code/Magento/Wishlist/Test/Mftf/Section/StorefrontCustomerWishlistProductSection.xml index 88d68a460da74..ea9c4748f67b1 100644 --- a/app/code/Magento/Wishlist/Test/Mftf/Section/StorefrontCustomerWishlistProductSection.xml +++ b/app/code/Magento/Wishlist/Test/Mftf/Section/StorefrontCustomerWishlistProductSection.xml @@ -17,7 +17,9 @@ <element name="productImageByImageName" type="text" selector="//main//li//a//img[contains(@src, '{{var1}}')]" parameterized="true"/> <element name="productDescription" type="input" selector="//a[contains(text(), '{{productName}}')]/ancestor::div[@class='product-item-info']//textarea[@class='product-item-comment']" parameterized="true"/> <element name="productQuantity" type="input" selector="//a[contains(text(), '{{productName}}')]/ancestor::div[@class='product-item-info']//input[@class='input-text qty']" parameterized="true"/> + <element name="productEditButtonByName" type="button" selector="//li[.//a[contains(text(), '{{var1}}')]]//span[contains(text(), 'Edit')]" parameterized="true"/> <element name="productUpdateWishList" type="button" selector=".column.main .actions-toolbar .action.update" timeout="30"/> <element name="productAddAllToCart" type="button" selector=".column.main .actions-toolbar .action.tocart" timeout="30"/> + <element name="productQtyError" type="text" selector="//li[.//a[contains(text(), '{{var1}}')]]//div[@class='mage-error']" parameterized="true" timeout="30"/> </section> </sections> diff --git a/app/code/Magento/Wishlist/Test/Mftf/Test/StorefrontAddMultipleStoreProductsToWishlistTest.xml b/app/code/Magento/Wishlist/Test/Mftf/Test/StorefrontAddMultipleStoreProductsToWishlistTest.xml index 60745d15e7688..b9ea513288c81 100644 --- a/app/code/Magento/Wishlist/Test/Mftf/Test/StorefrontAddMultipleStoreProductsToWishlistTest.xml +++ b/app/code/Magento/Wishlist/Test/Mftf/Test/StorefrontAddMultipleStoreProductsToWishlistTest.xml @@ -12,7 +12,6 @@ <title value="Add products to wishlist from different stores"/> <description value="All products added to wishlist should be visible on any store. Even if product visibility was set to 'Not Visible Individually' for this store"/> <group value="wishlist"/> - <group value="skip" /><!-- Skipped; see MAGETWO-94100 --> </annotations> <before> <getData entity="DefaultRootCategoryGetter" stepKey="getDefaultRootCategory"/> diff --git a/app/code/Magento/Wishlist/Test/Mftf/Test/StorefrontCheckAmountLimitWishlistTest.xml b/app/code/Magento/Wishlist/Test/Mftf/Test/StorefrontCheckAmountLimitWishlistTest.xml new file mode 100644 index 0000000000000..3bc924a7e60fa --- /dev/null +++ b/app/code/Magento/Wishlist/Test/Mftf/Test/StorefrontCheckAmountLimitWishlistTest.xml @@ -0,0 +1,60 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontCheckAmountLimitWishlistTest"> + <annotations> + <stories value="MAGETWO-73613: My Wishlist - quantity input box issue"/> + <title value="Check amount limit for Wishlist"/> + <description value="Check amount limit for Wishlist with different config settings"/> + <features value="Wishlist"/> + <severity value="AVERAGE"/> + <testCaseId value="MAGETWO-96606"/> + <group value="wishlist"/> + </annotations> + <before> + <createData entity="SimpleSubCategory" stepKey="createCategory"/> + <createData entity="SimpleProduct" stepKey="createProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + </before> + <after> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <actionGroup ref="CustomerLogoutStorefrontActionGroup" stepKey="logoutCustomerAccount"/> + <deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/> + <createData entity="DefaultProductStockOptions" stepKey="changeToDefaultQtyAllowAmount"/> + </after> + <!--Login as Customer--> + <actionGroup ref="CustomerLoginOnStorefront" stepKey="loginToStorefrontAccount"> + <argument name="customer" value="$$createCustomer$$"/> + </actionGroup> + <!--Go to category page--> + <amOnPage url="{{StorefrontCategoryPage.url($$createCategory.name$$)}}" stepKey="goToCreateCategoryPage"/> + <!--Add created product to Wish List--> + <actionGroup ref="StorefrontCustomerAddCategoryProductToWishlistActionGroup" stepKey="addSimpleProduct1ToWishlist"> + <argument name="productVar" value="$$createProduct$$"/> + </actionGroup> + <actionGroup ref="StorefrontCustomerEditProductInWishlistMakeQuantityValidationError" stepKey="checkWishListError1"> + <argument name="product" value="$$createProduct$$"/> + <argument name="description" value="It`s my dream"/> + <argument name="quantity" value="1234567890"/> + <argument name="errorNum" value="10000"/> + </actionGroup> + <createData entity="ProductStockOptions" stepKey="changeDefaultQtyAllowAmount"/> + <!--Go to wishlist page--> + <amOnPage url="{{StorefrontCustomerWishlistPage.url}}" stepKey="amOnWishlist" /> + <actionGroup ref="StorefrontCustomerEditProductInWishlistMakeQuantityValidationError" stepKey="checkWishListError2"> + <argument name="product" value="$$createProduct$$"/> + <argument name="description" value="It`s my dream"/> + <argument name="quantity" value="1234567890"/> + <argument name="errorNum" value="99999999"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Wishlist/composer.json b/app/code/Magento/Wishlist/composer.json index 4db0e55d869fc..05cf40372517b 100644 --- a/app/code/Magento/Wishlist/composer.json +++ b/app/code/Magento/Wishlist/composer.json @@ -23,7 +23,7 @@ "magento/module-wishlist-sample-data": "Sample Data version:100.2.*" }, "type": "magento2-module", - "version": "101.0.4", + "version": "101.0.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/code/Magento/Wishlist/view/frontend/templates/item/column/cart.phtml b/app/code/Magento/Wishlist/view/frontend/templates/item/column/cart.phtml index 9ea0d1a823235..49c35331b7868 100644 --- a/app/code/Magento/Wishlist/view/frontend/templates/item/column/cart.phtml +++ b/app/code/Magento/Wishlist/view/frontend/templates/item/column/cart.phtml @@ -11,6 +11,7 @@ /** @var \Magento\Wishlist\Model\Item $item */ $item = $block->getItem(); $product = $item->getProduct(); +$allowedQty = $block->getMinMaxQty(); ?> <?php foreach ($block->getChildNames() as $childName): ?> <?= /* @noEscape */ $block->getLayout()->renderElement($childName, false) ?> @@ -21,7 +22,7 @@ $product = $item->getProduct(); <div class="field qty"> <label class="label" for="qty[<?= $block->escapeHtmlAttr($item->getId()) ?>]"><span><?= $block->escapeHtml(__('Qty')) ?></span></label> <div class="control"> - <input type="number" data-role="qty" id="qty[<?= $block->escapeHtmlAttr($item->getId()) ?>]" class="input-text qty" data-validate="{'required-number':true,'validate-greater-than-zero':true}" + <input type="number" data-role="qty" id="qty[<?= $block->escapeHtmlAttr($item->getId()) ?>]" class="input-text qty" data-validate="{'required-number':true,'validate-greater-than-zero':true, 'validate-item-quantity':{'minAllowed':<?= /* @noEscape */ $allowedQty['minAllowed'] ?>,'maxAllowed':<?= /* @noEscape */ $allowedQty['maxAllowed'] ?>}}" name="qty[<?= $block->escapeHtmlAttr($item->getId()) ?>]" value="<?= /* @noEscape */ (int)($block->getAddToCartQty($item) * 1) ?>"> </div> </div> diff --git a/app/code/Magento/Wishlist/view/frontend/web/js/add-to-wishlist.js b/app/code/Magento/Wishlist/view/frontend/web/js/add-to-wishlist.js index 7ce934317263b..cab130f7c2104 100644 --- a/app/code/Magento/Wishlist/view/frontend/web/js/add-to-wishlist.js +++ b/app/code/Magento/Wishlist/view/frontend/web/js/add-to-wishlist.js @@ -63,12 +63,6 @@ define([ isFileUploaded = false, self = this; - if (event.handleObj.selector == this.options.qtyInfo) { //eslint-disable-line eqeqeq - this._updateAddToWishlistButton({}); - event.stopPropagation(); - - return; - } $(event.handleObj.selector).each(function (index, element) { if ($(element).is('input[type=text]') || $(element).is('input[type=email]') || @@ -89,9 +83,7 @@ define([ } }); - if (isFileUploaded) { - this.bindFormSubmit(); - } + this.bindFormSubmit(isFileUploaded); this._updateAddToWishlistButton(dataToAdd); event.stopPropagation(); }, @@ -195,34 +187,45 @@ define([ /** * Bind form submit. + * + * @param {Boolean} isFileUploaded */ - bindFormSubmit: function () { + bindFormSubmit: function (isFileUploaded) { var self = this; $('[data-action="add-to-wishlist"]').on('click', function (event) { var element, params, form, action; - event.stopPropagation(); - event.preventDefault(); + if (!$($(self.options.qtyInfo).closest('form')).valid()) { + event.stopPropagation(); + event.preventDefault(); - element = $('input[type=file]' + self.options.customOptionsInfo); - params = $(event.currentTarget).data('post'); - form = $(element).closest('form'); - action = params.action; - - if (params.data.id) { - $('<input>', { - type: 'hidden', - name: 'id', - value: params.data.id - }).appendTo(form); + return; } - if (params.data.uenc) { - action += 'uenc/' + params.data.uenc; - } + if (isFileUploaded) { - $(form).attr('action', action).submit(); + element = $('input[type=file]' + self.options.customOptionsInfo); + params = $(event.currentTarget).data('post'); + form = $(element).closest('form'); + action = params.action; + + if (params.data.id) { + $('<input>', { + type: 'hidden', + name: 'id', + value: params.data.id + }).appendTo(form); + } + + if (params.data.uenc) { + action += 'uenc/' + params.data.uenc; + } + + $(form).attr('action', action).submit(); + event.stopPropagation(); + event.preventDefault(); + } }); } }); diff --git a/app/code/Magento/WishlistAnalytics/composer.json b/app/code/Magento/WishlistAnalytics/composer.json index 820430f6c7d17..6acee91b65ff2 100644 --- a/app/code/Magento/WishlistAnalytics/composer.json +++ b/app/code/Magento/WishlistAnalytics/composer.json @@ -7,7 +7,7 @@ "magento/module-wishlist": "101.0.*" }, "type": "magento2-module", - "version": "100.2.2", + "version": "100.2.3", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/design/adminhtml/Magento/backend/Magento_AdminNotification/web/css/source/_module.less b/app/design/adminhtml/Magento/backend/Magento_AdminNotification/web/css/source/_module.less index c1b684aef354f..afd91ed3dbde6 100644 --- a/app/design/adminhtml/Magento/backend/Magento_AdminNotification/web/css/source/_module.less +++ b/app/design/adminhtml/Magento/backend/Magento_AdminNotification/web/css/source/_module.less @@ -83,7 +83,7 @@ .message-system-short-wrapper { overflow: hidden; - padding: 0 1.5rem 0 @indent__l; + padding: 0 1.5rem 0 1rem; } .message-system-collapsible { diff --git a/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/_menu.less b/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/_menu.less index 8825f4ea3f5a2..42c9b289afdb7 100644 --- a/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/_menu.less +++ b/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/_menu.less @@ -15,9 +15,9 @@ @menu__background-color: @color-very-dark-grayish-orange; -@menu-logo__padding-bottom: 2.2rem; +@menu-logo__padding-bottom: 1.7rem; @menu-logo__outer-size: @menu-logo__padding-top + @menu-logo-img__height + @menu-logo__padding-bottom; -@menu-logo__padding-top: 1.2rem; +@menu-logo__padding-top: 1.7rem; @menu-logo-img__height: 4.1rem; @menu-logo-img__width: 3.5rem; @@ -187,12 +187,12 @@ display: block; } - // External link marker + // External link marker [target='_blank'] { &:after { &:extend(.abs-icon all); content: @icon-external-link__content; - font-size: 0.5rem; + font-size: .5rem; margin-left: @indent__xs; vertical-align: super; } @@ -271,9 +271,17 @@ &._show { > .submenu { + display: block; + float: left; + left: 100%; + max-width: 1640px; + min-height: 98.65%; + min-width: 100%; + overflow-x: scroll; + position: absolute; transform: translateX(0); visibility: visible; - z-index: @submenu__z-index; + z-index: 698; } } } diff --git a/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/main/_actions-bar.less b/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/main/_actions-bar.less index 07050c1e5111d..08434727ccc9c 100644 --- a/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/main/_actions-bar.less +++ b/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/main/_actions-bar.less @@ -44,7 +44,6 @@ .page-actions { @_page-action__indent: 1.3rem; - float: right; .page-main-actions & { &._fixed { diff --git a/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/main/_collapsible-blocks.less b/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/main/_collapsible-blocks.less index 6420738c6fb9b..248c7d2947174 100644 --- a/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/main/_collapsible-blocks.less +++ b/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/main/_collapsible-blocks.less @@ -127,8 +127,24 @@ } } } + td.admin__collapsible-block-wrapper { + .admin__collapsible-title { + &:before { + content: @icon-expand-open__content; + } + } + &._show { + .admin__collapsible-title { + &:before { + content: @icon-expand-close__content; + } + } + } + } } + + &.fieldset-wrapper { border-bottom: 1px solid @collapsible__border-color; padding: 0; @@ -147,6 +163,14 @@ &.collapsible-block-wrapper-last { border-bottom: 0; } + + .admin__dynamic-rows.admin__control-collapsible { + td { + &.admin__collapsible-block-wrapper { + border-bottom: none; + } + } + } } .admin__collapsible-content { @@ -322,7 +346,7 @@ } .value { - padding-right: 4rem; + padding-right: 2rem; } } diff --git a/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/main/actions-bar/_store-switcher.less b/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/main/actions-bar/_store-switcher.less index 80bebb22a9043..0bd6cc62bd509 100644 --- a/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/main/actions-bar/_store-switcher.less +++ b/app/design/adminhtml/Magento/backend/Magento_Backend/web/css/source/module/main/actions-bar/_store-switcher.less @@ -10,11 +10,6 @@ // ToDo UI: Consist old styles, should be changed with new design .store-switcher { - color: @text__color; // ToDo UI: Delete with admin scope - float: left; - font-size: round(@font-size__base - .1rem, 1); - margin-top: .7rem; - .admin__action-dropdown { background-color: @page-main-actions__background-color; margin-left: .5em; @@ -47,8 +42,8 @@ width: 7px; } &::-webkit-scrollbar-thumb { - border-radius: 4px; background-color: rgba(0, 0, 0, .5); + border-radius: 4px; } li { @@ -116,6 +111,12 @@ } } } + + color: @text__color; // ToDo UI: Delete with admin scope + float: left; + font-size: round(@font-size__base - .1rem, 1); + margin-top: .59rem; + } .store-switcher-label { @@ -239,7 +240,6 @@ .store-switcher-label { display: inline-block; - margin-top: @indent__s; } } diff --git a/app/design/adminhtml/Magento/backend/Magento_Backend/web/images/logo-magento.png b/app/design/adminhtml/Magento/backend/Magento_Backend/web/images/logo-magento.png index d9395c938cbff..0cca183e08da2 100644 Binary files a/app/design/adminhtml/Magento/backend/Magento_Backend/web/images/logo-magento.png and b/app/design/adminhtml/Magento/backend/Magento_Backend/web/images/logo-magento.png differ diff --git a/app/design/adminhtml/Magento/backend/Magento_Catalog/web/css/source/_module.less b/app/design/adminhtml/Magento/backend/Magento_Catalog/web/css/source/_module.less index 3355950254072..ffbbaeb084162 100644 --- a/app/design/adminhtml/Magento/backend/Magento_Catalog/web/css/source/_module.less +++ b/app/design/adminhtml/Magento/backend/Magento_Catalog/web/css/source/_module.less @@ -15,6 +15,14 @@ } } +.catalog-category-edit { + .admin__grid-control { + .admin__grid-control-value { + display: none; + } + } +} + .product-composite-configure-inner { .admin__control-text { &.qty { diff --git a/app/design/adminhtml/Magento/backend/Magento_Review/web/css/source/_module.less b/app/design/adminhtml/Magento/backend/Magento_Review/web/css/source/_module.less index 17be2ca706076..faa5845405ab2 100644 --- a/app/design/adminhtml/Magento/backend/Magento_Review/web/css/source/_module.less +++ b/app/design/adminhtml/Magento/backend/Magento_Review/web/css/source/_module.less @@ -34,7 +34,7 @@ .admin__field-control { direction: rtl; display: inline-block; - margin: -4px 0 0; + margin: -2px 0 0; unicode-bidi: bidi-override; vertical-align: top; width: 125px; diff --git a/app/design/adminhtml/Magento/backend/Magento_Sales/web/css/source/module/_order.less b/app/design/adminhtml/Magento/backend/Magento_Sales/web/css/source/module/_order.less index 1e76679f594c1..fa1ae25628986 100644 --- a/app/design/adminhtml/Magento/backend/Magento_Sales/web/css/source/module/_order.less +++ b/app/design/adminhtml/Magento/backend/Magento_Sales/web/css/source/module/_order.less @@ -92,6 +92,14 @@ margin: 0; padding: 0; } + .admin__data-grid-pager-wrap{ + .selectmenu { + margin-bottom: 10px; + } + } + .data-grid-search-control-wrap { + margin-bottom: 10px; + } } // diff --git a/app/design/adminhtml/Magento/backend/composer.json b/app/design/adminhtml/Magento/backend/composer.json index 11f13279b8bc8..e15cd2e5f4271 100644 --- a/app/design/adminhtml/Magento/backend/composer.json +++ b/app/design/adminhtml/Magento/backend/composer.json @@ -6,7 +6,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-theme", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/design/adminhtml/Magento/backend/etc/view.xml b/app/design/adminhtml/Magento/backend/etc/view.xml index f10f7789b0888..18c2d8f1b1722 100644 --- a/app/design/adminhtml/Magento/backend/etc/view.xml +++ b/app/design/adminhtml/Magento/backend/etc/view.xml @@ -23,6 +23,8 @@ </images> </media> <exclude> + <item type="file">Lib::mage/captcha.js</item> + <item type="file">Lib::mage/captcha.min.js</item> <item type="file">Lib::mage/common.js</item> <item type="file">Lib::mage/cookies.js</item> <item type="file">Lib::mage/dataPost.js</item> diff --git a/app/design/adminhtml/Magento/backend/web/css/source/components/_file-insertion.less b/app/design/adminhtml/Magento/backend/web/css/source/components/_file-insertion.less index 773b70ca5f09d..0fe3a3e8b2ec7 100644 --- a/app/design/adminhtml/Magento/backend/web/css/source/components/_file-insertion.less +++ b/app/design/adminhtml/Magento/backend/web/css/source/components/_file-insertion.less @@ -42,6 +42,7 @@ margin: 0 @indent__xs 15px 0; overflow: hidden; padding: 3px; + text-overflow: ellipsis; width: 100px; &.selected { diff --git a/app/design/adminhtml/Magento/backend/web/css/source/components/_messages.less b/app/design/adminhtml/Magento/backend/web/css/source/components/_messages.less index de24bf89620d4..15cd295885892 100644 --- a/app/design/adminhtml/Magento/backend/web/css/source/components/_messages.less +++ b/app/design/adminhtml/Magento/backend/web/css/source/components/_messages.less @@ -76,7 +76,8 @@ position: absolute; speak: none; text-shadow: none; - top: 1.3rem; + top: 50%; + margin-top: -1.25rem; width: auto; } } @@ -110,7 +111,7 @@ content: @alert-icon__error__content; font-size: @alert-icon__error__font-size; left: 2.2rem; - margin-top: 0.5rem; + margin-top: -1.1rem; } } diff --git a/app/design/adminhtml/Magento/backend/web/css/source/components/_modals_extend.less b/app/design/adminhtml/Magento/backend/web/css/source/components/_modals_extend.less index 63d97dc52e453..565b127ccad3e 100644 --- a/app/design/adminhtml/Magento/backend/web/css/source/components/_modals_extend.less +++ b/app/design/adminhtml/Magento/backend/web/css/source/components/_modals_extend.less @@ -146,13 +146,13 @@ } .action-close { - padding: @modal-popup__padding; + padding: @modal-popup__padding - 2; &:active, &:focus { background: transparent; - padding-right: @modal-popup__padding + (@modal-action-close__font-size - @modal-action-close__active__font-size) / 2; - padding-top: @modal-popup__padding + (@modal-action-close__font-size - @modal-action-close__active__font-size) / 2; + padding-right: @modal-popup__padding - 2; + padding-top: @modal-popup__padding - 2; } } } diff --git a/app/design/adminhtml/Magento/backend/web/css/source/components/_popups.less b/app/design/adminhtml/Magento/backend/web/css/source/components/_popups.less index 7bd10ad491f0c..334e0a4a396bc 100644 --- a/app/design/adminhtml/Magento/backend/web/css/source/components/_popups.less +++ b/app/design/adminhtml/Magento/backend/web/css/source/components/_popups.less @@ -264,7 +264,7 @@ } } - #contents-uploader { + .contents-uploader { margin: 0 0 @indent__base; } @@ -298,6 +298,7 @@ margin: 0 @indent__xs 15px 0; overflow: hidden; padding: 3px; + text-overflow: ellipsis; width: 100px; &.selected { @@ -309,7 +310,7 @@ } } - #contents-uploader { + .contents-uploader { &:extend(.abs-clearfix all); } diff --git a/app/design/adminhtml/Magento/backend/web/css/source/forms/_controls.less b/app/design/adminhtml/Magento/backend/web/css/source/forms/_controls.less index 5f1cee13b5b88..9a5f9af5bfd68 100644 --- a/app/design/adminhtml/Magento/backend/web/css/source/forms/_controls.less +++ b/app/design/adminhtml/Magento/backend/web/css/source/forms/_controls.less @@ -85,7 +85,7 @@ cursor: pointer; } - &:focus { + &:active { background-image+: url('../images/arrows-bg.svg'); background-position+: ~'calc(100% - 12px)' 13px; @@ -107,19 +107,6 @@ } } -// ToDo UI: add month and date styles -// .admin__control-select-month { -// width: 140px; -// } - -// .admin__control-select-year { -// width: 103px; -// } - -// .admin__control-cvn { -// width: 3em; -// } - option:empty { display: none; } @@ -151,22 +138,24 @@ option:empty { .admin__control-file-label { &:before { &:extend(.abs-form-control-pattern); - - content:''; - left: 0; - position: absolute; - top: 0; - width: 100%; - z-index: 0; - .admin__control-file:active + &, .admin__control-file:focus + & { + /** + * @codingStandardsIgnoreStart + */ &:extend(.abs-form-control-pattern:focus); } .admin__control-file[disabled] + & { &:extend(.abs-form-control-pattern[disabled]); } + + content: ''; + left: 0; + position: absolute; + top: 0; + width: 100%; + z-index: 0; } } diff --git a/app/design/adminhtml/Magento/backend/web/css/source/forms/_fields.less b/app/design/adminhtml/Magento/backend/web/css/source/forms/_fields.less index f195a28908abe..f2fb9a597ad3f 100644 --- a/app/design/adminhtml/Magento/backend/web/css/source/forms/_fields.less +++ b/app/design/adminhtml/Magento/backend/web/css/source/forms/_fields.less @@ -60,11 +60,12 @@ .abs-field-no-label { /** - * @codingStandardsIgnoreStart + *@codingStandardsIgnoreStart */ #mix-grid .return_length(@field-label-grid__column, @field-grid__columns, '+'); //@codingStandardsIgnoreEnd margin-left: @_length; + //@codingStandardsIgnoreEnd } // @@ -121,6 +122,9 @@ > .admin__field-control { #mix-grid .column(@field-control-grid__column, @field-grid__columns); + input[type="checkbox"] { + margin-top: @indent__s; + } } > .admin__field-label { @@ -170,13 +174,6 @@ .admin__control-text, .admin__control-textarea { width: 100%; - &.disabled { - background-color: #e9e9e9; - border-color: #adadad; - color: #303030; - cursor: not-allowed; - opacity: .5; - } } } @@ -194,13 +191,10 @@ .admin__field-label { color: @field-label__color; + cursor: pointer; margin: 0; text-align: right; - label { - cursor: pointer; - } - + br { display: none; } @@ -297,17 +291,33 @@ } } + legend.admin__field-label { + span { + &:after { + display: none; + } + } + } + // ToDo UI: Scope Labels must be moved from right side of each control to the place under the label of the control. // This code must be removed after Scope Labels are moved completely. // Till that time they'll be disabled by commenting pseudo-element content property. &[data-config-scope] { &:before { + /** + *@codingStandardsIgnoreStart + */ #mix-grid .return_length(@field-label-grid__column + @field-control-grid__column, @field-grid__columns); + //@codingStandardsIgnoreEnd color: @field-scope__color; content: attr(data-config-scope); display: inline-block; font-size: @font-size__s; + /** + *@codingStandardsIgnoreStart + */ left: @_length; + //@codingStandardsIgnoreEnd line-height: 3.2rem; margin-left: 2 * @temp_gutter; position: absolute; @@ -636,8 +646,8 @@ &.admin__field { > .admin__field-control { &:extend(.abs-field-size-small all); - float: left; position: relative; + display: inline-block; } } diff --git a/app/design/adminhtml/Magento/backend/web/css/styles-old.less b/app/design/adminhtml/Magento/backend/web/css/styles-old.less index 6d46145df388c..116163cc375a8 100644 --- a/app/design/adminhtml/Magento/backend/web/css/styles-old.less +++ b/app/design/adminhtml/Magento/backend/web/css/styles-old.less @@ -2738,7 +2738,8 @@ // --------------------------------------------- #widget_instace_tabs_properties_section_content .widget-option-label { - margin-top: 6px; + margin-top: 7px; + display: inline-block; } // @@ -4832,6 +4833,9 @@ margin-bottom: @indent__m; margin-top: -@indent__xl; } + select + .mage-error { + margin-top: 0; + } } } } diff --git a/app/design/frontend/Magento/blank/Magento_Catalog/web/css/source/_module.less b/app/design/frontend/Magento/blank/Magento_Catalog/web/css/source/_module.less index 2dd8463308a2c..85ef048ef0fc7 100644 --- a/app/design/frontend/Magento/blank/Magento_Catalog/web/css/source/_module.less +++ b/app/design/frontend/Magento/blank/Magento_Catalog/web/css/source/_module.less @@ -488,6 +488,7 @@ .product-items-names { .product-item { + display: flex; margin-bottom: @indent__s; } diff --git a/app/design/frontend/Magento/blank/Magento_Catalog/web/css/source/module/_listings.less b/app/design/frontend/Magento/blank/Magento_Catalog/web/css/source/module/_listings.less index 8b727b0d9c28d..951ca89a07988 100644 --- a/app/design/frontend/Magento/blank/Magento_Catalog/web/css/source/module/_listings.less +++ b/app/design/frontend/Magento/blank/Magento_Catalog/web/css/source/module/_listings.less @@ -63,7 +63,6 @@ } &-actions { - display: none; .actions-secondary { > button.action { diff --git a/app/design/frontend/Magento/blank/Magento_Checkout/web/css/source/module/_minicart.less b/app/design/frontend/Magento/blank/Magento_Checkout/web/css/source/module/_minicart.less index 54457353f90ae..5cf1e9f59af39 100644 --- a/app/design/frontend/Magento/blank/Magento_Checkout/web/css/source/module/_minicart.less +++ b/app/design/frontend/Magento/blank/Magento_Checkout/web/css/source/module/_minicart.less @@ -107,7 +107,7 @@ @_toggle-selector: ~'.action.showcart', @_options-selector: ~'.block-minicart', @_dropdown-list-width: 320px, - @_dropdown-list-position-right: 0px, + @_dropdown-list-position-right: 0, @_dropdown-list-pointer-position: right, @_dropdown-list-pointer-position-left-right: 26px, @_dropdown-list-z-index: 101, @@ -341,7 +341,7 @@ .item-qty { margin-right: @indent__s; text-align: center; - width: 40px; + width: 45px; } .update-cart-item { diff --git a/app/design/frontend/Magento/blank/Magento_Checkout/web/css/source/module/checkout/_tooltip.less b/app/design/frontend/Magento/blank/Magento_Checkout/web/css/source/module/checkout/_tooltip.less index cf84f34279086..39b9a051e6592 100644 --- a/app/design/frontend/Magento/blank/Magento_Checkout/web/css/source/module/checkout/_tooltip.less +++ b/app/design/frontend/Magento/blank/Magento_Checkout/web/css/source/module/checkout/_tooltip.less @@ -8,7 +8,6 @@ // _____________________________________________ @checkout-tooltip__hover__z-index: @tooltip__z-index; -@checkout-tooltip-breakpoint__screen-m: @modal-popup-breakpoint-screen__m; @checkout-tooltip-icon-arrow__font-size: 10px; @checkout-tooltip-icon-arrow__left: -( @checkout-tooltip-content__padding + @checkout-tooltip-icon-arrow__font-size - @checkout-tooltip-content__border-width); @@ -138,10 +137,42 @@ } } -.media-width(@extremum, @break) when (@extremum = 'max') and (@break = @checkout-tooltip-breakpoint__screen-m) { +.media-width(@extremum, @break) when (@extremum = 'max') and (@break >= @screen__m) { .field-tooltip { .field-tooltip-content { + .lib-css(right, @checkout-tooltip-content-mobile__right); + .lib-css(top, @checkout-tooltip-content-mobile__top); + left: auto; &:extend(.abs-checkout-tooltip-content-position-top-mobile all); } } } + +// +// Tablet +// _____________________________________________ + +@media only screen and (max-width: @screen__m) { + .field-tooltip .field-tooltip-content { + left: auto; + right: -10px; + top: 40px; + } + .field-tooltip .field-tooltip-content::before, + .field-tooltip .field-tooltip-content::after { + border: 10px solid transparent; + height: 0; + left: auto; + margin-top: -21px; + right: 10px; + top: 0; + width: 0; + } + .field-tooltip .field-tooltip-content::before { + border-bottom-color: @color-gray40; + } + .field-tooltip .field-tooltip-content::after { + border-bottom-color: @color-gray-light01; + top: 1px; + } +} diff --git a/app/design/frontend/Magento/blank/Magento_Customer/web/css/source/_module.less b/app/design/frontend/Magento/blank/Magento_Customer/web/css/source/_module.less index 3ffaeb82cdc2a..0ebd722429480 100644 --- a/app/design/frontend/Magento/blank/Magento_Customer/web/css/source/_module.less +++ b/app/design/frontend/Magento/blank/Magento_Customer/web/css/source/_module.less @@ -367,8 +367,8 @@ } .account { - .page.messages { - margin-bottom: @indent__xl; + .messages { + margin-bottom: 0; } .toolbar { @@ -421,7 +421,7 @@ > .field { > .control { - width: 55%; + width: 80%; } } } @@ -451,7 +451,8 @@ .form.password.reset, .form.send.confirmation, .form.password.forget, - .form.create.account { + .form.create.account, + .form.form-orders-search { min-width: 600px; width: 50%; } diff --git a/app/design/frontend/Magento/blank/Magento_MultipleWishlist/web/css/source/_module.less b/app/design/frontend/Magento/blank/Magento_MultipleWishlist/web/css/source/_module.less index 6baa2432ff035..7d86850c4e517 100644 --- a/app/design/frontend/Magento/blank/Magento_MultipleWishlist/web/css/source/_module.less +++ b/app/design/frontend/Magento/blank/Magento_MultipleWishlist/web/css/source/_module.less @@ -342,7 +342,7 @@ .product { &-item { &-checkbox { - left: 20px; + left: 0; position: absolute; top: 20px; } @@ -381,16 +381,16 @@ .media-width(@extremum, @break) when (@extremum = 'min') and (@break = @screen__m) { .wishlist { &.window.popup { + .field { + .lib-form-field-type-revert(@_type: block); + } + bottom: auto; .lib-css(top, @desktop-popup-position-top); .lib-css(left, @desktop-popup-position-left); .lib-css(margin-left, @desktop-popup-margin-left); .lib-css(width, @desktop-popup-width); right: auto; - - .field { - .lib-form-field-type-revert(@_type: block); - } } } diff --git a/app/design/frontend/Magento/blank/Magento_Wishlist/web/css/source/_module.less b/app/design/frontend/Magento/blank/Magento_Wishlist/web/css/source/_module.less index 0e8350261e002..2163fe2aee897 100644 --- a/app/design/frontend/Magento/blank/Magento_Wishlist/web/css/source/_module.less +++ b/app/design/frontend/Magento/blank/Magento_Wishlist/web/css/source/_module.less @@ -177,10 +177,10 @@ .media-width(@extremum, @break) when (@extremum = 'max') and (@break = @screen__m) { .products-grid.wishlist { margin-bottom: @indent__l; - margin-right: -@indent__s; + margin-right: 0; .product { &-item { - padding: @indent__base @indent__s @indent__base @indent__base; + padding: @indent__base 0 @indent__base 0; position: relative; &-photo { @@ -194,6 +194,7 @@ &-actions { display: block; + float: left; .action { margin-right: 15px; diff --git a/app/design/frontend/Magento/blank/composer.json b/app/design/frontend/Magento/blank/composer.json index 5219254e42674..2f40a5918c8ca 100644 --- a/app/design/frontend/Magento/blank/composer.json +++ b/app/design/frontend/Magento/blank/composer.json @@ -6,7 +6,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-theme", - "version": "100.2.4", + "version": "100.2.5", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/design/frontend/Magento/blank/web/css/source/_forms.less b/app/design/frontend/Magento/blank/web/css/source/_forms.less index 94b993b53b508..c9f3c3d72ef4c 100644 --- a/app/design/frontend/Magento/blank/web/css/source/_forms.less +++ b/app/design/frontend/Magento/blank/web/css/source/_forms.less @@ -18,7 +18,7 @@ .fieldset { .lib-form-fieldset(); &:last-child { - margin-bottom: 0; + margin-bottom: @indent__base; } > .field, diff --git a/app/design/frontend/Magento/blank/web/css/source/_navigation.less b/app/design/frontend/Magento/blank/web/css/source/_navigation.less index 4499886ef0f10..21b7315779764 100644 --- a/app/design/frontend/Magento/blank/web/css/source/_navigation.less +++ b/app/design/frontend/Magento/blank/web/css/source/_navigation.less @@ -131,12 +131,18 @@ ); } } - .switcher-dropdown { .lib-list-reset-styles(); + display: none; padding: @indent__s 0; } - + .switcher-options { + &.active { + .switcher-dropdown { + display: block; + } + } + } .header.links { .lib-list-reset-styles(); border-bottom: 1px solid @color-gray82; @@ -207,7 +213,7 @@ } .nav-toggle { - &:after{ + &:after { background: rgba(0, 0, 0, @overlay__opacity); content: ''; display: block; diff --git a/app/design/frontend/Magento/blank/web/images/logo.svg b/app/design/frontend/Magento/blank/web/images/logo.svg index 013d6e7c5a107..0f29d4e3eef21 100644 --- a/app/design/frontend/Magento/blank/web/images/logo.svg +++ b/app/design/frontend/Magento/blank/web/images/logo.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" height="55.139px" viewBox="0 0 189 55.139" width="189px" version="1.1" y="0px" x="0px" xmlns:xlink="http://www.w3.org/1999/xlink" enable-background="new 0 0 189 55.139"><path d="m23.333 0l-23.333 14.135v26.865l6.06 3.568v-26.865l17.278-10.504 17.292 10.488 0.074 0.042-0.008 26.798 6.001-3.527v-26.865l-23.364-14.135zm3.088 16.538v31.407l-3.088 1.889-3.091-1.896v-31.376l-8.003 4.928v26.86l11.094 6.789 11.189-6.837v-26.829l-8.101-4.935z" fill="#ED7402"/><path d="m12.239 21.491l8.003 4.886v-9.814l-8.003 4.928zm14.182-4.953v9.902l8.101-4.967-8.101-4.935zm20.276-2.403l-23.364-14.135-23.333 14.135 6.06 3.568 17.278-10.504 17.365 10.53 5.994-3.594z" fill="#F8B97F"/><path d="m82.328 39.984l-1.608-20.54-8.156 20.651h-2.656l-8.156-20.651-1.571 20.541h-3.293l2.058-25.814h4.34l8.043 21.174 8.044-21.174h4.301l2.021 25.814h-3.367z" fill="#131108"/><path d="m99.91 39.984l-0.375-2.396c-1.421 1.458-3.366 2.769-6.284 2.769-3.218 0-5.237-1.945-5.237-4.977 0-4.452 3.815-6.209 11.261-6.996v-0.748c0-2.244-1.348-3.03-3.406-3.03-2.17 0-4.227 0.673-6.172 1.533l-0.449-2.88c2.133-0.861 4.153-1.496 6.922-1.496 4.339 0 6.436 1.757 6.436 5.723v12.498h-2.7zm-0.635-9.055c-6.586 0.636-7.97 2.432-7.97 4.266 0 1.457 0.973 2.394 2.657 2.394 1.945 0 3.815-0.974 5.312-2.508v-4.152h0.001z" fill="#131108"/><path d="m121.72 21.876l0.485 2.992-3.404 0.336c0.486 0.824 0.712 1.76 0.712 2.769 0 3.817-3.219 6.134-6.848 6.134-0.449 0-0.898-0.037-1.348-0.111-0.523 0.338-0.897 0.75-0.897 1.085 0 0.636 0.634 0.787 3.777 1.349l1.272 0.223c3.778 0.673 6.135 1.871 6.135 4.639 0 3.741-4.078 5.5-8.715 5.5-4.64 0-8.343-1.458-8.343-4.6 0-1.834 1.271-3.256 3.777-4.603-0.784-0.562-1.121-1.198-1.121-1.872 0-0.861 0.672-1.722 1.87-2.432-1.982-0.973-3.33-2.879-3.33-5.312 0-3.852 3.217-6.208 6.846-6.208 1.795 0 3.368 0.522 4.604 1.496l4.53-1.385zm-13.99 20.052c0 1.424 1.834 2.471 5.312 2.471 3.48 0 5.424-1.197 5.424-2.693 0-1.086-0.822-1.832-3.365-2.282l-2.134-0.375c-0.972-0.187-1.495-0.299-2.207-0.448-2.1 1.045-3.03 2.094-3.03 3.327zm4.86-17.733c-2.244 0-3.629 1.722-3.629 3.891 0 2.058 1.421 3.665 3.629 3.665 2.283 0 3.704-1.682 3.704-3.815 0.01-2.132-1.49-3.741-3.7-3.741z" fill="#131108"/><path d="m137.58 31.416h-12.122c0.112 4.15 2.094 6.098 5.2 6.098 2.583 0 4.453-1.009 6.397-2.544l0.484 2.994c-1.907 1.497-4.188 2.394-7.143 2.394-4.642 0-8.271-2.807-8.271-9.354 0-5.724 3.368-9.239 7.856-9.239 5.199 0 7.595 4.002 7.595 8.94v0.711zm-7.63-7.033c-2.059 0-3.817 1.459-4.34 4.526h8.604c-0.41-2.881-1.68-4.526-4.26-4.526z" fill="#131108"/><path d="m151.61 39.984v-12.16c0-1.832-0.786-3.067-2.73-3.067-1.759 0-3.555 1.161-5.163 2.88v12.347h-3.329v-17.846h2.655l0.412 2.582c1.683-1.533 3.78-2.955 6.321-2.955 3.369 0 5.166 2.019 5.166 5.236v12.983h-3.33z" fill="#131108"/><path d="m164.78 40.284c-3.143 0-5.199-1.124-5.199-4.716v-10.624h-2.694v-2.806h2.694v-5.949l3.255-0.485v6.434h3.853l0.449 2.806h-4.302v10.025c0 1.461 0.599 2.357 2.47 2.357 0.598 0 1.121-0.035 1.531-0.112l0.45 2.843c-0.57 0.112-1.35 0.227-2.51 0.227z" fill="#131108"/><path d="m175.82 40.357c-4.752 0-8.194-3.403-8.194-9.278 0-5.874 3.442-9.314 8.194-9.314 4.787 0 8.305 3.44 8.305 9.314 0 5.875-3.52 9.278-8.3 9.278zm0-15.788c-3.217 0-4.826 2.769-4.826 6.51 0 3.668 1.683 6.511 4.826 6.511 3.291 0 4.938-2.77 4.938-6.511-0.01-3.666-1.73-6.51-4.94-6.51z" fill="#131108"/><path d="m186.48 24.81c-1.338 0-2.268-0.929-2.268-2.318 0-1.379 0.95-2.328 2.268-2.328 1.34 0 2.27 0.939 2.27 2.328 0 1.378-0.95 2.318-2.27 2.318zm0-4.376c-1.078 0-1.938 0.739-1.938 2.058 0 1.31 0.859 2.049 1.938 2.049 1.09 0 1.949-0.739 1.949-2.049 0-1.319-0.87-2.058-1.95-2.058zm0.67 3.297l-0.768-1.099h-0.249v1.059h-0.44v-2.568h0.779c0.54 0 0.898 0.27 0.898 0.75 0 0.37-0.201 0.609-0.52 0.709l0.74 1.049-0.44 0.1zm-0.68-2.209h-0.34v0.759h0.319c0.29 0 0.471-0.12 0.471-0.379 0-0.25-0.16-0.38-0.45-0.38z" fill="#131108"/></svg> \ No newline at end of file +<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 179.073 50"><style>.st0{fill:#f26322}.st1{fill:#4d4d4d}</style><path class="st0" d="M21.432 0L0 12.373v24.713l6.117 3.533-.041-24.713 15.315-8.842 15.313 8.842v24.706l6.118-3.526V12.35z"/><path class="st0" d="M24.47 40.618l-3.058 1.772-3.071-1.759V15.906l-6.116 3.532.01 24.712 9.172 5.298 9.18-5.298V19.438l-6.117-3.532z"/><path class="st1" d="M56.838 12.522l8.415 21.258h.068l8.21-21.258h3.203V36.88h-2.215V15.656h-.068c-.114.386-.239.772-.374 1.158-.115.318-.246.67-.393 1.055-.147.387-.278.75-.391 1.09l-7.052 17.919h-2.01L57.11 18.96a19.913 19.913 0 0 1-.408-1.039 43.558 43.558 0 0 1-.375-1.073 68.067 68.067 0 0 0-.408-1.192h-.069v21.223h-2.112V12.522h3.1zM83.17 36.982a5.205 5.205 0 0 1-1.823-.92 4.327 4.327 0 0 1-1.209-1.533 4.881 4.881 0 0 1-.443-2.145 5.018 5.018 0 0 1 .579-2.556 4.472 4.472 0 0 1 1.568-1.584 7.927 7.927 0 0 1 2.299-.903 24.732 24.732 0 0 1 2.811-.477 36 36 0 0 0 2.198-.29 6.689 6.689 0 0 0 1.465-.392c.329-.123.614-.343.817-.63.187-.325.276-.698.256-1.073v-.34a3.212 3.212 0 0 0-1.09-2.674 4.93 4.93 0 0 0-3.134-.87c-3.135 0-4.781 1.306-4.941 3.918h-2.077a5.748 5.748 0 0 1 1.891-4.089 7.5 7.5 0 0 1 5.127-1.533 7.335 7.335 0 0 1 4.564 1.278 4.923 4.923 0 0 1 1.67 4.173v9.573c-.034.402.069.804.29 1.141.219.251.536.394.868.392.12-.001.24-.012.358-.034.124-.022.266-.057.425-.102h.103v1.533c-.188.077-.382.14-.58.188-.28.062-.566.091-.852.086a2.694 2.694 0 0 1-1.839-.597 2.575 2.575 0 0 1-.75-1.891v-.374h-.102c-.277.372-.578.725-.903 1.056-.38.385-.81.717-1.278.988a7.14 7.14 0 0 1-1.737.715 8.443 8.443 0 0 1-2.249.272 8.186 8.186 0 0 1-2.282-.306m5.195-1.857a5.971 5.971 0 0 0 1.857-1.175 4.767 4.767 0 0 0 1.499-3.441V27.34a7.295 7.295 0 0 1-2.061.732 33.21 33.21 0 0 1-2.504.426c-.749.114-1.441.233-2.077.358a5.237 5.237 0 0 0-1.652.596 3.055 3.055 0 0 0-1.108 1.107 3.579 3.579 0 0 0-.408 1.824c-.018.53.093 1.056.324 1.533.201.392.493.73.851.987a3.35 3.35 0 0 0 1.244.529c.493.104.995.155 1.499.153a6.555 6.555 0 0 0 2.536-.46M99.35 41.734a4.687 4.687 0 0 1-2.009-3.287h2.043c.14.966.763 1.794 1.652 2.197a7.522 7.522 0 0 0 3.288.664 5.688 5.688 0 0 0 4.173-1.345 4.997 4.997 0 0 0 1.345-3.697v-2.793h-.102a7.293 7.293 0 0 1-2.283 2.282 6.297 6.297 0 0 1-3.304.784 7.375 7.375 0 0 1-3.135-.647 6.921 6.921 0 0 1-2.385-1.806 8.095 8.095 0 0 1-1.516-2.777 11.429 11.429 0 0 1-.528-3.56 10.89 10.89 0 0 1 .613-3.799 8.483 8.483 0 0 1 1.636-2.777 6.75 6.75 0 0 1 2.402-1.703 7.45 7.45 0 0 1 2.913-.58 6.234 6.234 0 0 1 3.372.835 6.938 6.938 0 0 1 2.215 2.265h.102v-2.725h2.078v16.931a6.78 6.78 0 0 1-1.636 4.735 7.751 7.751 0 0 1-5.893 2.112 8.337 8.337 0 0 1-5.041-1.309m9.232-8.908a8.565 8.565 0 0 0 1.397-5.11 11.22 11.22 0 0 0-.34-2.862 6.239 6.239 0 0 0-1.056-2.231 4.828 4.828 0 0 0-1.789-1.448 5.772 5.772 0 0 0-2.503-.511 4.782 4.782 0 0 0-4.071 1.941 8.464 8.464 0 0 0-1.448 5.179c-.008.936.107 1.87.34 2.777a6.64 6.64 0 0 0 1.022 2.214 4.81 4.81 0 0 0 1.703 1.465 5.208 5.208 0 0 0 2.42.528 4.967 4.967 0 0 0 4.325-1.942M119.244 36.624a7.19 7.19 0 0 1-2.572-1.941 8.66 8.66 0 0 1-1.583-2.93 11.839 11.839 0 0 1-.546-3.662 11.179 11.179 0 0 1 .58-3.663 9.138 9.138 0 0 1 1.617-2.929 7.307 7.307 0 0 1 2.522-1.942 7.684 7.684 0 0 1 3.321-.698 7.275 7.275 0 0 1 3.56.8 6.678 6.678 0 0 1 2.351 2.146 8.806 8.806 0 0 1 1.278 3.083 16.87 16.87 0 0 1 .374 3.577h-13.422c.013.941.157 1.875.426 2.777a6.968 6.968 0 0 0 1.124 2.231 5.108 5.108 0 0 0 1.857 1.499 5.948 5.948 0 0 0 2.623.546 4.985 4.985 0 0 0 3.424-1.074 5.875 5.875 0 0 0 1.719-2.878h2.044a7.51 7.51 0 0 1-2.385 4.19 7.072 7.072 0 0 1-4.803 1.567 8.386 8.386 0 0 1-3.509-.699m8.312-12.264a5.986 5.986 0 0 0-.988-1.976 4.525 4.525 0 0 0-1.635-1.311 5.362 5.362 0 0 0-2.351-.478 5.623 5.623 0 0 0-2.368.478 5.064 5.064 0 0 0-1.754 1.311 6.566 6.566 0 0 0-1.141 1.96 9.615 9.615 0 0 0-.562 2.453h11.174a9.268 9.268 0 0 0-.375-2.437M134.879 19.267v2.691h.068a7.237 7.237 0 0 1 2.333-2.197 6.798 6.798 0 0 1 3.561-.868 5.834 5.834 0 0 1 4.037 1.413 5.174 5.174 0 0 1 1.584 4.071V36.88h-2.112V24.581a3.716 3.716 0 0 0-1.073-2.947 4.334 4.334 0 0 0-2.948-.937 5.896 5.896 0 0 0-2.111.375 5.558 5.558 0 0 0-1.738 1.039 4.717 4.717 0 0 0-1.601 3.593V36.88h-2.112V19.267h2.112zM151.912 36.284a2.934 2.934 0 0 1-.92-2.436V21.005h-2.657v-1.738h2.657v-5.416h2.112v5.416h3.271v1.738h-3.271v12.502a1.65 1.65 0 0 0 .426 1.312c.371.265.823.391 1.277.357.258-.001.515-.029.766-.085.215-.043.426-.106.63-.188h.103v1.806a5.907 5.907 0 0 1-1.942.306 3.819 3.819 0 0 1-2.452-.731M162.625 36.624a7.368 7.368 0 0 1-2.571-1.942 8.732 8.732 0 0 1-1.618-2.929 12.217 12.217 0 0 1 0-7.324 8.744 8.744 0 0 1 1.618-2.93 7.386 7.386 0 0 1 2.571-1.942 8.106 8.106 0 0 1 3.424-.698 7.989 7.989 0 0 1 3.406.698 7.424 7.424 0 0 1 2.556 1.942 8.504 8.504 0 0 1 1.601 2.93 12.57 12.57 0 0 1 0 7.324 8.5 8.5 0 0 1-1.601 2.929 7.415 7.415 0 0 1-2.556 1.942 7.959 7.959 0 0 1-3.406.698 8.075 8.075 0 0 1-3.424-.698m6.013-1.652a5.308 5.308 0 0 0 1.873-1.601 7.215 7.215 0 0 0 1.124-2.385 11.348 11.348 0 0 0 0-5.792 7.215 7.215 0 0 0-1.124-2.385 5.289 5.289 0 0 0-1.873-1.601 6.109 6.109 0 0 0-5.195 0 5.497 5.497 0 0 0-1.874 1.601 7.046 7.046 0 0 0-1.141 2.385 11.392 11.392 0 0 0 0 5.792c.227.86.614 1.669 1.141 2.385a5.817 5.817 0 0 0 7.069 1.601M176.856 22.191a2.128 2.128 0 0 1-2.213-2.265 2.216 2.216 0 1 1 4.431 0 2.146 2.146 0 0 1-2.218 2.265m0-4.277a1.845 1.845 0 0 0-1.892 2.012 1.9 1.9 0 1 0 3.797 0 1.854 1.854 0 0 0-1.905-2.012m.653 3.222l-.751-1.073h-.243v1.035h-.43v-2.509h.763c.526 0 .877.264.877.732a.681.681 0 0 1-.508.693l.724 1.025-.432.097zm-.661-2.157h-.333v.741h.312c.283 0 .46-.117.46-.371.001-.244-.158-.37-.439-.37"/></svg> diff --git a/app/design/frontend/Magento/luma/Magento_Bundle/web/css/source/_module.less b/app/design/frontend/Magento/luma/Magento_Bundle/web/css/source/_module.less index 43ae23bab7895..eeb17653c877b 100644 --- a/app/design/frontend/Magento/luma/Magento_Bundle/web/css/source/_module.less +++ b/app/design/frontend/Magento/luma/Magento_Bundle/web/css/source/_module.less @@ -253,7 +253,7 @@ .box-tocart { .action.primary { margin-right: 1%; - width: 49%; + width: auto; } } diff --git a/app/design/frontend/Magento/luma/Magento_Catalog/web/css/source/_module.less b/app/design/frontend/Magento/luma/Magento_Catalog/web/css/source/_module.less index 228c6947c938b..6aa0e50f3c393 100644 --- a/app/design/frontend/Magento/luma/Magento_Catalog/web/css/source/_module.less +++ b/app/design/frontend/Magento/luma/Magento_Catalog/web/css/source/_module.less @@ -563,6 +563,7 @@ .product-items-names { .product-item { + display: flex; margin-bottom: @indent__s; } diff --git a/app/design/frontend/Magento/luma/Magento_Catalog/web/css/source/module/_listings.less b/app/design/frontend/Magento/luma/Magento_Catalog/web/css/source/module/_listings.less index d0382d34d39fc..6bf766b7400a7 100644 --- a/app/design/frontend/Magento/luma/Magento_Catalog/web/css/source/module/_listings.less +++ b/app/design/frontend/Magento/luma/Magento_Catalog/web/css/source/module/_listings.less @@ -68,7 +68,6 @@ } &-actions { - display: none; .actions-secondary { > button.action { diff --git a/app/design/frontend/Magento/luma/Magento_CatalogSearch/web/css/source/_module.less b/app/design/frontend/Magento/luma/Magento_CatalogSearch/web/css/source/_module.less index f785dd74d900e..0f91f857a715c 100644 --- a/app/design/frontend/Magento/luma/Magento_CatalogSearch/web/css/source/_module.less +++ b/app/design/frontend/Magento/luma/Magento_CatalogSearch/web/css/source/_module.less @@ -18,6 +18,20 @@ // _____________________________________________ & when (@media-common = true) { + + .search { + .fieldset { + .control { + .addon { + input { + flex-basis: auto; + width: 100%; + } + } + } + } + } + .block-search { margin-bottom: 0; diff --git a/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/_cart.less b/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/_cart.less index 1015bb584ff7b..67057967451d8 100644 --- a/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/_cart.less +++ b/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/_cart.less @@ -698,6 +698,9 @@ position: static; } } + &.discount { + width: auto; + } } } diff --git a/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/_minicart.less b/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/_minicart.less index 547460c387c33..05ce84995afae 100644 --- a/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/_minicart.less +++ b/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/_minicart.less @@ -110,9 +110,9 @@ @_toggle-selector: ~'.action.showcart', @_options-selector: ~'.block-minicart', @_dropdown-list-width: 320px, - @_dropdown-list-position-right: 0px, + @_dropdown-list-position-right: -10px, @_dropdown-list-pointer-position: right, - @_dropdown-list-pointer-position-left-right: 26px, + @_dropdown-list-pointer-position-left-right: 12px, @_dropdown-list-z-index: 101, @_dropdown-toggle-icon-content: @icon-cart, @_dropdown-toggle-active-icon-content: @icon-cart, @@ -352,7 +352,7 @@ .item-qty { margin-right: @indent__s; text-align: center; - width: 40px; + width: 45px; } .update-cart-item { @@ -412,7 +412,6 @@ margin-left: 13px; .block-minicart { - right: -15px; width: 390px; } } diff --git a/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/checkout/_checkout.less b/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/checkout/_checkout.less index 0df0cace338c0..3ea1f5b7f6842 100644 --- a/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/checkout/_checkout.less +++ b/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/checkout/_checkout.less @@ -48,6 +48,7 @@ .step-title { &:extend(.abs-checkout-title all); .lib-css(border-bottom, @checkout-step-title__border); + margin-bottom: 15px; } .step-content { diff --git a/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/checkout/_order-summary.less b/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/checkout/_order-summary.less index 5ecc4d4713bf1..5e2c010c13e8f 100644 --- a/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/checkout/_order-summary.less +++ b/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/checkout/_order-summary.less @@ -137,6 +137,10 @@ } .product-item { + .product-item-details { + &:extend(.abs-add-clearfix all); + } + .product-item-inner { display: table; margin: 0 0 @indent__s; @@ -166,6 +170,10 @@ } } } + + .message { + margin-top: 10px; + } } .actions-toolbar { @@ -204,6 +212,12 @@ &:extend(.abs-sidebar-totals-mobile all); } } + + .opc-block-shipping-information { + .shipping-information-title { + font-size: 2.4rem; + } + } } // diff --git a/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/checkout/_payment-options.less b/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/checkout/_payment-options.less index 0b27454b206e3..3b584bc26fe34 100644 --- a/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/checkout/_payment-options.less +++ b/app/design/frontend/Magento/luma/Magento_Checkout/web/css/source/module/checkout/_payment-options.less @@ -69,6 +69,13 @@ .payment-option-content { .lib-css(padding, 0 0 @indent__base @checkout-payment-option-content__padding__xl); + .primary { + .action { + &.action-apply { + margin-right: 0; + } + } + } } .payment-option-inner { diff --git a/app/design/frontend/Magento/luma/Magento_Customer/web/css/source/_module.less b/app/design/frontend/Magento/luma/Magento_Customer/web/css/source/_module.less index d7ae6c3b28f4a..460f8e50d59a9 100755 --- a/app/design/frontend/Magento/luma/Magento_Customer/web/css/source/_module.less +++ b/app/design/frontend/Magento/luma/Magento_Customer/web/css/source/_module.less @@ -371,7 +371,7 @@ .fieldset { > .field { > .control { - width: 55%; + width: 80%; } } } @@ -402,7 +402,8 @@ .form.password.reset, .form.send.confirmation, .form.password.forget, - .form.create.account { + .form.create.account, + .form.form-orders-search { min-width: 600px; width: 50%; } @@ -417,6 +418,12 @@ .column.main { width: 77.7%; } + + .sidebar-main { + .block { + margin-bottom: 0; + } + } } .account { @@ -528,11 +535,18 @@ .column.main, .sidebar-additional { margin: 0; + padding: 0; } .data.table { &:extend(.abs-table-striped-mobile all); } + + .sidebar-main { + .account-nav { + margin-bottom: 0; + } + } } } @@ -550,8 +564,8 @@ } .account { - .page.messages { - margin-bottom: @indent__xl; + .messages { + margin-bottom: 0; } .column.main { diff --git a/app/design/frontend/Magento/luma/Magento_GiftMessage/web/css/source/_module.less b/app/design/frontend/Magento/luma/Magento_GiftMessage/web/css/source/_module.less index 0c329f32d3739..621bf40b03093 100644 --- a/app/design/frontend/Magento/luma/Magento_GiftMessage/web/css/source/_module.less +++ b/app/design/frontend/Magento/luma/Magento_GiftMessage/web/css/source/_module.less @@ -246,6 +246,10 @@ .gift-messages-order { margin-bottom: @indent__m; } + + .gift-message-summary { + padding-right: 7rem; + } } // @@ -282,10 +286,6 @@ } } - .gift-message-summary { - padding-right: 7rem; - } - // // In-table block // --------------------------------------------- diff --git a/app/design/frontend/Magento/luma/Magento_MultipleWishlist/web/css/source/_module.less b/app/design/frontend/Magento/luma/Magento_MultipleWishlist/web/css/source/_module.less index e10278e3abbec..917e85cdf94b5 100644 --- a/app/design/frontend/Magento/luma/Magento_MultipleWishlist/web/css/source/_module.less +++ b/app/design/frontend/Magento/luma/Magento_MultipleWishlist/web/css/source/_module.less @@ -435,7 +435,7 @@ .product { &-item { &-checkbox { - left: 20px; + left: 0; position: absolute; top: 20px; } diff --git a/app/design/frontend/Magento/luma/Magento_Multishipping/web/css/source/_module.less b/app/design/frontend/Magento/luma/Magento_Multishipping/web/css/source/_module.less index 7662c60734a1b..9761f36b96344 100644 --- a/app/design/frontend/Magento/luma/Magento_Multishipping/web/css/source/_module.less +++ b/app/design/frontend/Magento/luma/Magento_Multishipping/web/css/source/_module.less @@ -374,7 +374,7 @@ text-align: right; .action { - margin-left: @indent__s; + margin-left: 0; &.back { display: block; @@ -496,4 +496,12 @@ margin-left: @indent__xl; } } + + .multicheckout { + .actions-toolbar { + > .primary { + margin-right: 0; + } + } + } } diff --git a/app/design/frontend/Magento/luma/Magento_Review/web/css/source/_module.less b/app/design/frontend/Magento/luma/Magento_Review/web/css/source/_module.less index da78406f92212..f40762ee6fc6d 100644 --- a/app/design/frontend/Magento/luma/Magento_Review/web/css/source/_module.less +++ b/app/design/frontend/Magento/luma/Magento_Review/web/css/source/_module.less @@ -298,6 +298,9 @@ a:not(:last-child) { margin-right: 30px; } + .action.add { + white-space: nowrap; + } } } diff --git a/app/design/frontend/Magento/luma/Magento_Theme/web/css/source/_module.less b/app/design/frontend/Magento/luma/Magento_Theme/web/css/source/_module.less index 68938ed206038..f3e0f810481ed 100644 --- a/app/design/frontend/Magento/luma/Magento_Theme/web/css/source/_module.less +++ b/app/design/frontend/Magento/luma/Magento_Theme/web/css/source/_module.less @@ -151,6 +151,12 @@ } } + .page-print { + .nav-toggle { + display: none; + } + } + .page-main { > .page-title-wrapper { .page-title + .action { @@ -319,6 +325,23 @@ } } } + .page-header { + .switcher { + .options { + ul.dropdown { + right: 0; + &:before { + left: auto; + right: 10px; + } + &:after { + left: auto; + right: 9px; + } + } + } + } + } // // Widgets diff --git a/app/design/frontend/Magento/luma/Magento_Wishlist/web/css/source/_module.less b/app/design/frontend/Magento/luma/Magento_Wishlist/web/css/source/_module.less index 584eefb9bc643..48db03d791657 100644 --- a/app/design/frontend/Magento/luma/Magento_Wishlist/web/css/source/_module.less +++ b/app/design/frontend/Magento/luma/Magento_Wishlist/web/css/source/_module.less @@ -185,11 +185,11 @@ .media-width(@extremum, @break) when (@extremum = 'max') and (@break = @screen__m) { .products-grid.wishlist { margin-bottom: @indent__l; - margin-right: -@indent__s; + margin-right: 0; .product { &-item { - padding: @indent__base @indent__s @indent__base @indent__base; + padding: @indent__base 0 @indent__base 0; position: relative; &-photo { @@ -203,6 +203,7 @@ &-actions { display: block; + float: left; .action { margin-right: 15px; diff --git a/app/design/frontend/Magento/luma/composer.json b/app/design/frontend/Magento/luma/composer.json index 19d37d0216064..0a82ce6fdea2e 100644 --- a/app/design/frontend/Magento/luma/composer.json +++ b/app/design/frontend/Magento/luma/composer.json @@ -7,7 +7,7 @@ "magento/framework": "101.0.*" }, "type": "magento2-theme", - "version": "100.2.5", + "version": "100.2.6", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/app/design/frontend/Magento/luma/web/css/source/_forms.less b/app/design/frontend/Magento/luma/web/css/source/_forms.less index 7c5027aef113b..6701d5f9e9d21 100644 --- a/app/design/frontend/Magento/luma/web/css/source/_forms.less +++ b/app/design/frontend/Magento/luma/web/css/source/_forms.less @@ -20,7 +20,7 @@ .lib-form-fieldset(); &:last-child { - margin-bottom: 0; + margin-bottom: @indent__base; } > .field, diff --git a/app/design/frontend/Magento/luma/web/css/source/components/_modals_extend.less b/app/design/frontend/Magento/luma/web/css/source/components/_modals_extend.less index ed01ef7d027f5..0e34d7b87387d 100644 --- a/app/design/frontend/Magento/luma/web/css/source/components/_modals_extend.less +++ b/app/design/frontend/Magento/luma/web/css/source/components/_modals_extend.less @@ -83,7 +83,8 @@ .modal-slide { .action-close { - padding: @modal-slide-action-close__padding; + margin: 15px; + padding: 0; } .page-main-actions { diff --git a/app/etc/di.xml b/app/etc/di.xml index ad77ae3adc566..05fd34a178ded 100755 --- a/app/etc/di.xml +++ b/app/etc/di.xml @@ -1356,4 +1356,11 @@ <argument name="scopeType" xsi:type="const">Magento\Framework\App\Config\ScopeConfigInterface::SCOPE_TYPE_DEFAULT</argument> </arguments> </type> + <type name="Magento\Framework\App\ScopeResolverPool"> + <arguments> + <argument name="scopeResolvers" xsi:type="array"> + <item name="default" xsi:type="object">Magento\Framework\App\ScopeResolver</item> + </argument> + </arguments> + </type> </config> diff --git a/composer.json b/composer.json index 891667b6ee942..d04bf269d4485 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "magento/magento2ce", "description": "Magento 2 (Open Source)", "type": "project", - "version": "2.2.7-dev", + "version": "2.2.8-dev", "license": [ "OSL-3.0", "AFL-3.0" @@ -74,7 +74,7 @@ "ramsey/uuid": "~3.7.3" }, "require-dev": { - "magento/magento2-functional-testing-framework": "2.3.11", + "magento/magento2-functional-testing-framework": "2.3.13", "phpunit/phpunit": "~6.2.0", "squizlabs/php_codesniffer": "3.2.2", "phpmd/phpmd": "@stable", @@ -87,125 +87,125 @@ "ext-pcntl": "Need for run processes in parallel mode" }, "replace": { - "magento/module-marketplace": "100.2.3", - "magento/module-admin-notification": "100.2.4", - "magento/module-advanced-pricing-import-export": "100.2.4", - "magento/module-analytics": "100.2.3", - "magento/module-authorization": "100.2.2", - "magento/module-authorizenet": "100.2.2", - "magento/module-backend": "100.2.6", - "magento/module-backup": "100.2.5", - "magento/module-braintree": "100.2.6", - "magento/module-bundle": "100.2.5", - "magento/module-bundle-import-export": "100.2.3", - "magento/module-cache-invalidate": "100.2.2", - "magento/module-captcha": "100.2.3", - "magento/module-catalog": "102.0.6", - "magento/module-catalog-analytics": "100.2.2", - "magento/module-catalog-import-export": "100.2.5", - "magento/module-catalog-inventory": "100.2.5", - "magento/module-catalog-rule": "101.0.5", - "magento/module-catalog-rule-configurable": "100.2.2", - "magento/module-catalog-search": "100.2.5", - "magento/module-catalog-url-rewrite": "100.2.5", - "magento/module-catalog-widget": "100.2.3", - "magento/module-checkout": "100.2.6", - "magento/module-checkout-agreements": "100.2.2", - "magento/module-cms": "102.0.6", - "magento/module-cms-url-rewrite": "100.2.2", - "magento/module-config": "101.0.6", - "magento/module-configurable-import-export": "100.2.3", - "magento/module-configurable-product": "100.2.6", - "magento/module-configurable-product-sales": "100.2.3", - "magento/module-contact": "100.2.3", - "magento/module-cookie": "100.2.2", - "magento/module-cron": "100.2.4", - "magento/module-currency-symbol": "100.2.2", - "magento/module-customer": "101.0.6", - "magento/module-customer-analytics": "100.2.2", - "magento/module-customer-import-export": "100.2.4", - "magento/module-deploy": "100.2.5", - "magento/module-developer": "100.2.4", - "magento/module-dhl": "100.2.3", - "magento/module-directory": "100.2.5", - "magento/module-downloadable": "100.2.5", - "magento/module-downloadable-import-export": "100.2.2", - "magento/module-eav": "101.0.5", - "magento/module-email": "100.2.4", - "magento/module-encryption-key": "100.2.2", - "magento/module-fedex": "100.2.3", - "magento/module-gift-message": "100.2.2", - "magento/module-google-adwords": "100.2.2", - "magento/module-google-analytics": "100.2.4", - "magento/module-google-optimizer": "100.2.3", - "magento/module-grouped-import-export": "100.2.2", - "magento/module-grouped-product": "100.2.4", - "magento/module-import-export": "100.2.6", - "magento/module-indexer": "100.2.4", - "magento/module-instant-purchase": "100.2.2", - "magento/module-integration": "100.2.4", - "magento/module-layered-navigation": "100.2.3", - "magento/module-media-storage": "100.2.2", - "magento/module-msrp": "100.2.2", - "magento/module-multishipping": "100.2.3", - "magento/module-new-relic-reporting": "100.2.4", - "magento/module-newsletter": "100.2.5", - "magento/module-offline-payments": "100.2.2", - "magento/module-offline-shipping": "100.2.4", - "magento/module-page-cache": "100.2.3", - "magento/module-payment": "100.2.4", - "magento/module-paypal": "100.2.4", - "magento/module-persistent": "100.2.2", - "magento/module-product-alert": "100.2.3", - "magento/module-product-video": "100.2.4", - "magento/module-quote": "101.0.5", - "magento/module-quote-analytics": "100.2.2", - "magento/module-release-notification": "100.2.3", - "magento/module-reports": "100.2.6", - "magento/module-require-js": "100.2.3", - "magento/module-review": "100.2.6", - "magento/module-review-analytics": "100.2.2", - "magento/module-robots": "100.2.3", - "magento/module-rss": "100.2.2", - "magento/module-rule": "100.2.3", - "magento/module-sales": "101.0.5", - "magento/module-sales-analytics": "100.2.2", - "magento/module-sales-inventory": "100.2.2", - "magento/module-sales-rule": "101.0.4", - "magento/module-sales-sequence": "100.2.2", - "magento/module-sample-data": "100.2.4", - "magento/module-search": "100.2.5", - "magento/module-security": "100.2.3", - "magento/module-send-friend": "100.2.2", - "magento/module-shipping": "100.2.6", - "magento/module-signifyd": "100.2.3", - "magento/module-sitemap": "100.2.5", - "magento/module-store": "100.2.5", - "magento/module-swagger-webapi": "100.2.0", - "magento/module-swagger": "100.2.4", - "magento/module-swatches": "100.2.4", - "magento/module-swatches-layered-navigation": "100.2.2", - "magento/module-tax": "100.2.6", - "magento/module-tax-import-export": "100.2.2", - "magento/module-theme": "100.2.6", - "magento/module-translation": "100.2.5", - "magento/module-ui": "101.0.6", - "magento/module-ups": "100.2.4", - "magento/module-url-rewrite": "101.0.5", - "magento/module-user": "101.0.4", - "magento/module-usps": "100.2.4", - "magento/module-variable": "100.2.5", - "magento/module-vault": "101.0.4", - "magento/module-version": "100.2.2", - "magento/module-webapi": "100.2.4", - "magento/module-webapi-security": "100.2.3", - "magento/module-weee": "100.2.3", - "magento/module-widget": "101.0.4", - "magento/module-wishlist": "101.0.4", - "magento/module-wishlist-analytics": "100.2.2", - "magento/theme-adminhtml-backend": "100.2.4", - "magento/theme-frontend-blank": "100.2.4", - "magento/theme-frontend-luma": "100.2.5", + "magento/module-marketplace": "100.2.4", + "magento/module-admin-notification": "100.2.5", + "magento/module-advanced-pricing-import-export": "100.2.5", + "magento/module-analytics": "100.2.4", + "magento/module-authorization": "100.2.3", + "magento/module-authorizenet": "100.2.3", + "magento/module-backend": "100.2.7", + "magento/module-backup": "100.2.6", + "magento/module-braintree": "100.2.7", + "magento/module-bundle": "100.2.6", + "magento/module-bundle-import-export": "100.2.4", + "magento/module-cache-invalidate": "100.2.3", + "magento/module-captcha": "100.2.4", + "magento/module-catalog": "102.0.7", + "magento/module-catalog-analytics": "100.2.3", + "magento/module-catalog-import-export": "100.2.6", + "magento/module-catalog-inventory": "100.2.6", + "magento/module-catalog-rule": "101.0.6", + "magento/module-catalog-rule-configurable": "100.2.3", + "magento/module-catalog-search": "100.2.6", + "magento/module-catalog-url-rewrite": "100.2.6", + "magento/module-catalog-widget": "100.2.4", + "magento/module-checkout": "100.2.7", + "magento/module-checkout-agreements": "100.2.3", + "magento/module-cms": "102.0.7", + "magento/module-cms-url-rewrite": "100.2.3", + "magento/module-config": "101.0.7", + "magento/module-configurable-import-export": "100.2.4", + "magento/module-configurable-product": "100.2.7", + "magento/module-configurable-product-sales": "100.2.4", + "magento/module-contact": "100.2.4", + "magento/module-cookie": "100.2.3", + "magento/module-cron": "100.2.5", + "magento/module-currency-symbol": "100.2.3", + "magento/module-customer": "101.0.7", + "magento/module-customer-analytics": "100.2.3", + "magento/module-customer-import-export": "100.2.5", + "magento/module-deploy": "100.2.6", + "magento/module-developer": "100.2.5", + "magento/module-dhl": "100.2.4", + "magento/module-directory": "100.2.6", + "magento/module-downloadable": "100.2.6", + "magento/module-downloadable-import-export": "100.2.3", + "magento/module-eav": "101.0.6", + "magento/module-email": "100.2.5", + "magento/module-encryption-key": "100.2.3", + "magento/module-fedex": "100.2.4", + "magento/module-gift-message": "100.2.3", + "magento/module-google-adwords": "100.2.3", + "magento/module-google-analytics": "100.2.5", + "magento/module-google-optimizer": "100.2.4", + "magento/module-grouped-import-export": "100.2.3", + "magento/module-grouped-product": "100.2.5", + "magento/module-import-export": "100.2.7", + "magento/module-indexer": "100.2.5", + "magento/module-instant-purchase": "100.2.3", + "magento/module-integration": "100.2.5", + "magento/module-layered-navigation": "100.2.4", + "magento/module-media-storage": "100.2.3", + "magento/module-msrp": "100.2.3", + "magento/module-multishipping": "100.2.4", + "magento/module-new-relic-reporting": "100.2.5", + "magento/module-newsletter": "100.2.6", + "magento/module-offline-payments": "100.2.3", + "magento/module-offline-shipping": "100.2.5", + "magento/module-page-cache": "100.2.4", + "magento/module-payment": "100.2.5", + "magento/module-paypal": "100.2.5", + "magento/module-persistent": "100.2.3", + "magento/module-product-alert": "100.2.4", + "magento/module-product-video": "100.2.5", + "magento/module-quote": "101.0.6", + "magento/module-quote-analytics": "100.2.3", + "magento/module-release-notification": "100.2.4", + "magento/module-reports": "100.2.7", + "magento/module-require-js": "100.2.4", + "magento/module-review": "100.2.7", + "magento/module-review-analytics": "100.2.3", + "magento/module-robots": "100.2.4", + "magento/module-rss": "100.2.3", + "magento/module-rule": "100.2.4", + "magento/module-sales": "101.0.6", + "magento/module-sales-analytics": "100.2.3", + "magento/module-sales-inventory": "100.2.3", + "magento/module-sales-rule": "101.0.5", + "magento/module-sales-sequence": "100.2.3", + "magento/module-sample-data": "100.2.5", + "magento/module-search": "100.2.6", + "magento/module-security": "100.2.4", + "magento/module-send-friend": "100.2.3", + "magento/module-shipping": "100.2.7", + "magento/module-signifyd": "100.2.4", + "magento/module-sitemap": "100.2.6", + "magento/module-store": "100.2.6", + "magento/module-swagger-webapi": "100.2.1", + "magento/module-swagger": "100.2.5", + "magento/module-swatches": "100.2.5", + "magento/module-swatches-layered-navigation": "100.2.3", + "magento/module-tax": "100.2.7", + "magento/module-tax-import-export": "100.2.3", + "magento/module-theme": "100.2.7", + "magento/module-translation": "100.2.6", + "magento/module-ui": "101.0.7", + "magento/module-ups": "100.2.5", + "magento/module-url-rewrite": "101.0.6", + "magento/module-user": "101.0.5", + "magento/module-usps": "100.2.5", + "magento/module-variable": "100.2.6", + "magento/module-vault": "101.0.5", + "magento/module-version": "100.2.3", + "magento/module-webapi": "100.2.5", + "magento/module-webapi-security": "100.2.4", + "magento/module-weee": "100.2.4", + "magento/module-widget": "101.0.5", + "magento/module-wishlist": "101.0.5", + "magento/module-wishlist-analytics": "100.2.3", + "magento/theme-adminhtml-backend": "100.2.5", + "magento/theme-frontend-blank": "100.2.5", + "magento/theme-frontend-luma": "100.2.6", "magento/language-de_de": "100.2.0", "magento/language-en_us": "100.2.0", "magento/language-es_es": "100.2.0", @@ -213,7 +213,7 @@ "magento/language-nl_nl": "100.2.0", "magento/language-pt_br": "100.2.0", "magento/language-zh_hans_cn": "100.2.0", - "magento/framework": "101.0.6", + "magento/framework": "101.0.7", "trentrichardson/jquery-timepicker-addon": "1.4.3", "components/jquery": "1.11.0", "blueimp/jquery-file-upload": "5.6.14", diff --git a/composer.lock b/composer.lock index 98c4846dc228a..384ff14618a80 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d101565e8093f4857dc138911992d64b", + "content-hash": "d57f148fd874b8685bf73bbf0a0ea75a", "packages": [ { "name": "braintree/braintree_php", @@ -1756,7 +1756,7 @@ }, { "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" + "email": "backendtea@gmail.com" } ], "description": "Symfony polyfill for ctype functions", @@ -4058,16 +4058,16 @@ "packages-dev": [ { "name": "allure-framework/allure-codeception", - "version": "1.2.7", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/allure-framework/allure-codeception.git", - "reference": "48598f4b4603b50b663bfe977260113a40912131" + "reference": "9d31d781b3622b028f1f6210bc76ba88438bd518" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/allure-framework/allure-codeception/zipball/48598f4b4603b50b663bfe977260113a40912131", - "reference": "48598f4b4603b50b663bfe977260113a40912131", + "url": "https://api.github.com/repos/allure-framework/allure-codeception/zipball/9d31d781b3622b028f1f6210bc76ba88438bd518", + "reference": "9d31d781b3622b028f1f6210bc76ba88438bd518", "shasum": "" }, "require": { @@ -4105,7 +4105,7 @@ "steps", "testing" ], - "time": "2018-03-07T11:18:27+00:00" + "time": "2018-12-18T19:47:23+00:00" }, { "name": "allure-framework/allure-php-api", @@ -5972,23 +5972,24 @@ }, { "name": "magento/magento2-functional-testing-framework", - "version": "2.3.11", + "version": "2.3.13", "source": { "type": "git", "url": "https://github.com/magento/magento2-functional-testing-framework.git", - "reference": "3ca1bd74228a61bd05520bed1ef88b5a19764d92" + "reference": "2c8a4c3557c9a8412eb2ea50ce3f69abc2f47ba1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/magento/magento2-functional-testing-framework/zipball/3ca1bd74228a61bd05520bed1ef88b5a19764d92", - "reference": "3ca1bd74228a61bd05520bed1ef88b5a19764d92", + "url": "https://api.github.com/repos/magento/magento2-functional-testing-framework/zipball/2c8a4c3557c9a8412eb2ea50ce3f69abc2f47ba1", + "reference": "2c8a4c3557c9a8412eb2ea50ce3f69abc2f47ba1", "shasum": "" }, "require": { - "allure-framework/allure-codeception": "~1.2.6", - "codeception/codeception": "~2.3.4", + "allure-framework/allure-codeception": "~1.3.0", + "codeception/codeception": "~2.3.4 || ~2.4.0 ", "consolidation/robo": "^1.0.0", "epfremme/swagger-php": "^2.0", + "ext-curl": "*", "flow/jsonpath": ">0.2", "fzaninotto/faker": "^1.6", "monolog/monolog": "^1.0", @@ -6005,6 +6006,7 @@ "goaop/framework": "2.2.0", "php-coveralls/php-coveralls": "^1.0", "phpmd/phpmd": "^2.6.0", + "phpunit/phpunit": "~6.5.0 || ~7.0.0", "rregeer/phpunit-coverage-check": "^0.1.4", "sebastian/phpcpd": "~3.0 || ~4.0", "squizlabs/php_codesniffer": "~3.2", @@ -6039,7 +6041,7 @@ "magento", "testing" ], - "time": "2018-11-13T18:22:25+00:00" + "time": "2019-01-29T15:31:14+00:00" }, { "name": "moontoast/math", diff --git a/dev/tests/acceptance/.gitignore b/dev/tests/acceptance/.gitignore index 7e27d5178fc48..9ca88b3597bad 100755 --- a/dev/tests/acceptance/.gitignore +++ b/dev/tests/acceptance/.gitignore @@ -6,5 +6,6 @@ tests/_output/* tests/functional.suite.yml tests/functional/Magento/FunctionalTest/_generated vendor/* -mftf.logm +mftf.log +/.credentials.example /utils/ diff --git a/dev/tests/acceptance/tests/_data/catalog_products.csv b/dev/tests/acceptance/tests/_data/catalog_products.csv new file mode 100644 index 0000000000000..e6651ffcfae21 --- /dev/null +++ b/dev/tests/acceptance/tests/_data/catalog_products.csv @@ -0,0 +1,4 @@ +sku,store_view_code,attribute_set_code,product_type,categories,product_websites,name,description,short_description,weight,product_online,tax_class_name,visibility,price,special_price,special_price_from_date,special_price_to_date,url_key,meta_title,meta_keywords,meta_description,base_image,base_image_label,small_image,small_image_label,thumbnail_image,thumbnail_image_label,swatch_image,swatch_image_label,created_at,updated_at,new_from_date,new_to_date,display_product_options_in,map_price,msrp_price,map_enabled,gift_message_available,custom_design,custom_design_from,custom_design_to,custom_layout_update,page_layout,product_options_container,msrp_display_actual_price_type,country_of_manufacture,additional_attributes,qty,out_of_stock_qty,use_config_min_qty,is_qty_decimal,allow_backorders,use_config_backorders,min_cart_qty,use_config_min_sale_qty,max_cart_qty,use_config_max_sale_qty,is_in_stock,notify_on_stock_below,use_config_notify_stock_qty,manage_stock,use_config_manage_stock,use_config_qty_increments,qty_increments,use_config_enable_qty_inc,enable_qty_increments,is_decimal_divided,website_id,deferred_stock_update,use_config_deferred_stock_update,related_skus,related_position,crosssell_skus,crosssell_position,upsell_skus,upsell_position,additional_images,additional_image_labels,hide_from_product_page,custom_options,bundle_price_type,bundle_sku_type,bundle_price_view,bundle_weight_type,bundle_values,bundle_shipment_type,configurable_variations,configurable_variation_labels,associated_skus +"Simple Product for Test",,Default,simple,,base,"Simple Product for Test",,,,1,"Taxable Goods","Catalog, Search",123.0000,,,,simple-product-for-test,,,,,,,,,,,,"12/26/18, 5:05 AM","12/26/18, 5:05 AM",,,"Block after Info Column",,,,,,,,,,,"Use config",,,1000.0000,0.0000,1,0,0,1,1.0000,1,0.0000,1,1,,1,0,1,1,0.0000,1,0,0,0,0,1,,,,,,,,,,,,,,,,,,, +"Virtual Product for Test",,Default,virtual,,base,"Virtual Product for Test",,,0.0000,1,"Taxable Goods","Catalog, Search",99.9900,,,,virtual-product-for-test,,,,,,,,,,,,"12/26/18, 5:05 AM","12/26/18, 5:05 AM",,,"Block after Info Column",,,,,,,,,,,"Use config",,,1000.0000,0.0000,1,0,0,1,1.0000,1,0.0000,1,1,,1,0,1,1,0.0000,1,0,0,0,0,1,,,,,,,,,,,,,,,,,,, +"Api Downloadable Product for Test",,Default,downloadable,,base,"Api Downloadable Product for Test","API Product Description5c23605cc09b71","API Product Short Description5c23605cc0a111",,1,"Taxable Goods","Catalog, Search",123.0000,,,,api-downloadable-product-for-test,,,,,,,,,,,,"12/26/18, 5:05 AM","12/26/18, 5:05 AM",,,"Block after Info Column",,,,,,,,,,,"Use config",,,1000.0000,0.0000,1,0,0,1,1.0000,1,0.0000,1,1,,1,0,1,1,0.0000,1,0,0,0,0,1,,,,,,,,,,,,,,,,,,, diff --git a/dev/tests/acceptance/tests/_data/import_updated.csv b/dev/tests/acceptance/tests/_data/import_updated.csv new file mode 100644 index 0000000000000..620af02641ecc --- /dev/null +++ b/dev/tests/acceptance/tests/_data/import_updated.csv @@ -0,0 +1,4 @@ +product_websites,store_view_code,attribute_set_code,product_type,categories,sku,price,name,url_key +base,,Default,simple,Default Category/category-admin,productformagetwo76287,123,productformagetwo76287,productformagetwo76287 +,en,Default,simple,,productformagetwo76287,,productformagetwo76287-english,productformagetwo76287-english +,nl,Default,simple,,productformagetwo76287,,productformagetwo76287-dutch,productformagetwo76287-dutch diff --git a/dev/tests/acceptance/tests/_data/magento2.jpg b/dev/tests/acceptance/tests/_data/magento2.jpg new file mode 100644 index 0000000000000..d0b76b45d46be Binary files /dev/null and b/dev/tests/acceptance/tests/_data/magento2.jpg differ diff --git a/dev/tests/acceptance/tests/_suite/WYSIWYGDisabledSuite.xml b/dev/tests/acceptance/tests/_suite/WYSIWYGDisabledSuite.xml new file mode 100644 index 0000000000000..65c2bb7004503 --- /dev/null +++ b/dev/tests/acceptance/tests/_suite/WYSIWYGDisabledSuite.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<suites xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../vendor/magento/magento2-functional-testing-framework/src/Magento/FunctionalTestingFramework/Suite/etc/suiteSchema.xsd"> + <suite name="WYSIWYGDisabledSuite"> + <before> + <magentoCLI stepKey="disableWYSYWYG" command="config:set cms/wysiwyg/enabled disabled" /> + </before> + <include> + <group name="WYSIWYGDisabled"/> + </include> + <exclude> + <group name="skip"/> + </exclude> + <after> + <magentoCLI stepKey="disableWYSYWYG" command="config:set cms/wysiwyg/enabled enabled" /> + </after> + </suite> +</suites> \ No newline at end of file diff --git a/dev/tests/api-functional/testsuite/Magento/Customer/Api/CustomerMetadataTest.php b/dev/tests/api-functional/testsuite/Magento/Customer/Api/CustomerMetadataTest.php index f2632aa1481e4..6a68925f76cbc 100644 --- a/dev/tests/api-functional/testsuite/Magento/Customer/Api/CustomerMetadataTest.php +++ b/dev/tests/api-functional/testsuite/Magento/Customer/Api/CustomerMetadataTest.php @@ -9,6 +9,7 @@ use Magento\Customer\Api\Data\CustomerInterface as Customer; use Magento\Customer\Model\Data\AttributeMetadata; use Magento\TestFramework\TestCase\WebapiAbstract; +use Magento\TestFramework\Helper\Bootstrap; /** * Class CustomerMetadataTest @@ -19,6 +20,19 @@ class CustomerMetadataTest extends WebapiAbstract const SERVICE_VERSION = "V1"; const RESOURCE_PATH = "/V1/attributeMetadata/customer"; + /** + * @var CustomerMetadataInterface + */ + private $customerMetadata; + + /** + * @inheritdoc + */ + public function setUp() + { + $this->customerMetadata = Bootstrap::getObjectManager()->create(CustomerMetadataInterface::class); + } + /** * Test retrieval of attribute metadata for the customer entity type. * @@ -200,8 +214,7 @@ public function testGetCustomAttributesMetadata() $attributeMetadata = $this->_webApiCall($serviceInfo); - // There are no default custom attributes. - $this->assertCount(0, $attributeMetadata); + $this->assertCount(count($this->customerMetadata->getCustomAttributesMetadata()), $attributeMetadata); } /** diff --git a/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/CreditMemoCreateRefundTest.php b/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/CreditMemoCreateRefundTest.php index eae0e600434a6..2b9c539f64e66 100644 --- a/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/CreditMemoCreateRefundTest.php +++ b/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/CreditMemoCreateRefundTest.php @@ -10,7 +10,7 @@ use Magento\TestFramework\TestCase\WebapiAbstract; /** - * Class CreditMemoCreateRefundTest + * API tests for CreditMemoCreateRefund. */ class CreditMemoCreateRefundTest extends WebapiAbstract { @@ -25,12 +25,17 @@ class CreditMemoCreateRefundTest extends WebapiAbstract */ protected $objectManager; + /** + * @inheritdoc + */ protected function setUp() { $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); } /** + * Test for Invoke method. + * * @magentoApiDataFixture Magento/Sales/_files/invoice.php */ public function testInvoke() @@ -115,8 +120,7 @@ public function testInvoke() ); $this->assertNotEmpty($result); $order = $this->objectManager->get(OrderRepositoryInterface::class)->get($order->getId()); - //Totally refunded orders still can be processed and shipped. - $this->assertEquals(Order::STATE_PROCESSING, $order->getState()); + $this->assertEquals(Order::STATE_CLOSED, $order->getState()); } private function getItemsForRest($order) diff --git a/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderGetTest.php b/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderGetTest.php index 6715c73510290..0eb9e4229b957 100644 --- a/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderGetTest.php +++ b/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderGetTest.php @@ -197,4 +197,42 @@ private function getBundleProduct(array $items): array return []; } + + /** + * @return void + * @magentoApiDataFixture Magento/Sales/_files/order_with_tax.php + */ + public function testOrderGetExtensionAttributes() + { + $expectedTax = [ + 'code' => 'US-NY-*-Rate 1', + 'type' => 'shipping', + ]; + + /** @var \Magento\Sales\Model\Order $order */ + $order = $this->objectManager->create(\Magento\Sales\Model\Order::class); + $order->loadByIncrementId(self::ORDER_INCREMENT_ID); + + $serviceInfo = [ + 'rest' => [ + 'resourcePath' => self::RESOURCE_PATH . '/' . $order->getId(), + 'httpMethod' => \Magento\Framework\Webapi\Rest\Request::HTTP_METHOD_GET, + ], + 'soap' => [ + 'service' => self::SERVICE_READ_NAME, + 'serviceVersion' => self::SERVICE_VERSION, + 'operation' => self::SERVICE_READ_NAME . 'get', + ], + ]; + $result = $this->_webApiCall($serviceInfo, ['id' => $order->getId()]); + + $appliedTaxes = $result['extension_attributes']['applied_taxes']; + $this->assertEquals($expectedTax['code'], $appliedTaxes[0]['code']); + $appliedTaxes = $result['extension_attributes']['item_applied_taxes']; + $this->assertEquals($expectedTax['type'], $appliedTaxes[0]['type']); + $this->assertNotEmpty($appliedTaxes[0]['applied_taxes']); + $this->assertEquals(true, $result['extension_attributes']['converting_from_quote']); + $this->assertArrayHasKey('payment_additional_info', $result['extension_attributes']); + $this->assertNotEmpty($result['extension_attributes']['payment_additional_info']); + } } diff --git a/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderItemGetTest.php b/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderItemGetTest.php index 3ab93f9aecb99..a527c69c7e92d 100644 --- a/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderItemGetTest.php +++ b/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderItemGetTest.php @@ -77,4 +77,37 @@ protected function assertOrderItem(\Magento\Sales\Model\Order\Item $orderItem, a $this->assertEquals($orderItem->getBasePrice(), $response['base_price']); $this->assertEquals($orderItem->getRowTotal(), $response['row_total']); } + + /** + * @return void + * @magentoApiDataFixture Magento/Sales/_files/order_with_discount.php + */ + public function testGetOrderWithDiscount() + { + /** @var \Magento\Sales\Model\Order $order */ + $order = $this->objectManager->create(\Magento\Sales\Model\Order::class); + $order->loadByIncrementId(self::ORDER_INCREMENT_ID); + /** @var \Magento\Sales\Model\Order\Item $orderItem */ + $orderItem = current($order->getItems()); + + $serviceInfo = [ + 'rest' => [ + 'resourcePath' => self::RESOURCE_PATH . '/' . $orderItem->getId(), + 'httpMethod' => \Magento\Framework\Webapi\Rest\Request::HTTP_METHOD_GET, + ], + 'soap' => [ + 'service' => self::SERVICE_NAME, + 'serviceVersion' => self::SERVICE_VERSION, + 'operation' => self::SERVICE_NAME . 'get', + ], + ]; + + $response = $this->_webApiCall($serviceInfo, ['id' => $orderItem->getId()]); + + $this->assertTrue(is_array($response)); + $this->assertEquals(8.00, $response['row_total']); + $this->assertEquals(8.00, $response['base_row_total']); + $this->assertEquals(9.00, $response['row_total_incl_tax']); + $this->assertEquals(9.00, $response['base_row_total_incl_tax']); + } } diff --git a/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderListTest.php b/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderListTest.php index 0500f31858291..5e092bb1ebd69 100644 --- a/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderListTest.php +++ b/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/OrderListTest.php @@ -30,9 +30,78 @@ protected function setUp() } /** + * @return void * @magentoApiDataFixture Magento/Sales/_files/order_list.php */ public function testOrderList() + { + $searchData = $this->getSearchData(); + + $requestData = ['searchCriteria' => $searchData]; + $serviceInfo = [ + 'rest' => [ + 'resourcePath' => self::RESOURCE_PATH . '?' . http_build_query($requestData), + 'httpMethod' => \Magento\Framework\Webapi\Rest\Request::HTTP_METHOD_GET, + ], + 'soap' => [ + 'service' => self::SERVICE_READ_NAME, + 'serviceVersion' => self::SERVICE_VERSION, + 'operation' => self::SERVICE_READ_NAME . 'getList', + ], + ]; + + $result = $this->_webApiCall($serviceInfo, $requestData); + $this->assertArrayHasKey('items', $result); + $this->assertCount(2, $result['items']); + $this->assertArrayHasKey('search_criteria', $result); + $this->assertEquals($searchData, $result['search_criteria']); + $this->assertEquals('100000002', $result['items'][0]['increment_id']); + $this->assertEquals('100000001', $result['items'][1]['increment_id']); + } + + /** + * @return void + * @magentoApiDataFixture Magento/Sales/_files/order_list_with_tax.php + */ + public function testOrderListExtensionAttributes() + { + $searchData = $this->getSearchData(); + + $requestData = ['searchCriteria' => $searchData]; + $serviceInfo = [ + 'rest' => [ + 'resourcePath' => self::RESOURCE_PATH . '?' . http_build_query($requestData), + 'httpMethod' => \Magento\Framework\Webapi\Rest\Request::HTTP_METHOD_GET, + ], + 'soap' => [ + 'service' => self::SERVICE_READ_NAME, + 'serviceVersion' => self::SERVICE_VERSION, + 'operation' => self::SERVICE_READ_NAME . 'getList', + ], + ]; + + $result = $this->_webApiCall($serviceInfo, $requestData); + + $expectedTax = [ + 'code' => 'US-NY-*-Rate 1', + 'type' => 'shipping', + ]; + $appliedTaxes = $result['items'][0]['extension_attributes']['applied_taxes']; + $this->assertEquals($expectedTax['code'], $appliedTaxes[0]['code']); + $appliedTaxes = $result['items'][0]['extension_attributes']['item_applied_taxes']; + $this->assertEquals($expectedTax['type'], $appliedTaxes[0]['type']); + $this->assertNotEmpty($appliedTaxes[0]['applied_taxes']); + $this->assertEquals(true, $result['items'][0]['extension_attributes']['converting_from_quote']); + $this->assertArrayHasKey('payment_additional_info', $result['items'][0]['extension_attributes']); + $this->assertNotEmpty($result['items'][0]['extension_attributes']['payment_additional_info']); + } + + /** + * Get search data for request. + * + * @return array + */ + private function getSearchData() : array { /** @var \Magento\Framework\Api\SortOrderBuilder $sortOrderBuilder */ $sortOrderBuilder = $this->objectManager->get( @@ -70,25 +139,6 @@ public function testOrderList() $searchCriteriaBuilder->addSortOrder($sortOrder); $searchData = $searchCriteriaBuilder->create()->__toArray(); - $requestData = ['searchCriteria' => $searchData]; - $serviceInfo = [ - 'rest' => [ - 'resourcePath' => self::RESOURCE_PATH . '?' . http_build_query($requestData), - 'httpMethod' => \Magento\Framework\Webapi\Rest\Request::HTTP_METHOD_GET, - ], - 'soap' => [ - 'service' => self::SERVICE_READ_NAME, - 'serviceVersion' => self::SERVICE_VERSION, - 'operation' => self::SERVICE_READ_NAME . 'getList', - ], - ]; - - $result = $this->_webApiCall($serviceInfo, $requestData); - $this->assertArrayHasKey('items', $result); - $this->assertCount(2, $result['items']); - $this->assertArrayHasKey('search_criteria', $result); - $this->assertEquals($searchData, $result['search_criteria']); - $this->assertEquals('100000002', $result['items'][0]['increment_id']); - $this->assertEquals('100000001', $result['items'][1]['increment_id']); + return $searchData; } } diff --git a/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/RefundOrderTest.php b/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/RefundOrderTest.php index aacda763ca2aa..69bbecc1317a7 100644 --- a/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/RefundOrderTest.php +++ b/dev/tests/api-functional/testsuite/Magento/Sales/Service/V1/RefundOrderTest.php @@ -5,8 +5,10 @@ */ namespace Magento\Sales\Service\V1; +use Magento\Sales\Model\Order; + /** - * API test for creation of Creditmemo for certain Order. + * API tests for creation of Creditmemo for certain Order. */ class RefundOrderTest extends \Magento\TestFramework\TestCase\WebapiAbstract { @@ -23,6 +25,9 @@ class RefundOrderTest extends \Magento\TestFramework\TestCase\WebapiAbstract */ private $creditmemoRepository; + /** + * @inheritdoc + */ protected function setUp() { $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); @@ -86,9 +91,8 @@ public function testShortRequest() 'Failed asserting that proper shipping amount of the Order was refunded' ); - //Totally refunded orders can be processed. $this->assertEquals( - $existingOrder->getStatus(), + Order::STATE_COMPLETE, $updatedOrder->getStatus(), 'Failed asserting that order status has not changed' ); diff --git a/dev/tests/functional/lib/Magento/Mtf/App/State/State1.php b/dev/tests/functional/lib/Magento/Mtf/App/State/State1.php index 60abe18f5a7ba..fc54e73ff1ac2 100644 --- a/dev/tests/functional/lib/Magento/Mtf/App/State/State1.php +++ b/dev/tests/functional/lib/Magento/Mtf/App/State/State1.php @@ -7,6 +7,7 @@ namespace Magento\Mtf\App\State; use Magento\Mtf\ObjectManager; +use Magento\Mtf\Util\Command\Cli; use Magento\Mtf\Util\Protocol\CurlInterface; use Magento\Mtf\Util\Protocol\CurlTransport; @@ -27,7 +28,7 @@ class State1 extends AbstractState * * @var string */ - protected $config ='admin_session_lifetime_1_hour, wysiwyg_disabled, admin_account_sharing_enable, log_to_file'; + protected $config ='admin_session_lifetime_1_hour, wysiwyg_disabled, admin_account_sharing_enable'; /** * HTTP CURL Adapter. @@ -55,6 +56,7 @@ public function __construct( * Apply set up configuration profile. * * @return void + * @throws \Exception */ public function apply() { @@ -67,6 +69,10 @@ public function apply() ['configData' => $this->config] )->run(); } + + /** @var Cli $cli */ + $cli = $this->objectManager->create(Cli::class); + $cli->execute('setup:config:set', ['--enable-debug-logging=true']); } /** diff --git a/dev/tests/functional/tests/app/Magento/Analytics/Test/TestCase/NavigateMenuTest.xml b/dev/tests/functional/tests/app/Magento/Analytics/Test/TestCase/NavigateMenuTest.xml index 9c19f80e91d39..76f60a51c0ece 100644 --- a/dev/tests/functional/tests/app/Magento/Analytics/Test/TestCase/NavigateMenuTest.xml +++ b/dev/tests/functional/tests/app/Magento/Analytics/Test/TestCase/NavigateMenuTest.xml @@ -8,9 +8,10 @@ <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../../vendor/magento/mtf/etc/variations.xsd"> <testCase name="Magento\Backend\Test\TestCase\NavigateMenuTest" summary="Navigate to menu chapter"> <variation name="NavigateMenuTestBIEssentials" summary="Navigate through BI Essentials admin menu to Sign Up page" ticketId="MAGETWO-63700"> + <data name="issue" xsi:type="string">MAGETWO-97298: [2.2-develop] Magento\Backend\Test\TestCase\NavigateMenuTest fails on Jenkins</data> <data name="menuItem" xsi:type="string">Reports > BI Essentials</data> <data name="waitMenuItemNotVisible" xsi:type="boolean">false</data> - <data name="businessIntelligenceLink" xsi:type="string">https://dashboard.rjmetrics.com/v2/magento/signup</data> + <data name="businessIntelligenceLink" xsi:type="string">https://account.magento.com/onboarding/steps/view/step/gmv/</data> <constraint name="Magento\Analytics\Test\Constraint\AssertBIEssentialsLink" /> </variation> <variation name="NavigateMenuTestAdvancedReporting" summary="Navigate through Advanced Reporting admin menu to BI Reports page" ticketId="MAGETWO-65748"> diff --git a/dev/tests/functional/tests/app/Magento/Backend/Test/Repository/ConfigData.xml b/dev/tests/functional/tests/app/Magento/Backend/Test/Repository/ConfigData.xml index 1792ddb5abdc9..5587d605e14b3 100644 --- a/dev/tests/functional/tests/app/Magento/Backend/Test/Repository/ConfigData.xml +++ b/dev/tests/functional/tests/app/Magento/Backend/Test/Repository/ConfigData.xml @@ -175,15 +175,6 @@ </field> </dataset> - <dataset name="log_to_file"> - <field name="dev/debug/debug_logging" xsi:type="array"> - <item name="scope" xsi:type="string">default</item> - <item name="scope_id" xsi:type="number">0</item> - <item name="label" xsi:type="string">Yes</item> - <item name="value" xsi:type="number">1</item> - </field> - </dataset> - <dataset name="minify_js_files"> <field name="dev/js/minify_files" xsi:type="array"> <item name="scope" xsi:type="string">default</item> diff --git a/dev/tests/functional/tests/app/Magento/Backup/Test/Repository/ConfigData.xml b/dev/tests/functional/tests/app/Magento/Backup/Test/Repository/ConfigData.xml new file mode 100644 index 0000000000000..c8b19aa2bd32b --- /dev/null +++ b/dev/tests/functional/tests/app/Magento/Backup/Test/Repository/ConfigData.xml @@ -0,0 +1,24 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="../../../../../../vendor/magento/mtf/Magento/Mtf/Repository/etc/repository.xsd"> + <repository class="Magento\Config\Test\Repository\ConfigData"> + <dataset name="enable_backups_functionality"> + <field name="system/backup/functionality_enabled" xsi:type="array"> + <item name="label" xsi:type="string">Yes</item> + <item name="value" xsi:type="number">1</item> + </field> + </dataset> + <dataset name="enable_backups_functionality_rollback"> + <field name="web/url/use_store" xsi:type="array"> + <item name="label" xsi:type="string">No</item> + <item name="value" xsi:type="number">0</item> + </field> + </dataset> + </repository> +</config> diff --git a/dev/tests/functional/tests/app/Magento/Backup/Test/TestCase/NavigateMenuTest.xml b/dev/tests/functional/tests/app/Magento/Backup/Test/TestCase/NavigateMenuTest.xml deleted file mode 100644 index 0c024f0e3f5aa..0000000000000 --- a/dev/tests/functional/tests/app/Magento/Backup/Test/TestCase/NavigateMenuTest.xml +++ /dev/null @@ -1,16 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ - --> -<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../../vendor/magento/mtf/etc/variations.xsd"> - <testCase name="Magento\Backend\Test\TestCase\NavigateMenuTest"> - <variation name="NavigateMenuTest7"> - <data name="menuItem" xsi:type="string">System > Backups</data> - <data name="pageTitle" xsi:type="string">Backups</data> - <constraint name="Magento\Backend\Test\Constraint\AssertBackendPageIsAvailable"/> - </variation> - </testCase> -</config> diff --git a/dev/tests/functional/tests/app/Magento/Catalog/Test/Block/Adminhtml/Product/Edit/Section/Options.php b/dev/tests/functional/tests/app/Magento/Catalog/Test/Block/Adminhtml/Product/Edit/Section/Options.php index 0b13b0eedb6f3..807b9d9ab9a3c 100644 --- a/dev/tests/functional/tests/app/Magento/Catalog/Test/Block/Adminhtml/Product/Edit/Section/Options.php +++ b/dev/tests/functional/tests/app/Magento/Catalog/Test/Block/Adminhtml/Product/Edit/Section/Options.php @@ -402,4 +402,21 @@ public function getFileOptionElements() { return $this->_rootElement->getElements($this->hintMessage); } + + /** + * @inheritdoc + */ + protected function _fill(array $fields, SimpleElement $element = null) + { + $context = ($element === null) ? $this->_rootElement : $element; + foreach ($fields as $name => $field) { + $element = $this->getElement($context, $field); + if (!$element->isDisabled()) { + $element->getContext()->hover(); + $element->setValue($field['value']); + } else { + throw new \Exception("Unable to set value to field '$name' as it's disabled."); + } + } + } } diff --git a/dev/tests/functional/tests/app/Magento/Catalog/Test/Handler/CatalogProductAttribute/Curl.php b/dev/tests/functional/tests/app/Magento/Catalog/Test/Handler/CatalogProductAttribute/Curl.php index f1086f4871f3b..2d9dea375e97c 100644 --- a/dev/tests/functional/tests/app/Magento/Catalog/Test/Handler/CatalogProductAttribute/Curl.php +++ b/dev/tests/functional/tests/app/Magento/Catalog/Test/Handler/CatalogProductAttribute/Curl.php @@ -91,15 +91,18 @@ public function persist(FixtureInterface $fixture = null) $data['frontend_label'] = [0 => $data['frontend_label']]; if (isset($data['options'])) { + $optionsData = []; foreach ($data['options'] as $key => $values) { + $optionRowData = []; $index = 'option_' . $key; if ($values['is_default'] == 'Yes') { - $data['default'][] = $index; + $optionRowData['default'][] = $index; } - $data['option']['value'][$index] = [$values['admin'], $values['view']]; - $data['option']['order'][$index] = $key; + $optionRowData['option']['value'][$index] = [$values['admin'], $values['view']]; + $optionRowData['option']['order'][$index] = $key; + $optionsData[] = $optionRowData; } - unset($data['options']); + $data['options'] = $optionsData; } $data = $this->changeStructureOfTheData($data); @@ -134,11 +137,39 @@ public function persist(FixtureInterface $fixture = null) } /** + * Additional data handling. + * * @param array $data * @return array */ - protected function changeStructureOfTheData(array $data) + protected function changeStructureOfTheData(array $data): array { + if (!isset($data['options'])) { + return $data; + } + + $serializedOptions = $this->getSerializeOptions($data['options']); + if ($serializedOptions) { + $data['serialized_options'] = $serializedOptions; + unset($data['options']); + } + return $data; } + + /** + * Provides serialized product attribute options. + * + * @param array $data + * @return string + */ + protected function getSerializeOptions(array $data): string + { + $options = []; + foreach ($data as $optionRowData) { + $options[] = http_build_query($optionRowData); + } + + return json_encode($options); + } } diff --git a/dev/tests/functional/tests/app/Magento/Catalog/Test/Repository/CatalogProductSimple.xml b/dev/tests/functional/tests/app/Magento/Catalog/Test/Repository/CatalogProductSimple.xml index 721b0ff570079..e90ca6bf7868a 100644 --- a/dev/tests/functional/tests/app/Magento/Catalog/Test/Repository/CatalogProductSimple.xml +++ b/dev/tests/functional/tests/app/Magento/Catalog/Test/Repository/CatalogProductSimple.xml @@ -41,7 +41,7 @@ <field name="attribute_set_id" xsi:type="array"> <item name="dataset" xsi:type="string">default</item> </field> - <field name="name" xsi:type="string">Product \'!@#$%^&*()+:;\\|}{][?=-~` %isolation%</field> + <field name="name" xsi:type="string">Product \'!@#$%^&*()+:;\\|}{][?=~` %isolation%</field> <field name="sku" xsi:type="string">sku_simple_product_%isolation%</field> <field name="is_virtual" xsi:type="string">No</field> <field name="product_has_weight" xsi:type="string">This item has weight</field> diff --git a/dev/tests/functional/tests/app/Magento/Catalog/Test/TestCase/Product/ProductTypeSwitchingOnCreationTest.xml b/dev/tests/functional/tests/app/Magento/Catalog/Test/TestCase/Product/ProductTypeSwitchingOnCreationTest.xml index 7c4824c604e29..49cd2a347c687 100644 --- a/dev/tests/functional/tests/app/Magento/Catalog/Test/TestCase/Product/ProductTypeSwitchingOnCreationTest.xml +++ b/dev/tests/functional/tests/app/Magento/Catalog/Test/TestCase/Product/ProductTypeSwitchingOnCreationTest.xml @@ -58,7 +58,6 @@ <constraint name="Magento\ConfigurableProduct\Test\Constraint\AssertChildProductIsNotDisplayedSeparately" /> </variation> <variation name="ProductTypeSwitchingOnCreationTestVariation7"> - <data name="tag" xsi:type="string">stable:no</data> <data name="createProduct" xsi:type="string">virtual</data> <data name="product" xsi:type="string">downloadableProduct::default</data> <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> diff --git a/dev/tests/functional/tests/app/Magento/Catalog/Test/TestCase/Product/ProductTypeSwitchingOnUpdateTest.php b/dev/tests/functional/tests/app/Magento/Catalog/Test/TestCase/Product/ProductTypeSwitchingOnUpdateTest.php index 43741393e7968..90cd6bdb76328 100644 --- a/dev/tests/functional/tests/app/Magento/Catalog/Test/TestCase/Product/ProductTypeSwitchingOnUpdateTest.php +++ b/dev/tests/functional/tests/app/Magento/Catalog/Test/TestCase/Product/ProductTypeSwitchingOnUpdateTest.php @@ -143,5 +143,6 @@ protected function clearDownloadableData() /** @var Downloadable $downloadableInfoTab */ $downloadableInfoTab = $this->catalogProductEdit->getProductForm()->getSection('downloadable_information'); $downloadableInfoTab->getDownloadableBlock('Links')->clearDownloadableData(); + $downloadableInfoTab->setIsDownloadable('No'); } } diff --git a/dev/tests/functional/tests/app/Magento/Catalog/Test/TestCase/Product/ProductTypeSwitchingOnUpdateTest.xml b/dev/tests/functional/tests/app/Magento/Catalog/Test/TestCase/Product/ProductTypeSwitchingOnUpdateTest.xml index f3df374a8bac8..5fa1cfe5e5911 100644 --- a/dev/tests/functional/tests/app/Magento/Catalog/Test/TestCase/Product/ProductTypeSwitchingOnUpdateTest.xml +++ b/dev/tests/functional/tests/app/Magento/Catalog/Test/TestCase/Product/ProductTypeSwitchingOnUpdateTest.xml @@ -11,7 +11,6 @@ <data name="productOrigin" xsi:type="string">catalogProductSimple::default</data> <data name="product" xsi:type="string">configurableProduct::default</data> <data name="actionName" xsi:type="string">-</data> - <data name="tag" xsi:type="string">to_maintain:yes</data> <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> <constraint name="Magento\ConfigurableProduct\Test\Constraint\AssertChildProductsInGrid" /> @@ -21,7 +20,6 @@ <constraint name="Magento\ConfigurableProduct\Test\Constraint\AssertChildProductIsNotDisplayedSeparately" /> </variation> <variation name="ProductTypeSwitchingOnUpdateTestVariation2"> - <data name="tag" xsi:type="string">to_maintain:yes</data> <data name="productOrigin" xsi:type="string">catalogProductSimple::default</data> <data name="product" xsi:type="string">catalogProductVirtual::default</data> <data name="actionName" xsi:type="string">-</data> @@ -29,7 +27,6 @@ <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> </variation> <variation name="ProductTypeSwitchingOnUpdateTestVariation3"> - <data name="tag" xsi:type="string">stable:no</data> <data name="productOrigin" xsi:type="string">configurableProduct::default</data> <data name="product" xsi:type="string">catalogProductSimple::product_without_category</data> <data name="actionName" xsi:type="string">deleteVariations</data> @@ -40,12 +37,10 @@ <data name="productOrigin" xsi:type="string">configurableProduct::default</data> <data name="product" xsi:type="string">catalogProductVirtual::required_fields</data> <data name="actionName" xsi:type="string">deleteVariations</data> - <data name="tag" xsi:type="string">to_maintain:yes</data> <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> </variation> <variation name="ProductTypeSwitchingOnUpdateTestVariation5"> - <data name="tag" xsi:type="string">to_maintain:yes</data> <data name="productOrigin" xsi:type="string">catalogProductVirtual::default</data> <data name="product" xsi:type="string">catalogProductSimple::default</data> <data name="actionName" xsi:type="string">-</data> @@ -56,7 +51,6 @@ <data name="productOrigin" xsi:type="string">catalogProductVirtual::default</data> <data name="product" xsi:type="string">configurableProduct::not_virtual_for_type_switching</data> <data name="actionName" xsi:type="string">-</data> - <data name="tag" xsi:type="string">to_maintain:yes</data> <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> <constraint name="Magento\ConfigurableProduct\Test\Constraint\AssertChildProductsInGrid" /> @@ -69,7 +63,6 @@ <data name="productOrigin" xsi:type="string">catalogProductVirtual::default</data> <data name="product" xsi:type="string">downloadableProduct::default</data> <data name="actionName" xsi:type="string">-</data> - <data name="tag" xsi:type="string">to_maintain:yes</data> <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm" /> @@ -81,15 +74,13 @@ <data name="productOrigin" xsi:type="string">downloadableProduct::default</data> <data name="product" xsi:type="string">catalogProductSimple::default</data> <data name="actionName" xsi:type="string">-</data> - <data name="tag" xsi:type="string">to_maintain:yes</data> <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> </variation> <variation name="ProductTypeSwitchingOnUpdateTestVariation9"> <data name="productOrigin" xsi:type="string">downloadableProduct::default</data> <data name="product" xsi:type="string">configurableProduct::not_virtual_for_type_switching</data> - <data name="actionName" xsi:type="string">-</data> - <data name="tag" xsi:type="string">to_maintain:yes</data> + <data name="actionName" xsi:type="string">clearDownloadableData</data> <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> <constraint name="Magento\ConfigurableProduct\Test\Constraint\AssertChildProductsInGrid" /> @@ -99,7 +90,6 @@ <constraint name="Magento\ConfigurableProduct\Test\Constraint\AssertChildProductIsNotDisplayedSeparately" /> </variation> <variation name="ProductTypeSwitchingOnUpdateTestVariation10"> - <data name="tag" xsi:type="string">stable:no</data> <data name="productOrigin" xsi:type="string">downloadableProduct::default</data> <data name="product" xsi:type="string">catalogProductVirtual::default</data> <data name="actionName" xsi:type="string">clearDownloadableData</data> @@ -110,7 +100,6 @@ <data name="productOrigin" xsi:type="string">catalogProductSimple::default</data> <data name="product" xsi:type="string">downloadableProduct::default</data> <data name="actionName" xsi:type="string">-</data> - <data name="tag" xsi:type="string">to_maintain:yes</data> <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm" /> diff --git a/dev/tests/functional/tests/app/Magento/CatalogRule/Test/Constraint/AssertCatalogPriceRuleAppliedShoppingCart.php b/dev/tests/functional/tests/app/Magento/CatalogRule/Test/Constraint/AssertCatalogPriceRuleAppliedShoppingCart.php index 4ecbb382e66fe..3830bd483743c 100644 --- a/dev/tests/functional/tests/app/Magento/CatalogRule/Test/Constraint/AssertCatalogPriceRuleAppliedShoppingCart.php +++ b/dev/tests/functional/tests/app/Magento/CatalogRule/Test/Constraint/AssertCatalogPriceRuleAppliedShoppingCart.php @@ -6,6 +6,7 @@ namespace Magento\CatalogRule\Test\Constraint; +use Magento\Checkout\Test\Constraint\Utils\CartPageLoadTrait; use Magento\Customer\Test\Fixture\Customer; use Magento\Checkout\Test\Page\CheckoutCart; use Magento\Mtf\Constraint\AbstractConstraint; @@ -16,6 +17,8 @@ */ class AssertCatalogPriceRuleAppliedShoppingCart extends AbstractConstraint { + use CartPageLoadTrait; + /** * Assert that Catalog Price Rule is applied for product(s) in Shopping Cart * according to Priority(Priority/Stop Further Rules Processing). @@ -48,6 +51,7 @@ public function processAssert( ['products' => $products] )->run(); $checkoutCartPage->open(); + $this->waitForCartPageLoaded($checkoutCartPage); foreach ($products as $key => $product) { $actualPrice = $checkoutCartPage->getCartBlock()->getCartItem($product)->getSubtotalPrice(); \PHPUnit_Framework_Assert::assertEquals( diff --git a/dev/tests/functional/tests/app/Magento/Downloadable/Test/Block/Adminhtml/Catalog/Product/Edit/Section/Downloadable/Samples.php b/dev/tests/functional/tests/app/Magento/Downloadable/Test/Block/Adminhtml/Catalog/Product/Edit/Section/Downloadable/Samples.php index 98c7e1abf0d88..bcc8a012c1a63 100644 --- a/dev/tests/functional/tests/app/Magento/Downloadable/Test/Block/Adminhtml/Catalog/Product/Edit/Section/Downloadable/Samples.php +++ b/dev/tests/functional/tests/app/Magento/Downloadable/Test/Block/Adminhtml/Catalog/Product/Edit/Section/Downloadable/Samples.php @@ -114,8 +114,9 @@ protected function sortSample($position, $sortOrder, SimpleElement $element = nu foreach ($this->sortRowsData as &$sortRowData) { if ($sortRowData['sort_order'] > $currentSortRowData['sort_order']) { // need to reload block because we are changing dom - $target = $this->getRowBlock($sortRowData['current_position_in_grid'], $element)->getSortHandle(); - $this->getRowBlock($currentSortRowData['current_position_in_grid'], $element)->dragAndDropTo($target); + $target = $this->getRowBlock($currentSortRowData['current_position_in_grid'], $element) + ->getSortHandle(); + $this->getRowBlock($sortRowData['current_position_in_grid'], $element)->dragAndDropTo($target); $currentSortRowData['current_position_in_grid']--; $sortRowData['current_position_in_grid']++; diff --git a/dev/tests/functional/tests/app/Magento/Downloadable/Test/TestCase/CreateDownloadableProductEntityTest.xml b/dev/tests/functional/tests/app/Magento/Downloadable/Test/TestCase/CreateDownloadableProductEntityTest.xml index 5afe815edad45..b18f6849e0c8a 100644 --- a/dev/tests/functional/tests/app/Magento/Downloadable/Test/TestCase/CreateDownloadableProductEntityTest.xml +++ b/dev/tests/functional/tests/app/Magento/Downloadable/Test/TestCase/CreateDownloadableProductEntityTest.xml @@ -18,10 +18,10 @@ <data name="product/data/downloadable_links/dataset" xsi:type="string">one_separately_link</data> <data name="product/data/checkout_data/dataset" xsi:type="string">downloadable_one_dollar_product_with_separated_link</data> <data name="product/data/url_key" xsi:type="string">downloadableproduct-%isolation%</data> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductVisibleInCategory" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductInCart" /> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductVisibleInCategory"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductInCart"/> </variation> <variation name="CreateDownloadableProductEntityTestVariation2" summary="Create product with default set links"> <data name="product/data/name" xsi:type="string">DownloadableProduct_%isolation%</data> @@ -33,16 +33,14 @@ <data name="product/data/category" xsi:type="string">Default Category</data> <data name="product/data/downloadable_links/dataset" xsi:type="string">with_two_separately_links</data> <data name="product/data/url_key" xsi:type="string">downloadableproduct-%isolation%</data> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductVisibleInCategory" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductInStock" /> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductVisibleInCategory"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductInStock"/> </variation> <variation name="CreateDownloadableProductEntityTestVariation3" summary="Create product with default sets samples and links"> - <data name="tag" xsi:type="string">to_maintain:yes</data> - <data name="issue" xsi:type="string">MAGETWO-67096: [FT] Magento\Downloadable\Test\TestCase\CreateDownloadableProductEntityTest fails on Jenkins</data> <data name="product/data/name" xsi:type="string">DownloadableProduct_%isolation%</data> <data name="product/data/sku" xsi:type="string">DownloadableProduct_%isolation%</data> <data name="product/data/price/value" xsi:type="string">1</data> @@ -53,12 +51,12 @@ <data name="product/data/downloadable_sample/dataset" xsi:type="string">with_two_samples</data> <data name="product/data/downloadable_links/dataset" xsi:type="string">with_two_separately_links</data> <data name="product/data/url_key" xsi:type="string">downloadableproduct-%isolation%</data> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductVisibleInCategory" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableSamplesData" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableLinksData" /> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductVisibleInCategory"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableSamplesData"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableLinksData"/> </variation> <variation name="CreateDownloadableProductEntityTestVariation4" summary="Create product with custom options"> <data name="product/data/name" xsi:type="string">DownloadableProduct_%isolation%</data> @@ -71,16 +69,14 @@ <data name="product/data/downloadable_links/dataset" xsi:type="string">with_two_separately_links</data> <data name="product/data/custom_options/dataset" xsi:type="string">default</data> <data name="product/data/url_key" xsi:type="string">downloadableproduct-%isolation%</data> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductCustomOptionsOnProductPage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductVisibleInCategory" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableLinksData" /> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductCustomOptionsOnProductPage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductVisibleInCategory"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableLinksData"/> </variation> <variation name="CreateDownloadableProductEntityTestVariation5" summary="Create product without category"> - <data name="tag" xsi:type="string">to_maintain:yes</data> - <data name="issue" xsi:type="string">MAGETWO-67096: [FT] Magento\Downloadable\Test\TestCase\CreateDownloadableProductEntityTest fails on Jenkins</data> <data name="product/data/name" xsi:type="string">DownloadableProduct_%isolation%</data> <data name="product/data/sku" xsi:type="string">DownloadableProduct_%isolation%</data> <data name="product/data/price/value" xsi:type="string">55</data> @@ -91,15 +87,15 @@ <data name="product/data/downloadable_links/dataset" xsi:type="string">with_three_links</data> <data name="product/data/custom_options/dataset" xsi:type="string">two_options</data> <data name="product/data/url_key" xsi:type="string">downloadableproduct-%isolation%</data> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductCustomOptionsOnProductPage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductVisibleInCategory" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableLinksData" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductInStock" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductSearchableBySku" /> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductCustomOptionsOnProductPage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductVisibleInCategory"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableLinksData"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductInStock"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductSearchableBySku"/> </variation> <variation name="CreateDownloadableProductEntityTestVariation6" summary="Create product with out of stock status"> <data name="product/data/name" xsi:type="string">DownloadableProduct_%isolation%</data> @@ -111,10 +107,10 @@ <data name="product/data/category" xsi:type="string">Default Category</data> <data name="product/data/downloadable_links/dataset" xsi:type="string">with_two_separately_links</data> <data name="product/data/url_key" xsi:type="string">downloadableproduct-%isolation%</data> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductOutOfStock" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm" /> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductOutOfStock"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm"/> </variation> <variation name="CreateDownloadableProductEntityTestVariation7" summary="Create product with manage stock"> <data name="product/data/name" xsi:type="string">DownloadableProduct_%isolation%</data> @@ -127,10 +123,10 @@ <data name="product/data/stock_data/min_qty" xsi:type="string">123</data> <data name="product/data/downloadable_links/dataset" xsi:type="string">with_two_separately_links</data> <data name="product/data/url_key" xsi:type="string">downloadableproduct-%isolation%</data> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductOutOfStock" /> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductOutOfStock"/> </variation> <variation name="CreateDownloadableProductEntityTestVariation8" summary="Create product without tax class id"> <data name="product/data/name" xsi:type="string">DownloadableProduct_%isolation%</data> @@ -143,13 +139,12 @@ <data name="product/data/description" xsi:type="string">This is description for downloadable product</data> <data name="product/data/downloadable_links/dataset" xsi:type="string">with_two_separately_links</data> <data name="product/data/url_key" xsi:type="string">downloadableproduct-%isolation%</data> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage" /> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage"/> </variation> <variation name="CreateDownloadableProductEntityTestVariation9" summary="Create product with import custom options"> - <data name="issue" xsi:type="string">MAGETWO-59316: Sort order of Customizable Options isn't taken into account while creating Product via Webapi</data> <data name="product/data/name" xsi:type="string">DownloadableProduct_%isolation%</data> <data name="product/data/sku" xsi:type="string">DownloadableProduct_%isolation%</data> <data name="product/data/price/value" xsi:type="string">57</data> @@ -163,17 +158,15 @@ <data name="product/data/custom_options/dataset" xsi:type="string">default</data> <data name="product/data/custom_options/import_products" xsi:type="string">catalogProductSimple::with_two_custom_option,catalogProductSimple::with_all_custom_option</data> <data name="product/data/url_key" xsi:type="string">downloadableproduct-%isolation%</data> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductCustomOptionsOnProductPage" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableSamplesData" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableLinksData" /> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductCustomOptionsOnProductPage"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableSamplesData"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableLinksData"/> </variation> <variation name="CreateDownloadableProductEntityTestVariation10" summary="Create product with three links"> - <data name="tag" xsi:type="string">to_maintain:yes</data> - <data name="issue" xsi:type="string">MAGETWO-67096: [FT] Magento\Downloadable\Test\TestCase\CreateDownloadableProductEntityTest fails on Jenkins</data> <data name="product/data/name" xsi:type="string">DownloadableProduct_%isolation%</data> <data name="product/data/sku" xsi:type="string">DownloadableProduct_%isolation%</data> <data name="product/data/price/value" xsi:type="string">65</data> @@ -187,17 +180,15 @@ <data name="product/data/downloadable_links/dataset" xsi:type="string">with_three_links</data> <data name="product/data/custom_options/dataset" xsi:type="string">default</data> <data name="product/data/url_key" xsi:type="string">downloadableproduct-%isolation%</data> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductCustomOptionsOnProductPage" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableSamplesData" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableLinksData" /> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductCustomOptionsOnProductPage"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableSamplesData"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableLinksData"/> </variation> <variation name="CreateDownloadableProductEntityTestVariation11" summary="Create product with three links without description"> - <data name="tag" xsi:type="string">to_maintain:yes</data> - <data name="issue" xsi:type="string">MAGETWO-67096: [FT] Magento\Downloadable\Test\TestCase\CreateDownloadableProductEntityTest fails on Jenkins</data> <data name="product/data/name" xsi:type="string">DownloadableProduct_%isolation%</data> <data name="product/data/sku" xsi:type="string">DownloadableProduct_%isolation%</data> <data name="product/data/price/value" xsi:type="string">65</data> @@ -209,12 +200,12 @@ <data name="product/data/downloadable_links/dataset" xsi:type="string">with_three_links</data> <data name="product/data/custom_options/dataset" xsi:type="string">default</data> <data name="product/data/url_key" xsi:type="string">downloadableproduct-%isolation%</data> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableLinksData" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductCustomOptionsOnProductPage" /> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableLinksData"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductCustomOptionsOnProductPage"/> </variation> <variation name="CreateDownloadableProductEntityTestVariation12" summary="Create product without filling quantity and stock"> <data name="product/data/name" xsi:type="string">DownloadableProduct_%isolation%</data> @@ -225,10 +216,10 @@ <data name="product/data/category" xsi:type="string">default_category</data> <data name="product/data/downloadable_links/dataset" xsi:type="string">with_two_separately_links</data> <data name="product/data/url_key" xsi:type="string">downloadableproduct-%isolation%</data> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductVisibleInCategory" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage" /> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductVisibleInCategory"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage"/> </variation> <variation name="CreateDownloadableProductEntityTestVariation13" summary="Create product with special price"> <data name="product/data/name" xsi:type="string">DownloadableProduct_%isolation%</data> @@ -241,11 +232,11 @@ <data name="product/data/downloadable_links/dataset" xsi:type="string">with_two_separately_links</data> <data name="product/data/special_price" xsi:type="string">5</data> <data name="product/data/url_key" xsi:type="string">downloadableproduct-%isolation%</data> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductSpecialPriceOnProductPage" /> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductSpecialPriceOnProductPage"/> </variation> <variation name="CreateDownloadableProductEntityTestVariation14" summary="Create product with group price"> <data name="product/data/name" xsi:type="string">DownloadableProduct_%isolation%</data> @@ -257,10 +248,10 @@ <data name="product/data/category" xsi:type="string">category %isolation%</data> <data name="product/data/downloadable_links/dataset" xsi:type="string">with_two_separately_links</data> <data name="product/data/url_key" xsi:type="string">downloadableproduct-%isolation%</data> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage" /> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage"/> </variation> <variation name="CreateDownloadableProductEntityTestVariation15" summary="Create product with tier price"> <data name="product/data/name" xsi:type="string">DownloadableProduct_%isolation%</data> @@ -273,11 +264,11 @@ <data name="product/data/downloadable_links/dataset" xsi:type="string">with_two_separately_links</data> <data name="product/data/tier_price/dataset" xsi:type="string">default</data> <data name="product/data/url_key" xsi:type="string">downloadableproduct-%isolation%</data> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid" /> - <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductTierPriceOnProductPage" /> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductInGrid"/> + <constraint name="Magento\Downloadable\Test\Constraint\AssertDownloadableProductForm"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductPage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductTierPriceOnProductPage"/> </variation> <variation name="CreateDownloadableProductEntityTestVariation16" summary="Create downloadable product and assign it to custom website"> <data name="product/data/name" xsi:type="string">DownloadableProduct_%isolation%</data> @@ -288,8 +279,8 @@ <data name="product/data/downloadable_links/dataset" xsi:type="string">one_separately_link</data> <data name="product/data/url_key" xsi:type="string">downloadableproduct-%isolation%</data> <data name="product/data/website_ids/0/dataset" xsi:type="string">custom_store</data> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage" /> - <constraint name="Magento\Catalog\Test\Constraint\AssertProductOnCustomWebsite" /> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductSaveMessage"/> + <constraint name="Magento\Catalog\Test\Constraint\AssertProductOnCustomWebsite"/> </variation> </testCase> </config> diff --git a/dev/tests/functional/tests/app/Magento/GroupedProduct/Test/Fixture/GroupedProduct.xml b/dev/tests/functional/tests/app/Magento/GroupedProduct/Test/Fixture/GroupedProduct.xml index dce1358a1ecf4..59c00683e3b1a 100644 --- a/dev/tests/functional/tests/app/Magento/GroupedProduct/Test/Fixture/GroupedProduct.xml +++ b/dev/tests/functional/tests/app/Magento/GroupedProduct/Test/Fixture/GroupedProduct.xml @@ -58,7 +58,7 @@ <field name="sku" is_required="1" group="product-details" /> <field name="small_image" is_required="0" /> <field name="small_image_label" is_required="0" /> - <field name="status" is_required="0" /> + <field name="status" is_required="0" group="product-details" /> <field name="thumbnail" is_required="0" /> <field name="thumbnail_label" is_required="0" /> <field name="updated_at" is_required="1" /> @@ -66,7 +66,7 @@ <field name="upsell_tgtr_position_limit" is_required="0" /> <field name="url_key" is_required="0" group="search-engine-optimization" /> <field name="url_path" is_required="0" /> - <field name="visibility" is_required="0" /> + <field name="visibility" is_required="0" group="product-details" /> <field name="id" /> <field name="type_id" /> <field name="attribute_set_id" group="product-details" source="Magento\Catalog\Test\Fixture\Product\AttributeSetId" /> diff --git a/dev/tests/functional/tests/app/Magento/GroupedProduct/Test/TestCase/CreateGroupedProductEntityTest.xml b/dev/tests/functional/tests/app/Magento/GroupedProduct/Test/TestCase/CreateGroupedProductEntityTest.xml index 38ef02ff49441..39f4fd08bb922 100644 --- a/dev/tests/functional/tests/app/Magento/GroupedProduct/Test/TestCase/CreateGroupedProductEntityTest.xml +++ b/dev/tests/functional/tests/app/Magento/GroupedProduct/Test/TestCase/CreateGroupedProductEntityTest.xml @@ -8,7 +8,7 @@ <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../../vendor/magento/mtf/etc/variations.xsd"> <testCase name="Magento\GroupedProduct\Test\TestCase\CreateGroupedProductEntityTest" summary="Create Grouped Product" ticketId="MAGETWO-24877"> <variation name="CreateGroupedProductEntityTestVariation1" summary="Create Grouped Product and Assign It to the Category" ticketId="MAGETWO-13610"> - <data name="tag" xsi:type="string">test_type:acceptance_test, test_type:extended_acceptance_test, stable:no</data> + <data name="tag" xsi:type="string">test_type:acceptance_test, test_type:extended_acceptance_test</data> <data name="product/data/url_key" xsi:type="string">test-grouped-product-%isolation%</data> <data name="product/data/name" xsi:type="string">GroupedProduct %isolation%</data> <data name="product/data/sku" xsi:type="string">GroupedProduct_sku%isolation%</data> diff --git a/dev/tests/functional/tests/app/Magento/Reports/Test/TestCase/SalesOrderReportEntityTest.xml b/dev/tests/functional/tests/app/Magento/Reports/Test/TestCase/SalesOrderReportEntityTest.xml index 998102bcfbc45..02bfacae43730 100644 --- a/dev/tests/functional/tests/app/Magento/Reports/Test/TestCase/SalesOrderReportEntityTest.xml +++ b/dev/tests/functional/tests/app/Magento/Reports/Test/TestCase/SalesOrderReportEntityTest.xml @@ -8,6 +8,7 @@ <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../../vendor/magento/mtf/etc/variations.xsd"> <testCase name="Magento\Reports\Test\TestCase\SalesOrderReportEntityTest" summary="Sales Order Report" ticketId="MAGETWO-29136"> <variation name="SalesOrderReportEntityTestVariation1" summary="Create sales order report, check report data and date" ticketId="MAGETWO-45399"> + <data name="issue" xsi:type="string">MAGETWO-97404: 2019-01-02: Tests fail Magento\Reports\Test\TestCase\SalesOrderReportEntityTest</data> <data name="order/dataset" xsi:type="string">default</data> <data name="order/data/price/dataset" xsi:type="string">full_invoice</data> <data name="salesReport/report_type" xsi:type="string">Order Created</data> diff --git a/dev/tests/functional/tests/app/Magento/Reports/Test/TestCase/SalesRefundsReportEntityTest.xml b/dev/tests/functional/tests/app/Magento/Reports/Test/TestCase/SalesRefundsReportEntityTest.xml index 27014c0afb375..3142eec7079ba 100644 --- a/dev/tests/functional/tests/app/Magento/Reports/Test/TestCase/SalesRefundsReportEntityTest.xml +++ b/dev/tests/functional/tests/app/Magento/Reports/Test/TestCase/SalesRefundsReportEntityTest.xml @@ -8,6 +8,7 @@ <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../../vendor/magento/mtf/etc/variations.xsd"> <testCase name="Magento\Reports\Test\TestCase\SalesRefundsReportEntityTest" summary="Sales Refunds Report" ticketId="MAGETWO-29348"> <variation name="SalesRefundsReportEntityTestVariation1"> + <data name="issue" xsi:type="string">MAGETWO-97404: 2019-01-02: Tests fail Magento\Reports\Test\TestCase\SalesOrderReportEntityTest</data> <data name="description" xsi:type="string">assert refunds year report</data> <data name="order/dataset" xsi:type="string">default</data> <data name="order/data/price/dataset" xsi:type="string">full_invoice</data> diff --git a/dev/tests/functional/tests/app/Magento/Sales/Test/Block/Adminhtml/Order/AbstractForm.php b/dev/tests/functional/tests/app/Magento/Sales/Test/Block/Adminhtml/Order/AbstractForm.php index 5572c06816a39..bc7ee4372d61b 100644 --- a/dev/tests/functional/tests/app/Magento/Sales/Test/Block/Adminhtml/Order/AbstractForm.php +++ b/dev/tests/functional/tests/app/Magento/Sales/Test/Block/Adminhtml/Order/AbstractForm.php @@ -6,7 +6,9 @@ namespace Magento\Sales\Test\Block\Adminhtml\Order; +use function GuzzleHttp\Psr7\str; use Magento\Mtf\Block\Form; +use Magento\Mtf\Client\Locator; /** * Abstract Form block. @@ -42,6 +44,27 @@ function () { ); } + /** + * Wait for element is enabled. + * + * @param string $selector + * @param string $strategy + * @return bool|null + */ + protected function waitForElementEnabled(string $selector, string $strategy = Locator::SELECTOR_CSS) + { + $browser = $this->browser; + + return $browser->waitUntil( + function () use ($browser, $selector, $strategy) { + $element = $browser->find($selector, $strategy); + $class = $element->getAttribute('class'); + + return (!$element->isDisabled() && !strpos($class, 'disabled')) ? true : null; + } + ); + } + /** * Fill form data. * @@ -113,7 +136,7 @@ abstract protected function getItemsBlock(); */ public function submit() { - $this->waitLoader(); + $this->waitForElementEnabled($this->send); $this->_rootElement->find($this->send)->click(); } diff --git a/dev/tests/functional/tests/app/Magento/Sales/Test/Constraint/AssertOrderCancelMassActionFailMessage.php b/dev/tests/functional/tests/app/Magento/Sales/Test/Constraint/AssertOrderCancelMassActionFailMessage.php index 6d3b99ce81a04..9f4181b70e801 100644 --- a/dev/tests/functional/tests/app/Magento/Sales/Test/Constraint/AssertOrderCancelMassActionFailMessage.php +++ b/dev/tests/functional/tests/app/Magento/Sales/Test/Constraint/AssertOrderCancelMassActionFailMessage.php @@ -11,17 +11,17 @@ /** * Class AssertOrderCancelMassActionFailMessage - * Assert cancel fail message is displayed on order index page + * Assert cancel fail message is displayed on order index page. */ class AssertOrderCancelMassActionFailMessage extends AbstractConstraint { /** - * Text value to be checked + * Text value to be checked. */ - const FAIL_CANCEL_MESSAGE = '1 order(s) cannot be canceled.'; + const FAIL_CANCEL_MESSAGE = 'You cannot cancel the order(s).'; /** - * Assert cancel fail message is displayed on order index page + * Assert cancel fail message is displayed on order index page. * * @param OrderIndex $orderIndex * @return void @@ -35,7 +35,7 @@ public function processAssert(OrderIndex $orderIndex) } /** - * Returns a string representation of the object + * Returns a string representation of the object. * * @return string */ diff --git a/dev/tests/functional/tests/app/Magento/Sales/Test/Constraint/AssertOrderStatusIsCorrect.php b/dev/tests/functional/tests/app/Magento/Sales/Test/Constraint/AssertOrderStatusIsCorrect.php index 46f6ebba51fe1..7a46d0c5ef820 100644 --- a/dev/tests/functional/tests/app/Magento/Sales/Test/Constraint/AssertOrderStatusIsCorrect.php +++ b/dev/tests/functional/tests/app/Magento/Sales/Test/Constraint/AssertOrderStatusIsCorrect.php @@ -39,8 +39,8 @@ public function processAssert( /** @var \Magento\Sales\Test\Block\Adminhtml\Order\View\Tab\Info $infoTab */ $infoTab = $salesOrderView->getOrderForm()->openTab('info')->getTab('info'); \PHPUnit_Framework_Assert::assertEquals( - $infoTab->getOrderStatus(), - $orderStatus + $orderStatus, + $infoTab->getOrderStatus() ); } diff --git a/dev/tests/functional/tests/app/Magento/Sales/Test/TestCase/GridSortingTest.xml b/dev/tests/functional/tests/app/Magento/Sales/Test/TestCase/GridSortingTest.xml index 6ae9d19a898bc..28894ed6cc158 100644 --- a/dev/tests/functional/tests/app/Magento/Sales/Test/TestCase/GridSortingTest.xml +++ b/dev/tests/functional/tests/app/Magento/Sales/Test/TestCase/GridSortingTest.xml @@ -9,7 +9,6 @@ <testCase name="Magento\Ui\Test\TestCase\GridSortingTest" summary="Grid UI Component Sorting" ticketId="MAGETWO-41328"> <variation name="SalesOrderGridSorting"> <data name="tag" xsi:type="string">severity:S2</data> - <data name="tag" xsi:type="string">to_maintain:yes</data> <data name="description" xsi:type="string">Verify sales order grid storting</data> <data name="steps" xsi:type="array"> <item name="0" xsi:type="string">-</item> diff --git a/dev/tests/functional/tests/app/Magento/Sales/Test/TestCase/MassOrdersUpdateTest.xml b/dev/tests/functional/tests/app/Magento/Sales/Test/TestCase/MassOrdersUpdateTest.xml index eddc321e9ca52..1f75b07c8ca1e 100644 --- a/dev/tests/functional/tests/app/Magento/Sales/Test/TestCase/MassOrdersUpdateTest.xml +++ b/dev/tests/functional/tests/app/Magento/Sales/Test/TestCase/MassOrdersUpdateTest.xml @@ -8,7 +8,6 @@ <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../../vendor/magento/mtf/etc/variations.xsd"> <testCase name="Magento\Sales\Test\TestCase\MassOrdersUpdateTest" summary="Mass Update Orders" ticketId="MAGETWO-27897"> <variation name="MassOrdersUpdateTestVariation1"> - <data name="tag" xsi:type="string">stable:no</data> <data name="description" xsi:type="string">cancel orders in status Pending and Processing</data> <data name="steps" xsi:type="string">-</data> <data name="action" xsi:type="string">Cancel</data> @@ -18,20 +17,20 @@ <constraint name="Magento\Sales\Test\Constraint\AssertOrdersInOrdersGrid" /> </variation> <variation name="MassOrdersUpdateTestVariation2"> - <data name="description" xsi:type="string">try to cancel orders in status Complete, Canceled</data> + <data name="description" xsi:type="string">try to cancel orders in status Complete, Closed</data> <data name="steps" xsi:type="string">invoice, shipment|invoice, credit memo</data> <data name="action" xsi:type="string">Cancel</data> <data name="ordersCount" xsi:type="string">2</data> - <data name="resultStatuses" xsi:type="string">Complete,Canceled</data> + <data name="resultStatuses" xsi:type="string">Complete,Closed</data> <constraint name="Magento\Sales\Test\Constraint\AssertOrderCancelMassActionFailMessage" /> <constraint name="Magento\Sales\Test\Constraint\AssertOrdersInOrdersGrid" /> </variation> <variation name="MassOrdersUpdateTestVariation3"> - <data name="description" xsi:type="string">try to cancel orders in status Pending, Closed</data> + <data name="description" xsi:type="string">try to cancel orders in status Processing, Closed</data> <data name="steps" xsi:type="string">invoice|invoice, credit memo</data> <data name="action" xsi:type="string">Cancel</data> <data name="ordersCount" xsi:type="string">2</data> - <data name="resultStatuses" xsi:type="string">Processing,Canceled</data> + <data name="resultStatuses" xsi:type="string">Processing,Closed</data> <constraint name="Magento\Sales\Test\Constraint\AssertOrderCancelMassActionFailMessage" /> <constraint name="Magento\Sales\Test\Constraint\AssertOrdersInOrdersGrid" /> </variation> @@ -45,7 +44,6 @@ <constraint name="Magento\Sales\Test\Constraint\AssertOrdersInOrdersGrid" /> </variation> <variation name="MassOrdersUpdateTestVariation5"> - <data name="tag" xsi:type="string">stable:no</data> <data name="description" xsi:type="string">Try to put order in status Complete on Hold</data> <data name="steps" xsi:type="string">invoice, shipment</data> <data name="action" xsi:type="string">Hold</data> diff --git a/dev/tests/functional/tests/app/Magento/Sales/Test/TestCase/MoveLastOrderedProductsOnOrderPageTest.xml b/dev/tests/functional/tests/app/Magento/Sales/Test/TestCase/MoveLastOrderedProductsOnOrderPageTest.xml index 6f568df8f21ca..a9bd3fcacea8a 100644 --- a/dev/tests/functional/tests/app/Magento/Sales/Test/TestCase/MoveLastOrderedProductsOnOrderPageTest.xml +++ b/dev/tests/functional/tests/app/Magento/Sales/Test/TestCase/MoveLastOrderedProductsOnOrderPageTest.xml @@ -14,7 +14,6 @@ <constraint name="Magento\Sales\Test\Constraint\AssertProductInItemsOrderedGrid" /> </variation> <variation name="MoveLastOrderedProductsOnOrderPageTestVariation2"> - <data name="issue" xsi:type="string">MAGETWO-58762: Customer grid does not open in MoveLastOrderedProductsOnOrderPageTestVariation2 on Jenkins</data> <data name="order/dataset" xsi:type="string">default</data> <data name="order/data/entity_id/products" xsi:type="string">configurableProduct::configurable_with_qty_1</data> <constraint name="Magento\Sales\Test\Constraint\AssertProductInItemsOrderedGrid" /> diff --git a/dev/tests/functional/tests/app/Magento/SalesRule/Test/Block/Adminhtml/Promo/Quote/Edit/PromoQuoteForm.php b/dev/tests/functional/tests/app/Magento/SalesRule/Test/Block/Adminhtml/Promo/Quote/Edit/PromoQuoteForm.php index 2627f99d4c8c2..54cec6cf279f6 100644 --- a/dev/tests/functional/tests/app/Magento/SalesRule/Test/Block/Adminhtml/Promo/Quote/Edit/PromoQuoteForm.php +++ b/dev/tests/functional/tests/app/Magento/SalesRule/Test/Block/Adminhtml/Promo/Quote/Edit/PromoQuoteForm.php @@ -28,6 +28,13 @@ class PromoQuoteForm extends FormSections */ protected $waitForSelectorVisible = false; + /** + * Selector of name element on the form. + * + * @var string + */ + private $nameElementSelector = 'input[name=name]'; + /** * Fill form with sections. * @@ -38,6 +45,8 @@ class PromoQuoteForm extends FormSections */ public function fill(FixtureInterface $fixture, SimpleElement $element = null, array $replace = null) { + $this->waitForElementNotVisible($this->waitForSelector); + $this->waitForElementVisible($this->nameElementSelector); $sections = $this->getFixtureFieldsByContainers($fixture); if ($replace) { $sections = $this->prepareData($sections, $replace); diff --git a/dev/tests/functional/tests/app/Magento/Search/Test/TestCase/AdvancedSearchWithAttributeTest.xml b/dev/tests/functional/tests/app/Magento/Search/Test/TestCase/AdvancedSearchWithAttributeTest.xml index 13c7051d0c1ba..733b110ec5494 100644 --- a/dev/tests/functional/tests/app/Magento/Search/Test/TestCase/AdvancedSearchWithAttributeTest.xml +++ b/dev/tests/functional/tests/app/Magento/Search/Test/TestCase/AdvancedSearchWithAttributeTest.xml @@ -8,8 +8,6 @@ <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../../vendor/magento/mtf/etc/variations.xsd"> <testCase name="Magento\Search\Test\TestCase\AdvancedSearchWithAttributeTest" summary="Use Advanced Search by Decimal indexable attribute if Edit/Add Attribute" ticketId="MAGETWO-25931"> <variation name="AdvancedSearchWithWeightAttributeTestVariation1"> - <data name="issue" xsi:type="string">MAGETWO-65408: [FT] Magento\Search\Test\TestCase\AdvancedSearchWithAttributeTest fails on Jenkins</data> - <data name="tag" xsi:type="string">to_maintain:yes</data> <data name="productDropDownList/0" xsi:type="string">configurable</data> <data name="productDropDownList/1" xsi:type="string">simple</data> <data name="productDropDownList/2" xsi:type="string">bundle</data> diff --git a/dev/tests/functional/tests/app/Magento/Shipping/Test/TestCase/SalesShippingReportEntityTest.xml b/dev/tests/functional/tests/app/Magento/Shipping/Test/TestCase/SalesShippingReportEntityTest.xml index 069bf23f5f25f..6b4c14ff3142a 100644 --- a/dev/tests/functional/tests/app/Magento/Shipping/Test/TestCase/SalesShippingReportEntityTest.xml +++ b/dev/tests/functional/tests/app/Magento/Shipping/Test/TestCase/SalesShippingReportEntityTest.xml @@ -8,6 +8,7 @@ <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../../vendor/magento/mtf/etc/variations.xsd"> <testCase name="Magento\Shipping\Test\TestCase\SalesShippingReportEntityTest" summary="Sales Shipping Report" ticketId="MAGETWO-40914"> <variation name="SalesShippingReportEntityTestVariation1"> + <data name="issue" xsi:type="string">MAGETWO-97404: 2019-01-02: Tests fail Magento\Reports\Test\TestCase\SalesOrderReportEntityTest</data> <data name="order/dataset" xsi:type="string">default</data> <data name="order/data/price/dataset" xsi:type="string">full_shipment</data> <data name="shippingReport/report_type" xsi:type="string">Order Created</data> diff --git a/dev/tests/functional/tests/app/Magento/Store/Test/TestCase/DeleteStoreEntityTest.php b/dev/tests/functional/tests/app/Magento/Store/Test/TestCase/DeleteStoreEntityTest.php index e867ebe399784..86c7c365b16ad 100644 --- a/dev/tests/functional/tests/app/Magento/Store/Test/TestCase/DeleteStoreEntityTest.php +++ b/dev/tests/functional/tests/app/Magento/Store/Test/TestCase/DeleteStoreEntityTest.php @@ -12,6 +12,7 @@ use Magento\Backup\Test\Page\Adminhtml\BackupIndex; use Magento\Store\Test\Fixture\Store; use Magento\Mtf\TestCase\Injectable; +use Magento\Config\Test\TestStep\SetupConfigurationStep; /** * Test Creation for DeleteStoreEntity @@ -100,7 +101,15 @@ public function test(Store $store, $createBackup) { // Preconditions: $store->persist(); - $this->backupIndex->open()->getBackupGrid()->massaction([], 'Delete', true, 'Select All'); + /** @var SetupConfigurationStep $enableBackupsStep */ + $enableBackupsStep = $this->objectManager->create( + SetupConfigurationStep::class, + ['configData' => 'enable_backups_functionality'] + ); + $enableBackupsStep->run(); + $this->backupIndex->open() + ->getBackupGrid() + ->massaction([], 'Delete', true, 'Select All'); // Steps: $this->storeIndex->open(); @@ -110,4 +119,19 @@ public function test(Store $store, $createBackup) $this->storeDelete->getFormPageActions()->delete(); $this->storeDelete->getModalBlock()->acceptAlert(); } + + /** + * Reset config settings to default. + * + * @return void + */ + public function tearDown() + { + /** @var SetupConfigurationStep $enableBackupsStep */ + $enableBackupsStep = $this->objectManager->create( + SetupConfigurationStep::class, + ['configData' => 'enable_backups_functionality', 'rollback' => true] + ); + $enableBackupsStep->run(); + } } diff --git a/dev/tests/functional/tests/app/Magento/Store/Test/TestCase/DeleteStoreGroupEntityTest.php b/dev/tests/functional/tests/app/Magento/Store/Test/TestCase/DeleteStoreGroupEntityTest.php index cd37576443cdb..4338da84545d6 100644 --- a/dev/tests/functional/tests/app/Magento/Store/Test/TestCase/DeleteStoreGroupEntityTest.php +++ b/dev/tests/functional/tests/app/Magento/Store/Test/TestCase/DeleteStoreGroupEntityTest.php @@ -12,6 +12,7 @@ use Magento\Backup\Test\Page\Adminhtml\BackupIndex; use Magento\Store\Test\Fixture\StoreGroup; use Magento\Mtf\TestCase\Injectable; +use Magento\Config\Test\TestStep\SetupConfigurationStep; /** * Delete StoreGroup (Store Management) @@ -101,7 +102,15 @@ public function test(StoreGroup $storeGroup, $createBackup) { //Preconditions $storeGroup->persist(); - $this->backupIndex->open()->getBackupGrid()->massaction([], 'Delete', true, 'Select All'); + /** @var SetupConfigurationStep $enableBackupsStep */ + $enableBackupsStep = $this->objectManager->create( + SetupConfigurationStep::class, + ['configData' => 'enable_backups_functionality'] + ); + $enableBackupsStep->run(); + $this->backupIndex->open() + ->getBackupGrid() + ->massaction([], 'Delete', true, 'Select All'); //Steps $this->storeIndex->open(); @@ -110,4 +119,19 @@ public function test(StoreGroup $storeGroup, $createBackup) $this->deleteGroup->getDeleteGroupForm()->fillForm(['create_backup' => $createBackup]); $this->deleteGroup->getFormPageActions()->delete(); } + + /** + * Reset config settings to default. + * + * @return void + */ + public function tearDown() + { + /** @var SetupConfigurationStep $enableBackupsStep */ + $enableBackupsStep = $this->objectManager->create( + SetupConfigurationStep::class, + ['configData' => 'enable_backups_functionality', 'rollback' => true] + ); + $enableBackupsStep->run(); + } } diff --git a/dev/tests/functional/tests/app/Magento/Store/Test/TestCase/DeleteWebsiteEntityTest.php b/dev/tests/functional/tests/app/Magento/Store/Test/TestCase/DeleteWebsiteEntityTest.php index 2431cb3e065d2..9157e1cbd12d4 100644 --- a/dev/tests/functional/tests/app/Magento/Store/Test/TestCase/DeleteWebsiteEntityTest.php +++ b/dev/tests/functional/tests/app/Magento/Store/Test/TestCase/DeleteWebsiteEntityTest.php @@ -12,6 +12,7 @@ use Magento\Backup\Test\Page\Adminhtml\BackupIndex; use Magento\Store\Test\Fixture\Website; use Magento\Mtf\TestCase\Injectable; +use Magento\Config\Test\TestStep\SetupConfigurationStep; /** * Delete Website (Store Management) @@ -102,7 +103,15 @@ public function test(Website $website, $createBackup) { //Preconditions $website->persist(); - $this->backupIndex->open()->getBackupGrid()->massaction([], 'Delete', true, 'Select All'); + /** @var SetupConfigurationStep $enableBackupsStep */ + $enableBackupsStep = $this->objectManager->create( + SetupConfigurationStep::class, + ['configData' => 'enable_backups_functionality'] + ); + $enableBackupsStep->run(); + $this->backupIndex->open() + ->getBackupGrid() + ->massaction([], 'Delete', true, 'Select All'); //Steps $this->storeIndex->open(); @@ -111,4 +120,19 @@ public function test(Website $website, $createBackup) $this->deleteWebsite->getDeleteWebsiteForm()->fillForm(['create_backup' => $createBackup]); $this->deleteWebsite->getFormPageActions()->delete(); } + + /** + * Reset config settings to default. + * + * @return void + */ + public function tearDown() + { + /** @var SetupConfigurationStep $enableBackupsStep */ + $enableBackupsStep = $this->objectManager->create( + SetupConfigurationStep::class, + ['configData' => 'enable_backups_functionality', 'rollback' => true] + ); + $enableBackupsStep->run(); + } } diff --git a/dev/tests/functional/tests/app/Magento/Store/Test/TestStep/DeleteWebsitesEntityStep.php b/dev/tests/functional/tests/app/Magento/Store/Test/TestStep/DeleteWebsitesEntityStep.php index c18f5629d2e02..1c1413274d60f 100644 --- a/dev/tests/functional/tests/app/Magento/Store/Test/TestStep/DeleteWebsitesEntityStep.php +++ b/dev/tests/functional/tests/app/Magento/Store/Test/TestStep/DeleteWebsitesEntityStep.php @@ -10,10 +10,12 @@ use Magento\Backend\Test\Page\Adminhtml\DeleteWebsite; use Magento\Backend\Test\Page\Adminhtml\StoreIndex; use Magento\Backup\Test\Page\Adminhtml\BackupIndex; +use Magento\Config\Test\TestStep\SetupConfigurationStep; use Magento\Store\Test\Fixture\Store; use Magento\Mtf\TestStep\TestStepInterface; use Magento\Mtf\Fixture\FixtureFactory; use Magento\Mtf\Fixture\FixtureInterface; +use Magento\Mtf\TestStep\TestStepFactory; /** * Test Step for DeleteStoreEntity @@ -72,6 +74,11 @@ class DeleteWebsitesEntityStep implements TestStepInterface */ private $createBackup; + /** + * @var TestStepFactory + */ + private $stepFactory; + /** * Prepare pages for test * @@ -81,6 +88,7 @@ class DeleteWebsitesEntityStep implements TestStepInterface * @param DeleteWebsite $deleteWebsite * @param FixtureFactory $fixtureFactory * @param FixtureInterface $item + * @param TestStepFactory $testStepFactory * @param string $createBackup */ public function __construct( @@ -90,6 +98,7 @@ public function __construct( DeleteWebsite $deleteWebsite, FixtureFactory $fixtureFactory, FixtureInterface $item, + TestStepFactory $testStepFactory, $createBackup = 'No' ) { $this->storeIndex = $storeIndex; @@ -99,6 +108,7 @@ public function __construct( $this->item = $item; $this->createBackup = $createBackup; $this->fixtureFactory = $fixtureFactory; + $this->stepFactory = $testStepFactory; } /** @@ -108,6 +118,12 @@ public function __construct( */ public function run() { + /** @var SetupConfigurationStep $enableBackupsStep */ + $enableBackupsStep = $this->stepFactory->create( + SetupConfigurationStep::class, + ['configData' => 'enable_backups_functionality'] + ); + $enableBackupsStep->run(); $this->backupIndex->open()->getBackupGrid()->massaction([], 'Delete', true, 'Select All'); $this->storeIndex->open(); $websiteNames = $this->item->getWebsiteIds(); diff --git a/dev/tests/functional/tests/app/Magento/Swatches/Test/Handler/SwatchProductAttribute/Curl.php b/dev/tests/functional/tests/app/Magento/Swatches/Test/Handler/SwatchProductAttribute/Curl.php index 083fa246c96ef..f4adb9dec1250 100644 --- a/dev/tests/functional/tests/app/Magento/Swatches/Test/Handler/SwatchProductAttribute/Curl.php +++ b/dev/tests/functional/tests/app/Magento/Swatches/Test/Handler/SwatchProductAttribute/Curl.php @@ -29,21 +29,32 @@ public function __construct(DataInterface $configuration, EventManagerInterface ]; } + /** + * @inheritdoc + */ + protected function changeStructureOfTheData(array $data): array + { + return parent::changeStructureOfTheData($data); + } + /** * Re-map options from default options structure to swatches structure, * as swatches was initially created with name convention differ from other attributes. * - * @param array $data - * @return array + * @inheritdoc */ - protected function changeStructureOfTheData(array $data) + protected function getSerializeOptions(array $data): string { - $data = parent::changeStructureOfTheData($data); - $data['optiontext'] = $data['option']; - $data['swatchtext'] = [ - 'value' => $data['option']['value'] - ]; - unset($data['option']); - return $data; + $options = []; + foreach ($data as $optionRowData) { + $optionRowData['optiontext'] = $optionRowData['option']; + $optionRowData['swatchtext'] = [ + 'value' => $optionRowData['option']['value'] + ]; + unset($optionRowData['option']); + $options[] = http_build_query($optionRowData); + } + + return json_encode($options); } } diff --git a/dev/tests/functional/tests/app/Magento/Wishlist/Test/TestCase/AddProductToWishlistEntityTest.xml b/dev/tests/functional/tests/app/Magento/Wishlist/Test/TestCase/AddProductToWishlistEntityTest.xml index eaffe2485f222..dadf97d7940bd 100644 --- a/dev/tests/functional/tests/app/Magento/Wishlist/Test/TestCase/AddProductToWishlistEntityTest.xml +++ b/dev/tests/functional/tests/app/Magento/Wishlist/Test/TestCase/AddProductToWishlistEntityTest.xml @@ -110,6 +110,7 @@ <data name="product" xsi:type="array"> <item name="0" xsi:type="string">bundleProduct::with_special_price_and_custom_options</item> </data> + <data name="configure" xsi:type="boolean">false</data> <constraint name="Magento\Wishlist\Test\Constraint\AssertAddProductToWishlistSuccessMessage"/> <constraint name="Magento\Wishlist\Test\Constraint\AssertProductIsPresentInWishlist"/> <constraint name="Magento\Wishlist\Test\Constraint\AssertProductIsPresentInCustomerBackendWishlist"/> diff --git a/dev/tests/integration/bin/magento b/dev/tests/integration/bin/magento new file mode 100755 index 0000000000000..303fbfb217d2b --- /dev/null +++ b/dev/tests/integration/bin/magento @@ -0,0 +1,42 @@ +#!/usr/bin/env php +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +if (PHP_SAPI !== 'cli') { + echo 'bin/magento must be run as a CLI application'; + exit(1); +} + +if (isset($_SERVER['INTEGRATION_TEST_PARAMS'])) { + parse_str($_SERVER['INTEGRATION_TEST_PARAMS'], $params); + foreach ($params as $paramName => $paramValue) { + $_SERVER[$paramName] = $paramValue; + } +} else { + echo 'Test parameters are required'; + exit(1); +} + +try { + require $_SERVER['MAGE_DIRS']['base']['path'] . '/app/bootstrap.php'; +} catch (\Exception $e) { + echo 'Autoload error: ' . $e->getMessage(); + exit(1); +} +try { + $handler = new \Magento\Framework\App\ErrorHandler(); + set_error_handler([$handler, 'handler']); + $application = new Magento\Framework\Console\Cli('Magento CLI'); + $application->run(); +} catch (\Exception $e) { + while ($e) { + echo $e->getMessage(); + echo $e->getTraceAsString(); + echo "\n\n"; + $e = $e->getPrevious(); + } + exit(Cli::RETURN_FAILURE); +} diff --git a/dev/tests/integration/etc/di/preferences/ce.php b/dev/tests/integration/etc/di/preferences/ce.php index d5aaa7e730826..6839d0ae9a7ff 100644 --- a/dev/tests/integration/etc/di/preferences/ce.php +++ b/dev/tests/integration/etc/di/preferences/ce.php @@ -24,5 +24,9 @@ \Magento\Framework\App\ResourceConnection\ConnectionAdapterInterface::class => \Magento\TestFramework\Db\ConnectionAdapter::class, \Magento\Framework\Filesystem\DriverInterface::class => \Magento\Framework\Filesystem\Driver\File::class, - \Magento\Framework\App\Config\ScopeConfigInterface::class => \Magento\TestFramework\App\Config::class + \Magento\Framework\App\Config\ScopeConfigInterface::class => \Magento\TestFramework\App\Config::class, + \Magento\Framework\Lock\Backend\Cache::class => + \Magento\TestFramework\Lock\Backend\DummyLocker::class, + \Magento\Framework\ShellInterface::class => \Magento\TestFramework\App\Shell::class, + \Magento\Framework\App\Shell::class => \Magento\TestFramework\App\Shell::class, ]; diff --git a/dev/tests/integration/framework/Magento/TestFramework/App/Shell.php b/dev/tests/integration/framework/Magento/TestFramework/App/Shell.php new file mode 100644 index 0000000000000..89f64aec16d06 --- /dev/null +++ b/dev/tests/integration/framework/Magento/TestFramework/App/Shell.php @@ -0,0 +1,37 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +namespace Magento\TestFramework\App; + +/** + * Shell command line wrapper encapsulates command execution and arguments escaping + */ +class Shell extends \Magento\Framework\App\Shell +{ + /** + * Override app/shell by running bin/magento located in the integration test and pass environment parameters + * + * @inheritdoc + */ + public function execute($command, array $arguments = []) + { + if (strpos($command, BP . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'magento ') !== false) { + $command = str_replace( + BP . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'magento ', + BP . DIRECTORY_SEPARATOR . 'dev' . DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'integration' + . DIRECTORY_SEPARATOR. 'bin' . DIRECTORY_SEPARATOR . 'magento ', + $command + ); + } + + $params = \Magento\TestFramework\Helper\Bootstrap::getInstance()->getAppInitParams(); + + $params['MAGE_DIRS']['base']['path'] = BP; + $params = 'INTEGRATION_TEST_PARAMS="' . urldecode(http_build_query($params)) . '"'; + $integrationTestCommand = $params . ' ' . $command; + $output = parent::execute($integrationTestCommand, $arguments); + return $output; + } +} diff --git a/dev/tests/integration/framework/Magento/TestFramework/Isolation/DeploymentConfig.php b/dev/tests/integration/framework/Magento/TestFramework/Isolation/DeploymentConfig.php index 873fb0366a8e1..3132aed4d21e3 100644 --- a/dev/tests/integration/framework/Magento/TestFramework/Isolation/DeploymentConfig.php +++ b/dev/tests/integration/framework/Magento/TestFramework/Isolation/DeploymentConfig.php @@ -28,6 +28,17 @@ class DeploymentConfig */ private $config; + /** + * Ignore values in the config nested array, paths are separated by single slash "/". + * + * Example: compiled_config is not set in default mode, and once set it can't be unset + * + * @var array + */ + private $ignoreValues = [ + 'cache_types/compiled_config', + ]; + /** * Memorizes the initial value of configuration reader and the configuration value * @@ -57,7 +68,7 @@ public function startTestSuite() */ public function endTest(\PHPUnit\Framework\TestCase $test) { - $config = $this->reader->load(); + $config = $this->filterIgnoredConfigValues($this->reader->load()); if ($this->config != $config) { $error = "\n\nERROR: deployment configuration is corrupted. The application state is no longer valid.\n" . 'Further tests may fail.' @@ -66,4 +77,28 @@ public function endTest(\PHPUnit\Framework\TestCase $test) $test->fail($error); } } + + /** + * Filter ignored config values which are not set by default and appear when tests would change state. + * + * Example: compiled_config is not set in default mode, and once set it can't be unset + * + * @param array $config + * @param string $path + * @return array + */ + private function filterIgnoredConfigValues(array $config, string $path = '') + { + foreach ($config as $configKeyName => $configValue) { + $newPath = !empty($path) ? $path . '/' . $configKeyName : $configKeyName; + if (is_array($configValue)) { + $config[$configKeyName] = $this->filterIgnoredConfigValues($configValue, $newPath); + } else { + if (array_key_exists($newPath, array_flip($this->ignoreValues))) { + unset($config[$configKeyName]); + } + } + } + return $config; + } } diff --git a/dev/tests/integration/framework/Magento/TestFramework/Lock/Backend/DummyLocker.php b/dev/tests/integration/framework/Magento/TestFramework/Lock/Backend/DummyLocker.php new file mode 100644 index 0000000000000..41125493643e3 --- /dev/null +++ b/dev/tests/integration/framework/Magento/TestFramework/Lock/Backend/DummyLocker.php @@ -0,0 +1,40 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\TestFramework\Lock\Backend; + +use Magento\Framework\Lock\LockManagerInterface; + +/** + * Dummy locker for the integration framework. + */ +class DummyLocker implements LockManagerInterface +{ + /** + * @inheritdoc + */ + public function lock(string $name, int $timeout = -1): bool + { + return true; + } + + /** + * @inheritdoc + */ + public function unlock(string $name): bool + { + return true; + } + + /** + * @inheritdoc + */ + public function isLocked(string $name): bool + { + return false; + } +} diff --git a/dev/tests/integration/framework/Magento/TestFramework/TestCase/AbstractBackendController.php b/dev/tests/integration/framework/Magento/TestFramework/TestCase/AbstractBackendController.php index 543bac2c6b5b5..0845ef640aa0c 100644 --- a/dev/tests/integration/framework/Magento/TestFramework/TestCase/AbstractBackendController.php +++ b/dev/tests/integration/framework/Magento/TestFramework/TestCase/AbstractBackendController.php @@ -98,7 +98,7 @@ public function testAclHasAccess() public function testAclNoAccess() { - if ($this->resource === null) { + if ($this->resource === null || $this->uri === null) { $this->markTestIncomplete('Acl test is not complete'); } $this->_objectManager->get(\Magento\Framework\Acl\Builder::class) diff --git a/dev/tests/integration/testsuite/Magento/Braintree/Controller/Paypal/PlaceOrderTest.php b/dev/tests/integration/testsuite/Magento/Braintree/Controller/Paypal/PlaceOrderTest.php new file mode 100644 index 0000000000000..4f2b0fd67840d --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Braintree/Controller/Paypal/PlaceOrderTest.php @@ -0,0 +1,173 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Braintree\Controller\Paypal; + +use Braintree\Result\Successful; +use Braintree\Transaction; +use Magento\Braintree\Model\Adapter\BraintreeAdapter; +use Magento\Braintree\Model\Adapter\BraintreeAdapterFactory; +use Magento\Checkout\Model\Session; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\App\Request\Http as HttpRequest; +use Magento\Framework\Message\MessageInterface; +use Magento\Quote\Api\CartRepositoryInterface; +use Magento\Quote\Api\Data\CartInterface; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\TestFramework\TestCase\AbstractController; +use PHPUnit\Framework\MockObject\MockObject; + +/** + * PlaceOrderTest + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class PlaceOrderTest extends AbstractController +{ + /** + * @var Session|MockObject + */ + private $session; + + /** + * @var BraintreeAdapter|MockObject + */ + private $adapter; + + /** + * @inheritdoc + */ + protected function setUp() + { + parent::setUp(); + + $this->session = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->setMethods(['getQuote', 'setLastOrderStatus', 'unsLastBillingAgreementReferenceId']) + ->getMock(); + + $adapterFactory = $this->getMockBuilder(BraintreeAdapterFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->adapter = $this->getMockBuilder(BraintreeAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + $adapterFactory->method('create') + ->willReturn($this->adapter); + + $this->_objectManager->addSharedInstance($this->session, Session::class); + $this->_objectManager->addSharedInstance($adapterFactory, BraintreeAdapterFactory::class); + } + + /** + * @inheritdoc + */ + protected function tearDown() + { + $this->_objectManager->removeSharedInstance(Session::class); + $this->_objectManager->removeSharedInstance(BraintreeAdapterFactory::class); + parent::tearDown(); + } + + /** + * Tests a negative scenario for a place order flow when exception throws after placing an order. + * + * @magentoDataFixture Magento/Braintree/Fixtures/paypal_quote.php + */ + public function testExecuteWithFailedOrder() + { + $reservedOrderId = 'test01'; + $quote = $this->getQuote($reservedOrderId); + + $this->session->method('getQuote') + ->willReturn($quote); + + $this->adapter->method('sale') + ->willReturn($this->getTransactionStub('authorized')); + $this->adapter->method('void') + ->willReturn($this->getTransactionStub('voided')); + + // emulates an error after placing the order + $this->session->method('setLastOrderStatus') + ->willThrowException(new \Exception('Test Exception')); + + $this->getRequest()->setMethod(HttpRequest::METHOD_POST); + $this->dispatch('braintree/paypal/placeOrder'); + + self::assertRedirect(self::stringContains('checkout/cart')); + self::assertSessionMessages( + self::equalTo(['The order #' . $reservedOrderId . ' cannot be processed.']), + MessageInterface::TYPE_ERROR + ); + + $order = $this->getOrder($reservedOrderId); + self::assertEquals('canceled', $order->getState()); + } + + /** + * Gets quote by reserved order ID. + * + * @param string $reservedOrderId + * @return CartInterface + */ + private function getQuote(string $reservedOrderId): CartInterface + { + $searchCriteria = $this->_objectManager->get(SearchCriteriaBuilder::class) + ->addFilter('reserved_order_id', $reservedOrderId) + ->create(); + + /** @var CartRepositoryInterface $quoteRepository */ + $quoteRepository = $this->_objectManager->get(CartRepositoryInterface::class); + $items = $quoteRepository->getList($searchCriteria) + ->getItems(); + + return array_pop($items); + } + + /** + * Gets order by increment ID. + * + * @param string $incrementId + * @return OrderInterface + */ + private function getOrder(string $incrementId): OrderInterface + { + $searchCriteria = $this->_objectManager->get(SearchCriteriaBuilder::class) + ->addFilter('increment_id', $incrementId) + ->create(); + + /** @var OrderRepositoryInterface $repository */ + $repository = $this->_objectManager->get(OrderRepositoryInterface::class); + $items = $repository->getList($searchCriteria) + ->getItems(); + + return array_pop($items); + } + + /** + * Creates stub for Braintree Transaction. + * + * @param string $status + * @return Successful + */ + private function getTransactionStub(string $status): Successful + { + $transaction = $this->getMockBuilder(Transaction::class) + ->disableOriginalConstructor() + ->getMock(); + $transaction->status = $status; + $transaction->paypal = [ + 'paymentId' => 'pay-001', + 'payerEmail' => 'test@test.com' + ]; + $response = new Successful(); + $response->success = true; + $response->transaction = $transaction; + + return $response; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Braintree/Fixtures/paypal_quote.php b/dev/tests/integration/testsuite/Magento/Braintree/Fixtures/paypal_quote.php new file mode 100644 index 0000000000000..d0db6233f05b1 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Braintree/Fixtures/paypal_quote.php @@ -0,0 +1,29 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\Braintree\Model\Ui\PayPal\ConfigProvider; +use Magento\Quote\Api\CartRepositoryInterface; + +require __DIR__ . '/../_files/paypal_vault_token.php'; +require __DIR__ . '/../../Sales/_files/quote_with_customer.php'; + +$quote->getShippingAddress() + ->setShippingMethod('flatrate_flatrate') + ->setCollectShippingRates(true); +$quote->getPayment() + ->setMethod(ConfigProvider::PAYPAL_VAULT_CODE) + ->setAdditionalInformation( + [ + 'customer_id' => $quote->getCustomerId(), + 'public_hash' => $paymentToken->getPublicHash() + ] + ); + +$quote->collectTotals(); + +/** @var CartRepositoryInterface $quoteRepository */ +$quoteRepository = $objectManager->get(CartRepositoryInterface::class); +$quoteRepository->save($quote); diff --git a/dev/tests/integration/testsuite/Magento/Braintree/Fixtures/paypal_quote_rollback.php b/dev/tests/integration/testsuite/Magento/Braintree/Fixtures/paypal_quote_rollback.php new file mode 100644 index 0000000000000..5d0fa8cca85d9 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Braintree/Fixtures/paypal_quote_rollback.php @@ -0,0 +1,9 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +require __DIR__ . '/../../Sales/_files/quote_with_customer_rollback.php'; diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/CategoryTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/CategoryTest.php index bfc7e33d5f771..a6cffda80e705 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/CategoryTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/CategoryTest.php @@ -5,11 +5,35 @@ */ namespace Magento\Catalog\Controller\Adminhtml; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Store\Model\Store; +use Magento\Catalog\Model\ResourceModel\Product; + /** * @magentoAppArea adminhtml */ class CategoryTest extends \Magento\TestFramework\TestCase\AbstractBackendController { + /** + * @var \Magento\Catalog\Model\ResourceModel\Product + */ + protected $productResource; + + /** + * @inheritDoc + * + * @throws \Magento\Framework\Exception\AuthenticationException + */ + protected function setUp() + { + parent::setUp(); + + /** @var Product $productResource */ + $this->productResource = Bootstrap::getObjectManager()->get( + Product::class + ); + } + /** * @magentoDataFixture Magento/Store/_files/core_fixturestore.php * @magentoDbIsolation enabled @@ -123,6 +147,9 @@ public static function categoryCreatedFromProductCreationPageDataProvider() return [[$postData], [$postData + ['return_session_messages_only' => 1]]]; } + /** + * Test SuggestCategories finds any categories. + */ public function testSuggestCategoriesActionDefaultCategoryFound() { $this->getRequest()->setParam('label_part', 'Default'); @@ -133,6 +160,9 @@ public function testSuggestCategoriesActionDefaultCategoryFound() ); } + /** + * Test SuggestCategories properly processes search by label. + */ public function testSuggestCategoriesActionNoSuggestions() { $this->getRequest()->setParam('label_part', strrev('Default')); @@ -322,6 +352,9 @@ public function saveActionDataProvider() ]; } + /** + * Test validation. + */ public function testSaveActionCategoryWithDangerRequest() { $this->getRequest()->setPostValue( @@ -388,9 +421,132 @@ public function moveActionDataProvider() { return [ [400, 401, 'first_url_key', 402, 'second_url_key', false], - [400, 401, 'duplicated_url_key', 402, 'duplicated_url_key', true], + [400, 401, 'duplicated_url_key', 402, 'duplicated_url_key', false], [0, 401, 'first_url_key', 402, 'second_url_key', true], [400, 401, 'first_url_key', 0, 'second_url_key', true], ]; } + + /** + * @magentoDataFixture Magento/Catalog/_files/products_in_different_stores.php + * @magentoDbIsolation disabled + * @dataProvider saveActionWithDifferentWebsitesDataProvider + * + * @param array $postData + */ + public function testSaveCategoryWithProductPosition(array $postData) + { + /** @var $store \Magento\Store\Model\Store */ + $store = Bootstrap::getObjectManager()->create(Store::class); + $store->load('fixturestore', 'code'); + $storeId = $store->getId(); + $oldCategoryProductsCount = $this->getCategoryProductsCount(); + $this->getRequest()->setParam('store', $storeId); + $this->getRequest()->setParam('id', 96377); + $this->getRequest()->setPostValue($postData); + $this->dispatch('backend/catalog/category/save'); + $newCategoryProductsCount = $this->getCategoryProductsCount(); + $this->assertEquals( + $oldCategoryProductsCount, + $newCategoryProductsCount, + 'After changing product position number of records from catalog_category_product has changed' + ); + } + + /** + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @return array + */ + public function saveActionWithDifferentWebsitesDataProvider() + { + return [ + 'default_values' => [ + [ + 'store_id' => '1', + 'entity_id' => '96377', + 'attribute_set_id' => '4', + 'parent_id' => '2', + 'created_at' => '2018-11-29 08:28:37', + 'updated_at' => '2018-11-29 08:57:43', + 'path' => '1/2/96377', + 'level' => '2', + 'children_count' => '0', + 'row_id' => '96377', + 'name' => 'Category 1', + 'display_mode' => 'PRODUCTS', + 'url_key' => 'category-1', + 'url_path' => 'category-1', + 'automatic_sorting' => '0', + 'is_active' => '1', + 'is_anchor' => '1', + 'include_in_menu' => '1', + 'custom_use_parent_settings' => '0', + 'custom_apply_to_products' => '0', + 'path_ids' => [ + 0 => '1', + 1 => '2', + 2 => '96377' + ], + 'use_config' => [ + 'available_sort_by' => 'true', + 'default_sort_by' => 'true', + 'filter_price_range' => 'true' + ], + 'id' => '', + 'parent' => '0', + 'use_default' => [ + 'name' => '1', + 'url_key' => '1', + 'meta_title' => '1', + 'is_active' => '1', + 'include_in_menu' => '1', + 'custom_use_parent_settings' => '1', + 'custom_apply_to_products' => '1', + 'description' => '1', + 'landing_page' => '1', + 'display_mode' => '1', + 'custom_design' => '1', + 'page_layout' => '1', + 'meta_keywords' => '1', + 'meta_description' => '1', + 'custom_layout_update' => '1', + 'image' => '1' + ], + 'filter_price_range' => false, + 'meta_title' => false, + 'url_key_create_redirect' => 'category-1', + 'description' => false, + 'landing_page' => false, + 'default_sort_by' => 'position', + 'available_sort_by' => false, + 'custom_design' => false, + 'page_layout' => false, + 'meta_keywords' => false, + 'meta_description' => false, + 'custom_layout_update' => false, + 'position_cache_key' => '5c069248346ac', + 'is_smart_category' => '0', + 'smart_category_rules' => false, + 'sort_order' => '0', + 'vm_category_products' => '{"1":1,"3":0}' + ] + ] + ]; + } + + /** + * Get items count from catalog_category_product + * + * @return int + */ + private function getCategoryProductsCount(): int + { + $oldCategoryProducts = $this->productResource->getConnection()->select()->from( + $this->productResource->getTable('catalog_category_product'), + 'product_id' + ); + return count( + $this->productResource->getConnection()->fetchAll($oldCategoryProducts) + ); + } } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Product/AttributeTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Product/AttributeTest.php index 098b18d6f38c9..45c1583d76400 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Product/AttributeTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Controller/Adminhtml/Product/AttributeTest.php @@ -282,13 +282,15 @@ public function testLargeOptionsDataSet() $optionsData = []; $expectedOptionsLabels = []; for ($i = 0; $i < $optionsCount; $i++) { - $order = $i + 1; - $expectedOptionLabelOnStoreView = "value_{$i}_store_1"; + $expectedOptionLabelOnStoreView = 'value_' . $i . '_store_1'; $expectedOptionsLabels[$i+1] = $expectedOptionLabelOnStoreView; - $optionsData []= "option[order][option_{$i}]={$order}"; - $optionsData []= "option[value][option_{$i}][0]=value_{$i}_admin"; - $optionsData []= "option[value][option_{$i}][1]={$expectedOptionLabelOnStoreView}"; - $optionsData []= "option[delete][option_{$i}="; + $optionId = 'option_' . $i; + $optionRowData = []; + $optionRowData['option']['order'][$optionId] = $i + 1; + $optionRowData['option']['value'][$optionId][0] = 'value_' . $i . '_admin'; + $optionRowData['option']['value'][$optionId][1] = $expectedOptionLabelOnStoreView; + $optionRowData['option']['delete'][$optionId] = ''; + $optionsData[] = http_build_query($optionRowData); } $attributeData['serialized_options'] = json_encode($optionsData); $this->getRequest()->setPostValue($attributeData); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/Product/Flat/Action/CustomFlatAttributeTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/Product/Flat/Action/CustomFlatAttributeTest.php new file mode 100644 index 0000000000000..763237c702933 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/Product/Flat/Action/CustomFlatAttributeTest.php @@ -0,0 +1,221 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Catalog\Model\Indexer\Product\Flat\Action; + +use Magento\Store\Api\StoreRepositoryInterface; +use Magento\TestFramework\Indexer\TestCase as IndexerTestCase; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\Indexer\Product\Flat\Processor; +use Magento\Catalog\Model\ResourceModel\Product\Flat as FlatResource; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Catalog\Api\ProductAttributeRepositoryInterface; +use Magento\Store\Model\Store; +use Magento\Catalog\Model\ResourceModel\Product\Collection; +use Magento\Catalog\Model\Indexer\Product\Flat\State as FlatState; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Catalog\Api\Data\ProductInterface; + +/** + * Custom Flat Attribute Test + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class CustomFlatAttributeTest extends IndexerTestCase +{ + /** + * @var Processor + */ + private $processor; + + /** + * @var \Magento\Framework\ObjectManagerInterface + */ + private $objectManager; + + /** + * @var ProductRepositoryInterface + */ + private $productRepository; + + /** + * @var ProductAttributeRepositoryInterface + */ + private $attributeRepository; + + /** + * @var FlatResource + */ + private $flatResource; + + /** + * @var FlatState + */ + private $flatState; + + /** + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * @var \Magento\Store\Api\Data\StoreInterface + */ + private $savedCurrentStore; + + /** + * @inheritdoc + */ + protected function setUp() + { + $this->objectManager = Bootstrap::getObjectManager(); + $this->processor = $this->objectManager->get(Processor::class); + $this->productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $this->attributeRepository = $this->objectManager->get(ProductAttributeRepositoryInterface::class); + $this->flatResource = $this->objectManager->get(FlatResource::class); + $this->flatState = $this->objectManager->create(FlatState::class, [ + 'isAvailable' => true + ]); + $this->storeManager = $this->objectManager->get(StoreManagerInterface::class); + $this->savedCurrentStore = $this->storeManager->getStore(); + } + + /** + * @inheritdoc + */ + protected function tearDown() + { + $this->storeManager->setCurrentStore($this->savedCurrentStore); + } + + /** + * Tests that custom product attribute will appear in flat table and can be updated in it. + * + * @magentoDbIsolation disabled + * @magentoAppArea frontend + * @magentoConfigFixture current_store catalog/frontend/flat_catalog_product 1 + * @magentoDataFixture Magento/Catalog/_files/product_simple_with_custom_attribute_in_flat.php + * + * @return void + * @throws \Magento\Framework\Exception\CouldNotSaveException + * @throws \Magento\Framework\Exception\InputException + * @throws \Magento\Framework\Exception\NoSuchEntityException + * @throws \Magento\Framework\Exception\StateException + */ + public function testProductUpdateCustomAttribute() + { + $product = $this->productRepository->get('simple_with_custom_flat_attribute'); + $product->setCustomAttribute('flat_attribute', 'changed flat attribute'); + $this->productRepository->save($product); + + /** @var SearchCriteriaBuilder $searchCriteriaBuilder */ + $searchCriteriaBuilder = $this->objectManager->create(SearchCriteriaBuilder::class); + /** @var \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria */ + $searchCriteria = $searchCriteriaBuilder->addFilter('sku', 'simple_with_custom_flat_attribute') + ->create(); + + $items = $this->productRepository->getList($searchCriteria) + ->getItems(); + $product = reset($items); + $resourceModel = $product->getResourceCollection() + ->getEntity(); + + self::assertInstanceOf( + FlatResource::class, + $resourceModel, + 'Product should be received from flat resource' + ); + + self::assertEquals( + 'changed flat attribute', + $product->getFlatAttribute(), + 'Product flat attribute should be able to change.' + ); + } + + /** + * Tests flat dropdown attribute. + * Tests that flat dropdown attribute will be changed for different flat tables (it means for different stores) + * + * @magentoDbIsolation disabled + * @magentoAppArea adminhtml + * @magentoConfigFixture default_store catalog/frontend/flat_catalog_product 1 + * @magentoConfigFixture default_store catalog/frontend/flat_catalog_category 1 + * @magentoConfigFixture fixturestore_store catalog/frontend/flat_catalog_product 1 + * @magentoConfigFixture fixturestore_store catalog/frontend/flat_catalog_category 1 + * @magentoDataFixture Magento/Catalog/_files/product_simple_multistore.php + * @magentoDataFixture Magento/Catalog/_files/flat_dropdown_attribute.php + * + * @return void + * @throws \Magento\Framework\Exception\CouldNotSaveException + * @throws \Magento\Framework\Exception\InputException + * @throws \Magento\Framework\Exception\NoSuchEntityException + * @throws \Magento\Framework\Exception\StateException + */ + public function testFlatDropDownAttribute() + { + $attribute = $this->attributeRepository->get('flat_attribute'); + $attributeOptions = $attribute->getOptions(); + + $firstStoreAttributeOption = $attributeOptions[1]; + $productStore1 = $this->productRepository->get('simple', false, 1); + $productStore1->setFlatAttribute($firstStoreAttributeOption->getValue()); + $this->productRepository->save($productStore1); + + /** @var StoreRepositoryInterface $storeRepository */ + $storeRepository = $this->objectManager->get(StoreRepositoryInterface::class); + $store = $storeRepository->get('fixturestore'); + + $secondStoreAttributeOption = $attributeOptions[2]; + $productStore2 = $this->productRepository->get('simple', false, $store->getId()); + $productStore2->setFlatAttribute($secondStoreAttributeOption->getValue()); + $this->productRepository->save($productStore2); + + $this->processor = $this->objectManager->create(Processor::class); + $this->processor->reindexAll(); + + $productStore1 = $this->getFlatProductFromStore('simple'); + self::assertEquals($firstStoreAttributeOption->getLabel(), $productStore1->getFlatAttributeValue()); + + $productStore2 = $this->getFlatProductFromStore('simple', $store); + self::assertEquals($secondStoreAttributeOption->getLabel(), $productStore2->getFlatAttributeValue()); + } + + /** + * Get product from store with flat data + * + * @param string $sku + * @param Store|null $store + * @return ProductInterface + */ + private function getFlatProductFromStore(string $sku, $store = null): ProductInterface + { + if ($store) { + $this->storeManager->setCurrentStore($store); + } + + /** @var Collection $productCollection */ + $productCollection = $this->objectManager->create( + Collection::class, + [ + 'catalogProductFlatState' => $this->flatState + ] + ); + + if ($store) { + $productCollection->setStoreId($store->getId()); + } + + $productCollection->setEntity($this->flatResource); + $productCollection->addAttributeToFilter('sku', $sku); + $productCollection->addAttributeToSelect('flat_attribute'); + + return $productCollection->load() + ->getFirstItem(); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/Product/Flat/Action/RowTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/Product/Flat/Action/RowTest.php index 15c90891878a0..67c81772462d6 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/Product/Flat/Action/RowTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Indexer/Product/Flat/Action/RowTest.php @@ -3,87 +3,110 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\Catalog\Model\Indexer\Product\Flat\Action; +use Magento\TestFramework\Indexer\TestCase as IndexerTestCase; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\Indexer\Product\Flat\Processor; +use Magento\Catalog\Block\Product\ListProduct; +use Magento\Catalog\Api\CategoryRepositoryInterface; + /** * Class RowTest */ -class RowTest extends \Magento\TestFramework\Indexer\TestCase +class RowTest extends IndexerTestCase { /** - * @var \Magento\Catalog\Model\Product + * @var Processor */ - protected $_product; + private $processor; /** - * @var \Magento\Catalog\Model\Category + * @var \Magento\Framework\ObjectManagerInterface */ - protected $_category; + private $objectManager; /** - * @var \Magento\Catalog\Model\Indexer\Product\Flat\State + * @var ProductRepositoryInterface */ - protected $_state; + private $productRepository; /** - * @var \Magento\Catalog\Model\Indexer\Product\Flat\Processor + * @var CategoryRepositoryInterface */ - protected $_processor; + private $categoryRepository; + /** + * @inheritdoc + */ protected function setUp() { - $this->_product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class - ); - $this->_category = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Category::class - ); - $this->_processor = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Indexer\Product\Flat\Processor::class - ); + $this->objectManager = Bootstrap::getObjectManager(); + $this->processor = $this->objectManager->get(Processor::class); + $this->productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $this->categoryRepository = $this->objectManager->get(CategoryRepositoryInterface::class); } /** + * Tests product update + * * @magentoDbIsolation disabled * @magentoDataFixture Magento/Catalog/_files/row_fixture.php * @magentoConfigFixture current_store catalog/frontend/flat_catalog_product 1 * @magentoAppArea frontend + * + * @return void + * @throws \Magento\Framework\Exception\CouldNotSaveException + * @throws \Magento\Framework\Exception\InputException + * @throws \Magento\Framework\Exception\LocalizedException + * @throws \Magento\Framework\Exception\NoSuchEntityException + * @throws \Magento\Framework\Exception\StateException */ public function testProductUpdate() { - $categoryFactory = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Catalog\Model\CategoryFactory::class); - $listProduct = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Catalog\Block\Product\ListProduct::class); + /** @var ListProduct $listProduct */ + $listProduct = $this->objectManager->create(ListProduct::class); - $this->_processor->getIndexer()->setScheduled(false); - $this->assertFalse( - $this->_processor->getIndexer()->isScheduled(), + $this->processor->getIndexer() + ->setScheduled(false); + $isScheduled = $this->processor->getIndexer() + ->isScheduled(); + self::assertFalse( + $isScheduled, 'Indexer is in scheduled mode when turned to update on save mode' ); - $this->_product->load(1); - $this->_product->setName('Updated Product'); - $this->_product->save(); + $this->processor->reindexAll(); - $this->_processor->reindexAll(); - - $category = $categoryFactory->create()->load(9); + $product = $this->productRepository->get('simple'); + $product->setName('Updated Product'); + $this->productRepository->save($product); + + /** @var \Magento\Catalog\Api\Data\CategoryInterface $category */ + $category = $this->categoryRepository->get(9); + /** @var \Magento\Catalog\Model\Layer $layer */ $layer = $listProduct->getLayer(); $layer->setCurrentCategory($category); /** @var \Magento\Catalog\Model\ResourceModel\Product\Collection $productCollection */ $productCollection = $layer->getProductCollection(); - $this->assertTrue( + self::assertTrue( $productCollection->isEnabledFlat(), 'Product collection is not using flat resource when flat is on' ); - $this->assertEquals(2, $productCollection->count(), 'Product collection items count must be exactly 2'); + self::assertEquals( + 2, + $productCollection->count(), + 'Product collection items count must be exactly 2' + ); foreach ($productCollection as $product) { /** @var $product \Magento\Catalog\Model\Product */ - if ($product->getId() == 1) { - $this->assertEquals( + if ($product->getSku() === 'simple') { + self::assertEquals( 'Updated Product', $product->getName(), 'Product name from flat does not match with updated name' diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/ProductExternalTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/ProductExternalTest.php index 8370e514dc2f2..a1923e63972ee 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/ProductExternalTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/ProductExternalTest.php @@ -69,7 +69,7 @@ public function testGetCategoryId() { $this->assertFalse($this->_model->getCategoryId()); $category = new \Magento\Framework\DataObject(['id' => 5]); - + $this->_model->setCategoryIds([5]); $this->objectManager->get(\Magento\Framework\Registry::class)->register('current_category', $category); try { $this->assertEquals(5, $this->_model->getCategoryId()); @@ -83,6 +83,7 @@ public function testGetCategoryId() public function testGetCategory() { $this->assertEmpty($this->_model->getCategory()); + $this->_model->setCategoryIds([3]); $this->objectManager->get(\Magento\Framework\Registry::class) ->register('current_category', new \Magento\Framework\DataObject(['id' => 3])); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/RemoveRedundantImageTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/RemoveRedundantImageTest.php new file mode 100644 index 0000000000000..1bc545a25ac32 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/RemoveRedundantImageTest.php @@ -0,0 +1,82 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Catalog\Model; + +use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\Filesystem; +use Magento\Framework\Filesystem\Directory\WriteInterface; +use Magento\Framework\ObjectManagerInterface; + +/** + * Test removing old Category Image file from pub/media/catalog/category directory if such Image is not used anymore. + */ +class RemoveRedundantImageTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var ObjectManagerInterface + */ + private $objectManager; + + /** + * @var Filesystem + */ + private $filesystem; + + /** + * @var WriteInterface + */ + private $mediaDirectory; + + /** + * @var CategoryRepository + */ + private $categoryRepository; + + protected function setUp() + { + $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + /** @var Filesystem $filesystem */ + $this->filesystem = $this->objectManager->get(Filesystem::class); + $this->mediaDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA); + $this->categoryRepository = $this->objectManager->get(CategoryRepository::class); + } + + /** + * Tests removing Image file if it is not used anymore. + * + * @magentoDataFixture Magento/Catalog/_files/categories_with_image.php + * + * @magentoAppIsolation enabled + * @magentoDbIsolation enabled + */ + public function testRemoveRedundantImage() + { + $imagesPath = 'catalog' . DIRECTORY_SEPARATOR . 'category'; + $absoluteImagesPath = $this->mediaDirectory->getAbsolutePath($imagesPath); + $filePath1 = $absoluteImagesPath . DIRECTORY_SEPARATOR . 'test_image_1.jpg'; + $filePath2 = $absoluteImagesPath . DIRECTORY_SEPARATOR . 'test_image_2.jpg'; + $this->mediaDirectory->create($absoluteImagesPath); + $this->mediaDirectory->touch($filePath1); + $this->mediaDirectory->touch($filePath2); + + $category1 = $this->categoryRepository->get(3); + $category1->setImage('test_image_3.jpg'); + $this->categoryRepository->save($category1); + $category2 = $this->categoryRepository->get(5); + $category2->setImage('test_image_3.jpg'); + $this->categoryRepository->save($category2); + + $this->assertTrue($this->mediaDirectory->isExist($filePath1)); + $this->assertFalse($this->mediaDirectory->isExist($filePath2)); + } + + protected function tearDown() + { + $this->mediaDirectory->delete(); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/categories_with_image.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/categories_with_image.php new file mode 100644 index 0000000000000..6fc0f4e8b6efa --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/categories_with_image.php @@ -0,0 +1,53 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + +/** + * After installation system has two categories: root one with ID:1 and Default category with ID:2 + */ +/** @var $category \Magento\Catalog\Model\Category */ +$category = $objectManager->create(\Magento\Catalog\Model\Category::class); +$category->isObjectNew(true); +$category->setId(3) + ->setName('Category 1') + ->setParentId(2) + ->setPath('1/2/3') + ->setLevel(2) + ->setAvailableSortBy('name') + ->setDefaultSortBy('name') + ->setIsActive(true) + ->setPosition(1) + ->setImage('test_image_1.jpg') + ->save(); + +$category = $objectManager->create(\Magento\Catalog\Model\Category::class); +$category->isObjectNew(true); +$category->setId(4) + ->setName('Category 1.1') + ->setParentId(3) + ->setPath('1/2/3/4') + ->setLevel(3) + ->setAvailableSortBy('name') + ->setDefaultSortBy('name') + ->setIsActive(true) + ->setIsAnchor(true) + ->setPosition(1) + ->setImage('test_image_1.jpg') + ->save(); + +$category = $objectManager->create(\Magento\Catalog\Model\Category::class); +$category->isObjectNew(true); +$category->setId(5) + ->setName('Category 2') + ->setParentId(2) + ->setPath('1/2/5') + ->setLevel(2) + ->setAvailableSortBy('name') + ->setDefaultSortBy('name') + ->setIsActive(true) + ->setPosition(2) + ->setImage('test_image_2.jpg') + ->save(); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/categories_with_image_rollback.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/categories_with_image_rollback.php new file mode 100644 index 0000000000000..d290a164f639e --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/categories_with_image_rollback.php @@ -0,0 +1,23 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); +/** @var \Magento\Framework\Registry $registry */ +$registry = $objectManager->get(\Magento\Framework\Registry::class); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +//Remove categories +/** @var Magento\Catalog\Model\ResourceModel\Category\Collection $collection */ +$collection = $objectManager->create(\Magento\Catalog\Model\ResourceModel\Category\Collection::class); +$collection + ->addAttributeToFilter('level', 2) + ->load() + ->delete(); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/category_product.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/category_product.php index 6803d96f0c0de..fe61b3e197ca0 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/category_product.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/category_product.php @@ -15,7 +15,7 @@ )->setParentId( 2 )->setPath( - '1/2/3' + '1/2/333' )->setLevel( 2 )->setAvailableSortBy( diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/flat_dropdown_attribute.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/flat_dropdown_attribute.php new file mode 100644 index 0000000000000..290958b56b886 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/flat_dropdown_attribute.php @@ -0,0 +1,72 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Catalog\Setup\CategorySetup; +use Magento\Eav\Model\Entity; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Api\Data\ProductAttributeInterface; +use Magento\Eav\Api\Data\AttributeOptionInterfaceFactory; +use Magento\Catalog\Api\ProductAttributeRepositoryInterface; + +/** @var \Magento\TestFramework\ObjectManager $objectManager */ +$objectManager = Bootstrap::getObjectManager(); +/** @var AttributeOptionInterfaceFactory $attributeOptionFactory */ +$attributeOptionFactory = $objectManager->get(AttributeOptionInterfaceFactory::class); +/** @var ProductAttributeRepositoryInterface $attributeRepository */ +$attributeRepository = $objectManager->get(ProductAttributeRepositoryInterface::class); +/** @var $installer CategorySetup */ +$installer = $objectManager->create(CategorySetup::class); +$entityModel = $objectManager->create(Entity::class); +$attributeSetId = $installer->getAttributeSetId(Product::ENTITY, 'Default'); +$entityTypeId = $entityModel->setType(Product::ENTITY) + ->getTypeId(); +$groupId = $installer->getDefaultAttributeGroupId($entityTypeId, $attributeSetId); +/** @var ProductAttributeInterface $attribute */ +$attribute = $objectManager->create(ProductAttributeInterface::class); + +$attribute->setAttributeCode('flat_attribute') + ->setEntityTypeId($entityTypeId) + ->setIsVisible(true) + ->setFrontendInput('select') + ->setIsFilterable(1) + ->setIsUserDefined(1) + ->setUsedInProductListing(1) + ->setBackendType('int') + ->setIsUsedInGrid(1) + ->setIsVisibleInGrid(1) + ->setIsFilterable(0) + ->setIsHtmlAllowedOnFront(1) + ->setIsFilterableInGrid(1) + ->setAttributeGroupId($groupId) + ->setIsGlobal(0) + ->setIsUsedInProductListing(1) + ->setFrontendLabel('nobody cares') + ->setAttributeGroupId($groupId) + ->setAttributeSetId(4); + +$optionsArray = [ + [ + 'label' => 'Option 1', + 'value' => 'option_1' + ], + [ + 'label' => 'Option 2', + 'value' => 'option_2' + ], + [ + 'label' => 'Option 3', + 'value' => 'option_3' + ], +]; + +$options = []; +foreach ($optionsArray as $option) { + $options[] = $attributeOptionFactory->create(['data' => $option]); +} +$attribute->setOptions($options); +$attributeRepository->save($attribute); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/flat_dropdown_attribute_rollback.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/flat_dropdown_attribute_rollback.php new file mode 100644 index 0000000000000..d56fefaa99aa2 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/flat_dropdown_attribute_rollback.php @@ -0,0 +1,21 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Catalog\Api\ProductAttributeRepositoryInterface; +use Magento\Framework\Exception\NoSuchEntityException; + +/** @var \Magento\TestFramework\ObjectManager $objectManager */ +$objectManager = Bootstrap::getObjectManager(); +/** @var ProductAttributeRepositoryInterface $attributeRepository */ +$attributeRepository = $objectManager->get(ProductAttributeRepositoryInterface::class); + +try { + $attribute = $attributeRepository->get('flat_attribute'); + $attributeRepository->delete($attribute); +} catch (NoSuchEntityException $e) { +} diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/product_simple_with_custom_attribute_in_flat.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/product_simple_with_custom_attribute_in_flat.php new file mode 100644 index 0000000000000..2b1b271a8bb3c --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/product_simple_with_custom_attribute_in_flat.php @@ -0,0 +1,79 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\ProductFactory; +use Magento\Catalog\Api\Data\ProductAttributeInterface; +use Magento\Catalog\Api\ProductAttributeRepositoryInterface; +use Magento\Catalog\Model\Indexer\Product\Flat\Processor; +use Magento\Catalog\Setup\CategorySetup; +use Magento\Eav\Model\Entity; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Type; + +/** @var \Magento\TestFramework\ObjectManager $objectManager */ +$objectManager = Bootstrap::getObjectManager(); +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->get(ProductRepositoryInterface::class); +/** @var ProductFactory $productFactory */ +$productFactory = $objectManager->get(ProductFactory::class); +/** @var ProductAttributeRepositoryInterface $attributeRepository */ +$attributeRepository = $objectManager->get(ProductAttributeRepositoryInterface::class); + + +/** @var $installer CategorySetup */ +$installer = $objectManager->create(CategorySetup::class); +$entityModel = $objectManager->create(Entity::class); +$attributeSetId = $installer->getAttributeSetId(Product::ENTITY, 'Default'); +$entityTypeId = $entityModel->setType(Product::ENTITY) + ->getTypeId(); +$groupId = $installer->getDefaultAttributeGroupId($entityTypeId, $attributeSetId); + +/** @var ProductAttributeInterface $attribute */ +$attribute = $objectManager->create(ProductAttributeInterface::class); + +$attribute->setAttributeCode('flat_attribute') + ->setEntityTypeId($entityTypeId) + ->setIsVisible(true) + ->setFrontendInput('text') + ->setIsFilterable(1) + ->setIsUserDefined(1) + ->setUsedInProductListing(1) + ->setBackendType('varchar') + ->setIsUsedInGrid(1) + ->setIsVisibleInGrid(1) + ->setIsFilterableInGrid(1) + ->setFrontendLabel('nobody cares') + ->setAttributeGroupId($groupId) + ->setAttributeSetId(4); + +$attributeRepository->save($attribute); + +/** @var Processor $processor */ +$processor = $objectManager->create(Processor::class); +$scheduled = $processor->getIndexer() + ->isScheduled(); +$processor->reindexAll(); + +$product = $productFactory->create() + ->setTypeId(Type::TYPE_SIMPLE) + ->setAttributeSetId(4) + ->setName('Simple With Attribute That Used In Flat') + ->setSku('simple_with_custom_flat_attribute') + ->setPrice(100) + ->setVisibility(1) + ->setStockData( + [ + 'use_config_manage_stock' => 1, + 'qty' => 100, + 'is_in_stock' => 1, + ] + ) + ->setStatus(1); +$product->setCustomAttribute('flat_attribute', 'flat attribute value'); +$productRepository->save($product); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/product_simple_with_custom_attribute_in_flat_rollback.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/product_simple_with_custom_attribute_in_flat_rollback.php new file mode 100644 index 0000000000000..c1892d504ecc3 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/product_simple_with_custom_attribute_in_flat_rollback.php @@ -0,0 +1,41 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Framework\Registry; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Catalog\Api\ProductAttributeRepositoryInterface; + +/** @var Magento\Framework\ObjectManagerInterface $objectManager */ +$objectManager = Bootstrap::getObjectManager(); +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->get(ProductRepositoryInterface::class); +/** @var ProductAttributeRepositoryInterface $attributeRepository */ +$attributeRepository = $objectManager->get(ProductAttributeRepositoryInterface::class); +/** @var Registry $registry */ +$registry = $objectManager->get(Registry::class); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +try { + /** @var \Magento\Catalog\Api\Data\ProductInterface $product */ + $product = $productRepository->get('simple_with_custom_flat_attribute'); + $productRepository->delete($product); +} catch (NoSuchEntityException $e) { +} + +try { + /** @var \Magento\Catalog\Api\Data\ProductAttributeInterface $attribute */ + $attribute = $attributeRepository->get('flat_attribute'); + $attributeRepository->delete($attribute); +} catch (NoSuchEntityException $e) { +} + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_in_different_stores.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_in_different_stores.php new file mode 100644 index 0000000000000..6102738bd6be4 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_in_different_stores.php @@ -0,0 +1,122 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Framework\Indexer\IndexerRegistry; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Type; +use Magento\Catalog\Model\Product\Visibility; +use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\Catalog\Api\ProductRepositoryInterface; + +require __DIR__ . '/../../Store/_files/core_fixturestore.php'; + +$objectManager = Bootstrap::getObjectManager(); + +/** @var Magento\Store\Model\Website $website */ +$website = $objectManager->get(Magento\Store\Model\Website::class); + +$website->setData( + [ + 'code' => 'second_website', + 'name' => 'Test Website', + ] +); + +$website->save(); + +$objectManager->get(\Magento\Store\Model\StoreManagerInterface::class)->reinitStores(); + +/** @var IndexerRegistry $indexerRegistry */ +$indexerRegistry = $objectManager->create(IndexerRegistry::class); +$indexer = $indexerRegistry->get('catalogsearch_fulltext'); + +$indexer->reindexAll(); + +$category = $objectManager->create(\Magento\Catalog\Model\Category::class); +$category->isObjectNew(true); +$category->setId(96377) + ->setName('Category 1') + ->setParentId(2) + ->setPath('1/2/96377') + ->setLevel(2) + ->setAvailableSortBy('name') + ->setDefaultSortBy('name') + ->setIsActive(true) + ->setPosition(1) + ->save(); + +/** @var $product Product */ +$product = $objectManager->create(Product::class); +$product->isObjectNew(true); +$product->setTypeId(Type::TYPE_SIMPLE) + ->setAttributeSetId(4) + ->setWebsiteIds([1]) + ->setName('Simple Product') + ->setSku('simple_1') + ->setPrice(10) + ->setWeight(1) + ->setShortDescription("Short description") + ->setTaxClassId(0) + ->setDescription('Description with <b>html tag</b>') + ->setMetaTitle('meta title') + ->setMetaKeyword('meta keyword') + ->setMetaDescription('meta description') + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED) + ->setCategoryIds([96377]); + +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->create(ProductRepositoryInterface::class); +$productRepository->save($product); + +/** @var $product Product */ +$product = $objectManager->create(Product::class); +$product->isObjectNew(true); +$product->setTypeId(Type::TYPE_SIMPLE) + ->setAttributeSetId(4) + ->setWebsiteIds([$website->getId()]) + ->setName('Simple Product 2') + ->setSku('simple_2') + ->setPrice(10) + ->setWeight(1) + ->setShortDescription("Short description") + ->setTaxClassId(0) + ->setDescription('Description with <b>html tag</b>') + ->setMetaTitle('meta title') + ->setMetaKeyword('meta keyword') + ->setMetaDescription('meta description') + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED) + ->setCategoryIds([96377]); + +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->create(ProductRepositoryInterface::class); +$productRepository->save($product); + +/** @var $product Product */ +$product = $objectManager->create(Product::class); +$product->isObjectNew(true); +$product->setTypeId(Type::TYPE_SIMPLE) + ->setAttributeSetId(4) + ->setWebsiteIds([1, $website->getId()]) + ->setName('Simple Product 3') + ->setSku('simple_3') + ->setPrice(10) + ->setWeight(1) + ->setShortDescription("Short description") + ->setTaxClassId(0) + ->setDescription('Description with <b>html tag</b>') + ->setMetaTitle('meta title') + ->setMetaKeyword('meta keyword') + ->setMetaDescription('meta description') + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED) + ->setCategoryIds([96377]); + +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->create(ProductRepositoryInterface::class); +$productRepository->save($product); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/products_in_different_stores_rollback.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_in_different_stores_rollback.php new file mode 100644 index 0000000000000..9b957b75eb2a3 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/products_in_different_stores_rollback.php @@ -0,0 +1,46 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +require __DIR__ . '/../../Store/_files/core_fixturestore_rollback.php'; + +$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); +/** @var \Magento\Framework\Registry $registry */ +$registry = $objectManager->get(\Magento\Framework\Registry::class); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +//Remove category +/** @var $category \Magento\Catalog\Model\Category */ +$category = $objectManager->create(\Magento\Catalog\Model\Category::class); +$category->load(96377); +if ($category->getId()) { + $category->delete(); +} + +$productSkuList = ['simple_1', 'simple_2', 'simple_3']; +foreach ($productSkuList as $sku) { + try { + $productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + ->get(\Magento\Catalog\Api\ProductRepositoryInterface::class); + $product = $productRepository->get($sku, true); + if ($product->getId()) { + $productRepository->delete($product); + } + } catch (\Magento\Framework\Exception\NoSuchEntityException $e) { + //Product already removed + } +} + +/** @var Magento\Store\Model\Website $website */ +$website = $objectManager->get(Magento\Store\Model\Website::class); +$website->load('second_website', 'code'); +if ($website->getId()) { + $website->delete(); +} + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Export/ProductTest.php b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Export/ProductTest.php index 81592b6901f1c..47d74fcfd6719 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Export/ProductTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Export/ProductTest.php @@ -325,8 +325,10 @@ public function testExportWithMedia() /** * @magentoDataFixture Magento/CatalogImportExport/_files/product_export_data.php + * + * @return void */ - public function testExportWithCustomOptions() + public function testExportWithCustomOptionsAndSecondStore() { $storeCode = 'default'; $expectedData = []; @@ -380,6 +382,56 @@ public function testExportWithCustomOptions() self::assertSame($expectedData, $customOptionData); } + /** + * @magentoDataFixture Magento/Catalog/_files/product_simple_with_custom_options.php + * + * @return void + */ + public function testExportWithCustomOptions() + { + $expectedData = []; + /** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ + $productRepository = $this->objectManager->get(\Magento\Catalog\Api\ProductRepositoryInterface::class); + /** @var \Magento\Catalog\Api\Data\ProductInterface $product */ + $product = $productRepository->get('simple_with_custom_options', true); + + foreach ($product->getOptions() as $customOption) { + $optionTitle = $customOption->getTitle(); + $expectedData[$optionTitle] = []; + if ($customOption->getValues()) { + foreach ($customOption->getValues() as $customOptionValue) { + $expectedData[$optionTitle][] = $customOptionValue->getTitle(); + } + } + } + + ksort($expectedData); + + $this->model->setWriter( + $this->objectManager->create( + \Magento\ImportExport\Model\Export\Adapter\Csv::class + ) + ); + $exportData = $this->model->export(); + /** @var $varDirectory \Magento\Framework\Filesystem\Directory\WriteInterface */ + $varDirectory = $this->objectManager->get(\Magento\Framework\Filesystem::class) + ->getDirectoryWrite(\Magento\Framework\App\Filesystem\DirectoryList::VAR_DIR); + $varDirectory->writeFile('test_product_with_custom_options.csv', $exportData); + /** @var \Magento\Framework\File\Csv $csv */ + $csv = $this->objectManager->get(\Magento\Framework\File\Csv::class); + $data = $csv->getData($varDirectory->getAbsolutePath('test_product_with_custom_options.csv')); + + foreach ($data[0] as $columnNumber => $columnName) { + if ($columnName === 'custom_options') { + $exportedCustomOptionData = $this->parseExportedCustomOption($data[1][$columnNumber]); + } + } + + ksort($exportedCustomOptionData); + + self::assertSame($expectedData, $exportedCustomOptionData); + } + /** * @param $exportedCustomOption * @return array diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractTest.php b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractTest.php index 9f7f92b1ebe54..a03675664afba 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/Product/Type/AbstractTest.php @@ -43,6 +43,11 @@ protected function setUp() } /** + * Test adding default attribute to product before save. + * + * @param array $rowData + * @param bool $withDefaultValue + * @param array $expectedAttributes * @dataProvider prepareAttributesWithDefaultValueForSaveDataProvider */ public function testPrepareAttributesWithDefaultValueForSave($rowData, $withDefaultValue, $expectedAttributes) @@ -52,9 +57,15 @@ public function testPrepareAttributesWithDefaultValueForSave($rowData, $withDefa $this->assertArrayHasKey($key, $actualAttributes); $this->assertEquals($value, $actualAttributes[$key]); } + + if (!empty($rowData['_store'])) { + $this->assertEquals($expectedAttributes, $actualAttributes, '', 0.0, 10, true); + } } /** + * Data provider for testPrepareAttributesWithDefaultValueForSave. + * * @return array * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ @@ -78,6 +89,17 @@ public function prepareAttributesWithDefaultValueForSaveDataProvider() false, ['price' => 65, 'visibility' => 1, 'tax_class_id' => ''], ], + 'Updating existing product with attributes that do not have default values with store in dataRow' => [ + [ + 'sku' => 'simple_product_3', + 'price' => 75, + '_attribute_set' => 'Default', + 'product_type' => 'simple', + '_store' => 1 + ], + true, + ['price' => 75], + ], 'Adding new product with attributes that do not have default values' => [ [ 'sku' => 'simple_product_3', diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ProductTest.php b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ProductTest.php index f3cb01a9bbe2e..9cc7c452e646f 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ProductTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/ProductTest.php @@ -25,6 +25,7 @@ use Magento\Framework\Filesystem; use Magento\Framework\Registry; use Magento\ImportExport\Model\Import; +use Magento\ImportExport\Model\Import\ErrorProcessing\ProcessingErrorAggregatorInterface; use Magento\Store\Model\Store; use Psr\Log\LoggerInterface; @@ -36,6 +37,7 @@ * @magentoDataFixtureBeforeTransaction Magento/Catalog/_files/enable_catalog_product_reindex_schedule.php * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) + * @SuppressWarnings(PHPMD.ExcessivePublicCount) */ class ProductTest extends \Magento\TestFramework\Indexer\TestCase { @@ -69,16 +71,20 @@ class ProductTest extends \Magento\TestFramework\Indexer\TestCase */ private $logger; + /** + * @inheritdoc + */ protected function setUp() { $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); $this->logger = $this->getMockBuilder(LoggerInterface::class) ->disableOriginalConstructor() ->getMock(); - $this->_model = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( + $this->_model = $this->objectManager->create( \Magento\CatalogImportExport\Model\Import\Product::class, ['logger' => $this->logger] ); + parent::setUp(); } @@ -103,7 +109,7 @@ protected function setUp() protected $_assertOptionValues = [ 'title' => 'option_title', 'price' => 'price', - 'sku' => 'sku' + 'sku' => 'sku', ]; /** @@ -127,24 +133,19 @@ protected function setUp() public function testSaveProductsVisibility() { /** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ - $productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Api\ProductRepositoryInterface::class - ); + $productRepository = $this->objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); $id1 = $productRepository->get('simple1')->getId(); $id2 = $productRepository->get('simple2')->getId(); $id3 = $productRepository->get('simple3')->getId(); $existingProductIds = [$id1, $id2, $id3]; $productsBeforeImport = []; foreach ($existingProductIds as $productId) { - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class - ); + $product = $this->objectManager->create(\Magento\Catalog\Model\Product::class); $product->load($productId); $productsBeforeImport[] = $product; } - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Framework\Filesystem::class); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( @@ -167,9 +168,7 @@ public function testSaveProductsVisibility() /** @var $productBeforeImport \Magento\Catalog\Model\Product */ foreach ($productsBeforeImport as $productBeforeImport) { /** @var $productAfterImport \Magento\Catalog\Model\Product */ - $productAfterImport = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class - ); + $productAfterImport = $this->objectManager->create(\Magento\Catalog\Model\Product::class); $productAfterImport->load($productBeforeImport->getId()); $this->assertEquals($productBeforeImport->getVisibility(), $productAfterImport->getVisibility()); @@ -188,9 +187,7 @@ public function testSaveProductsVisibility() public function testSaveStockItemQty() { /** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ - $productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Api\ProductRepositoryInterface::class - ); + $productRepository = $this->objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); $id1 = $productRepository->get('simple1')->getId(); $id2 = $productRepository->get('simple2')->getId(); $id3 = $productRepository->get('simple3')->getId(); @@ -198,16 +195,13 @@ public function testSaveStockItemQty() $stockItems = []; foreach ($existingProductIds as $productId) { /** @var $stockRegistry \Magento\CatalogInventory\Model\StockRegistry */ - $stockRegistry = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\CatalogInventory\Model\StockRegistry::class - ); + $stockRegistry = $this->objectManager->create(\Magento\CatalogInventory\Model\StockRegistry::class); $stockItem = $stockRegistry->getStockItem($productId, 1); $stockItems[$productId] = $stockItem; } - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Framework\Filesystem::class); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( \Magento\ImportExport\Model\Import\Source\Csv::class, @@ -229,9 +223,7 @@ public function testSaveStockItemQty() /** @var $stockItmBeforeImport \Magento\CatalogInventory\Model\Stock\Item */ foreach ($stockItems as $productId => $stockItmBeforeImport) { /** @var $stockRegistry \Magento\CatalogInventory\Model\StockRegistry */ - $stockRegistry = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\CatalogInventory\Model\StockRegistry::class - ); + $stockRegistry = $this->objectManager->create(\Magento\CatalogInventory\Model\StockRegistry::class); $stockItemAfterImport = $stockRegistry->getStockItem($productId, 1); @@ -251,8 +243,7 @@ public function testSaveStockItemQty() */ public function testStockState() { - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Framework\Filesystem::class); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( \Magento\ImportExport\Model\Import\Source\Csv::class, @@ -292,7 +283,7 @@ public function testSaveCustomOptions($importFile, $sku, $expectedOptionsQty) $importModel->importData(); /** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ - $productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( + $productRepository = $this->objectManager->create( \Magento\Catalog\Api\ProductRepositoryInterface::class ); $product = $productRepository->get($sku); @@ -350,13 +341,12 @@ public function testSaveCustomOptions($importFile, $sku, $expectedOptionsQty) */ public function testSaveCustomOptionsWithMultipleStoreViews() { - $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); /** @var \Magento\Store\Model\StoreManagerInterface $storeManager */ - $storeManager = $objectManager->get(\Magento\Store\Model\StoreManagerInterface::class); + $storeManager = $this->objectManager->get(\Magento\Store\Model\StoreManagerInterface::class); $storeCodes = [ 'admin', 'default', - 'fixture_second_store' + 'fixture_second_store', ]; /** @var \Magento\Store\Model\StoreManagerInterface $storeManager */ $importFile = 'product_with_custom_options_and_multiple_store_views.csv'; @@ -367,9 +357,7 @@ public function testSaveCustomOptionsWithMultipleStoreViews() $this->assertTrue($errors->getErrorsCount() == 0); $importModel->importData(); /** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ - $productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Api\ProductRepositoryInterface::class - ); + $productRepository = $this->objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); foreach ($storeCodes as $storeCode) { $storeManager->setCurrentStore($storeCode); $product = $productRepository->get($sku); @@ -416,24 +404,24 @@ public function testSaveCustomOptionsWithMultipleStoreViews() /** * @return array */ - public function getBehaviorDataProvider() + public function getBehaviorDataProvider(): array { return [ 'Append behavior with existing product' => [ 'importFile' => 'product_with_custom_options.csv', 'sku' => 'simple', - 'expectedOptionsQty' => 6 + 'expectedOptionsQty' => 6, ], 'Append behavior with existing product and without options in import file' => [ 'importFile' => 'product_without_custom_options.csv', 'sku' => 'simple', - 'expectedOptionsQty' => 0 + 'expectedOptionsQty' => 0, ], 'Append behavior with new product' => [ 'importFile' => 'product_with_custom_options_new.csv', 'sku' => 'simple_new', - 'expectedOptionsQty' => 4 - ] + 'expectedOptionsQty' => 4, + ], ]; } @@ -444,8 +432,7 @@ public function getBehaviorDataProvider() */ private function createImportModel($pathToFile, $behavior = \Magento\ImportExport\Model\Import::BEHAVIOR_APPEND) { - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Framework\Filesystem::class); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); /** @var \Magento\ImportExport\Model\Import\Source\Csv $source */ @@ -458,9 +445,7 @@ private function createImportModel($pathToFile, $behavior = \Magento\ImportExpor ); /** @var \Magento\CatalogImportExport\Model\Import\Product $importModel */ - $importModel = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\CatalogImportExport\Model\Import\Product::class - ); + $importModel = $this->objectManager->create(\Magento\CatalogImportExport\Model\Import\Product::class); $importModel->setParameters(['behavior' => $behavior, 'entity' => 'catalog_product'])->setSource($source); return $importModel; @@ -497,24 +482,19 @@ private function getCustomOptionValues($productSku) public function testSaveDatetimeAttribute() { /** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ - $productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Api\ProductRepositoryInterface::class - ); + $productRepository = $this->objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); $id1 = $productRepository->get('simple1')->getId(); $id2 = $productRepository->get('simple2')->getId(); $id3 = $productRepository->get('simple3')->getId(); $existingProductIds = [$id1, $id2, $id3]; $productsBeforeImport = []; foreach ($existingProductIds as $productId) { - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class - ); + $product = $this->objectManager->create(\Magento\Catalog\Model\Product::class); $product->load($productId); $productsBeforeImport[$product->getSku()] = $product; } - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Framework\Filesystem::class); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( @@ -540,9 +520,7 @@ public function testSaveDatetimeAttribute() $productBeforeImport = $productsBeforeImport[$row['sku']]; /** @var $productAfterImport \Magento\Catalog\Model\Product */ - $productAfterImport = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class - ); + $productAfterImport = $this->objectManager->create(\Magento\Catalog\Model\Product::class); $productAfterImport->load($productBeforeImport->getId()); $this->assertEquals( @strtotime(date('m/d/Y', @strtotime($row['news_from_date']))), @@ -588,7 +566,7 @@ protected function getExpectedOptionsData($pathToFile, $storeCode = '') $option = array_values( array_map( function ($input) { - $data = explode('=',$input); + $data = explode('=', $input); return [$data[0] => $data[1]]; }, explode(',', $optionData) @@ -624,11 +602,12 @@ function ($input) { } } } + return [ 'id' => $expectedOptionId, 'options' => $expectedOptions, 'data' => $expectedData, - 'values' => $expectedValues + 'values' => $expectedValues, ]; } @@ -662,13 +641,14 @@ protected function mergeWithExistingData( $expectedData[$existingOptionId] = array_merge( $this->getOptionData($option), $expectedData[$existingOptionId] - ); if ($optionValues) { - foreach ($optionValues as $optionKey => $optionValue) - $expectedValues[$existingOptionId][$optionKey] = array_merge( - $optionValue, $expectedValues[$existingOptionId][$optionKey] - ); + foreach ($optionValues as $optionKey => $optionValue) { + $expectedValues[$existingOptionId][$optionKey] = array_merge( + $optionValue, + $expectedValues[$existingOptionId][$optionKey] + ); + } } } } @@ -761,7 +741,6 @@ protected function getOptionValues(\Magento\Catalog\Model\Product\Option $option /** * Test that product import with images works properly * - * @magentoDataIsolation enabled * @magentoDataFixture mediaImportImageFixture * @magentoAppIsolation enabled * @SuppressWarnings(PHPMD.ExcessiveMethodLength) @@ -812,7 +791,6 @@ public function testSaveMediaImage() /** * Test that errors occurred during importing images are logged. * - * @magentoDataIsolation enabled * @magentoAppIsolation enabled * @magentoDataFixture mediaImportImageFixture * @magentoDataFixture mediaImportImageFixtureError @@ -905,7 +883,6 @@ public static function mediaImportImageFixtureError() } } - /** * Export CSV string to array * @@ -923,7 +900,7 @@ protected function csvToArray($content, $entityId = null) $data['header'] = str_getcsv($line); } else { $row = array_combine($data['header'], str_getcsv($line)); - if (!is_null($entityId) && !empty($row[$entityId])) { + if ($entityId !== null && !empty($row[$entityId])) { $data['data'][$row[$entityId]] = $row; } else { $data['data'][] = $row; @@ -944,9 +921,7 @@ public function testInvalidSkuLink() { // import data from CSV file $pathToFile = __DIR__ . '/_files/products_to_import_invalid_attribute_set.csv'; - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Framework\Filesystem::class - ); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( \Magento\ImportExport\Model\Import\Source\Csv::class, @@ -958,6 +933,7 @@ public function testInvalidSkuLink() $errors = $this->_model->setParameters( [ 'behavior' => \Magento\ImportExport\Model\Import::BEHAVIOR_APPEND, + Import::FIELD_NAME_VALIDATION_STRATEGY => null, 'entity' => 'catalog_product' ] )->setSource( @@ -971,7 +947,7 @@ public function testInvalidSkuLink() ); $this->_model->importData(); - $productCollection = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( + $productCollection = $this->objectManager->create( \Magento\Catalog\Model\ResourceModel\Product\Collection::class ); @@ -992,9 +968,7 @@ public function testInvalidSkuLink() */ public function testValidateInvalidMultiselectValues() { - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Framework\Filesystem::class - ); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( \Magento\ImportExport\Model\Import\Source\Csv::class, @@ -1028,9 +1002,7 @@ public function testValidateInvalidMultiselectValues() */ public function testProductsWithMultipleStores() { - $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); - - $filesystem = $objectManager->create(\Magento\Framework\Filesystem::class); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( \Magento\ImportExport\Model\Import\Source\Csv::class, @@ -1050,15 +1022,15 @@ public function testProductsWithMultipleStores() $this->_model->importData(); /** @var \Magento\Catalog\Model\Product $product */ - $product = $objectManager->create(\Magento\Catalog\Model\Product::class); + $product = $this->objectManager->create(\Magento\Catalog\Model\Product::class); $id = $product->getIdBySku('Configurable 03'); $product->load($id); $this->assertEquals('1', $product->getHasOptions()); - $objectManager->get(\Magento\Store\Model\StoreManagerInterface::class)->setCurrentStore('fixturestore'); + $this->objectManager->get(\Magento\Store\Model\StoreManagerInterface::class)->setCurrentStore('fixturestore'); /** @var \Magento\Catalog\Model\Product $simpleProduct */ - $simpleProduct = $objectManager->create(\Magento\Catalog\Model\Product::class); + $simpleProduct = $this->objectManager->create(\Magento\Catalog\Model\Product::class); $id = $simpleProduct->getIdBySku('Configurable 03-Option 1'); $simpleProduct->load($id); $this->assertTrue(count($simpleProduct->getWebsiteIds()) == 2); @@ -1076,9 +1048,7 @@ public function testProductsWithMultipleStores() */ public function testGenerateUrlsWithMultipleStores() { - $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); - - $filesystem = $objectManager->create(\Magento\Framework\Filesystem::class); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( \Magento\ImportExport\Model\Import\Source\Csv::class, @@ -1109,22 +1079,21 @@ public function testGenerateUrlsWithMultipleStores() */ private function assertProductRequestPath($storeCode, $expected) { - $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); /** @var Store $storeCode */ - $store = $objectManager->get(Store::class); + $store = $this->objectManager->get(Store::class); $storeId = $store->load($storeCode)->getId(); /** @var Category $category */ - $category = $objectManager->get(Category::class); + $category = $this->objectManager->get(Category::class); $category->setStoreId($storeId); $category->load(555); /** @var Registry $registry */ - $registry = $objectManager->get(Registry::class); + $registry = $this->objectManager->get(Registry::class); $registry->register('current_category', $category); /** @var \Magento\Catalog\Model\Product $product */ - $product = $objectManager->create(\Magento\Catalog\Model\Product::class); + $product = $this->objectManager->create(\Magento\Catalog\Model\Product::class); $id = $product->getIdBySku('product'); $product->setStoreId($storeId); $product->load($id); @@ -1140,9 +1109,7 @@ public function testProductWithInvalidWeight() { // import data from CSV file $pathToFile = __DIR__ . '/_files/product_to_import_invalid_weight.csv'; - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Framework\Filesystem::class - ); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( @@ -1175,9 +1142,7 @@ public function testProductCategories($fixture, $separator) { // import data from CSV file $pathToFile = __DIR__ . '/_files/' . $fixture; - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Framework\Filesystem::class - ); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( @@ -1200,14 +1165,11 @@ public function testProductCategories($fixture, $separator) $this->assertTrue($errors->getErrorsCount() == 0); $this->_model->importData(); - $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); - $resource = $objectManager->get(\Magento\Catalog\Model\ResourceModel\Product::class); + $resource = $this->objectManager->get(\Magento\Catalog\Model\ResourceModel\Product::class); $productId = $resource->getIdBySku('simple1'); $this->assertTrue(is_numeric($productId)); /** @var \Magento\Catalog\Model\Product $product */ - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class - ); + $product = $this->objectManager->create(\Magento\Catalog\Model\Product::class); $product->load($productId); $this->assertFalse($product->isObjectNew()); $categories = $product->getCategoryIds(); @@ -1240,9 +1202,7 @@ public function testProductPositionInCategory() $category->setPostedProducts($categoryProducts); $category->save(); - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Framework\Filesystem::class - ); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( @@ -1265,9 +1225,7 @@ public function testProductPositionInCategory() $this->_model->importData(); /** @var \Magento\Framework\App\ResourceConnection $resourceConnection */ - $resourceConnection = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( - \Magento\Framework\App\ResourceConnection::class - ); + $resourceConnection = $this->objectManager->get(\Magento\Framework\App\ResourceConnection::class); $tableName = $resourceConnection->getTableName('catalog_category_product'); $select = $resourceConnection->getConnection()->select()->from($tableName) ->where('category_id = ?', $category->getId()); @@ -1278,6 +1236,49 @@ public function testProductPositionInCategory() } } + /** + * Check new product position in category. + * + * @magentoAppArea adminhtml + * @magentoDbIsolation enabled + * @magentoAppIsolation enabled + * @magentoDataFixture Magento/Catalog/_files/category_product.php + * @return void + */ + public function testNewProductPositionInCategory() + { + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); + + $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); + $source = $this->objectManager->create( + \Magento\ImportExport\Model\Import\Source\Csv::class, + [ + 'file' => __DIR__ . '/_files/product_to_import_with_category.csv', + 'directory' => $directory, + ] + ); + $errors = $this->_model->setSource( + $source + )->setParameters( + [ + 'behavior' => \Magento\ImportExport\Model\Import::BEHAVIOR_APPEND, + 'entity' => 'catalog_product', + ] + )->validateData(); + + $this->assertTrue($errors->getErrorsCount() === 0); + $this->_model->importData(); + + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $this->objectManager->create(ProductRepositoryInterface::class); + /** @var ProductInterface $product */ + $product = $productRepository->get('simpleImported'); + $categoryLinks = $product->getExtensionAttributes('category_links')->getCategoryLinks(); + $position = $categoryLinks[0]->getPosition(); + + $this->assertEquals(0, $position); + } + /** * @return array */ @@ -1302,9 +1303,7 @@ public function testProductDuplicateCategories() $csvFixture = 'products_duplicate_category.csv'; // import data from CSV file $pathToFile = __DIR__ . '/_files/' . $csvFixture; - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Framework\Filesystem::class - ); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( @@ -1324,9 +1323,7 @@ public function testProductDuplicateCategories() $this->assertTrue($errors->getErrorsCount() === 0); /** @var \Magento\Catalog\Model\Category $category */ - $category = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Category::class - ); + $category = $this->objectManager->create(\Magento\Catalog\Model\Category::class); $category->load(444); @@ -1338,7 +1335,7 @@ public function testProductDuplicateCategories() $this->_model->importData(); - $errorProcessor = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( + $errorProcessor = $this->objectManager->get( \Magento\ImportExport\Model\Import\ErrorProcessing\ProcessingErrorAggregator::class ); $errorCount = count($errorProcessor->getAllErrors()); @@ -1352,9 +1349,7 @@ public function testProductDuplicateCategories() $this->assertTrue($categoryAfter === null); /** @var \Magento\Catalog\Model\Product $product */ - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class - ); + $product = $this->objectManager->create(\Magento\Catalog\Model\Product::class); $product->load(1); $categories = $product->getCategoryIds(); $this->assertTrue(count($categories) == 1); @@ -1377,9 +1372,7 @@ protected function loadCategoryByName($categoryName) */ public function testValidateUrlKeys($importFile, $expectedErrors) { - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Framework\Filesystem::class - ); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( @@ -1437,9 +1430,7 @@ public function validateUrlKeysDataProvider() */ public function testValidateUrlKeysMultipleStores() { - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Framework\Filesystem::class - ); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( @@ -1481,9 +1472,7 @@ public function testProductWithLinks() ]; // import data from CSV file $pathToFile = __DIR__ . '/_files/products_to_import_with_product_links.csv'; - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Framework\Filesystem::class - ); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( @@ -1505,13 +1494,10 @@ public function testProductWithLinks() $this->assertTrue($errors->getErrorsCount() == 0); $this->_model->importData(); - $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); - $resource = $objectManager->get(\Magento\Catalog\Model\ResourceModel\Product::class); + $resource = $this->objectManager->get(\Magento\Catalog\Model\ResourceModel\Product::class); $productId = $resource->getIdBySku('simple4'); /** @var \Magento\Catalog\Model\Product $product */ - $product = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\Product::class - ); + $product = $this->objectManager->create(\Magento\Catalog\Model\Product::class); $product->load($productId); $productLinks = [ 'upsell' => $product->getUpSellProducts(), @@ -1539,8 +1525,7 @@ public function testExistingProductWithUrlKeys() 'simple2' => 'url-key2', 'simple3' => 'url-key3' ]; - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Framework\Filesystem::class); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( \Magento\ImportExport\Model\Import\Source\Csv::class, @@ -1559,9 +1544,7 @@ public function testExistingProductWithUrlKeys() $this->assertTrue($errors->getErrorsCount() == 0); $this->_model->importData(); - $productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Api\ProductRepositoryInterface::class - ); + $productRepository = $this->objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); foreach ($products as $productSku => $productUrlKey) { $this->assertEquals($productUrlKey, $productRepository->get($productSku)->getUrlKey()); } @@ -1690,8 +1673,7 @@ public function testProductWithUseConfigSettings() 'simple2' => true, 'simple3' => false ]; - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Framework\Filesystem::class); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( \Magento\ImportExport\Model\Import\Source\Csv::class, @@ -1712,7 +1694,7 @@ public function testProductWithUseConfigSettings() foreach ($products as $sku => $manageStockUseConfig) { /** @var \Magento\CatalogInventory\Model\StockRegistry $stockRegistry */ - $stockRegistry = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( + $stockRegistry = $this->objectManager->create( \Magento\CatalogInventory\Model\StockRegistry::class ); $stockItem = $stockRegistry->getStockItemBySku($sku); @@ -1789,8 +1771,7 @@ function (ProductInterface $item) { $productSkuList = ['simple1', 'simple2', 'simple3']; foreach ($productSkuList as $sku) { try { - $productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->get(\Magento\Catalog\Api\ProductRepositoryInterface::class); + $productRepository = $this->objectManager->get(\Magento\Catalog\Api\ProductRepositoryInterface::class); $product = $productRepository->get($sku, true); if ($product->getId()) { $productRepository->delete($product); @@ -1836,9 +1817,7 @@ public function testProductWithWrappedAdditionalAttributes() $this->_model->importData(); /** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ - $productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Api\ProductRepositoryInterface::class - ); + $productRepository = $this->objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); /** @var \Magento\Eav\Api\AttributeOptionManagementInterface $multiselectOptions */ $multiselectOptions = $this->objectManager->get(\Magento\Eav\Api\AttributeOptionManagementInterface::class) @@ -2032,8 +2011,7 @@ public function validateRowDataProvider() */ public function testValidateData() { - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Framework\Filesystem::class); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $source = $this->objectManager->create( @@ -2081,8 +2059,7 @@ public function testImportWithFilesystemImages() */ public function testImportDataChangeAttributeSet() { - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Framework\Filesystem::class); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); @@ -2107,18 +2084,14 @@ public function testImportDataChangeAttributeSet() $this->_model->importData(); /** @var \Magento\Catalog\Model\Product[] $products */ - $products[] = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Catalog\Model\ProductRepository::class)->get('simple'); - $products[] = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Catalog\Model\ProductRepository::class)->get('simple2'); + $products[] = $this->objectManager->create(\Magento\Catalog\Model\ProductRepository::class)->get('simple'); + $products[] = $this->objectManager->create(\Magento\Catalog\Model\ProductRepository::class)->get('simple2'); /** @var \Magento\Catalog\Model\Config $catalogConfig */ - $catalogConfig = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Catalog\Model\Config::class); + $catalogConfig = $this->objectManager->create(\Magento\Catalog\Model\Config::class); /** @var \Magento\Eav\Model\Config $eavConfig */ - $eavConfig = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Eav\Model\Config::class); + $eavConfig = $this->objectManager->create(\Magento\Eav\Model\Config::class); $entityTypeId = (int)$eavConfig->getEntityType(\Magento\Catalog\Model\Product::ENTITY) ->getId(); @@ -2135,12 +2108,9 @@ public function testImportDataChangeAttributeSet() public function testImportWithDifferentSkuCase() { /** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ - $productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Api\ProductRepositoryInterface::class - ); + $productRepository = $this->objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); /** @var \Magento\Framework\Api\SearchCriteria $searchCriteria */ - $searchCriteria = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Framework\Api\SearchCriteria::class); + $searchCriteria = $this->objectManager->create(\Magento\Framework\Api\SearchCriteria::class); $importedPrices = [ 'simple1' => 25, @@ -2153,8 +2123,7 @@ public function testImportWithDifferentSkuCase() 'simple3' => 333, ]; - $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Framework\Filesystem::class); + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); @@ -2219,7 +2188,6 @@ public function testImportWithDifferentSkuCase() /** * Test that product import with images for non-default store works properly. * - * @magentoDataIsolation enabled * @magentoDataFixture mediaImportImageFixture * @magentoAppIsolation enabled */ @@ -2266,7 +2234,6 @@ public function testProductsWithMultipleStoresWhenMediaIsDisabled() /** * Test that imported product stock status with backorders functionality enabled can be set to 'out of stock'. * - * @magentoDataIsolation enabled * @magentoAppIsolation enabled */ public function testImportWithBackordersEnabled() @@ -2276,6 +2243,20 @@ public function testImportWithBackordersEnabled() $this->assertFalse($product->getDataByKey('quantity_and_stock_status')['is_in_stock']); } + /** + * Test that imported product stock status with stock quantity > 0 and backorders functionality disabled + * can be set to 'out of stock'. + * + * @magentoDbIsolation enabled + * @magentoAppIsolation enabled + */ + public function testImportWithBackordersDisabled() + { + $this->importFile('products_to_import_with_backorders_disabled_and_not_0_qty.csv'); + $product = $this->getProductBySku('simple_new'); + $this->assertFalse($product->getDataByKey('quantity_and_stock_status')['is_in_stock']); + } + /** * Import file by providing import filename in parameters * @@ -2307,6 +2288,41 @@ private function importFile(string $fileName) $this->_model->importData(); } + /** + * Import file with non-existing images and skip-errors strategy. + * + * @return void + */ + public function testImportWithSkipErrorsAndNonExistingImage() + { + $fileName = 'products_error_img.csv'; + $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); + $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); + $source = $this->objectManager->create( + \Magento\ImportExport\Model\Import\Source\Csv::class, + [ + 'file' => __DIR__ . '/_files/' . $fileName, + 'directory' => $directory, + ] + ); + $this->_model->getErrorAggregator()->initValidationStrategy('validation-skip-errors', 10); + $errors = $this->_model->setParameters( + [ + 'behavior' => \Magento\ImportExport\Model\Import::BEHAVIOR_APPEND, + 'entity' => 'catalog_product', + ] + )->setSource( + $source + )->validateData(); + + $this->assertTrue($errors->getErrorsCount() == 0); + + $this->_model->importData(); + foreach ($this->_model->getErrorAggregator()->getAllErrors() as $error) { + $this->assertEquals('mediaUrlNotAvailable', $error->getErrorCode()); + } + } + /** * @magentoDataFixture Magento/Catalog/_files/product_simple.php * @magentoDbIsolation disabled @@ -2354,4 +2370,78 @@ public function testImportProductWithUpdateUrlKey() $collUrlRewrite->getFirstItem()->getRequestPath() ); } + + /** + * Test that product import with non existing images does not broke roles on existing images. + * + * @magentoDataFixture mediaImportImageFixture + * @magentoAppIsolation enabled + * @return void + */ + public function testSaveProductOnImportNonExistingImage() + { + $this->importDataForMediaTest('import_media.csv'); + + $product = $this->getProductBySku('simple_new'); + + $this->assertEquals('/m/a/magento_image.jpg', $product->getData('image')); + $this->assertEquals('/m/a/magento_small_image.jpg', $product->getData('small_image')); + $this->assertEquals('/m/a/magento_thumbnail.jpg', $product->getData('thumbnail')); + $this->assertEquals('/m/a/magento_image.jpg', $product->getData('swatch_image')); + + $this->importDataForMediaTest('import_media_non_existing_images.csv', 1); + + $this->assertNotEquals('/u/p/uploaded.jpg', $product->getData('image')); + $this->assertNotEquals('/u/p/uploaded.jpg', $product->getData('small_image')); + $this->assertNotEquals('/u/p/uploaded.jpg', $product->getData('thumbnail')); + $this->assertNotEquals('/u/p/uploaded.jpg', $product->getData('swatch_image')); + + $this->assertEquals('/m/a/magento_image.jpg', $product->getData('image')); + $this->assertEquals('/m/a/magento_small_image.jpg', $product->getData('small_image')); + $this->assertEquals('/m/a/magento_thumbnail.jpg', $product->getData('thumbnail')); + $this->assertEquals('/m/a/magento_image.jpg', $product->getData('swatch_image')); + } + + /** + * Test import product with wrong sku when On Error option is set as Continue Processing. + * + * @magentoDataFixture Magento/Catalog/_files/product_simple.php + * @magentoDbIsolation disabled + * @magentoAppIsolation enabled + * @return void + */ + public function testImportProductWithContinueOnError() + { + $filesystem = $this->objectManager->create(Filesystem::class); + $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); + $source = $this->objectManager->create( + \Magento\ImportExport\Model\Import\Source\Csv::class, + [ + 'file' => __DIR__ . '/_files/product_import_qty_wrong_sku.csv', + 'directory' => $directory, + ] + ); + + $this->_model->setParameters( + [ + 'behavior' => Import::BEHAVIOR_APPEND, + Import::FIELD_NAME_VALIDATION_STRATEGY => + ProcessingErrorAggregatorInterface::VALIDATION_STRATEGY_SKIP_ERRORS, + 'entity' => 'catalog_product', + ] + )->setSource($source)->validateData(); + + $result = $this->_model->importData(); + + $this->assertTrue($result); + + /** @var $repository \Magento\Catalog\Model\ProductRepository */ + $repository = $this->objectManager->create(\Magento\Catalog\Model\ProductRepository::class); + $product = $repository->get('simple'); + /** @var $stockRegistry \Magento\CatalogInventory\Model\StockRegistry */ + $stockRegistry = $this->objectManager->create(\Magento\CatalogInventory\Model\StockRegistry::class); + + $stockItem = $stockRegistry->getStockItem($product->getId()); + $this->assertEquals(17, $stockItem->getQty()); + } } diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/UploaderTest.php b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/UploaderTest.php index f1308347b138e..95fa9500815bb 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/UploaderTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/UploaderTest.php @@ -46,6 +46,7 @@ protected function setUp() $mediaPath = $appParams[DirectoryList::MEDIA][DirectoryList::PATH]; $this->directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); $tmpDir = $this->directory->getRelativePath($mediaPath . '/import'); + $this->directory->create($tmpDir); $this->uploader->setTmpDir($tmpDir); parent::setUp(); @@ -75,4 +76,16 @@ public function testMoveWithInvalidFile() $this->uploader->move($fileName); $this->assertFalse($this->directory->isExist($this->uploader->getTmpDir() . '/' . $fileName)); } + + /** + * @inheritdoc + */ + public static function tearDownAfterClass() + { + $filesystem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + ->get(\Magento\Framework\Filesystem::class); + /** @var \Magento\Framework\Filesystem\Directory\WriteInterface $directory */ + $directory = $filesystem->getDirectoryWrite(DirectoryList::MEDIA); + $directory->delete('import'); + } } diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_non_existing_images.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_non_existing_images.csv new file mode 100644 index 0000000000000..99fdf641d9a33 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/import_media_non_existing_images.csv @@ -0,0 +1,2 @@ +sku,store_view_code,attribute_set_code,product_type,categories,product_websites,name,description,short_description,weight,product_online,tax_class_name,visibility,price,special_price,special_price_from_date,special_price_to_date,url_key,meta_title,meta_keywords,meta_description,base_image,base_image_label,small_image,small_image_label,thumbnail_image,thumbnail_image_label,swatch_image,swatch_image_label1,created_at,updated_at,new_from_date,new_to_date,display_product_options_in,map_price,msrp_price,map_enabled,gift_message_available,custom_design,custom_design_from,custom_design_to,custom_layout_update,page_layout,product_options_container,msrp_display_actual_price_type,country_of_manufacture,additional_attributes,qty,out_of_stock_qty,use_config_min_qty,is_qty_decimal,allow_backorders,use_config_backorders,min_cart_qty,use_config_min_sale_qty,max_cart_qty,use_config_max_sale_qty,is_in_stock,notify_on_stock_below,use_config_notify_stock_qty,manage_stock,use_config_manage_stock,use_config_qty_increments,qty_increments,use_config_enable_qty_inc,enable_qty_increments,is_decimal_divided,website_id,related_skus,crosssell_skus,upsell_skus,additional_images,additional_image_labels,hide_from_product_page,custom_options,bundle_price_type,bundle_sku_type,bundle_price_view,bundle_weight_type,bundle_values,associated_skus +simple_new,,Default,simple,,base,New Product,,,,1,Taxable Goods,"Catalog, Search",10,,,,new-product,New Product,New Product,New Product,/u/p/uploaded.jpg,Image Label,/u/p/uploaded.jpg,Small Image Label,/u/p/uploaded.jpg,Thumbnail Label,magento_image.jpg,Image Label,10/20/15 07:05,10/20/15 07:05,,,Block after Info Column,,,,,,,,,,,,,"has_options=1,quantity_and_stock_status=In Stock,required_options=1",100,0,1,0,0,1,1,1,10000,1,1,1,1,1,0,1,1,0,0,0,1,,,,"magento_additional_image_one.jpg, magento_additional_image_two.jpg","Additional Image Label One,Additional Image Label Two",,,,,,,, diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/product_import_qty_wrong_sku.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/product_import_qty_wrong_sku.csv new file mode 100644 index 0000000000000..6a9b7e0e1d62c --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/product_import_qty_wrong_sku.csv @@ -0,0 +1,3 @@ +sku,qty +simple100150,10 +simple,17 diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/product_to_import_with_category.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/product_to_import_with_category.csv new file mode 100644 index 0000000000000..2bab84ec1ca57 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/product_to_import_with_category.csv @@ -0,0 +1,2 @@ +sku,product_type,store_view_code,name,price,attribute_set_code,categories +simpleImported,simple,,"simple Imported",25,Default,"Default Category/Category 1" diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/products_error_img.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/products_error_img.csv new file mode 100755 index 0000000000000..97ac55e8e5a20 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/products_error_img.csv @@ -0,0 +1,11 @@ +sku,store_view_code,attribute_set_code,product_type,categories,product_websites,name,description,short_description,weight,product_online,tax_class_name,visibility,price,special_price,special_price_from_date,special_price_to_date,url_key,additional_images +simple1,,Default,simple,,base,simple1,,,,1,Taxable Goods,"Catalog, Search",100,,,,simple1,test.jpg +simple2,,Default,simple,,base,simple2,,,,2,Taxable Goods,"Catalog, Search",101,,,,simple2,test.jpg +simple3,,Default,simple,,base,simple3,,,,3,Taxable Goods,"Catalog, Search",102,,,,simple3,test.jpg +simple4,,Default,simple,,base,simple4,,,,4,Taxable Goods,"Catalog, Search",103,,,,simple4,test.jpg +simple5,,Default,simple,,base,simple5,,,,5,Taxable Goods,"Catalog, Search",104,,,,simple5,test.jpg +simple6,,Default,simple,,base,simple6,,,,6,Taxable Goods,"Catalog, Search",105,,,,simple6,test.jpg +simple7,,Default,simple,,base,simple7,,,,7,Taxable Goods,"Catalog, Search",106,,,,simple7,test.jpg +simple8,,Default,simple,,base,simple8,,,,8,Taxable Goods,"Catalog, Search",107,,,,simple8,test.jpg +simple9,,Default,simple,,base,simple9,,,,9,Taxable Goods,"Catalog, Search",108,,,,simple9,test.jpg +simple10,,Default,simple,,base,simple10,,,,10,Taxable Goods,"Catalog, Search",109,,,,simple10,test.jpg diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/products_to_import_with_backorders_disabled_and_not_0_qty.csv b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/products_to_import_with_backorders_disabled_and_not_0_qty.csv new file mode 100644 index 0000000000000..b22427a8af120 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/Model/Import/_files/products_to_import_with_backorders_disabled_and_not_0_qty.csv @@ -0,0 +1,2 @@ +sku,store_view_code,attribute_set_code,product_type,categories,product_websites,name,description,short_description,weight,product_online,tax_class_name,visibility,price,special_price,special_price_from_date,special_price_to_date,url_key,meta_title,meta_keywords,meta_description,base_image,base_image_label,small_image,small_image_label,thumbnail_image,thumbnail_image_label,created_at,updated_at,new_from_date,new_to_date,display_product_options_in,map_price,msrp_price,map_enabled,gift_message_available,custom_design,custom_design_from,custom_design_to,custom_layout_update,page_layout,product_options_container,msrp_display_actual_price_type,country_of_manufacture,additional_attributes,qty,out_of_stock_qty,use_config_min_qty,is_qty_decimal,allow_backorders,use_config_backorders,min_cart_qty,use_config_min_sale_qty,max_cart_qty,use_config_max_sale_qty,is_in_stock,notify_on_stock_below,use_config_notify_stock_qty,manage_stock,use_config_manage_stock,use_config_qty_increments,qty_increments,use_config_enable_qty_inc,enable_qty_increments,is_decimal_divided,website_id,related_skus,crosssell_skus,upsell_skus,additional_images,additional_image_labels,hide_from_product_page,custom_options,bundle_price_type,bundle_sku_type,bundle_price_view,bundle_weight_type,bundle_values,associated_skus +simple_new,,Default,simple,,base,New Product,,,,1,Taxable Goods,"Catalog, Search",10,,,,new-product,New Product,New Product,New Product ,,,,,,,10/20/2015 7:05,10/20/2015 7:05,,,Block after Info Column,,,,,,,,,,,,,"has_options=1,quantity_and_stock_status=In Stock,required_options=1",100,0,1,0,0,0,1,1,10000,1,0,1,1,1,0,1,1,0,0,0,1,,,,,,,,,,,,, diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/_files/product_export_data.php b/dev/tests/integration/testsuite/Magento/CatalogImportExport/_files/product_export_data.php index 5157874172579..de5350c8288f4 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogImportExport/_files/product_export_data.php +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/_files/product_export_data.php @@ -70,7 +70,7 @@ )->setPrice( 10 )->addData( - ['text_attribute' => '!@#$%^&*()_+1234567890-=|\\:;"\'<,>.?/'] + ['text_attribute' => '!@#$%^&*()_+1234567890-=|\\:;"\'<,>.?/›ƒª'] )->setTierPrice( [0 => ['website_id' => 0, 'cust_group' => 0, 'price_qty' => 3, 'price' => 8]] )->setVisibility( diff --git a/dev/tests/integration/testsuite/Magento/CatalogImportExport/_files/product_export_data_special_chars.php b/dev/tests/integration/testsuite/Magento/CatalogImportExport/_files/product_export_data_special_chars.php index 95dd6329751d8..8914168f20ecc 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogImportExport/_files/product_export_data_special_chars.php +++ b/dev/tests/integration/testsuite/Magento/CatalogImportExport/_files/product_export_data_special_chars.php @@ -23,7 +23,7 @@ ->setName('New Product') ->setSku('simple "1"') ->setPrice(10) - ->addData(['text_attribute' => '!@#$%^&*()_+1234567890-=|\\:;"\'<,>.?/']) + ->addData(['text_attribute' => '!@#$%^&*()_+1234567890-=|\\:;"\'<,>.?/›ƒª']) ->setTierPrice([0 => ['website_id' => 0, 'cust_group' => 0, 'price_qty' => 3, 'price' => 8]]) ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) diff --git a/dev/tests/integration/testsuite/Magento/CatalogInventory/_files/simple_product_decimal_qty.php b/dev/tests/integration/testsuite/Magento/CatalogInventory/_files/simple_product_decimal_qty.php new file mode 100644 index 0000000000000..a43dfd76bd7cd --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogInventory/_files/simple_product_decimal_qty.php @@ -0,0 +1,49 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\Catalog\Model\Product\Type; +use Magento\Catalog\Model\Product\Visibility; +use Magento\CatalogInventory\Api\Data\StockItemInterface; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\ObjectManager; + +/** @var ObjectManager $objectManager */ +$objectManager = Bootstrap::getObjectManager(); + +/** @var Product $product */ +$product = $objectManager->create(Product::class); +$product->isObjectNew(true); +$product->setTypeId(Type::TYPE_SIMPLE) + ->setAttributeSetId(4) + ->setWebsiteIds([1]) + ->setName('Simple Product Decimal Qty') + ->setSku('simple_with_decimal_qty') + ->setPrice(10) + ->setWeight(1) + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED); + +/** @var StockItemInterface $stockItem */ +$stockItem = $objectManager->create(StockItemInterface::class); +$stockItem->setIsInStock(true) + ->setQty(10000) + ->setIsQtyDecimal(true) + ->setUseConfigMinSaleQty(false) + ->setMinSaleQty(1.1) + ->setUseConfigEnableQtyInc(false) + ->setEnableQtyIncrements(true) + ->setUseConfigQtyIncrements(false) + ->setQtyIncrements(1.1); + +$extensionAttributes = $product->getExtensionAttributes(); +$extensionAttributes->setStockItem($stockItem); + +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->get(ProductRepositoryInterface::class); +$product = $productRepository->save($product); diff --git a/dev/tests/integration/testsuite/Magento/CatalogInventory/_files/simple_product_decimal_qty_rollback.php b/dev/tests/integration/testsuite/Magento/CatalogInventory/_files/simple_product_decimal_qty_rollback.php new file mode 100644 index 0000000000000..cca9408f17776 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/CatalogInventory/_files/simple_product_decimal_qty_rollback.php @@ -0,0 +1,16 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\ObjectManager; + +/** @var ObjectManager $objectManager */ +$objectManager = Bootstrap::getObjectManager(); + +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->get(ProductRepositoryInterface::class); +$productRepository->deleteById('simple_with_decimal_qty'); diff --git a/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/FullTest.php b/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/FullTest.php index 59ad91ae7b076..993462350b638 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/FullTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogSearch/Model/Indexer/Fulltext/Action/FullTest.php @@ -29,6 +29,8 @@ protected function setUp() } /** + * Testing fulltext index rebuild. + * * @magentoDataFixture Magento/CatalogSearch/_files/products_for_index.php * @magentoDataFixture Magento/CatalogSearch/_files/product_configurable_not_available.php * @magentoDataFixture Magento/Framework/Search/_files/product_configurable.php @@ -58,6 +60,8 @@ public function testGetIndexData() } /** + * Prepare and return expected index data. + * * @return array */ private function getExpectedIndexData() @@ -68,32 +72,49 @@ private function getExpectedIndexData() $nameId = $attributeRepository->get(ProductInterface::NAME)->getAttributeId(); /** @see dev/tests/integration/testsuite/Magento/Framework/Search/_files/configurable_attribute.php */ $configurableId = $attributeRepository->get('test_configurable')->getAttributeId(); + $statusId = $attributeRepository->get(ProductInterface::STATUS)->getAttributeId(); + $taxClassId = $attributeRepository + ->get(\Magento\Customer\Api\Data\GroupInterface::TAX_CLASS_ID) + ->getAttributeId(); + return [ 'configurable' => [ $skuId => 'configurable', $configurableId => 'Option 1 | Option 2', $nameId => 'Configurable Product | Configurable OptionOption 1 | Configurable OptionOption 2', + $taxClassId => 'Taxable Goods | Taxable Goods | Taxable Goods', + $statusId => 'Enabled | Enabled | Enabled', ], 'index_enabled' => [ $skuId => 'index_enabled', $nameId => 'index enabled', + $taxClassId => 'Taxable Goods', + $statusId => 'Enabled', ], 'index_visible_search' => [ $skuId => 'index_visible_search', $nameId => 'index visible search', + $taxClassId => 'Taxable Goods', + $statusId => 'Enabled', ], 'index_visible_category' => [ $skuId => 'index_visible_category', $nameId => 'index visible category', + $taxClassId => 'Taxable Goods', + $statusId => 'Enabled', ], 'index_visible_both' => [ $skuId => 'index_visible_both', $nameId => 'index visible both', + $taxClassId => 'Taxable Goods', + $statusId => 'Enabled', ] ]; } /** + * Testing fulltext index rebuild with configurations. + * * @magentoDataFixture Magento/ConfigurableProduct/_files/product_configurable.php */ public function testRebuildStoreIndexConfigurable() @@ -114,6 +135,8 @@ public function testRebuildStoreIndexConfigurable() } /** + * Get product Id by its SKU. + * * @param string $sku * @return int */ diff --git a/dev/tests/integration/testsuite/Magento/CatalogUrlRewrite/Model/CategoryUrlRewriteGeneratorTest.php b/dev/tests/integration/testsuite/Magento/CatalogUrlRewrite/Model/CategoryUrlRewriteGeneratorTest.php index a6cd7d195731c..8dea5623a74c2 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogUrlRewrite/Model/CategoryUrlRewriteGeneratorTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogUrlRewrite/Model/CategoryUrlRewriteGeneratorTest.php @@ -14,6 +14,7 @@ /** * @magentoAppArea adminhtml + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class CategoryUrlRewriteGeneratorTest extends \PHPUnit\Framework\TestCase { @@ -263,4 +264,93 @@ protected function assertResults($expected, $actual) ); } } + + /** + * Get all actual url paths. + * + * @param array $filter + * @return array + */ + private function getActualUrlPaths(array $filter) + { + /** @var \Magento\UrlRewrite\Model\UrlFinderInterface $urlFinder */ + $urlFinder = $this->objectManager->get(\Magento\UrlRewrite\Model\UrlFinderInterface::class); + $actualResults = []; + foreach ($urlFinder->findAllByData($filter) as $url) { + $actualResults[] = [ + $url->getRequestPath(), + $url->getTargetPath(), + $url->getStoreId(), + ]; + } + + return $actualResults; + } + + /** + * Test getting url path. + * + * @magentoDataFixture Magento/Store/_files/second_store.php + * @magentoDataFixture Magento/CatalogUrlRewrite/_files/categories.php + * @magentoDbIsolation enabled + * @magentoAppIsolation enabled + */ + public function testGetUrlPath() + { + /** @var \Magento\Catalog\Api\CategoryRepositoryInterface $repository */ + $repository = $this->objectManager->get(\Magento\Catalog\Api\CategoryRepositoryInterface::class); + + /** @var \Magento\Store\Api\StoreRepositoryInterface $storeRepository */ + $storeRepository = $this->objectManager->get(\Magento\Store\Api\StoreRepositoryInterface::class); + $storeId = $storeRepository->get('fixture_second_store')->getId(); + + $storeManager = $this->objectManager->get(\Magento\Store\Model\StoreManagerInterface::class); + $store = $storeManager->getStore($storeId); + $storeManager->setCurrentStore($store->getCode()); + + $categoryFilter = [ + UrlRewrite::ENTITY_TYPE => CategoryUrlRewriteGenerator::ENTITY_TYPE, + UrlRewrite::ENTITY_ID => [5], + ]; + + $category = $repository->get(5); + $category->addData( + [ + 'use_default' => [ + 'url_key' => 0, + ], + 'url_key_create_redirect' => 0, + 'url_key' => 'url-key', + ]); + $category->setStoreId($storeId); + $repository->save($category); + + $actualResults = $this->getActualUrlPaths($categoryFilter); + $categoryExpectedResult = [ + ['category-1/category-1-1/category-1-1-1.html', 'catalog/category/view/id/5', 1], + ['category-1/category-1-1/url-key.html', 'catalog/category/view/id/5', $storeId], + ]; + + $this->assertResults($categoryExpectedResult, $actualResults); + + $category = $repository->get(5); + + $category->addData( + [ + 'use_default' => [ + 'url_key' => 1, + ], + 'url_key' => '', + ]); + + $repository->save($category); + + $actualResults = $this->getActualUrlPaths($categoryFilter); + $categoryExpectedResult = [ + ['category-1/category-1-1/category-1-1-1.html', 'catalog/category/view/id/5', 1], + ['category-1/category-1-1/category-1-1-1.html', 'catalog/category/view/id/5', $storeId], + ]; + + $this->assertResults($categoryExpectedResult, $actualResults); + } } diff --git a/dev/tests/integration/testsuite/Magento/Checkout/Controller/Cart/Index/CouponPostTest.php b/dev/tests/integration/testsuite/Magento/Checkout/Controller/Cart/Index/CouponPostTest.php index 2036042d0463c..661593b65adca 100644 --- a/dev/tests/integration/testsuite/Magento/Checkout/Controller/Cart/Index/CouponPostTest.php +++ b/dev/tests/integration/testsuite/Magento/Checkout/Controller/Cart/Index/CouponPostTest.php @@ -6,6 +6,8 @@ namespace Magento\Checkout\Controller\Cart\Index; +use Magento\Framework\App\Request\Http as HttpRequest; + /** * @magentoDbIsolation enabled */ @@ -36,4 +38,35 @@ public function testExecute() \Magento\Framework\Message\MessageInterface::TYPE_ERROR ); } + + /** + * Testing by adding a valid coupon to cart + * + * @magentoDataFixture Magento/Checkout/_files/quote_with_virtual_product_and_address.php + * @magentoDataFixture Magento/Usps/Fixtures/cart_rule_coupon_free_shipping.php + * @return void + */ + public function testAddingValidCoupon() + { + /** @var $session \Magento\Checkout\Model\Session */ + $session = $this->_objectManager->create(\Magento\Checkout\Model\Session::class); + $quote = $session->getQuote(); + $quote->setData('trigger_recollect', 1)->setTotalsCollectedFlag(true); + + $couponCode = 'IMPHBR852R61'; + $inputData = [ + 'remove' => 0, + 'coupon_code' => $couponCode + ]; + $this->getRequest()->setMethod(HttpRequest::METHOD_POST); + $this->getRequest()->setPostValue($inputData); + $this->dispatch( + 'checkout/cart/couponPost/' + ); + + $this->assertSessionMessages( + $this->equalTo(['You used coupon code "' . $couponCode . '".']), + \Magento\Framework\Message\MessageInterface::TYPE_SUCCESS + ); + } } diff --git a/dev/tests/integration/testsuite/Magento/Checkout/Controller/Sidebar/UpdateItemQtyTest.php b/dev/tests/integration/testsuite/Magento/Checkout/Controller/Sidebar/UpdateItemQtyTest.php new file mode 100644 index 0000000000000..3619d281c420a --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Checkout/Controller/Sidebar/UpdateItemQtyTest.php @@ -0,0 +1,119 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Checkout\Controller\Sidebar; + +use Magento\Checkout\Model\Session; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\Serialize\Serializer\Json; +use Magento\Quote\Api\CartRepositoryInterface; +use Magento\Quote\Model\Quote; + +/** + * Tests update item quantity controller. + */ +class UpdateItemQtyTest extends \Magento\TestFramework\TestCase\AbstractController +{ + /** + * @var Json + */ + private $json; + + /** + * @var Session + */ + private $session; + + /** + * @var ProductRepositoryInterface + */ + private $productRepository; + + protected function setUp() + { + parent::setUp(); + + $this->json = $this->_objectManager->create(Json::class); + $this->session = $this->_objectManager->create(Session::class); + $this->productRepository = $this->_objectManager->create(ProductRepositoryInterface::class); + } + + /** + * Tests of cart validation when contains product with decimal quantity. + * + * @param string $requestQuantity + * @param array $expectedResponse + * + * @magentoAppArea frontend + * @magentoDataFixture Magento/Checkout/_files/quote_with_simple_product_decimal_qty.php + * @dataProvider executeWithDecimalQtyDataProvider + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + public function testExecuteWithDecimalQty(string $requestQuantity, array $expectedResponse) + { + $product = $this->productRepository->get('simple_with_decimal_qty'); + $quote = $this->getQuote('decimal_quote_id'); + $quoteItem = $quote->getItemByProduct($product); + $this->session->setQuoteId($quote->getId()); + + $this->assertNotNull($quoteItem, 'Cannot get quote item for simple product'); + + $request= [ + 'item_id' => $quoteItem->getId(), + 'item_qty' => $requestQuantity + ]; + + $this->getRequest()->setPostValue($request); + $this->dispatch('checkout/sidebar/updateItemQty'); + $response = $this->getResponse()->getBody(); + + $this->assertEquals($this->json->unserialize($response), $expectedResponse); + } + + /** + * Variations of request data. + * @returns array + */ + public function executeWithDecimalQtyDataProvider(): array + { + return [ + [ + 'requestQuantity' => '2.2', + 'response' => [ + 'success' => true, + ] + ], + [ + 'requestQuantity' => '2', + 'response' => [ + 'success' => false, + 'error_message' => 'You can buy this product only in quantities of 1.1 at a time.'] + ], + ]; + } + + /** + * Retrieves quote by reserved order id. + * + * @param string $reservedOrderId + * @return Quote + */ + private function getQuote(string $reservedOrderId): Quote + { + /** @var SearchCriteriaBuilder $searchCriteriaBuilder */ + $searchCriteriaBuilder = $this->_objectManager->get(SearchCriteriaBuilder::class); + $searchCriteria = $searchCriteriaBuilder->addFilter('reserved_order_id', $reservedOrderId) + ->create(); + + /** @var CartRepositoryInterface $quoteRepository */ + $quoteRepository = $this->_objectManager->get(CartRepositoryInterface::class); + $items = $quoteRepository->getList($searchCriteria)->getItems(); + + return array_pop($items); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Checkout/_files/quote_with_simple_product_decimal_qty.php b/dev/tests/integration/testsuite/Magento/Checkout/_files/quote_with_simple_product_decimal_qty.php new file mode 100644 index 0000000000000..a23c9e32d0eed --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Checkout/_files/quote_with_simple_product_decimal_qty.php @@ -0,0 +1,33 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Quote\Api\CartRepositoryInterface; +use Magento\Quote\Model\Quote; +use Magento\TestFramework\Helper\Bootstrap; + +require __DIR__ . '/../../../Magento/CatalogInventory/_files/simple_product_decimal_qty.php'; + +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = Bootstrap::getObjectManager() + ->create(ProductRepositoryInterface::class); +/** @var $product \Magento\Catalog\Model\Product */ +$product = $productRepository->get('simple_with_decimal_qty'); + +/** @var Quote $quote */ +$quote = Bootstrap::getObjectManager()->create(Quote::class); +$quote->setReservedOrderId('decimal_quote_id'); +$item = $objectManager->create(\Magento\Quote\Model\Quote\Item::class); +$item->setProduct($product) + ->setPrice($product->getPrice()) + ->setQty(1.1); +$quote->addItem($item); + + +/** @var CartRepositoryInterface $quoteRepository */ +$quoteRepository = $objectManager->get(CartRepositoryInterface::class); +$quoteRepository->save($quote); diff --git a/dev/tests/integration/testsuite/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFilesTest.php b/dev/tests/integration/testsuite/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFilesTest.php index 8da29210d2f4b..e205f75fef2f4 100644 --- a/dev/tests/integration/testsuite/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFilesTest.php +++ b/dev/tests/integration/testsuite/Magento/Cms/Controller/Adminhtml/Wysiwyg/Images/DeleteFilesTest.php @@ -43,6 +43,11 @@ class DeleteFilesTest extends \PHPUnit\Framework\TestCase */ private $objectManager; + /** + * @var string + */ + private $fullDirectoryPath; + /** * @inheritdoc */ @@ -54,6 +59,7 @@ protected function setUp() $this->imagesHelper = $this->objectManager->get(\Magento\Cms\Helper\Wysiwyg\Images::class); $this->mediaDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA); $this->model = $this->objectManager->get(\Magento\Cms\Controller\Adminhtml\Wysiwyg\Images\DeleteFiles::class); + $this->fullDirectoryPath = $this->imagesHelper->getStorageRoot() . '/directory1'; } /** @@ -64,20 +70,15 @@ protected function setUp() */ public function testExecute() { - $directoryName = 'directory1'; - $fullDirectoryPath = $this->imagesHelper->getStorageRoot() . '/' . $directoryName; - $this->mediaDirectory->create($this->mediaDirectory->getRelativePath($fullDirectoryPath)); - $filePath = $fullDirectoryPath . DIRECTORY_SEPARATOR . $this->fileName; + $this->mediaDirectory->create($this->mediaDirectory->getRelativePath($this->fullDirectoryPath)); + $filePath = $this->fullDirectoryPath . DIRECTORY_SEPARATOR . $this->fileName; $fixtureDir = realpath(__DIR__ . '/../../../../../Catalog/_files'); copy($fixtureDir . '/' . $this->fileName, $filePath); - $this->model->getRequest()->setMethod('POST') - ->setPostValue('files', [$this->imagesHelper->idEncode($this->fileName)]); - $this->model->getStorage()->getSession()->setCurrentPath($fullDirectoryPath); - $this->model->execute(); + $this->executeFileDelete($this->fullDirectoryPath, $this->fileName); $this->assertFalse( $this->mediaDirectory->isExist( - $this->mediaDirectory->getRelativePath($fullDirectoryPath . '/' . $this->fileName) + $this->mediaDirectory->getRelativePath($this->fullDirectoryPath . '/' . $this->fileName) ) ); } @@ -99,11 +100,74 @@ public function testExecuteWithLinkedMedia() copy($fixtureDir . '/' . $this->fileName, $filePath); $wysiwygDir = $this->mediaDirectory->getAbsolutePath() . '/wysiwyg'; + $this->executeFileDelete($wysiwygDir, $this->fileName); + $this->assertFalse(is_file($fullDirectoryPath . DIRECTORY_SEPARATOR . $this->fileName)); + } + + /** + * Check that htaccess file couldn't be removed via + * \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images\DeleteFiles::execute method + * + * @return void + */ + public function testDeleteHtaccess() + { + $this->createFile($this->fullDirectoryPath, '.htaccess'); + $this->executeFileDelete($this->fullDirectoryPath, '.htaccess'); + + $this->assertTrue( + $this->mediaDirectory->isExist( + $this->mediaDirectory->getRelativePath($this->fullDirectoryPath . '/.htaccess') + ) + ); + } + + /** + * Check that random file could be removed via + * \Magento\Cms\Controller\Adminhtml\Wysiwyg\Images\DeleteFiles::execute method + * + * @return void + */ + public function testDeleteAnyFile() + { + $this->createFile($this->fullDirectoryPath, 'ahtaccess'); + $this->executeFileDelete($this->fullDirectoryPath, 'ahtaccess'); + + $this->assertFalse( + $this->mediaDirectory->isExist( + $this->mediaDirectory->getRelativePath($this->fullDirectoryPath . '/ahtaccess') + ) + ); + } + + /** + * Create file. + * + * @param string $path + * @param string $fileName + * @return void + */ + private function createFile(string $path, string $fileName) + { + $file = $path . '/' . $fileName; + if (!$this->mediaDirectory->isFile($file)) { + $this->mediaDirectory->writeFile($file, 'Content'); + } + } + + /** + * Execute file delete operation. + * + * @param string $path + * @param string $fileName + * @return void + */ + private function executeFileDelete(string $path, string $fileName) + { $this->model->getRequest()->setMethod('POST') - ->setPostValue('files', [$this->imagesHelper->idEncode($this->fileName)]); - $this->model->getStorage()->getSession()->setCurrentPath($wysiwygDir); + ->setPostValue('files', [$this->imagesHelper->idEncode($fileName)]); + $this->model->getStorage()->getSession()->setCurrentPath($path); $this->model->execute(); - $this->assertFalse(is_file($fullDirectoryPath . DIRECTORY_SEPARATOR . $this->fileName)); } /** diff --git a/dev/tests/integration/testsuite/Magento/Cms/Model/BlockTest.php b/dev/tests/integration/testsuite/Magento/Cms/Model/BlockTest.php index 9b2bb67c55da1..bc419f527830d 100644 --- a/dev/tests/integration/testsuite/Magento/Cms/Model/BlockTest.php +++ b/dev/tests/integration/testsuite/Magento/Cms/Model/BlockTest.php @@ -6,10 +6,9 @@ namespace Magento\Cms\Model; use Magento\Cms\Api\BlockRepositoryInterface; -use Magento\Cms\Model\BlockFactory; use Magento\Cms\Model\ResourceModel\Block; +use Magento\Framework\App\ResourceConnection; use Magento\Framework\ObjectManagerInterface; -use Magento\Framework\Stdlib\DateTime\DateTime; use Magento\TestFramework\Helper\Bootstrap; use PHPUnit\Framework\TestCase; @@ -38,42 +37,53 @@ class BlockTest extends TestCase */ private $blockRepository; + /** + * @inheritdoc + */ protected function setUp() { $this->objectManager = Bootstrap::getObjectManager(); - - /** @var BlockFactory $blockFactory */ - /** @var Block $blockResource */ $this->blockResource = $this->objectManager->create(Block::class); $this->blockFactory = $this->objectManager->create(BlockFactory::class); $this->blockRepository = $this->objectManager->create(BlockRepositoryInterface::class); } /** - * Test UpdateTime + * Tests update time. + * * @param array $blockData - * @throws \Exception + * @return void * @magentoDbIsolation enabled * @dataProvider testUpdateTimeDataProvider */ public function testUpdateTime(array $blockData) { + /** @var \Magento\Framework\DB\Adapter\AdapterInterface $db */ + $db = $this->objectManager->get(ResourceConnection::class) + ->getConnection(ResourceConnection::DEFAULT_CONNECTION); + # Prepare and save the temporary block $tempBlock = $this->blockFactory->create(); $tempBlock->setData($blockData); + $beforeTimestamp = $db->fetchOne('SELECT UNIX_TIMESTAMP()'); $this->blockResource->save($tempBlock); + $afterTimestamp = $db->fetchOne('SELECT UNIX_TIMESTAMP()'); # Load previously created block and compare update_time field $block = $this->blockRepository->getById($tempBlock->getId()); - $date = $this->objectManager->get(DateTime::class)->date(); - $this->assertEquals($date, $block->getUpdateTime()); + $blockTimestamp = strtotime($block->getUpdateTime()); + + /** These checks prevent a race condition */ + $this->assertGreaterThanOrEqual($beforeTimestamp, $blockTimestamp); + $this->assertLessThanOrEqual($afterTimestamp, $blockTimestamp); } /** - * Data provider "testUpdateTime" method + * Data provider "testUpdateTime" method. + * * @return array */ - public function testUpdateTimeDataProvider() + public function testUpdateTimeDataProvider(): array { return [ [ @@ -82,9 +92,9 @@ public function testUpdateTimeDataProvider() 'stores' => [0], 'identifier' => 'test-identifier', 'content' => 'Test content', - 'is_active' => 1 - ] - ] + 'is_active' => 1, + ], + ], ]; } } diff --git a/dev/tests/integration/testsuite/Magento/Cms/Model/PageTest.php b/dev/tests/integration/testsuite/Magento/Cms/Model/PageTest.php index c8040861b08eb..c3553717d0224 100644 --- a/dev/tests/integration/testsuite/Magento/Cms/Model/PageTest.php +++ b/dev/tests/integration/testsuite/Magento/Cms/Model/PageTest.php @@ -6,23 +6,32 @@ namespace Magento\Cms\Model; use Magento\Cms\Api\PageRepositoryInterface; -use Magento\Framework\Stdlib\DateTime\DateTime; +use Magento\Framework\App\ResourceConnection; /** * @magentoAppArea adminhtml */ class PageTest extends \PHPUnit\Framework\TestCase { + /** + * @var \Magento\Framework\ObjectManagerInterface + */ + private $objectManager; + + /** + * @inheritdoc + */ protected function setUp() { - $user = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( + $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + $user = $this->objectManager->create( \Magento\User\Model\User::class )->loadByUsername( \Magento\TestFramework\Bootstrap::ADMIN_NAME ); /** @var $session \Magento\Backend\Model\Auth\Session */ - $session = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( + $session = $this->objectManager->get( \Magento\Backend\Model\Auth\Session::class ); $session->setUser($user); @@ -30,13 +39,15 @@ protected function setUp() /** * @magentoDbIsolation enabled - * @dataProvider generateIdentifierFromTitleDataProvider + * @dataProvider generateIdentifierFromTitleDataProvider + * @param array $data + * @param string $expectedIdentifier + * @return void */ - public function testGenerateIdentifierFromTitle($data, $expectedIdentifier) + public function testGenerateIdentifierFromTitle(array $data, string $expectedIdentifier) { - $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); - /** @var \Magento\Cms\Model\Page $page */ - $page = $objectManager->create(\Magento\Cms\Model\Page::class); + /** @var Page $page */ + $page = $this->objectManager->create(Page::class); $page->setData($data); $page->save(); $this->assertEquals($expectedIdentifier, $page->getIdentifier()); @@ -44,31 +55,43 @@ public function testGenerateIdentifierFromTitle($data, $expectedIdentifier) /** * @magentoDbIsolation enabled + * @return void */ public function testUpdateTime() { - $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); - /** @var \Magento\Cms\Model\Page $page */ - $page = $objectManager->create(\Magento\Cms\Model\Page::class); + /** @var \Magento\Framework\DB\Adapter\AdapterInterface $db */ + $db = $this->objectManager->get(ResourceConnection::class) + ->getConnection(ResourceConnection::DEFAULT_CONNECTION); + + /** @var Page $page */ + $page = $this->objectManager->create(Page::class); $page->setData(['title' => 'Test', 'stores' => [1]]); + $beforeTimestamp = $db->fetchOne('SELECT UNIX_TIMESTAMP()'); $page->save(); - $page = $objectManager->get(PageRepositoryInterface::class)->getById($page->getId()); - $date = $objectManager->get(DateTime::class)->date(); - $this->assertEquals($date, $page->getUpdateTime()); + $afterTimestamp = $db->fetchOne('SELECT UNIX_TIMESTAMP()'); + $page = $this->objectManager->get(PageRepositoryInterface::class)->getById($page->getId()); + $pageTimestamp = strtotime($page->getUpdateTime()); + + /** These checks prevent a race condition */ + $this->assertGreaterThanOrEqual($beforeTimestamp, $pageTimestamp); + $this->assertLessThanOrEqual($afterTimestamp, $pageTimestamp); } - public function generateIdentifierFromTitleDataProvider() + /** + * @return array + */ + public function generateIdentifierFromTitleDataProvider(): array { return [ ['data' => ['title' => 'Test title', 'stores' => [1]], 'expectedIdentifier' => 'test-title'], [ 'data' => ['title' => 'Кирилический заголовок', 'stores' => [1]], - 'expectedIdentifier' => 'kirilicheskij-zagolovok' + 'expectedIdentifier' => 'kirilicheskij-zagolovok', ], [ 'data' => ['title' => 'Test title', 'identifier' => 'custom-identifier', 'stores' => [1]], - 'expectedIdentifier' => 'custom-identifier' - ] + 'expectedIdentifier' => 'custom-identifier', + ], ]; } } diff --git a/dev/tests/integration/testsuite/Magento/Config/Console/Command/ConfigSetCommandTest.php b/dev/tests/integration/testsuite/Magento/Config/Console/Command/ConfigSetCommandTest.php index 3f333a36c9c93..a1998c6c89536 100644 --- a/dev/tests/integration/testsuite/Magento/Config/Console/Command/ConfigSetCommandTest.php +++ b/dev/tests/integration/testsuite/Magento/Config/Console/Command/ConfigSetCommandTest.php @@ -27,7 +27,7 @@ /** * Tests the different flows of config:set command. * - * {@inheritdoc} + * @inheritdoc * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @magentoDbIsolation enabled */ @@ -291,8 +291,7 @@ public function runExtendedDataProvider() * @param string $scope * @param $scopeCode string|null * @dataProvider configSetValidationErrorDataProvider - * - * @magentoDbIsolation enabled + * @magentoDbIsolation disabled */ public function testConfigSetValidationError( $path, @@ -306,6 +305,7 @@ public function testConfigSetValidationError( /** * Data provider for testConfigSetValidationError + * * @return array */ public function configSetValidationErrorDataProvider() @@ -398,7 +398,6 @@ public function testConfigSetCurrency() * Saving values with successful validation * * @dataProvider configSetValidDataProvider - * * @magentoDbIsolation enabled */ public function testConfigSetValid() diff --git a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/ConfigurableTest.php b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/ConfigurableTest.php index 66091c108f0b2..f02ab6acf7ac3 100644 --- a/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/ConfigurableTest.php +++ b/dev/tests/integration/testsuite/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/ConfigurableTest.php @@ -12,11 +12,18 @@ use Magento\Store\Model\Store; use Magento\Store\Model\StoreManagerInterface; use Magento\TestFramework\Helper\Bootstrap; +use Magento\CatalogInventory\Model\Stock; +use Magento\CatalogInventory\Api\StockItemRepositoryInterface; +use PHPUnit\Framework\TestCase; +use Magento\Catalog\Api\Data\ProductInterface; /** + * Configurable test + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @magentoAppArea adminhtml */ -class ConfigurableTest extends \PHPUnit\Framework\TestCase +class ConfigurableTest extends TestCase { /** * @var StoreManagerInterface @@ -28,26 +35,36 @@ class ConfigurableTest extends \PHPUnit\Framework\TestCase */ private $productRepository; + /** + * @var StockItemRepositoryInterface + */ + private $stockRepository; + + /** + * @inheritdoc + */ protected function setUp() { $this->storeManager = Bootstrap::getObjectManager()->get(StoreManagerInterface::class); $this->productRepository = Bootstrap::getObjectManager()->get(ProductRepositoryInterface::class); + $this->stockRepository = Bootstrap::getObjectManager()->get(StockItemRepositoryInterface::class); } /** + * Test get product final price if one of child is disabled + * * @magentoDataFixture Magento/ConfigurableProduct/_files/product_configurable.php * @magentoDbIsolation disabled + * + * @return void + * @throws \Magento\Framework\Exception\CouldNotSaveException + * @throws \Magento\Framework\Exception\InputException + * @throws \Magento\Framework\Exception\NoSuchEntityException + * @throws \Magento\Framework\Exception\StateException */ public function testGetProductFinalPriceIfOneOfChildIsDisabled() { - /** @var Collection $collection */ - $collection = Bootstrap::getObjectManager()->get(CollectionFactory::class) - ->create(); - $configurableProduct = $collection - ->addIdFilter([1]) - ->addMinimalPrice() - ->load() - ->getFirstItem(); + $configurableProduct = $this->getConfigurableProductFromCollection(); $this->assertEquals(10, $configurableProduct->getMinimalPrice()); $childProduct = $this->productRepository->getById(10, false, null, true); @@ -58,31 +75,25 @@ public function testGetProductFinalPriceIfOneOfChildIsDisabled() $this->productRepository->save($childProduct); $this->storeManager->setCurrentStore($currentStoreId); - /** @var Collection $collection */ - $collection = Bootstrap::getObjectManager()->get(CollectionFactory::class) - ->create(); - $configurableProduct = $collection - ->addIdFilter([1]) - ->addMinimalPrice() - ->load() - ->getFirstItem(); + $configurableProduct = $this->getConfigurableProductFromCollection(); $this->assertEquals(20, $configurableProduct->getMinimalPrice()); } /** + * Test get product final price if one of child is disabled per store + * * @magentoDataFixture Magento/ConfigurableProduct/_files/product_configurable.php * @magentoDbIsolation disabled + * + * @return void + * @throws \Magento\Framework\Exception\CouldNotSaveException + * @throws \Magento\Framework\Exception\InputException + * @throws \Magento\Framework\Exception\NoSuchEntityException + * @throws \Magento\Framework\Exception\StateException */ public function testGetProductFinalPriceIfOneOfChildIsDisabledPerStore() { - /** @var Collection $collection */ - $collection = Bootstrap::getObjectManager()->get(CollectionFactory::class) - ->create(); - $configurableProduct = $collection - ->addIdFilter([1]) - ->addMinimalPrice() - ->load() - ->getFirstItem(); + $configurableProduct = $this->getConfigurableProductFromCollection(); $this->assertEquals(10, $configurableProduct->getMinimalPrice()); $childProduct = $this->productRepository->getById(10, false, null, true); @@ -95,14 +106,53 @@ public function testGetProductFinalPriceIfOneOfChildIsDisabledPerStore() $this->productRepository->save($childProduct); $this->storeManager->setCurrentStore($currentStoreId); + $configurableProduct = $this->getConfigurableProductFromCollection(); + $this->assertEquals(20, $configurableProduct->getMinimalPrice()); + } + + /** + * Test get product minimal price if one child is out of stock + * + * @magentoConfigFixture current_store cataloginventory/options/show_out_of_stock 1 + * @magentoDataFixture Magento/ConfigurableProduct/_files/product_configurable.php + * @magentoDbIsolation disabled + * + * @return void + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + public function testGetProductMinimalPriceIfOneOfChildIsOutOfStock() + { + $configurableProduct = $this->getConfigurableProductFromCollection(); + $this->assertEquals(10, $configurableProduct->getMinimalPrice()); + + $childProduct = $this->productRepository->get('simple_10', false, null, true); + $stockItem = $childProduct->getExtensionAttributes()->getStockItem(); + $stockItem->setIsInStock(Stock::STOCK_OUT_OF_STOCK); + $this->stockRepository->save($stockItem); + + $configurableProduct = $this->getConfigurableProductFromCollection(); + $this->assertEquals(20, $configurableProduct->getMinimalPrice()); + } + + /** + * Retrieve configurable product. + * Returns Configurable product that was created by Magento/ConfigurableProduct/_files/product_configurable.php + * fixture + * + * @return ProductInterface + */ + private function getConfigurableProductFromCollection(): ProductInterface + { /** @var Collection $collection */ $collection = Bootstrap::getObjectManager()->get(CollectionFactory::class) ->create(); + /** @var ProductInterface $configurableProduct */ $configurableProduct = $collection ->addIdFilter([1]) ->addMinimalPrice() ->load() ->getFirstItem(); - $this->assertEquals(20, $configurableProduct->getMinimalPrice()); + + return $configurableProduct; } } diff --git a/dev/tests/integration/testsuite/Magento/Customer/Controller/AccountTest.php b/dev/tests/integration/testsuite/Magento/Customer/Controller/AccountTest.php index ff0d838cb6f82..4fed6c84ab09d 100644 --- a/dev/tests/integration/testsuite/Magento/Customer/Controller/AccountTest.php +++ b/dev/tests/integration/testsuite/Magento/Customer/Controller/AccountTest.php @@ -19,10 +19,13 @@ use Magento\Framework\App\Http; use Magento\Framework\Data\Form\FormKey; use Magento\Framework\Message\MessageInterface; -use Magento\Store\Model\ScopeInterface; use Magento\TestFramework\Helper\Bootstrap; use Magento\TestFramework\Request; use Magento\TestFramework\Response; +use Magento\TestFramework\Mail\Template\TransportBuilderMock; +use Magento\Framework\Serialize\Serializer\Json; +use Magento\Framework\Stdlib\CookieManagerInterface; +use Magento\Theme\Controller\Result\MessagePlugin; use Zend\Stdlib\Parameters; /** @@ -30,6 +33,20 @@ */ class AccountTest extends \Magento\TestFramework\TestCase\AbstractController { + /** + * @var TransportBuilderMock + */ + private $transportBuilderMock; + + /** + * @inheritdoc + */ + protected function setUp() + { + parent::setUp(); + $this->transportBuilderMock = $this->_objectManager->get(TransportBuilderMock::class); + } + /** * Login the user * @@ -102,11 +119,7 @@ public function testForgotPasswordEmailMessageWithSpecialCharacters() $this->dispatch('customer/account/forgotPasswordPost'); $this->assertRedirect($this->stringContains('customer/account/')); - /** @var \Magento\TestFramework\Mail\Template\TransportBuilderMock $transportBuilder */ - $transportBuilder = $this->_objectManager->get( - \Magento\TestFramework\Mail\Template\TransportBuilderMock::class - ); - $subject = $transportBuilder->getSentMessage()->getSubject(); + $subject = $this->transportBuilderMock->getSentMessage()->getSubject(); $this->assertContains( 'Test special\' characters', $subject @@ -228,26 +241,10 @@ public function testNoFormKeyCreatePostAction() /** * @magentoDbIsolation enabled * @magentoAppIsolation enabled + * @magentoDataFixture Magento/Customer/_files/customer_confirmation_config_disable.php */ public function testNoConfirmCreatePostAction() { - /** @var \Magento\Framework\App\Config\MutableScopeConfigInterface $mutableScopeConfig */ - $mutableScopeConfig = Bootstrap::getObjectManager() - ->get(\Magento\Framework\App\Config\MutableScopeConfigInterface::class); - - $scopeValue = $mutableScopeConfig->getValue( - 'customer/create_account/confirm', - ScopeInterface::SCOPE_WEBSITES, - null - ); - - $mutableScopeConfig->setValue( - 'customer/create_account/confirm', - 0, - ScopeInterface::SCOPE_WEBSITES, - null - ); - $this->fillRequestWithAccountDataAndFormKey(); $this->dispatch('customer/account/createPost'); $this->assertRedirect($this->stringEndsWith('customer/account/')); @@ -255,38 +252,15 @@ public function testNoConfirmCreatePostAction() $this->equalTo(['Thank you for registering with Main Website Store.']), MessageInterface::TYPE_SUCCESS ); - - $mutableScopeConfig->setValue( - 'customer/create_account/confirm', - $scopeValue, - ScopeInterface::SCOPE_WEBSITES, - null - ); } /** * @magentoDbIsolation enabled * @magentoAppIsolation enabled + * @magentoDataFixture Magento/Customer/_files/customer_confirmation_config_enable.php */ public function testWithConfirmCreatePostAction() { - /** @var \Magento\Framework\App\Config\MutableScopeConfigInterface $mutableScopeConfig */ - $mutableScopeConfig = Bootstrap::getObjectManager() - ->get(\Magento\Framework\App\Config\MutableScopeConfigInterface::class); - - $scopeValue = $mutableScopeConfig->getValue( - 'customer/create_account/confirm', - ScopeInterface::SCOPE_WEBSITES, - null - ); - - $mutableScopeConfig->setValue( - 'customer/create_account/confirm', - 1, - ScopeInterface::SCOPE_WEBSITES, - null - ); - $this->fillRequestWithAccountDataAndFormKey(); $this->dispatch('customer/account/createPost'); $this->assertRedirect($this->stringContains('customer/account/index/')); @@ -298,13 +272,6 @@ public function testWithConfirmCreatePostAction() ]), MessageInterface::TYPE_SUCCESS ); - - $mutableScopeConfig->setValue( - 'customer/create_account/confirm', - $scopeValue, - ScopeInterface::SCOPE_WEBSITES, - null - ); } /** @@ -690,6 +657,46 @@ public function testLoginPostRedirect($redirectDashboard, string $redirectUrl) $this->assertTrue($this->_objectManager->get(Session::class)->isLoggedIn()); } + /** + * Test that confirmation email address displays special characters correctly. + * + * @magentoDbIsolation enabled + * @magentoDataFixture Magento/Customer/_files/customer_confirmation_email_address_with_special_chars.php + * + * @return void + */ + public function testConfirmationEmailWithSpecialCharacters() + { + $email = 'customer+confirmation@example.com'; + $this->dispatch('customer/account/confirmation/email/customer%2Bconfirmation%40email.com'); + $this->getRequest()->setPostValue('email', $email); + $this->dispatch('customer/account/confirmation/email/customer%2Bconfirmation%40email.com'); + + $this->assertRedirect($this->stringContains('customer/account/index')); + $this->assertSessionMessages( + $this->equalTo(['Please check your email for confirmation key.']), + MessageInterface::TYPE_SUCCESS + ); + + /** @var $message \Magento\Framework\Mail\Message */ + $message = $this->transportBuilderMock->getSentMessage(); + $rawMessage = $message->getRawMessage(); + + $this->assertContains('To: ' . $email, $rawMessage); + + $content = $message->getBody()->getPartContent(0); + $confirmationUrl = $this->getConfirmationUrlFromMessageContent($content); + $this->setRequestInfo($confirmationUrl, 'confirm'); + $this->clearCookieMessagesList(); + $this->dispatch($confirmationUrl); + + $this->assertRedirect($this->stringContains('customer/account/index')); + $this->assertSessionMessages( + $this->equalTo(['Thank you for registering with Main Website Store.']), + MessageInterface::TYPE_SUCCESS + ); + } + /** * Data provider for testLoginPostRedirect. * @@ -705,16 +712,17 @@ public function loginPostRedirectDataProvider() } /** + * @param string $email * @return void */ - private function fillRequestWithAccountData() + private function fillRequestWithAccountData(string $email = 'test1@email.com') { $this->getRequest() ->setMethod('POST') ->setParam('firstname', 'firstname1') ->setParam('lastname', 'lastname1') ->setParam('company', '') - ->setParam('email', 'test1@email.com') + ->setParam('email', $email) ->setParam('password', '_Password1') ->setParam('password_confirmation', '_Password1') ->setParam('telephone', '5123334444') @@ -731,11 +739,12 @@ private function fillRequestWithAccountData() } /** + * @param string $email * @return void */ - private function fillRequestWithAccountDataAndFormKey() + private function fillRequestWithAccountDataAndFormKey(string $email = 'test1@email.com') { - $this->fillRequestWithAccountData(); + $this->fillRequestWithAccountData($email); $formKey = $this->_objectManager->get(FormKey::class); $this->getRequest()->setParam('form_key', $formKey->getFormKey()); } @@ -805,4 +814,53 @@ private function assertResponseRedirect(Response $response, string $redirectUrl) $this->assertTrue($response->isRedirect()); $this->assertSame($redirectUrl, $response->getHeader('Location')->getUri()); } + + /** + * Add new request info (request uri, path info, action name). + * + * @param string $uri + * @param string $actionName + * @return void + */ + private function setRequestInfo(string $uri, string $actionName) + { + $this->getRequest() + ->setRequestUri($uri) + ->setPathInfo() + ->setActionName($actionName); + } + + /** + * Clear cookie messages list. + * + * @return void + */ + private function clearCookieMessagesList() + { + $cookieManager = $this->_objectManager->get(CookieManagerInterface::class); + $jsonSerializer = $this->_objectManager->get(Json::class); + $cookieManager->setPublicCookie( + MessagePlugin::MESSAGES_COOKIES_NAME, + $jsonSerializer->serialize([]) + ); + } + + /** + * Get confirmation URL from message content. + * + * @param string $content + * @return string + */ + private function getConfirmationUrlFromMessageContent(string $content): string + { + $confirmationUrl = ''; + + if (preg_match('<a\s*href="(?<url>.*?)".*>', $content, $matches)) { + $confirmationUrl = $matches['url']; + $confirmationUrl = str_replace('http://localhost/index.php/', '', $confirmationUrl); + $confirmationUrl = html_entity_decode($confirmationUrl); + } + + return $confirmationUrl; + } } diff --git a/dev/tests/integration/testsuite/Magento/Customer/Controller/Adminhtml/GroupTest.php b/dev/tests/integration/testsuite/Magento/Customer/Controller/Adminhtml/GroupTest.php index 28c8d7556270a..094cc46d42867 100644 --- a/dev/tests/integration/testsuite/Magento/Customer/Controller/Adminhtml/GroupTest.php +++ b/dev/tests/integration/testsuite/Magento/Customer/Controller/Adminhtml/GroupTest.php @@ -7,6 +7,7 @@ use Magento\Framework\Message\MessageInterface; use Magento\TestFramework\Helper\Bootstrap; +use Magento\Framework\Data\Form\FormKey; /** * @magentoAppArea adminhtml @@ -80,6 +81,11 @@ public function testNewActionWithCustomerGroupDataInSession() */ public function testDeleteActionNoGroupId() { + /** @var FormKey $formKey */ + $formKey = $this->_objectManager->get(FormKey::class); + + $this->getRequest()->setMethod('POST'); + $this->getRequest()->setParam('form_key', $formKey->getFormKey()); $this->dispatch('backend/customer/group/delete'); $this->assertRedirect($this->stringStartsWith(self::BASE_CONTROLLER_URL)); } @@ -90,7 +96,17 @@ public function testDeleteActionNoGroupId() public function testDeleteActionExistingGroup() { $groupId = $this->findGroupIdWithCode(self::CUSTOMER_GROUP_CODE); - $this->getRequest()->setParam('id', $groupId); + + /** @var FormKey $formKey */ + $formKey = $this->_objectManager->get(FormKey::class); + + $this->getRequest()->setMethod('POST'); + $this->getRequest()->setParams( + [ + 'id' => $groupId, + 'form_key' => $formKey->getFormKey() + ] + ); $this->dispatch('backend/customer/group/delete'); /** @@ -108,7 +124,16 @@ public function testDeleteActionExistingGroup() */ public function testDeleteActionNonExistingGroupId() { - $this->getRequest()->setParam('id', 10000); + /** @var FormKey $formKey */ + $formKey = $this->_objectManager->get(FormKey::class); + + $this->getRequest()->setMethod('POST'); + $this->getRequest()->setParams( + [ + 'id' => 10000, + 'form_key' => $formKey->getFormKey() + ] + ); $this->dispatch('backend/customer/group/delete'); /** diff --git a/dev/tests/integration/testsuite/Magento/Customer/Model/CustomerMetadataTest.php b/dev/tests/integration/testsuite/Magento/Customer/Model/CustomerMetadataTest.php index 5425af856bd9f..d013309eda3dc 100644 --- a/dev/tests/integration/testsuite/Magento/Customer/Model/CustomerMetadataTest.php +++ b/dev/tests/integration/testsuite/Magento/Customer/Model/CustomerMetadataTest.php @@ -54,16 +54,23 @@ protected function setUp() public function testGetCustomAttributesMetadata() { - $customAttributesMetadata = $this->service->getCustomAttributesMetadata(); - $this->assertCount(0, $customAttributesMetadata, "Invalid number of attributes returned."); + $customAttributesMetadataQty = count($this->service->getCustomAttributesMetadata()) ; // Verify the consistency of getCustomerAttributeMetadata() function from the 2nd call of the same service - $customAttributesMetadata1 = $this->service->getCustomAttributesMetadata(); - $this->assertCount(0, $customAttributesMetadata1, "Invalid number of attributes returned."); + $customAttributesMetadata1Qty = count($this->service->getCustomAttributesMetadata()); + $this->assertEquals( + $customAttributesMetadataQty, + $customAttributesMetadata1Qty, + "Invalid number of attributes returned." + ); // Verify the consistency of getCustomAttributesMetadata() function from the 2nd service - $customAttributesMetadata2 = $this->serviceTwo->getCustomAttributesMetadata(); - $this->assertCount(0, $customAttributesMetadata2, "Invalid number of attributes returned."); + $customAttributesMetadata2Qty = count($this->serviceTwo->getCustomAttributesMetadata()); + $this->assertEquals( + $customAttributesMetadataQty, + $customAttributesMetadata2Qty, + "Invalid number of attributes returned." + ); } public function testGetNestedOptionsCustomerAttributesMetadata() diff --git a/dev/tests/integration/testsuite/Magento/Customer/Model/ResourceModel/AddressRepositoryTest.php b/dev/tests/integration/testsuite/Magento/Customer/Model/ResourceModel/AddressRepositoryTest.php index 2b74a58288600..37a36b1b3c42a 100644 --- a/dev/tests/integration/testsuite/Magento/Customer/Model/ResourceModel/AddressRepositoryTest.php +++ b/dev/tests/integration/testsuite/Magento/Customer/Model/ResourceModel/AddressRepositoryTest.php @@ -17,6 +17,8 @@ use Magento\Store\Api\WebsiteRepositoryInterface; /** + * Class with integration tests for AddressRepository. + * * @SuppressWarnings(PHPMD.TooManyMethods) * @SuppressWarnings(PHPMD.ExcessivePublicCount) * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -39,6 +41,9 @@ class AddressRepositoryTest extends \PHPUnit\Framework\TestCase /** @var \Magento\Framework\Api\DataObjectHelper */ private $dataObjectHelper; + /** + * @inheritdoc + */ protected function setUp() { $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); @@ -87,6 +92,9 @@ protected function setUp() $this->expectedAddresses = [$address, $address2]; } + /** + * @inheritdoc + */ protected function tearDown() { $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); @@ -96,6 +104,8 @@ protected function tearDown() } /** + * Test for save address changes. + * * @magentoDataFixture Magento/Customer/_files/customer.php * @magentoDataFixture Magento/Customer/_files/customer_address.php * @magentoDataFixture Magento/Customer/_files/customer_two_addresses.php @@ -117,6 +127,8 @@ public function testSaveAddressChanges() } /** + * Test for method save address with new id. + * * @magentoDataFixture Magento/Customer/_files/customer.php * @magentoDataFixture Magento/Customer/_files/customer_address.php * @magentoDataFixture Magento/Customer/_files/customer_two_addresses.php @@ -131,6 +143,8 @@ public function testSaveAddressesIdSetButNotAlreadyExisting() } /** + * Test for method get address by id. + * * @magentoDataFixture Magento/Customer/_files/customer.php * @magentoDataFixture Magento/Customer/_files/customer_address.php * @magentoDataFixture Magento/Customer/_files/customer_two_addresses.php @@ -144,6 +158,8 @@ public function testGetAddressById() } /** + * Test for method get address by id with incorrect id. + * * @magentoDataFixture Magento/Customer/_files/customer.php * @expectedException \Magento\Framework\Exception\NoSuchEntityException * @expectedExceptionMessage No such entity with addressId = 12345 @@ -154,6 +170,8 @@ public function testGetAddressByIdBadAddressId() } /** + * Test for method save new address. + * * @magentoDataFixture Magento/Customer/_files/customer.php * @magentoDataFixture Magento/Customer/_files/customer_address.php * @magentoAppIsolation enabled @@ -174,6 +192,8 @@ public function testSaveNewAddress() } /** + * Test for saving address with invalid address. + * * @magentoDataFixture Magento/Customer/_files/customer.php * @magentoDataFixture Magento/Customer/_files/customer_address.php * @magentoAppIsolation enabled @@ -199,6 +219,8 @@ public function testSaveNewAddressWithAttributes() } /** + * Test for method saaveNewAddress with new attributes. + * * @magentoDataFixture Magento/Customer/_files/customer.php * @magentoDataFixture Magento/Customer/_files/customer_address.php * @magentoAppIsolation enabled @@ -222,6 +244,11 @@ public function testSaveNewInvalidAddress() } } + /** + * Test for saving address without existing customer. + * + * @return void + */ public function testSaveAddressesCustomerIdNotExist() { $proposedAddress = $this->_createSecondAddress()->setCustomerId(4200); @@ -233,6 +260,11 @@ public function testSaveAddressesCustomerIdNotExist() } } + /** + * Test for saving addresses with invalid customer id. + * + * @return void + */ public function testSaveAddressesCustomerIdInvalid() { $proposedAddress = $this->_createSecondAddress()->setCustomerId('this_is_not_a_valid_id'); @@ -245,6 +277,8 @@ public function testSaveAddressesCustomerIdInvalid() } /** + * Test for deleteAddressById. + * * @magentoDataFixture Magento/Customer/_files/customer.php * @magentoDataFixture Magento/Customer/_files/customer_address.php */ @@ -268,6 +302,8 @@ public function testDeleteAddress() } /** + * Test for delete method. + * * @magentoDataFixture Magento/Customer/_files/customer.php * @magentoDataFixture Magento/Customer/_files/customer_address.php */ @@ -291,6 +327,8 @@ public function testDeleteAddressById() } /** + * Test delete address from customer with incorrect address id. + * * @magentoDataFixture Magento/Customer/_files/customer.php */ public function testDeleteAddressFromCustomerBadAddressId() @@ -304,10 +342,14 @@ public function testDeleteAddressFromCustomerBadAddressId() } /** + * Test for searching addressed. + * * @param \Magento\Framework\Api\Filter[] $filters - * @param \Magento\Framework\Api\Filter[] $filterGroup - * @param \Magento\Framework\Api\SortOrder[] $filterOrders + * @param \Magento\Framework\Api\Filter[]|null $filterGroup + * @param \Magento\Framework\Api\SortOrder[]|null $filterOrders * @param array $expectedResult array of expected results indexed by ID + * @param int $currentPage current page for search criteria + * @return void * * @dataProvider searchAddressDataProvider * @@ -315,8 +357,13 @@ public function testDeleteAddressFromCustomerBadAddressId() * @magentoDataFixture Magento/Customer/_files/customer_two_addresses.php * @magentoAppIsolation enabled */ - public function testSearchAddresses($filters, $filterGroup, $filterOrders, $expectedResult) - { + public function testSearchAddresses( + array $filters, + $filterGroup, + $filterOrders, + array $expectedResult, + int $currentPage + ) { /** @var \Magento\Framework\Api\SearchCriteriaBuilder $searchBuilder */ $searchBuilder = $this->objectManager->create(\Magento\Framework\Api\SearchCriteriaBuilder::class); foreach ($filters as $filter) { @@ -332,7 +379,7 @@ public function testSearchAddresses($filters, $filterGroup, $filterOrders, $expe } $searchBuilder->setPageSize(1); - $searchBuilder->setCurrentPage(2); + $searchBuilder->setCurrentPage($currentPage); $searchCriteria = $searchBuilder->create(); $searchResults = $this->repository->getList($searchCriteria); @@ -350,7 +397,12 @@ public function testSearchAddresses($filters, $filterGroup, $filterOrders, $expe $this->assertEquals($expectedResult[$expectedResultIndex]['firstname'], $items[0]->getFirstname()); } - public function searchAddressDataProvider() + /** + * Data provider for searchAddresses. + * + * @return array + */ + public function searchAddressDataProvider(): array { /** * @var \Magento\Framework\Api\FilterBuilder $filterBuilder @@ -365,23 +417,25 @@ public function searchAddressDataProvider() return [ 'Address with postcode 75477' => [ [$filterBuilder->setField('postcode')->setValue('75477')->create()], - null, + [], null, [ ['id' => 1, 'city' => 'CityM', 'postcode' => 75477, 'firstname' => 'John'], ], + 1, ], 'Address with city CityM' => [ [$filterBuilder->setField('city')->setValue('CityM')->create()], - null, + [], null, [ ['id' => 1, 'city' => 'CityM', 'postcode' => 75477, 'firstname' => 'John'], ], + 1, ], 'Addresses with firstname John sorted by firstname desc, city asc' => [ [$filterBuilder->setField('firstname')->setValue('John')->create()], - null, + [], [ $orderBuilder->setField('firstname')->setDirection(SortOrder::SORT_DESC)->create(), $orderBuilder->setField('city')->setDirection(SortOrder::SORT_ASC)->create(), @@ -390,6 +444,7 @@ public function searchAddressDataProvider() ['id' => 1, 'city' => 'CityM', 'postcode' => 75477, 'firstname' => 'John'], ['id' => 2, 'city' => 'CityX', 'postcode' => 47676, 'firstname' => 'John'], ], + 2, ], 'Addresses with postcode of either 75477 or 47676 sorted by city desc' => [ [], @@ -404,10 +459,11 @@ public function searchAddressDataProvider() ['id' => 2, 'city' => 'CityX', 'postcode' => 47676, 'firstname' => 'John'], ['id' => 1, 'city' => 'CityM', 'postcode' => 75477, 'firstname' => 'John'], ], + 2, ], 'Addresses with postcode greater than 0 sorted by firstname asc, postcode desc' => [ [$filterBuilder->setField('postcode')->setValue('0')->setConditionType('gt')->create()], - null, + [], [ $orderBuilder->setField('firstname')->setDirection(SortOrder::SORT_ASC)->create(), $orderBuilder->setField('postcode')->setDirection(SortOrder::SORT_ASC)->create(), @@ -416,11 +472,14 @@ public function searchAddressDataProvider() ['id' => 2, 'city' => 'CityX', 'postcode' => 47676, 'firstname' => 'John'], ['id' => 1, 'city' => 'CityM', 'postcode' => 75477, 'firstname' => 'John'], ], + 2, ], ]; } /** + * Test for save addresses with restricted countries. + * * @magentoDataFixture Magento/Customer/Fixtures/customer_sec_website.php */ public function testSaveAddressWithRestrictedCountries() diff --git a/dev/tests/integration/testsuite/Magento/Customer/_files/customer_confirmation_config_disable.php b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_confirmation_config_disable.php new file mode 100644 index 0000000000000..7d4e451db514b --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_confirmation_config_disable.php @@ -0,0 +1,21 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +use Magento\Store\Model\ScopeInterface; +use Magento\Framework\App\Config\MutableScopeConfigInterface; +use Magento\TestFramework\Helper\Bootstrap; + +$objectManager = Bootstrap::getObjectManager(); +$mutableScopeConfig = $objectManager->create(MutableScopeConfigInterface::class); + +$mutableScopeConfig->setValue( + 'customer/create_account/confirm', + 0, + ScopeInterface::SCOPE_WEBSITES, + null +); diff --git a/dev/tests/integration/testsuite/Magento/Customer/_files/customer_confirmation_config_disable_rollback.php b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_confirmation_config_disable_rollback.php new file mode 100644 index 0000000000000..36743b4a20e9a --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_confirmation_config_disable_rollback.php @@ -0,0 +1,23 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +use Magento\Config\Model\ResourceModel\Config; +use Magento\Framework\Registry; +use Magento\TestFramework\Helper\Bootstrap; + +/** @var Registry $registry */ +$registry = Bootstrap::getObjectManager()->get(Registry::class); +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +/** @var Config $config */ +$config = Bootstrap::getObjectManager()->create(Config::class); +$config->deleteConfig('customer/create_account/confirm'); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/Customer/_files/customer_confirmation_config_enable.php b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_confirmation_config_enable.php new file mode 100644 index 0000000000000..c8deb7ec2a536 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_confirmation_config_enable.php @@ -0,0 +1,21 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +use Magento\Store\Model\ScopeInterface; +use Magento\Framework\App\Config\MutableScopeConfigInterface; +use Magento\TestFramework\Helper\Bootstrap; + +$objectManager = Bootstrap::getObjectManager(); +$mutableScopeConfig = $objectManager->create(MutableScopeConfigInterface::class); + +$mutableScopeConfig->setValue( + 'customer/create_account/confirm', + 1, + ScopeInterface::SCOPE_WEBSITES, + null +); diff --git a/dev/tests/integration/testsuite/Magento/Customer/_files/customer_confirmation_config_enable_rollback.php b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_confirmation_config_enable_rollback.php new file mode 100644 index 0000000000000..36743b4a20e9a --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_confirmation_config_enable_rollback.php @@ -0,0 +1,23 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +use Magento\Config\Model\ResourceModel\Config; +use Magento\Framework\Registry; +use Magento\TestFramework\Helper\Bootstrap; + +/** @var Registry $registry */ +$registry = Bootstrap::getObjectManager()->get(Registry::class); +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +/** @var Config $config */ +$config = Bootstrap::getObjectManager()->create(Config::class); +$config->deleteConfig('customer/create_account/confirm'); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/Customer/_files/customer_confirmation_email_address_with_special_chars.php b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_confirmation_email_address_with_special_chars.php new file mode 100644 index 0000000000000..c4f046bac57a6 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_confirmation_email_address_with_special_chars.php @@ -0,0 +1,37 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +use Magento\Customer\Api\CustomerRepositoryInterface; +use Magento\Customer\Api\Data\CustomerInterface; +use Magento\Customer\Model\Customer; +use Magento\TestFramework\Helper\Bootstrap; + +include __DIR__ . '/customer_confirmation_config_enable.php'; + +$objectManager = Bootstrap::getObjectManager(); + +/** @var Customer $customer */ +$customer = $objectManager->create(Customer::class); +/** @var CustomerRepositoryInterface $customerRepository */ +$customerRepository = $objectManager->create(CustomerRepositoryInterface::class); +/** @var CustomerInterface $customerInterface */ +$customerInterface = $objectManager->create(CustomerInterface::class); + +$customerInterface->setWebsiteId(1) + ->setEmail('customer+confirmation@example.com') + ->setConfirmation($customer->getRandomConfirmationKey()) + ->setGroupId(1) + ->setStoreId(1) + ->setFirstname('John') + ->setLastname('Smith') + ->setDefaultBilling(1) + ->setDefaultShipping(1) + ->setTaxvat('12') + ->setGender(0); + +$customerRepository->save($customerInterface, 'password'); diff --git a/dev/tests/integration/testsuite/Magento/Customer/_files/customer_confirmation_email_address_with_special_chars_rollback.php b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_confirmation_email_address_with_special_chars_rollback.php new file mode 100644 index 0000000000000..7a0ebf74ed8a0 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_confirmation_email_address_with_special_chars_rollback.php @@ -0,0 +1,31 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +include __DIR__ . '/customer_confirmation_config_enable_rollback.php'; + +use Magento\Customer\Api\CustomerRepositoryInterface; +use Magento\Framework\Registry; +use Magento\TestFramework\Helper\Bootstrap; + +/** @var Registry $registry */ +$registry = Bootstrap::getObjectManager()->get(Registry::class); +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +/** @var CustomerRepositoryInterface $customerRepository */ +$customerRepository = Bootstrap::getObjectManager()->create(CustomerRepositoryInterface::class); + +try { + $customer = $customerRepository->get('customer+confirmation@example.com'); + $customerRepository->delete($customer); +} catch (\Magento\Framework\Exception\NoSuchEntityException $e) { + // Customer with the specified email does not exist +} + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/Deploy/Console/Command/SetModeCommandTest.php b/dev/tests/integration/testsuite/Magento/Deploy/Console/Command/SetModeCommandTest.php new file mode 100644 index 0000000000000..687c80cc952af --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Deploy/Console/Command/SetModeCommandTest.php @@ -0,0 +1,203 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +namespace Magento\Deploy\Console\Command; + +use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\Console\Cli; +use Magento\Framework\Filesystem; +use Magento\Framework\ObjectManagerInterface; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Framework\App\State; +use Symfony\Component\Console\Tester\CommandTester; +use Magento\Deploy\Console\ConsoleLogger; +use Magento\Deploy\Console\ConsoleLoggerFactory; +use Magento\Framework\Config\File\ConfigFilePool; +use Magento\Framework\App\DeploymentConfig\FileReader; +use Magento\Framework\App\DeploymentConfig\Writer; + +/** + * Tests working status of deploy:mode:set command. + * + * {@inheritdoc} + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @magentoDbIsolation enabled + * @magentoAppIsolation enabled + */ +class SetModeCommandTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var ObjectManagerInterface + */ + private $objectManager; + + /** + * @var CommandTester + */ + private $commandTester; + + /** + * @var Filesystem + */ + private $filesystem; + + /** + * @var string + */ + private $prevMode; + + /** + * @var FileReader + */ + private $reader; + + /** + * @var Writer + */ + private $writer; + + /** + * @var array + */ + private $config; + + /** + * @var array + */ + private $envConfig; + + /** + * @inheritdoc + */ + protected function setUp() + { + $this->objectManager = Bootstrap::getObjectManager(); + $this->reader = $this->objectManager->get(FileReader::class); + $this->writer = $this->objectManager->get(Writer::class); + $this->prevMode = $this->objectManager->get(State::class)->getMode(); + $this->filesystem = $this->objectManager->get(Filesystem::class); + + // Load the original config to restore it on teardown + $this->config = $this->reader->load(ConfigFilePool::APP_CONFIG); + $this->envConfig = $this->reader->load(ConfigFilePool::APP_ENV); + } + + /** + * @inheritdoc + */ + public function tearDown() + { + // Restore the original config + $this->writer->saveConfig([ConfigFilePool::APP_CONFIG => $this->config]); + $this->writer->saveConfig([ConfigFilePool::APP_ENV => $this->envConfig]); + + $this->clearStaticFiles(); + // enable default mode + $this->commandTester = new CommandTester($this->getStaticContentDeployCommand()); + $this->commandTester->execute( + ['mode' => 'default'] + ); + $commandOutput = $this->commandTester->getDisplay(); + $this->assertEquals(Cli::RETURN_SUCCESS, $this->commandTester->getStatusCode()); + $this->assertContains('Enabled default mode', $commandOutput); + } + + /** + * Clear pub/static and var/view_preprocessed directories + * + * @return void + */ + private function clearStaticFiles() + { + $this->filesystem->getDirectoryWrite(DirectoryList::PUB)->delete(DirectoryList::STATIC_VIEW); + $this->filesystem->getDirectoryWrite(DirectoryList::VAR_DIR)->delete(DirectoryList::TMP_MATERIALIZATION_DIR); + } + + public function testSwitchMode() + { + if ($this->prevMode === 'production') { + //in production mode, so we have to switch to dev, then to production + $this->enableAndAssertDeveloperMode(); + $this->enableAndAssertProductionMode(); + } else { + //already in non production mode + $this->enableAndAssertProductionMode(); + } + } + + /** + * Enable production mode + * + * @return void + */ + private function enableAndAssertProductionMode() + { + // Enable production mode + $this->commandTester = new CommandTester($this->getStaticContentDeployCommand()); + $this->commandTester->execute( + ['mode' => 'production'] + ); + $commandOutput = $this->commandTester->getDisplay(); + + $this->assertEquals(Cli::RETURN_SUCCESS, $this->commandTester->getStatusCode(), $commandOutput); + + $this->assertContains('Deployment of static content complete', $commandOutput); + $this->assertContains('Enabled production mode', $commandOutput); + } + + /** + * Enable developer mode + * + * @return void + */ + private function enableAndAssertDeveloperMode() + { + $this->commandTester = new CommandTester($this->getStaticContentDeployCommand()); + $this->commandTester->execute( + ['mode' => 'developer'] + ); + $commandOutput = $this->commandTester->getDisplay(); + + $this->assertEquals(Cli::RETURN_SUCCESS, $this->commandTester->getStatusCode()); + $this->assertContains('Enabled developer mode', $commandOutput); + } + + /** + * Create SetModeCommand instance with mocked loggers + * + * @return SetModeCommand + */ + private function getStaticContentDeployCommand() + { + $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + $consoleLoggerFactoryMock = $this->getMockBuilder(ConsoleLoggerFactory::class) + ->setMethods(['getLogger']) + ->disableOriginalConstructor() + ->getMock(); + $consoleLoggerFactoryMock + ->method('getLogger') + ->will($this->returnCallback( + function ($output) use ($objectManager) { + return $objectManager->create(ConsoleLogger::class, ['output' => $output]); + } + )); + $objectManagerProviderMock = $this->getMockBuilder(ObjectManagerProvider::class) + ->setMethods(['get']) + ->disableOriginalConstructor() + ->getMock(); + $objectManagerProviderMock + ->method('get') + ->willReturn(\Magento\TestFramework\Helper\Bootstrap::getObjectManager()); + $deployStaticContentCommand = $objectManager->create( + SetModeCommand::class, + [ + 'consoleLoggerFactory' => $consoleLoggerFactoryMock, + 'objectManagerProvider' => $objectManagerProviderMock + ] + ); + + return $deployStaticContentCommand; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Developer/Model/Logger/Handler/DebugTest.php b/dev/tests/integration/testsuite/Magento/Developer/Model/Logger/Handler/DebugTest.php index 3bef48d8801f7..f7a47017f8b18 100644 --- a/dev/tests/integration/testsuite/Magento/Developer/Model/Logger/Handler/DebugTest.php +++ b/dev/tests/integration/testsuite/Magento/Developer/Model/Logger/Handler/DebugTest.php @@ -5,34 +5,20 @@ */ namespace Magento\Developer\Model\Logger\Handler; -use Magento\Config\Console\Command\ConfigSetCommand; -use Magento\Framework\App\Config; -use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Config\Setup\ConfigOptionsList; +use Magento\Framework\App\DeploymentConfig; use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\App\State; use Magento\Framework\Filesystem; use Magento\Framework\Filesystem\Directory\WriteInterface; use Magento\Framework\Logger\Monolog; +use Magento\Framework\Shell; +use Magento\Setup\Mvc\Bootstrap\InitParamListener; use Magento\TestFramework\Helper\Bootstrap; -use Magento\Deploy\Model\Mode; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; +use Magento\TestFramework\ObjectManager; /** - * Preconditions - * - Developer mode enabled - * - Log file isn't exists - * - 'Log to file' setting are enabled - * - * Test steps - * - Enable production mode without compilation - * - Try to log message into log file - * - Assert that log file isn't exists - * - Assert that 'Log to file' setting are disabled - * - * - Enable 'Log to file' setting - * - Try to log message into debug file - * - Assert that log file is exists - * - Assert that log file contain logged message + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class DebugTest extends \PHPUnit\Framework\TestCase { @@ -42,127 +28,212 @@ class DebugTest extends \PHPUnit\Framework\TestCase private $logger; /** - * @var Mode + * @var WriteInterface */ - private $mode; + private $etcDirectory; /** - * @var InputInterface|\PHPUnit_Framework_MockObject_MockObject + * @var ObjectManager */ - private $inputMock; + private $objectManager; /** - * @var OutputInterface|\PHPUnit_Framework_MockObject_MockObject + * @var Shell */ - private $outputMock; + private $shell; /** - * @var ConfigSetCommand + * @var DeploymentConfig */ - private $configSetCommand; + private $deploymentConfig; /** - * @var WriteInterface + * @var string */ - private $etcDirectory; + private $debugLogPath = ''; + + /** + * @var string + */ + private static $backupFile = 'env.base.php'; + + /** + * @var string + */ + private static $configFile = 'env.php'; /** - * @var Config + * @var Debug */ - private $appConfig; + private $debugHandler; + /** + * @inheritdoc + * @throws \Magento\Framework\Exception\FileSystemException + * @throws \Exception + */ public function setUp() { + $this->objectManager = Bootstrap::getObjectManager(); + $this->shell = $this->objectManager->get(Shell::class); + $this->logger = $this->objectManager->get(Monolog::class); + $this->deploymentConfig = $this->objectManager->get(DeploymentConfig::class); + /** @var Filesystem $filesystem */ - $filesystem = Bootstrap::getObjectManager()->create(Filesystem::class); + $filesystem = $this->objectManager->create(Filesystem::class); $this->etcDirectory = $filesystem->getDirectoryWrite(DirectoryList::CONFIG); - $this->etcDirectory->copyFile('env.php', 'env.base.php'); - - $this->inputMock = $this->getMockBuilder(InputInterface::class) - ->getMockForAbstractClass(); - $this->outputMock = $this->getMockBuilder(OutputInterface::class) - ->getMockForAbstractClass(); - $this->logger = Bootstrap::getObjectManager()->get(Monolog::class); - $this->mode = Bootstrap::getObjectManager()->create( - Mode::class, - [ - 'input' => $this->inputMock, - 'output' => $this->outputMock - ] - ); - $this->configSetCommand = Bootstrap::getObjectManager()->create(ConfigSetCommand::class); - $this->appConfig = Bootstrap::getObjectManager()->create(Config::class); - - // Preconditions - $this->mode->enableDeveloperMode(); - $this->enableDebugging(); - if (file_exists($this->getDebuggerLogPath())) { - unlink($this->getDebuggerLogPath()); - } + $this->etcDirectory->copyFile(self::$configFile, self::$backupFile); } + /** + * @inheritdoc + * @throws \Magento\Framework\Exception\FileSystemException + */ public function tearDown() { - $this->etcDirectory->delete('env.php'); - $this->etcDirectory->renameFile('env.base.php', 'env.php'); + $this->reinitDeploymentConfig(); + $this->etcDirectory->delete(self::$backupFile); } - private function enableDebugging() + /** + * @param bool $flag + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function enableDebugging(bool $flag) { - $this->inputMock = $this->getMockBuilder(InputInterface::class) - ->getMockForAbstractClass(); - $this->outputMock = $this->getMockBuilder(OutputInterface::class) - ->getMockForAbstractClass(); - $this->inputMock->expects($this->exactly(4)) - ->method('getOption') - ->withConsecutive( - [ConfigSetCommand::OPTION_LOCK_ENV], - [ConfigSetCommand::OPTION_LOCK_CONFIG], - [ConfigSetCommand::OPTION_SCOPE], - [ConfigSetCommand::OPTION_SCOPE_CODE] - ) - ->willReturnOnConsecutiveCalls( - true, - false, - ScopeConfigInterface::SCOPE_TYPE_DEFAULT, - null - ); - $this->inputMock->expects($this->exactly(2)) - ->method('getArgument') - ->withConsecutive([ConfigSetCommand::ARG_PATH], [ConfigSetCommand::ARG_VALUE]) - ->willReturnOnConsecutiveCalls('dev/debug/debug_logging', 1); - $this->outputMock->expects($this->once()) - ->method('writeln') - ->with('<info>Value was saved in app/etc/env.php and locked.</info>'); - $this->assertFalse((bool)$this->configSetCommand->run($this->inputMock, $this->outputMock)); + $this->shell->execute( + PHP_BINARY . ' -f %s setup:config:set -n --%s=%s --%s=%s', + [ + BP . '/bin/magento', + ConfigOptionsList::INPUT_KEY_DEBUG_LOGGING, + (int)$flag, + InitParamListener::BOOTSTRAP_PARAM, + urldecode(http_build_query(Bootstrap::getInstance()->getAppInitParams())), + ] + ); + $this->deploymentConfig->resetData(); + $this->assertSame((int)$flag, $this->deploymentConfig->get(ConfigOptionsList::CONFIG_PATH_DEBUG_LOGGING)); } + /** + * @throws \Magento\Framework\Exception\LocalizedException + */ public function testDebugInProductionMode() { $message = 'test message'; + $this->reinitDebugHandler(State::MODE_PRODUCTION); - $this->mode->enableProductionModeMinimal(); + $this->removeDebugLog(); $this->logger->debug($message); $this->assertFileNotExists($this->getDebuggerLogPath()); - $this->assertFalse((bool)$this->appConfig->getValue('dev/debug/debug_logging')); + $this->assertNull($this->deploymentConfig->get(ConfigOptionsList::CONFIG_PATH_DEBUG_LOGGING)); - $this->enableDebugging(); - $this->logger->debug($message); + $this->checkCommonFlow($message); + $this->reinitDeploymentConfig(); + } + + /** + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function testDebugInDeveloperMode() + { + $message = 'test message'; + $this->reinitDebugHandler(State::MODE_DEVELOPER); + $this->removeDebugLog(); + $this->logger->debug($message); $this->assertFileExists($this->getDebuggerLogPath()); $this->assertContains($message, file_get_contents($this->getDebuggerLogPath())); + $this->assertNull($this->deploymentConfig->get(ConfigOptionsList::CONFIG_PATH_DEBUG_LOGGING)); + + $this->checkCommonFlow($message); + $this->reinitDeploymentConfig(); } /** - * @return bool|string + * @return string */ private function getDebuggerLogPath() { - foreach ($this->logger->getHandlers() as $handler) { - if ($handler instanceof Debug) { - return $handler->getUrl(); + if (!$this->debugLogPath) { + foreach ($this->logger->getHandlers() as $handler) { + if ($handler instanceof Debug) { + $this->debugLogPath = $handler->getUrl(); + } } } - return false; + + return $this->debugLogPath; + } + + /** + * @throws \Magento\Framework\Exception\FileSystemException + */ + private function reinitDeploymentConfig() + { + $this->etcDirectory->delete(self::$configFile); + $this->etcDirectory->copyFile(self::$backupFile, self::$configFile); + } + + /** + * @param string $instanceMode + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function reinitDebugHandler(string $instanceMode) + { + $this->debugHandler = $this->objectManager->create( + Debug::class, + [ + 'filePath' => Bootstrap::getInstance()->getAppTempDir(), + 'state' => $this->objectManager->create( + State::class, + [ + 'mode' => $instanceMode, + ] + ), + ] + ); + $this->logger->setHandlers( + [ + $this->debugHandler, + ] + ); + } + + /** + * @return void + */ + private function detachLogger() + { + $this->debugHandler->close(); + } + + /** + * @return void + */ + private function removeDebugLog() + { + $this->detachLogger(); + if (file_exists($this->getDebuggerLogPath())) { + unlink($this->getDebuggerLogPath()); + } + } + + /** + * @param string $message + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function checkCommonFlow(string $message) + { + $this->enableDebugging(true); + $this->removeDebugLog(); + $this->logger->debug($message); + $this->assertFileExists($this->getDebuggerLogPath()); + $this->assertContains($message, file_get_contents($this->getDebuggerLogPath())); + + $this->enableDebugging(false); + $this->removeDebugLog(); + $this->logger->debug($message); + $this->assertFileNotExists($this->getDebuggerLogPath()); } } diff --git a/dev/tests/integration/testsuite/Magento/Email/Model/Template/FilterTest.php b/dev/tests/integration/testsuite/Magento/Email/Model/Template/FilterTest.php index a68b0942ec090..dd55dcc8b47c7 100644 --- a/dev/tests/integration/testsuite/Magento/Email/Model/Template/FilterTest.php +++ b/dev/tests/integration/testsuite/Magento/Email/Model/Template/FilterTest.php @@ -130,22 +130,22 @@ public function layoutDirectiveDataProvider() 'area parameter - omitted' => [ 'adminhtml', 'handle="email_template_test_handle"', - '<b>Email content for frontend/Magento/default theme</b>', + '<strong>Email content for frontend/Magento/default theme</strong>', ], 'area parameter - frontend' => [ 'adminhtml', 'handle="email_template_test_handle" area="frontend"', - '<b>Email content for frontend/Magento/default theme</b>', + '<strong>Email content for frontend/Magento/default theme</strong>', ], 'area parameter - backend' => [ 'frontend', 'handle="email_template_test_handle" area="adminhtml"', - '<b>Email content for adminhtml/Magento/default theme</b>', + '<strong>Email content for adminhtml/Magento/default theme</strong>', ], 'custom parameter' => [ 'frontend', 'handle="email_template_test_handle" template="Magento_Email::sample_email_content_custom.phtml"', - '<b>Custom Email content for frontend/Magento/default theme</b>', + '<strong>Custom Email content for frontend/Magento/default theme</strong>', ], ]; return $result; diff --git a/dev/tests/integration/testsuite/Magento/Email/Model/TemplateTest.php b/dev/tests/integration/testsuite/Magento/Email/Model/TemplateTest.php index a83de07443e95..7789a79794f39 100644 --- a/dev/tests/integration/testsuite/Magento/Email/Model/TemplateTest.php +++ b/dev/tests/integration/testsuite/Magento/Email/Model/TemplateTest.php @@ -315,25 +315,25 @@ public function templateDirectiveDataProvider() Area::AREA_FRONTEND, TemplateTypesInterface::TYPE_HTML, '{{template config_path="customer/create_account/email_template"}}', - '<b>customer_create_account_email_template template from Vendor/custom_theme</b>', + '<strong>customer_create_account_email_template template from Vendor/custom_theme</strong>', ], 'Template from parent theme - frontend' => [ Area::AREA_FRONTEND, TemplateTypesInterface::TYPE_HTML, '{{template config_path="customer/create_account/email_confirmation_template"}}', - '<b>customer_create_account_email_confirmation_template template from Vendor/default</b>', + '<strong>customer_create_account_email_confirmation_template template from Vendor/default</strong', ], 'Template from grandparent theme - frontend' => [ Area::AREA_FRONTEND, TemplateTypesInterface::TYPE_HTML, '{{template config_path="customer/create_account/email_confirmed_template"}}', - '<b>customer_create_account_email_confirmed_template template from Magento/default</b>', + '<strong>customer_create_account_email_confirmed_template template from Magento/default</strong', ], 'Template from grandparent theme - adminhtml' => [ BackendFrontNameResolver::AREA_CODE, TemplateTypesInterface::TYPE_HTML, '{{template config_path="catalog/productalert_cron/error_email_template"}}', - '<b>catalog_productalert_cron_error_email_template template from Magento/default</b>', + '<strong>catalog_productalert_cron_error_email_template template from Magento/default</strong', null, null, true, diff --git a/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/adminhtml/Magento/default/Magento_Email/templates/sample_email_content.phtml b/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/adminhtml/Magento/default/Magento_Email/templates/sample_email_content.phtml index d53468a38376f..bb0073cedee96 100644 --- a/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/adminhtml/Magento/default/Magento_Email/templates/sample_email_content.phtml +++ b/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/adminhtml/Magento/default/Magento_Email/templates/sample_email_content.phtml @@ -4,4 +4,4 @@ * See COPYING.txt for license details. */ ?> -<b>Email content for adminhtml/Magento/default theme</b> +<strong>Email content for adminhtml/Magento/default theme</strong> diff --git a/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/adminhtml/Magento/default/Magento_ProductAlert/email/cron_error.html b/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/adminhtml/Magento/default/Magento_ProductAlert/email/cron_error.html index f13e54edf93a4..d65f9d4c40877 100644 --- a/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/adminhtml/Magento/default/Magento_ProductAlert/email/cron_error.html +++ b/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/adminhtml/Magento/default/Magento_ProductAlert/email/cron_error.html @@ -4,4 +4,4 @@ * See COPYING.txt for license details. */ --> -<b>catalog_productalert_cron_error_email_template template from Magento/default</b> +<strong>catalog_productalert_cron_error_email_template template from Magento/default</strong> diff --git a/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Magento/default/Magento_Customer/email/account_new_confirmed.html b/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Magento/default/Magento_Customer/email/account_new_confirmed.html index f687fc041db1e..ffc4d8893fe98 100644 --- a/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Magento/default/Magento_Customer/email/account_new_confirmed.html +++ b/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Magento/default/Magento_Customer/email/account_new_confirmed.html @@ -4,4 +4,4 @@ * See COPYING.txt for license details. */ --> -<b>customer_create_account_email_confirmed_template template from Magento/default</b> +<strong>customer_create_account_email_confirmed_template template from Magento/default</strong> diff --git a/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Magento/default/Magento_Email/templates/sample_email_content.phtml b/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Magento/default/Magento_Email/templates/sample_email_content.phtml index acbdf16d474df..9c973818272c8 100644 --- a/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Magento/default/Magento_Email/templates/sample_email_content.phtml +++ b/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Magento/default/Magento_Email/templates/sample_email_content.phtml @@ -4,4 +4,4 @@ * See COPYING.txt for license details. */ ?> -<b>Email content for frontend/Magento/default theme</b> +<strong>Email content for frontend/Magento/default theme</strong> diff --git a/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Magento/default/Magento_Email/templates/sample_email_content_custom.phtml b/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Magento/default/Magento_Email/templates/sample_email_content_custom.phtml index 1730bf904bb34..4ed5685ee0106 100644 --- a/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Magento/default/Magento_Email/templates/sample_email_content_custom.phtml +++ b/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Magento/default/Magento_Email/templates/sample_email_content_custom.phtml @@ -4,4 +4,4 @@ * See COPYING.txt for license details. */ ?> -<b>Custom Email content for frontend/Magento/default theme</b> +<strong>Custom Email content for frontend/Magento/default theme</strong> diff --git a/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Vendor/custom_theme/Magento_Customer/email/account_new.html b/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Vendor/custom_theme/Magento_Customer/email/account_new.html index 7e8f9bd1b12b6..46257060f8284 100644 --- a/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Vendor/custom_theme/Magento_Customer/email/account_new.html +++ b/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Vendor/custom_theme/Magento_Customer/email/account_new.html @@ -4,4 +4,4 @@ * See COPYING.txt for license details. */ --> -<b>customer_create_account_email_template template from Vendor/custom_theme</b> +<strong>customer_create_account_email_template template from Vendor/custom_theme</strong> diff --git a/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Vendor/default/Magento_Customer/email/account_new_confirmation.html b/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Vendor/default/Magento_Customer/email/account_new_confirmation.html index c5801b6557a61..9c52c5a1b38cf 100644 --- a/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Vendor/default/Magento_Customer/email/account_new_confirmation.html +++ b/dev/tests/integration/testsuite/Magento/Email/Model/_files/design/frontend/Vendor/default/Magento_Customer/email/account_new_confirmation.html @@ -4,4 +4,4 @@ * See COPYING.txt for license details. */ --> -<b>customer_create_account_email_confirmation_template template from Vendor/default</b> +<strong>customer_create_account_email_confirmation_template template from Vendor/default</strong> diff --git a/dev/tests/integration/testsuite/Magento/Framework/App/Filesystem/DirectoryResolverTest.php b/dev/tests/integration/testsuite/Magento/Framework/App/Filesystem/DirectoryResolverTest.php index 278c08fbd2b07..4502134d45cc1 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/App/Filesystem/DirectoryResolverTest.php +++ b/dev/tests/integration/testsuite/Magento/Framework/App/Filesystem/DirectoryResolverTest.php @@ -6,6 +6,7 @@ namespace Magento\Framework\App\Filesystem; +use Magento\Framework\App\Filesystem\DirectoryList; use Magento\TestFramework\Helper\Bootstrap; /** @@ -24,9 +25,9 @@ class DirectoryResolverTest extends \PHPUnit\Framework\TestCase private $directoryResolver; /** - * @var \Magento\Framework\Filesystem\Directory\WriteInterface + * @var \Magento\Framework\Filesystem */ - private $directory; + private $filesystem; /** * @inheritdoc @@ -36,9 +37,7 @@ protected function setUp() $this->objectManager = Bootstrap::getObjectManager(); $this->directoryResolver = $this->objectManager ->create(\Magento\Framework\App\Filesystem\DirectoryResolver::class); - /** @var \Magento\Framework\Filesystem $filesystem */ - $filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); - $this->directory = $filesystem->getDirectoryWrite(\Magento\Framework\App\Filesystem\DirectoryList::MEDIA); + $this->filesystem = $this->objectManager->create(\Magento\Framework\Filesystem::class); } /** @@ -51,7 +50,8 @@ protected function setUp() */ public function testValidatePath($path, $directoryConfig, $expectation) { - $path = $this->directory->getAbsolutePath($path); + $directory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA); + $path = $directory->getAbsolutePath() .'/' .$path; $this->assertEquals($expectation, $this->directoryResolver->validatePath($path, $directoryConfig)); } @@ -62,7 +62,8 @@ public function testValidatePath($path, $directoryConfig, $expectation) */ public function testValidatePathWithException() { - $path = $this->directory->getAbsolutePath(); + $directory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA); + $path = $directory->getAbsolutePath(); $this->directoryResolver->validatePath($path, 'wrong_dir'); } diff --git a/dev/tests/integration/testsuite/Magento/Framework/Code/File/Validator/NotProtectedExtensionTest.php b/dev/tests/integration/testsuite/Magento/Framework/Code/File/Validator/NotProtectedExtensionTest.php new file mode 100644 index 0000000000000..265958f28d641 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Framework/Code/File/Validator/NotProtectedExtensionTest.php @@ -0,0 +1,42 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Framework\Code\File\Validator; + +use Magento\TestFramework\Helper\Bootstrap; + +/** + * Tests protected extensions. + */ +class NotProtectedExtensionTest extends \PHPUnit\Framework\TestCase +{ + /** + * Tests that phpt, pht are invalid extension types. + * + * @dataProvider isValidDataProvider + * @param string $extension + * @return void + */ + public function testIsValid(string $extension) + { + $objectManager = Bootstrap::getObjectManager(); + /** @var \Magento\MediaStorage\Model\File\Validator\NotProtectedExtension $model */ + $model = $objectManager->create(\Magento\MediaStorage\Model\File\Validator\NotProtectedExtension::class); + $this->assertFalse($model->isValid($extension)); + } + + /** + * @return array + */ + public function isValidDataProvider(): array + { + return [ + ['phpt'], + ['pht'], + ]; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Framework/DB/Adapter/Pdo/MysqlTest.php b/dev/tests/integration/testsuite/Magento/Framework/DB/Adapter/Pdo/MysqlTest.php index cf3b9f05cbe0f..403c45dde71a3 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/DB/Adapter/Pdo/MysqlTest.php +++ b/dev/tests/integration/testsuite/Magento/Framework/DB/Adapter/Pdo/MysqlTest.php @@ -8,6 +8,7 @@ use Magento\Framework\App\ResourceConnection; use Magento\TestFramework\Helper\CacheCleaner; use Magento\Framework\DB\Ddl\Table; +use Magento\TestFramework\Helper\Bootstrap; class MysqlTest extends \PHPUnit\Framework\TestCase { @@ -19,7 +20,7 @@ class MysqlTest extends \PHPUnit\Framework\TestCase protected function setUp() { set_error_handler(null); - $this->resourceConnection = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + $this->resourceConnection = Bootstrap::getObjectManager() ->get(ResourceConnection::class); CacheCleaner::cleanAll(); } @@ -40,7 +41,6 @@ public function testWaitTimeout() $this->markTestSkipped('This test is for \Magento\Framework\DB\Adapter\Pdo\Mysql'); } try { - $defaultWaitTimeout = $this->getWaitTimeout(); $minWaitTimeout = 1; $this->setWaitTimeout($minWaitTimeout); $this->assertEquals($minWaitTimeout, $this->getWaitTimeout(), 'Wait timeout was not changed'); @@ -49,17 +49,8 @@ public function testWaitTimeout() sleep($minWaitTimeout + 1); $result = $this->executeQuery('SELECT 1'); $this->assertInstanceOf(\Magento\Framework\DB\Statement\Pdo\Mysql::class, $result); - // Restore wait_timeout - $this->setWaitTimeout($defaultWaitTimeout); - $this->assertEquals( - $defaultWaitTimeout, - $this->getWaitTimeout(), - 'Default wait timeout was not restored' - ); - } catch (\Exception $e) { - // Reset connection on failure to restore global variables + } finally { $this->getDbAdapter()->closeConnection(); - throw $e; } } @@ -87,30 +78,14 @@ private function setWaitTimeout($waitTimeout) /** * Execute SQL query and return result statement instance * - * @param string $sql - * @return \Zend_Db_Statement_Interface - * @throws \Exception + * @param $sql + * @return void|\Zend_Db_Statement_Pdo + * @throws \Magento\Framework\Exception\LocalizedException + * @throws \Zend_Db_Adapter_Exception */ private function executeQuery($sql) { - /** - * Suppress PDO warnings to work around the bug https://bugs.php.net/bug.php?id=63812 - */ - $phpErrorReporting = error_reporting(); - /** @var $pdoConnection \PDO */ - $pdoConnection = $this->getDbAdapter()->getConnection(); - $pdoWarningsEnabled = $pdoConnection->getAttribute(\PDO::ATTR_ERRMODE) & \PDO::ERRMODE_WARNING; - if (!$pdoWarningsEnabled) { - error_reporting($phpErrorReporting & ~E_WARNING); - } - try { - $result = $this->getDbAdapter()->query($sql); - error_reporting($phpErrorReporting); - } catch (\Exception $e) { - error_reporting($phpErrorReporting); - throw $e; - } - return $result; + return $this->getDbAdapter()->query($sql); } /** diff --git a/dev/tests/integration/testsuite/Magento/Framework/Filesystem/Directory/ReadTest.php b/dev/tests/integration/testsuite/Magento/Framework/Filesystem/Directory/ReadTest.php index f43a523a12ed0..d8367ca79aee0 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/Filesystem/Directory/ReadTest.php +++ b/dev/tests/integration/testsuite/Magento/Framework/Filesystem/Directory/ReadTest.php @@ -7,6 +7,7 @@ */ namespace Magento\Framework\Filesystem\Directory; +use Magento\Framework\Exception\ValidatorException; use Magento\TestFramework\Helper\Bootstrap; /** @@ -34,13 +35,57 @@ public function testGetAbsolutePath() $this->assertContains('_files/foo/bar', $dir->getAbsolutePath('bar')); } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testGetAbsolutePathOutside(string $path) + { + $dir = $this->getDirectoryInstance('foo'); + + $dir->getAbsolutePath($path . '/ReadTest.php'); + } + + /** + * @return array + */ + public function pathDataProvider(): array + { + return [ + ['../../Directory'], + ['//./..///../Directory'], + ['\..\..\Directory'], + ]; + } + public function testGetRelativePath() { $dir = $this->getDirectoryInstance('foo'); + $this->assertEquals( + 'file_three.txt', + $dir->getRelativePath('file_three.txt') + ); $this->assertEquals('', $dir->getRelativePath()); $this->assertEquals('bar', $dir->getRelativePath(__DIR__ . '/../_files/foo/bar')); } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testGetRelativePathOutside(string $path) + { + $dir = $this->getDirectoryInstance('foo'); + + $dir->getRelativePath(__DIR__ . $path . '/ReadTest.php'); + } + /** * Test for read method * @@ -72,6 +117,20 @@ public function readProvider() ]; } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testReadOutside(string $path) + { + $dir = $this->getDirectoryInstance('foo'); + + $dir->read($path . '/ReadTest.php'); + } + /** * Test for search method * @@ -103,6 +162,20 @@ public function searchProvider() ]; } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testSearchOutside(string $path) + { + $dir = $this->getDirectoryInstance('foo'); + + $dir->search('/*/*.txt', $path . '/ReadTest.php'); + } + /** * Test for isExist method * @@ -127,6 +200,20 @@ public function existsProvider() return [['foo', 'bar', true], ['foo', 'bar/baz/', true], ['foo', 'bar/notexists', false]]; } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testIsExistOutside(string $path) + { + $dir = $this->getDirectoryInstance('foo'); + + $dir->isExist($path . '/ReadTest.php'); + } + /** * Test for stat method * @@ -168,6 +255,20 @@ public function statProvider() return [['foo', 'bar'], ['foo', 'file_three.txt']]; } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testStatOutside(string $path) + { + $dir = $this->getDirectoryInstance('foo'); + + $dir->stat('bar/' . $path); + } + /** * Test for isReadable method * @@ -182,6 +283,20 @@ public function testIsReadable($dirPath, $path, $readable) $this->assertEquals($readable, $dir->isReadable($path)); } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testIsReadableOutside(string $path) + { + $dir = $this->getDirectoryInstance('foo'); + + $dir->isReadable($path . '/ReadTest.php'); + } + /** * Test for isFile method * @@ -194,6 +309,20 @@ public function testIsFile($path, $isFile) $this->assertEquals($isFile, $this->getDirectoryInstance('foo')->isFile($path)); } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testIsFileOutside(string $path) + { + $dir = $this->getDirectoryInstance('foo'); + + $dir->isFile($path . '/ReadTest.php'); + } + /** * Test for isDirectory method * @@ -206,6 +335,20 @@ public function testIsDirectory($path, $isDirectory) $this->assertEquals($isDirectory, $this->getDirectoryInstance('foo')->isDirectory($path)); } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testIsDirectoryOutside(string $path) + { + $dir = $this->getDirectoryInstance('foo'); + + $dir->isDirectory($path . '/ReadTest.php'); + } + /** * Data provider for testIsReadable * @@ -246,6 +389,20 @@ public function testOpenFile() $this->assertTrue($file instanceof \Magento\Framework\Filesystem\File\ReadInterface); } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testOpenFileOutside(string $path) + { + $dir = $this->getDirectoryInstance('foo'); + + $dir->openFile($path . '/ReadTest.php'); + } + /** * Test readFile * @@ -268,10 +425,27 @@ public function readFileProvider() { return [ ['popup.csv', 'var myData = 5;'], - ['data.csv', '"field1", "field2"' . "\n" . '"field3", "field4"' . "\n"] + [ + 'data.csv', + '"field1", "field2"' . PHP_EOL . '"field3", "field4"' . PHP_EOL, + ], ]; } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testReadFileOutside(string $path) + { + $dir = $this->getDirectoryInstance('foo'); + + $dir->readFile($path . '/ReadTest.php'); + } + /** * Get readable file instance * Get full path for files located in _files directory @@ -301,4 +475,18 @@ public function testReadRecursively() sort($expected); $this->assertEquals($expected, $actual); } + + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testReadRecursivelyOutside(string $path) + { + $dir = $this->getDirectoryInstance('foo'); + + $dir->readRecursively($path); + } } diff --git a/dev/tests/integration/testsuite/Magento/Framework/Filesystem/Directory/WriteTest.php b/dev/tests/integration/testsuite/Magento/Framework/Filesystem/Directory/WriteTest.php index 380c7998680a1..49ff3965e1ec0 100644 --- a/dev/tests/integration/testsuite/Magento/Framework/Filesystem/Directory/WriteTest.php +++ b/dev/tests/integration/testsuite/Magento/Framework/Filesystem/Directory/WriteTest.php @@ -7,6 +7,7 @@ */ namespace Magento\Framework\Filesystem\Directory; +use Magento\Framework\Exception\ValidatorException; use Magento\Framework\Filesystem\DriverPool; use Magento\TestFramework\Helper\Bootstrap; @@ -63,6 +64,20 @@ public function createProvider() ]; } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testCreateOutside(string $path) + { + $dir = $this->getDirectoryInstance('newDir1', 0777); + + $dir->create($path); + } + /** * Test for delete method * @@ -88,6 +103,32 @@ public function deleteProvider() return [['subdir'], ['subdir/subsubdir']]; } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testDeleteOutside(string $path) + { + $dir = $this->getDirectoryInstance('newDir1', 0777); + + $dir->delete($path); + } + + /** + * @return array + */ + public function pathDataProvider(): array + { + return [ + ['../../Directory'], + ['//./..///../Directory'], + ['\..\..\Directory'], + ]; + } + /** * Test for rename method (in scope of one directory instance) * @@ -119,6 +160,20 @@ public function renameProvider() return [['newDir1', 0777, 'first_name.txt', 'second_name.txt']]; } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testRenameOutside(string $path) + { + $dir = $this->getDirectoryInstance('newDir1', 0777); + + $dir->renameFile($path . '/ReadTest.php', 'RenamedTest'); + } + /** * Test for rename method (moving to new directory instance) * @@ -185,6 +240,35 @@ public function copyProvider() ]; } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testCopyFromOutside(string $path) + { + $dir = $this->getDirectoryInstance('newDir1', 0777); + + $dir->copyFile($path . '/ReadTest.php', 'CopiedTest'); + } + + /** + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + */ + public function testCopyToOutside() + { + $dir = $this->getDirectoryInstance('newDir1', 0777); + $dir->touch('test_file_for_copy_outside.txt'); + + $dir->copyFile( + 'test_file_for_copy_outside.txt', + '../../Directory/copied_outside.txt' + ); + } + /** * Test for copy method (copy to another directory instance) * @@ -231,6 +315,20 @@ public function testChangePermissions() $this->assertTrue($directory->changePermissions('test_directory', 0644)); } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testChangePermissionsOutside(string $path) + { + $dir = $this->getDirectoryInstance('newDir1', 0777); + + $dir->changePermissions($path, 0777); + } + /** * Test for changePermissionsRecursively method */ @@ -244,6 +342,20 @@ public function testChangePermissionsRecursively() $this->assertTrue($directory->changePermissionsRecursively('test_directory', 0777, 0644)); } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testChangePermissionsRecursivelyOutside(string $path) + { + $dir = $this->getDirectoryInstance('newDir1', 0777); + + $dir->changePermissionsRecursively($path, 0777, 0777); + } + /** * Test for touch method * @@ -274,6 +386,20 @@ public function touchProvider() ]; } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testTouchOutside(string $path) + { + $dir = $this->getDirectoryInstance('newDir1', 0777); + + $dir->touch($path . '/foo.txt'); + } + /** * Test isWritable method */ @@ -285,6 +411,23 @@ public function testIsWritable() $this->assertTrue($directory->isWritable('bar')); } + /** + * @return void + */ + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testIsWritableOutside(string $path) + { + $dir = $this->getDirectoryInstance('newDir1', 0777); + + $dir->isWritable($path); + } + /** * Test for openFile method * @@ -315,6 +458,20 @@ public function openFileProvider() ]; } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testOpenFileOutside(string $path) + { + $dir = $this->getDirectoryInstance('newDir1', 0777); + + $dir->openFile($path . '/ReadTest.php'); + } + /** * Test writeFile * @@ -359,6 +516,20 @@ public function writeFileProvider() return [['file1', '123', '456'], ['folder1/file1', '123', '456']]; } + /** + * @param string $path + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @dataProvider pathDataProvider + */ + public function testWriteFileOutside(string $path) + { + $dir = $this->getDirectoryInstance('newDir1', 0777); + + $dir->writeFile($path . '/ReadTest.php', 'tst'); + } + /** * Tear down */ diff --git a/dev/tests/integration/testsuite/Magento/ImportExport/Controller/Adminhtml/Import/ValidateTest.php b/dev/tests/integration/testsuite/Magento/ImportExport/Controller/Adminhtml/Import/ValidateTest.php index 552a23a0c1fd5..aab94361f752c 100644 --- a/dev/tests/integration/testsuite/Magento/ImportExport/Controller/Adminhtml/Import/ValidateTest.php +++ b/dev/tests/integration/testsuite/Magento/ImportExport/Controller/Adminhtml/Import/ValidateTest.php @@ -17,12 +17,14 @@ class ValidateTest extends \Magento\TestFramework\TestCase\AbstractBackendContro /** * @dataProvider validationDataProvider * @param string $fileName + * @param string $mimeType * @param string $message * @param string $delimiter * @backupGlobals enabled * @magentoDbIsolation enabled + * @SuppressWarnings(PHPMD.Superglobals) */ - public function testValidationReturn($fileName, $message, $delimiter) + public function testValidationReturn(string $fileName, string $mimeType, string $message, string $delimiter) { $validationStrategy = ProcessingErrorAggregatorInterface::VALIDATION_STRATEGY_STOP_ON_ERROR; @@ -50,7 +52,7 @@ public function testValidationReturn($fileName, $message, $delimiter) $_FILES = [ 'import_file' => [ 'name' => $fileName, - 'type' => 'text/csv', + 'type' => $mimeType, 'tmp_name' => $target, 'error' => 0, 'size' => filesize($target) @@ -84,24 +86,34 @@ public function validationDataProvider() return [ [ 'file_name' => 'catalog_product.csv', + 'mime-type' => 'text/csv', 'message' => 'File is valid', 'delimiter' => ',', ], [ 'file_name' => 'test.txt', + 'mime-type' => 'text/csv', 'message' => '\'txt\' file extension is not supported', 'delimiter' => ',', ], [ 'file_name' => 'incorrect_catalog_product_comma.csv', + 'mime-type' => 'text/csv', 'message' => 'Download full report', 'delimiter' => ',', ], [ 'file_name' => 'incorrect_catalog_product_semicolon.csv', + 'mime-type' => 'text/csv', 'message' => 'Download full report', 'delimiter' => ';', ], + [ + 'file_name' => 'catalog_product.zip', + 'mime-type' => 'application/zip', + 'message' => 'File is valid', + 'delimiter' => ',', + ], ]; } } diff --git a/dev/tests/integration/testsuite/Magento/ImportExport/Controller/Adminhtml/Import/_files/catalog_product.zip b/dev/tests/integration/testsuite/Magento/ImportExport/Controller/Adminhtml/Import/_files/catalog_product.zip new file mode 100644 index 0000000000000..812beae22b786 Binary files /dev/null and b/dev/tests/integration/testsuite/Magento/ImportExport/Controller/Adminhtml/Import/_files/catalog_product.zip differ diff --git a/dev/tests/integration/testsuite/Magento/Newsletter/Controller/SubscriberTest.php b/dev/tests/integration/testsuite/Magento/Newsletter/Controller/SubscriberTest.php index 5347881f5e7d4..e13af108e6fa5 100644 --- a/dev/tests/integration/testsuite/Magento/Newsletter/Controller/SubscriberTest.php +++ b/dev/tests/integration/testsuite/Magento/Newsletter/Controller/SubscriberTest.php @@ -53,11 +53,10 @@ public function testNewActionUsedEmail() $this->getRequest()->setPostValue([ 'email' => 'customer@example.com', ]); - $this->dispatch('newsletter/subscriber/new'); $this->assertSessionMessages($this->equalTo([ - 'There was a problem with the subscription: This email address is already assigned to another user.', + 'Thank you for your subscription.', ])); $this->assertRedirect($this->anything()); } diff --git a/dev/tests/integration/testsuite/Magento/Newsletter/Model/SubscriberTest.php b/dev/tests/integration/testsuite/Magento/Newsletter/Model/SubscriberTest.php index c6ba498319ff8..6c7aa204fded2 100644 --- a/dev/tests/integration/testsuite/Magento/Newsletter/Model/SubscriberTest.php +++ b/dev/tests/integration/testsuite/Magento/Newsletter/Model/SubscriberTest.php @@ -8,6 +8,11 @@ class SubscriberTest extends \PHPUnit\Framework\TestCase { + /** + * @var \Magento\TestFramework\ObjectManager + */ + private $objectManager; + /** * @var Subscriber */ @@ -15,7 +20,8 @@ class SubscriberTest extends \PHPUnit\Framework\TestCase protected function setUp() { - $this->_model = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( + $this->objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + $this->_model = $this->objectManager->create( \Magento\Newsletter\Model\Subscriber::class ); } @@ -27,7 +33,7 @@ protected function setUp() public function testEmailConfirmation() { $this->_model->subscribe('customer_confirm@example.com'); - $transportBuilder = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + $transportBuilder = $this->objectManager ->get(\Magento\TestFramework\Mail\Template\TransportBuilderMock::class); // confirmationCode 'ysayquyajua23iq29gxwu2eax2qb6gvy' is taken from fixture $this->assertContains( @@ -77,4 +83,47 @@ public function testUnsubscribeSubscribeByCustomerId() $this->assertSame($this->_model, $this->_model->subscribeCustomerById(1)); $this->assertEquals(Subscriber::STATUS_SUBSCRIBED, $this->_model->getSubscriberStatus()); } + + /** + * @magentoDataFixture Magento/Store/_files/second_store.php + */ + public function testSubscribeGuestRegisterCustomerWithSameEmailFromDifferentStore() + { + /** @var \Magento\Store\Model\Store $store */ + $store = $this->objectManager->create(\Magento\Store\Model\Store::class); + $defaultStoreId = $store->load('default', 'code')->getId(); + $secondStoreId = $store->load('fixture_second_store', 'code')->getId(); + + $email = 'test@example.com'; + + // Subscribing guest to a newsletter from default store + $this->_model->setStoreId($defaultStoreId) + ->setCustomerId(0) + ->setSubscriberEmail($email) + ->setSubscriberStatus(\Magento\Newsletter\Model\Subscriber::STATUS_SUBSCRIBED) + ->save(); + + // Registering customer with the same email from second store + $customer = $this->objectManager->create( + \Magento\Customer\Model\Data\Customer::class, + [ + 'data' => [ + \Magento\Customer\Model\Data\Customer::FIRSTNAME => 'John', + \Magento\Customer\Model\Data\Customer::LASTNAME => 'Doe', + \Magento\Customer\Model\Data\Customer::EMAIL => $email, + \Magento\Customer\Model\Data\Customer::STORE_ID => $secondStoreId, + ] + ] + ); + + /** @var \Magento\Customer\Api\AccountManagementInterface $accountManagement */ + $accountManagement = $this->objectManager->get(\Magento\Customer\Api\AccountManagementInterface::class); + $customer = $accountManagement->createAccount($customer); + + /** @var \Magento\Newsletter\Model\ResourceModel\Subscriber\Collection $subscribers */ + $subscribers = $this->_model->getCollection()->addFieldToFilter('subscriber_email', $email); + + $this->assertCount(1, $subscribers->getItems()); + $this->assertEquals($subscribers->getFirstItem()->getCustomerId(), $customer->getId()); + } } diff --git a/dev/tests/integration/testsuite/Magento/Paypal/Controller/ExpressTest.php b/dev/tests/integration/testsuite/Magento/Paypal/Controller/ExpressTest.php index 95e3abbfe6ff1..d10befc3a811c 100644 --- a/dev/tests/integration/testsuite/Magento/Paypal/Controller/ExpressTest.php +++ b/dev/tests/integration/testsuite/Magento/Paypal/Controller/ExpressTest.php @@ -226,4 +226,42 @@ public function testReturnAction() $this->_objectManager->removeSharedInstance(ApiFactory::class); $this->_objectManager->removeSharedInstance(PaypalSession::class); } + + /** + * @magentoConfigFixture current_store carriers/freeshipping/active 1 + * @magentoDataFixture Magento/Sales/_files/quote.php + * @magentoDataFixture Magento/Paypal/_files/quote_payment.php + * @return void + */ + public function testPlaceOrderZeroGrandTotal() + { + /** @var Quote $quote */ + $quote = $this->_objectManager->create(Quote::class); + $quote->load('test01', 'reserved_order_id'); + $quote->getShippingAddress()->setShippingMethod('freeshipping'); + $quote->getShippingAddress()->setCollectShippingRates(true); + /** @var \Magento\Quote\Model\Quote\Item[] $items */ + $items = $quote->getItemsCollection()->getItems(); + $quoteItem = reset($items); + /** @var \Magento\Quote\Model\Quote\Item\Updater $quoteItemUpdater */ + $quoteItemUpdater = $this->_objectManager->get(\Magento\Quote\Model\Quote\Item\Updater::class); + $quoteItemUpdater->update($quoteItem, ['qty' => 1, 'custom_price' => 0]); + $quote->setTotalsCollectedFlag(false)->collectTotals()->save(); + + $this->_objectManager->get(Session::class)->setQuoteId($quote->getId()); + + $this->dispatch('paypal/express/placeOrder'); + $this->assertSessionMessages( + $this->equalTo( + [ + htmlspecialchars( + (string)__('PayPal can\'t process orders with a zero balance due. ' + . 'To finish your purchase, please go through the standard checkout process.'), + ENT_QUOTES + ) + ] + ), + \Magento\Framework\Message\MessageInterface::TYPE_ERROR + ); + } } diff --git a/dev/tests/integration/testsuite/Magento/Paypal/Model/IpnTest.php b/dev/tests/integration/testsuite/Magento/Paypal/Model/IpnTest.php index 6b92338e9932a..3c126bcdc2c5b 100644 --- a/dev/tests/integration/testsuite/Magento/Paypal/Model/IpnTest.php +++ b/dev/tests/integration/testsuite/Magento/Paypal/Model/IpnTest.php @@ -64,7 +64,7 @@ public function testProcessIpnRequestFullRefund() $creditmemoItems = $order->getCreditmemosCollection()->getItems(); $creditmemo = current($creditmemoItems); - $this->assertEquals(Order::STATE_PROCESSING, $order->getState()); + $this->assertEquals(Order::STATE_CLOSED, $order->getState()); $this->assertEquals(1, count($creditmemoItems)); $this->assertEquals(Creditmemo::STATE_REFUNDED, $creditmemo->getState()); $this->assertEquals(10, $order->getSubtotalRefunded()); @@ -146,7 +146,7 @@ public function testProcessIpnRequestRestRefund() $creditmemoItems = $order->getCreditmemosCollection()->getItems(); - $this->assertEquals(Order::STATE_PROCESSING, $order->getState()); + $this->assertEquals(Order::STATE_CLOSED, $order->getState()); $this->assertEquals(1, count($creditmemoItems)); $this->assertEquals(10, $order->getSubtotalRefunded()); $this->assertEquals(10, $order->getBaseSubtotalRefunded()); diff --git a/dev/tests/integration/testsuite/Magento/Persistent/Block/Header/AdditionalTest.php b/dev/tests/integration/testsuite/Magento/Persistent/Block/Header/AdditionalTest.php index fba5354b691d6..2e447e7854a7c 100644 --- a/dev/tests/integration/testsuite/Magento/Persistent/Block/Header/AdditionalTest.php +++ b/dev/tests/integration/testsuite/Magento/Persistent/Block/Header/AdditionalTest.php @@ -56,8 +56,8 @@ public function testToHtml() $this->_customerSession->loginById(1); $translation = __('Not you?'); - $this->assertStringMatchesFormat( - '%A<span>%A<a%Ahref="' . $this->_block->getHref() . '"%A>' . $translation . '</a>%A</span>%A', + $this->assertContains( + '<a href="' . $this->_block->getHref() . '">' . $translation . '</a>', $this->_block->toHtml() ); $this->_customerSession->logout(); diff --git a/dev/tests/integration/testsuite/Magento/Persistent/Model/ObserverTest.php b/dev/tests/integration/testsuite/Magento/Persistent/Model/ObserverTest.php index ecf2cd77a13ff..d0c253fc7a64b 100644 --- a/dev/tests/integration/testsuite/Magento/Persistent/Model/ObserverTest.php +++ b/dev/tests/integration/testsuite/Magento/Persistent/Model/ObserverTest.php @@ -9,7 +9,6 @@ use Magento\Customer\Model\Context; /** - * @magentoDataFixture Magento/Persistent/_files/persistent.php * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class ObserverTest extends \PHPUnit\Framework\TestCase @@ -29,11 +28,6 @@ class ObserverTest extends \PHPUnit\Framework\TestCase */ protected $customerRepository; - /** - * @var \Magento\Persistent\Helper\Session - */ - protected $_persistentSessionHelper; - /** * @var \Magento\Framework\ObjectManagerInterface */ @@ -44,11 +38,6 @@ class ObserverTest extends \PHPUnit\Framework\TestCase */ protected $_observer; - /** - * @var \Magento\Customer\Model\Session - */ - protected $_customerSession; - /** * @var \Magento\Checkout\Model\Session | \PHPUnit_Framework_MockObject_MockObject */ @@ -58,8 +47,6 @@ public function setUp() { $this->_objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); - $this->_customerSession = $this->_objectManager->get(\Magento\Customer\Model\Session::class); - $this->_customerViewHelper = $this->_objectManager->create( \Magento\Customer\Helper\View::class ); @@ -75,8 +62,6 @@ public function setUp() \Magento\Checkout\Model\Session::class )->disableOriginalConstructor()->setMethods([])->getMock(); - $this->_persistentSessionHelper = $this->_objectManager->create(\Magento\Persistent\Helper\Session::class); - $this->_observer = $this->_objectManager->create( \Magento\Persistent\Model\Observer::class, [ @@ -89,16 +74,11 @@ public function setUp() } /** - * @magentoConfigFixture current_store persistent/options/enabled 1 - * @magentoConfigFixture current_store persistent/options/remember_enabled 1 - * @magentoConfigFixture current_store persistent/options/remember_default 1 * @magentoAppArea frontend * @magentoAppIsolation enabled */ public function testEmulateWelcomeBlock() { - $this->_customerSession->loginById(1); - $httpContext = new \Magento\Framework\App\Http\Context(); $httpContext->setValue(Context::CONTEXT_AUTH, 1, 1); $block = $this->_objectManager->create( @@ -108,15 +88,7 @@ public function testEmulateWelcomeBlock() ] ); $this->_observer->emulateWelcomeBlock($block); - $customerName = $this->_escaper->escapeHtml( - $this->_customerViewHelper->getCustomerName( - $this->customerRepository->getById( - $this->_persistentSessionHelper->getSession()->getCustomerId() - ) - ) - ); - $translation = __('Welcome, %1!', $customerName)->__toString(); - $this->assertStringMatchesFormat('%A' . $translation . '%A', $block->getWelcome()->__toString()); - $this->_customerSession->logout(); + + $this->assertEquals(' ', $block->getWelcome()); } } diff --git a/dev/tests/integration/testsuite/Magento/Quote/Model/Quote/Item/UpdaterTest.php b/dev/tests/integration/testsuite/Magento/Quote/Model/Quote/Item/UpdaterTest.php new file mode 100644 index 0000000000000..137d3347653bc --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Quote/Model/Quote/Item/UpdaterTest.php @@ -0,0 +1,60 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +namespace Magento\Quote\Model\Quote\Item; + +use Magento\Quote\Api\CartItemRepositoryInterface; +use Magento\Quote\Api\Data\CartItemInterface; +use Magento\Quote\Model\Quote; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Quote\Model\Quote\Item\Updater; + +/** + * Tests \Magento\Quote\Model\Quote\Item\Updater + */ +class UpdaterTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var Updater + */ + private $updater; + + /** + * @inheritdoc + */ + protected function setUp() + { + parent::setUp(); + $this->updater = Bootstrap::getObjectManager()->create(Updater::class); + } + + /** + * @magentoDataFixture Magento/Sales/_files/quote_with_custom_price.php + * @return void + */ + public function testUpdate() + { + /** @var CartItemRepositoryInterface $quoteItemRepository */ + $quoteItemRepository = Bootstrap::getObjectManager()->create(CartItemRepositoryInterface::class); + /** @var Quote $quote */ + $quote = Bootstrap::getObjectManager()->create(Quote::class); + $quoteId = $quote->load('test01', 'reserved_order_id')->getId(); + /** @var CartItemInterface[] $quoteItems */ + $quoteItems = $quoteItemRepository->getList($quoteId); + /** @var CartItemInterface $actualQuoteItem */ + $actualQuoteItem = array_pop($quoteItems); + $this->assertInstanceOf(CartItemInterface::class, $actualQuoteItem); + + $info = [ + 'qty' => 1, + ]; + $this->updater->update($actualQuoteItem, $info); + + $this->assertNull( + $actualQuoteItem->getCustomPrice(), + 'Item custom price has to be null' + ); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Review/Controller/CaseCheckAddingProductReviewTest.php b/dev/tests/integration/testsuite/Magento/Review/Controller/CaseCheckAddingProductReviewTest.php new file mode 100644 index 0000000000000..49e980ed53602 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Review/Controller/CaseCheckAddingProductReviewTest.php @@ -0,0 +1,120 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +namespace Magento\Review\Controller; + +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Framework\Data\Form\FormKey; +use Magento\Framework\Message\MessageInterface; +use Magento\TestFramework\Request; +use Magento\TestFramework\TestCase\AbstractController; + +/** + * Test review product controller behavior + * + * @magentoAppArea frontend + */ +class CaseCheckAddingProductReviewTest extends AbstractController +{ + /** + * Test adding a review for allowed guests with incomplete data by a not logged in user + * + * @magentoDbIsolation enabled + * @magentoAppIsolation enabled + * @magentoDataFixture Magento/Review/_files/config.php + * @magentoDataFixture Magento/Catalog/_files/products.php + */ + public function testAttemptForGuestToAddReviewsWithIncompleteData() + { + $product = $this->getProduct(); + /** @var FormKey $formKey */ + $formKey = $this->_objectManager->get(FormKey::class); + $post = [ + 'nickname' => 'Test nick', + 'title' => 'Summary', + 'form_key' => $formKey->getFormKey(), + ]; + $this->prepareRequestData($post); + $this->dispatch('review/product/post/id/' . $product->getId()); + $this->assertSessionMessages( + $this->equalTo(['Please enter a review.']), + MessageInterface::TYPE_ERROR + ); + } + + /** + * Test adding a review for not allowed guests by a guest + * + * @magentoDbIsolation enabled + * @magentoAppIsolation enabled + * @magentoDataFixture Magento/Review/_files/disable_config.php + * @magentoDataFixture Magento/Catalog/_files/products.php + */ + public function testAttemptForGuestToAddReview() + { + $product = $this->getProduct(); + /** @var FormKey $formKey */ + $formKey = $this->_objectManager->get(FormKey::class); + $post = [ + 'nickname' => 'Test nick', + 'title' => 'Summary', + 'detail' => 'Test Details', + 'form_key' => $formKey->getFormKey(), + ]; + + $this->prepareRequestData($post); + $this->dispatch('review/product/post/id/' . $product->getId()); + + $this->assertRedirect($this->stringContains('customer/account/login')); + } + + /** + * Test successfully adding a product review by a guest + * + * @magentoDbIsolation enabled + * @magentoAppIsolation enabled + * @magentoDataFixture Magento/Review/_files/config.php + * @magentoDataFixture Magento/Catalog/_files/products.php + */ + public function testSuccessfullyAddingProductReviewForGuest() + { + $product = $this->getProduct(); + /** @var FormKey $formKey */ + $formKey = $this->_objectManager->get(FormKey::class); + $post = [ + 'nickname' => 'Test nick', + 'title' => 'Summary', + 'detail' => 'Test Details', + 'form_key' => $formKey->getFormKey(), + ]; + + $this->prepareRequestData($post); + $this->dispatch('review/product/post/id/' . $product->getId()); + + $this->assertSessionMessages( + $this->equalTo(['You submitted your review for moderation.']), + MessageInterface::TYPE_SUCCESS + ); + } + + /** + * @return ProductInterface + */ + private function getProduct() + { + return $this->_objectManager->get(ProductRepositoryInterface::class)->get('custom-design-simple-product'); + } + + /** + * @param array $postData + * @return void + */ + private function prepareRequestData($postData) + { + $this->getRequest()->setMethod(Request::METHOD_POST); + $this->getRequest()->setPostValue($postData); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Review/_files/disable_config.php b/dev/tests/integration/testsuite/Magento/Review/_files/disable_config.php new file mode 100644 index 0000000000000..ee21150bd6129 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Review/_files/disable_config.php @@ -0,0 +1,15 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +/** @var Value $config */ +use Magento\Framework\App\Config\Value; + +$config = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(Value::class); +$config->setPath('catalog/review/allow_guest'); +$config->setScope('default'); +$config->setScopeId(0); +$config->setValue(0); +$config->save(); diff --git a/dev/tests/integration/testsuite/Magento/Sales/Block/Adminhtml/Order/Create/Form/AccountTest.php b/dev/tests/integration/testsuite/Magento/Sales/Block/Adminhtml/Order/Create/Form/AccountTest.php index 999522a49e006..ce3f3a3e1fc8e 100644 --- a/dev/tests/integration/testsuite/Magento/Sales/Block/Adminhtml/Order/Create/Form/AccountTest.php +++ b/dev/tests/integration/testsuite/Magento/Sales/Block/Adminhtml/Order/Create/Form/AccountTest.php @@ -11,26 +11,37 @@ namespace Magento\Sales\Block\Adminhtml\Order\Create\Form; use Magento\Backend\Model\Session\Quote as SessionQuote; +use Magento\Customer\Api\Data\AttributeMetadataInterface; use Magento\Customer\Api\Data\AttributeMetadataInterfaceFactory; +use Magento\Customer\Model\Data\Option; use Magento\Customer\Model\Metadata\Form; use Magento\Customer\Model\Metadata\FormFactory; use Magento\Framework\View\LayoutInterface; use Magento\Quote\Model\Quote; use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\ObjectManager; +use PHPUnit\Framework\MockObject\MockObject; /** * @magentoAppArea adminhtml */ class AccountTest extends \PHPUnit\Framework\TestCase { - /** @var Account */ + /** + * @var Account + */ private $accountBlock; /** - * @var Bootstrap + * @var ObjectManager */ private $objectManager; + /** + * @var SessionQuote|MockObject + */ + private $session; + /** * @magentoDataFixture Magento/Sales/_files/quote.php */ @@ -38,19 +49,23 @@ protected function setUp() { $this->objectManager = Bootstrap::getObjectManager(); $quote = $this->objectManager->create(Quote::class)->load(1); - $sessionQuoteMock = $this->getMockBuilder( - SessionQuote::class - )->disableOriginalConstructor()->setMethods( - ['getCustomerId', 'getStore', 'getStoreId', 'getQuote'] - )->getMock(); - $sessionQuoteMock->expects($this->any())->method('getCustomerId')->will($this->returnValue(1)); - $sessionQuoteMock->expects($this->any())->method('getQuote')->will($this->returnValue($quote)); + + $this->session = $this->getMockBuilder(SessionQuote::class) + ->disableOriginalConstructor() + ->setMethods(['getCustomerId', 'getStore', 'getStoreId', 'getQuote', 'getQuoteId']) + ->getMock(); + $this->session->method('getCustomerId') + ->willReturn(1); + $this->session->method('getQuote') + ->willReturn($quote); + $this->session->method('getQuoteId') + ->willReturn($quote->getId()); /** @var LayoutInterface $layout */ $layout = $this->objectManager->get(LayoutInterface::class); $this->accountBlock = $layout->createBlock( Account::class, 'address_block' . rand(), - ['sessionQuote' => $sessionQuoteMock] + ['sessionQuote' => $this->session] ); parent::setUp(); } @@ -62,13 +77,13 @@ public function testGetForm() { $expectedFields = ['group_id', 'email']; $form = $this->accountBlock->getForm(); - $this->assertEquals(1, $form->getElements()->count(), "Form has invalid number of fieldsets"); + self::assertEquals(1, $form->getElements()->count(), "Form has invalid number of fieldsets"); $fieldset = $form->getElements()[0]; - $this->assertEquals(count($expectedFields), $fieldset->getElements()->count()); + self::assertEquals(count($expectedFields), $fieldset->getElements()->count()); foreach ($fieldset->getElements() as $element) { - $this->assertTrue( + self::assertTrue( in_array($element->getId(), $expectedFields), sprintf('Unexpected field "%s" in form.', $element->getId()) ); @@ -79,6 +94,7 @@ public function testGetForm() * Tests a case when user defined custom attribute has default value. * * @magentoDataFixture Magento/Customer/_files/customer.php + * @magentoConfigFixture current_store customer/create_account/default_group 3 */ public function testGetFormWithUserDefinedAttribute() { @@ -91,18 +107,27 @@ public function testGetFormWithUserDefinedAttribute() $form = $accountBlock->getForm(); $form->setUseContainer(true); + $content = $form->toHtml(); - $this->assertContains( + self::assertContains( '<option value="1" selected="selected">Yes</option>', - $form->toHtml(), - 'Default value for user defined custom attribute should be selected' + $content, + 'Default value for user defined custom attribute should be selected.' + ); + + self::assertContains( + '<option value="3" selected="selected">Customer Group 1</option>', + $content, + 'The Customer Group specified for the chosen store should be selected.' ); } /** - * @return \PHPUnit_Framework_MockObject_MockObject + * Creates a mock for Form object. + * + * @return MockObject */ - private function getFormFactoryMock(): \PHPUnit_Framework_MockObject_MockObject + private function getFormFactoryMock() { /** @var AttributeMetadataInterfaceFactory $attributeMetadataFactory */ $attributeMetadataFactory = $this->objectManager->create(AttributeMetadataInterfaceFactory::class); @@ -113,11 +138,12 @@ private function getFormFactoryMock(): \PHPUnit_Framework_MockObject_MockObject ->setDefaultValue('1') ->setFrontendLabel('Yes/No'); + /** @var Form|MockObject $form */ $form = $this->getMockBuilder(Form::class) ->disableOriginalConstructor() ->getMock(); $form->method('getUserAttributes')->willReturn([$booleanAttribute]); - $form->method('getSystemAttributes')->willReturn([]); + $form->method('getSystemAttributes')->willReturn([$this->createCustomerGroupAttribute()]); $formFactory = $this->getMockBuilder(FormFactory::class) ->disableOriginalConstructor() @@ -126,4 +152,33 @@ private function getFormFactoryMock(): \PHPUnit_Framework_MockObject_MockObject return $formFactory; } + + /** + * Creates a customer group attribute object. + * + * @return AttributeMetadataInterface + */ + private function createCustomerGroupAttribute(): AttributeMetadataInterface + { + /** @var Option $option1 */ + $option1 = $this->objectManager->create(Option::class); + $option1->setValue(3); + $option1->setLabel('Customer Group 1'); + + /** @var Option $option2 */ + $option2 = $this->objectManager->create(Option::class); + $option2->setValue(4); + $option2->setLabel('Customer Group 2'); + + /** @var AttributeMetadataInterfaceFactory $attributeMetadataFactory */ + $attributeMetadataFactory = $this->objectManager->create(AttributeMetadataInterfaceFactory::class); + $attribute = $attributeMetadataFactory->create() + ->setAttributeCode('group_id') + ->setBackendType('static') + ->setFrontendInput('select') + ->setOptions([$option1, $option2]) + ->setIsRequired(true); + + return $attribute; + } } diff --git a/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Create/SaveTest.php b/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Create/SaveTest.php index b9573c99b4493..da0f2be856c51 100644 --- a/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Create/SaveTest.php +++ b/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Create/SaveTest.php @@ -8,21 +8,61 @@ use Magento\Backend\Model\Session\Quote; use Magento\Customer\Api\CustomerRepositoryInterface; use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\Data\Form\FormKey; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Message\MessageInterface; use Magento\Quote\Api\CartRepositoryInterface; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Model\OrderRepository; use Magento\Sales\Model\Service\OrderService; +use Magento\TestFramework\Mail\Template\TransportBuilderMock; use Magento\TestFramework\TestCase\AbstractBackendController; +use PHPUnit\Framework\Constraint\StringContains; use PHPUnit_Framework_MockObject_MockObject as MockObject; +/** + * Class test backend order save. + * + * @magentoAppArea adminhtml + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class SaveTest extends AbstractBackendController { + /** + * @var TransportBuilderMock + */ + private $transportBuilder; + + /** + * @var FormKey + */ + private $formKey; + + /** + * @var string + */ + protected $resource = 'Magento_Sales::create'; + + /** + * @var string + */ + protected $uri = 'backend/sales/order_create/save'; + + /** + * @inheritdoc + */ + protected function setUp() + { + parent::setUp(); + $this->transportBuilder = $this->_objectManager->get(TransportBuilderMock::class); + $this->formKey = $this->_objectManager->get(FormKey::class); + } + /** * Checks a case when order creation is failed on payment method processing but new customer already created * in the database and after new controller dispatching the customer should be already loaded in session * to prevent invalid validation. * - * @magentoAppArea adminhtml * @magentoDataFixture Magento/Sales/_files/quote_with_new_customer.php */ public function testExecuteWithPaymentOperation() @@ -35,7 +75,7 @@ public function testExecuteWithPaymentOperation() $email = 'john.doe001@test.com'; $data = [ 'account' => [ - 'email' => $email + 'email' => $email, ] ]; $this->getRequest()->setPostValue(['order' => $data]); @@ -64,6 +104,45 @@ public function testExecuteWithPaymentOperation() $this->_objectManager->removeSharedInstance(OrderService::class); } + /** + * @magentoDbIsolation enabled + * @magentoDataFixture Magento/Sales/_files/guest_quote_with_addresses.php + * + * @return void + */ + public function testSendEmailOnOrderSave() + { + $this->prepareRequest(['send_confirmation' => true]); + $this->dispatch('backend/sales/order_create/save'); + $this->assertSessionMessages( + $this->equalTo([(string)__('You created the order.')]), + MessageInterface::TYPE_SUCCESS + ); + + $this->assertRedirect($this->stringContains('sales/order/view/')); + + $orderId = $this->getOrderId(); + if ($orderId === false) { + $this->fail('Order is not created.'); + } + $order = $this->getOrder($orderId); + + $message = $this->transportBuilder->getSentMessage(); + $subject = __('Your %1 order confirmation', $order->getStore()->getFrontendName())->render(); + $assert = $this->logicalAnd( + new StringContains($order->getBillingAddress()->getName()), + new StringContains( + 'Thank you for your order from ' . $order->getStore()->getFrontendName() + ), + new StringContains( + "Your Order <span class=\"no-link\">#{$order->getIncrementId()}</span>" + ) + ); + + $this->assertEquals($message->getSubject(), $subject); + $this->assertThat($message->getRawMessage(), $assert); + } + /** * Gets quote by reserved order id. * @@ -82,4 +161,78 @@ private function getQuote($reservedOrderId) $items = $quoteRepository->getList($searchCriteria)->getItems(); return array_pop($items); } + + /** + * @inheritdoc + * @magentoDbIsolation enabled + * @magentoDataFixture Magento/Sales/_files/guest_quote_with_addresses.php + */ + public function testAclHasAccess() + { + $this->prepareRequest(); + + parent::testAclHasAccess(); + } + + /** + * @inheritdoc + * @magentoDbIsolation enabled + * @magentoDataFixture Magento/Sales/_files/guest_quote_with_addresses.php + */ + public function testAclNoAccess() + { + $this->prepareRequest(); + + parent::testAclNoAccess(); + } + + /** + * @param int $orderId + * @return OrderInterface + */ + private function getOrder(int $orderId): OrderInterface + { + return $this->_objectManager->get(OrderRepository::class)->get($orderId); + } + + /** + * @param array $params + * @return void + */ + private function prepareRequest(array $params = []) + { + $quote = $this->getQuote('guest_quote'); + $session = $this->_objectManager->get(Quote::class); + $session->setQuoteId($quote->getId()); + $session->setCustomerId(0); + + $email = 'john.doe001@test.com'; + $data = [ + 'account' => [ + 'email' => $email, + ], + ]; + + $data = array_replace_recursive($data, $params); + + $this->getRequest() + ->setMethod('POST') + ->setParams(['form_key' => $this->formKey->getFormKey()]) + ->setPostValue(['order' => $data]); + } + + /** + * @return string|bool + */ + protected function getOrderId() + { + $currentUrl = $this->getResponse()->getHeader('Location'); + $orderId = false; + + if (preg_match('/order_id\/(?<order_id>\d+)/', $currentUrl, $matches)) { + $orderId = $matches['order_id'] ?? ''; + } + + return $orderId; + } } diff --git a/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Creditmemo/AbstractCreditmemoControllerTest.php b/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Creditmemo/AbstractCreditmemoControllerTest.php new file mode 100644 index 0000000000000..2a7731715021b --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Creditmemo/AbstractCreditmemoControllerTest.php @@ -0,0 +1,92 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Sales\Controller\Adminhtml\Order\Creditmemo; + +use Magento\Framework\Api\SearchCriteria; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\Data\Form\FormKey; +use Magento\Sales\Api\Data\CreditmemoInterface; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Model\OrderRepository; +use Magento\TestFramework\Mail\Template\TransportBuilderMock; +use Magento\TestFramework\TestCase\AbstractBackendController; + +/** + * Abstract backend creditmemo test. + */ +class AbstractCreditmemoControllerTest extends AbstractBackendController +{ + /** + * @var TransportBuilderMock + */ + protected $transportBuilder; + + /** + * @var OrderRepository + */ + protected $orderRepository; + + /** + * @var FormKey + */ + protected $formKey; + + /** + * @var string + */ + protected $resource = 'Magento_Sales::sales_creditmemo'; + + /** + * @inheritdoc + */ + protected function setUp() + { + parent::setUp(); + $this->transportBuilder = $this->_objectManager->get(TransportBuilderMock::class); + $this->orderRepository = $this->_objectManager->get(OrderRepository::class); + $this->formKey = $this->_objectManager->get(FormKey::class); + } + + /** + * @param string $incrementalId + * @return OrderInterface|null + */ + protected function getOrder(string $incrementalId) + { + /** @var SearchCriteria $searchCriteria */ + $searchCriteria = $this->_objectManager->create(SearchCriteriaBuilder::class) + ->addFilter(OrderInterface::INCREMENT_ID, $incrementalId) + ->create(); + + $orders = $this->orderRepository->getList($searchCriteria)->getItems(); + /** @var OrderInterface|null $order */ + $order = reset($orders); + + return $order; + } + + /** + * @param OrderInterface $order + * @return CreditmemoInterface + */ + protected function getCreditMemo(OrderInterface $order): CreditmemoInterface + { + /** @var \Magento\Sales\Model\ResourceModel\Order\Creditmemo\Collection $creditMemoCollection */ + $creditMemoCollection = $this->_objectManager->create( + \Magento\Sales\Model\ResourceModel\Order\Creditmemo\CollectionFactory::class + )->create(); + + /** @var CreditmemoInterface $creditMemo */ + $creditMemo = $creditMemoCollection + ->setOrderFilter($order) + ->setPageSize(1) + ->getFirstItem(); + + return $creditMemo; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Creditmemo/AddCommentTest.php b/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Creditmemo/AddCommentTest.php new file mode 100644 index 0000000000000..ac11f777daf9c --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Creditmemo/AddCommentTest.php @@ -0,0 +1,102 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Sales\Controller\Adminhtml\Order\Creditmemo; + +use PHPUnit\Framework\Constraint\RegularExpression; +use PHPUnit\Framework\Constraint\StringContains; + +/** + * Class verifies creditmemo add comment functionality. + * + * @magentoDbIsolation enabled + * @magentoAppArea adminhtml + * @magentoDataFixture Magento/Sales/_files/creditmemo_for_get.php + */ +class AddCommentTest extends AbstractCreditmemoControllerTest +{ + /** + * @var string + */ + protected $uri = 'backend/sales/order_creditmemo/addComment'; + + /** + * @return void + */ + public function testSendEmailOnAddCreditmemoComment() + { + $comment = 'Test Credit Memo Comment'; + $order = $this->prepareRequest( + [ + 'comment' => ['comment' => $comment, 'is_customer_notified' => true], + ] + ); + $this->dispatch('backend/sales/order_creditmemo/addComment'); + $html = $this->getResponse()->getBody(); + $this->assertContains($comment, $html); + + $message = $this->transportBuilder->getSentMessage(); + $subject =__('Update to your %1 credit memo', $order->getStore()->getFrontendName())->render(); + $messageConstraint = $this->logicalAnd( + new StringContains($order->getBillingAddress()->getName()), + new RegularExpression( + sprintf( + "/Your order #%s has been updated with a status of.*%s/", + $order->getIncrementId(), + $order->getFrontendStatusLabel() + ) + ), + new StringContains($comment) + ); + + $this->assertEquals($message->getSubject(), $subject); + $this->assertThat($message->getRawMessage(), $messageConstraint); + } + + /** + * @inheritdoc + */ + public function testAclHasAccess() + { + $this->prepareRequest(['comment' => ['comment' => 'Comment']]); + + parent::testAclHasAccess(); + } + + /** + * @inheritdoc + */ + public function testAclNoAccess() + { + $this->prepareRequest(['comment' => ['comment' => 'Comment']]); + + parent::testAclNoAccess(); + } + + /** + * @param array $params + * @return \Magento\Sales\Api\Data\OrderInterface|null + */ + private function prepareRequest(array $params = []) + { + $order = $this->getOrder('100000001'); + $creditmemo = $this->getCreditMemo($order); + + $this->getRequest()->setMethod('POST'); + $this->getRequest()->setParams( + [ + 'id' => $creditmemo->getEntityId(), + 'form_key' => $this->formKey->getFormKey(), + ] + ); + + $data = $params ?? []; + $this->getRequest()->setPostValue($data); + + return $order; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Creditmemo/SaveTest.php b/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Creditmemo/SaveTest.php new file mode 100644 index 0000000000000..4df7710bb4388 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Creditmemo/SaveTest.php @@ -0,0 +1,99 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Sales\Controller\Adminhtml\Order\Creditmemo; + +use PHPUnit\Framework\Constraint\StringContains; + +/** + * Class tests creditmemo creation in backend. + * + * @magentoDbIsolation enabled + * @magentoAppArea adminhtml + * @magentoDataFixture Magento/Sales/_files/invoice.php + */ +class SaveTest extends AbstractCreditmemoControllerTest +{ + /** + * @var string + */ + protected $uri = 'backend/sales/order_creditmemo/save'; + + /** + * @return void + */ + public function testSendEmailOnCreditmemoSave() + { + $order = $this->prepareRequest(['creditmemo' => ['send_email' => true]]); + $this->dispatch('backend/sales/order_creditmemo/save'); + + $this->assertSessionMessages( + $this->equalTo([(string)__('You created the credit memo.')]), + \Magento\Framework\Message\MessageInterface::TYPE_SUCCESS + ); + $this->assertRedirect($this->stringContains('sales/order/view/order_id/' . $order->getEntityId())); + + $creditMemo = $this->getCreditMemo($order); + $message = $this->transportBuilder->getSentMessage(); + $subject = __('Credit memo for your %1 order', $order->getStore()->getFrontendName())->render(); + $messageConstraint = $this->logicalAnd( + new StringContains($order->getBillingAddress()->getName()), + new StringContains( + 'Thank you for your order from ' . $creditMemo->getStore()->getFrontendName() + ), + new StringContains( + "Your Credit Memo #{$creditMemo->getIncrementId()} for Order #{$order->getIncrementId()}" + ) + ); + + $this->assertEquals($message->getSubject(), $subject); + $this->assertThat($message->getRawMessage(), $messageConstraint); + } + + /** + * @inheritdoc + */ + public function testAclHasAccess() + { + $this->prepareRequest(); + + parent::testAclHasAccess(); + } + + /** + * @inheritdoc + */ + public function testAclNoAccess() + { + $this->prepareRequest(); + + parent::testAclNoAccess(); + } + + /** + * @param array $params + * @return \Magento\Sales\Api\Data\OrderInterface|null + */ + private function prepareRequest(array $params = []) + { + $order = $this->getOrder('100000001'); + $this->getRequest()->setMethod('POST'); + $this->getRequest()->setParams( + [ + 'order_id' => $order->getEntityId(), + 'form_key' => $this->formKey->getFormKey(), + ] + ); + + $data = ['creditmemo' => ['do_offline' => true]]; + $data = array_replace_recursive($data, $params); + + $this->getRequest()->setPostValue($data); + + return $order; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/EmailTest.php b/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/EmailTest.php new file mode 100644 index 0000000000000..337ad206ade91 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/EmailTest.php @@ -0,0 +1,136 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Sales\Controller\Adminhtml\Order; + +use Magento\Framework\Api\SearchCriteria; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Model\OrderRepository; +use Magento\TestFramework\Mail\Template\TransportBuilderMock; +use PHPUnit\Framework\Constraint\StringContains; + +/** + * Class verifies order send email functionality. + * + * @magentoDbIsolation enabled + * @magentoAppArea adminhtml + * @magentoDataFixture Magento/Sales/_files/order.php + */ +class EmailTest extends \Magento\TestFramework\TestCase\AbstractBackendController +{ + /** + * @var OrderRepository + */ + private $orderRepository; + + /** + * @var TransportBuilderMock + */ + private $transportBuilder; + + /** + * @var string + */ + protected $resource = 'Magento_Sales::email'; + + /** + * @var string + */ + protected $uri = 'backend/sales/order/email'; + + /** + * @inheritdoc + */ + protected function setUp() + { + parent::setUp(); + $this->orderRepository = $this->_objectManager->get(OrderRepository::class); + $this->transportBuilder = $this->_objectManager->get(TransportBuilderMock::class); + } + + /** + * @return void + */ + public function testSendOrderEmail() + { + $order = $this->prepareRequest(); + $this->dispatch('backend/sales/order/email'); + + $this->assertSessionMessages( + $this->equalTo([(string)__('You sent the order email.')]), + \Magento\Framework\Message\MessageInterface::TYPE_SUCCESS + ); + + $redirectUrl = 'sales/order/view/order_id/' . $order->getEntityId(); + $this->assertRedirect($this->stringContains($redirectUrl)); + + $message = $this->transportBuilder->getSentMessage(); + $subject = __('Your %1 order confirmation', $order->getStore()->getFrontendName())->render(); + $assert = $this->logicalAnd( + new StringContains($order->getBillingAddress()->getName()), + new StringContains( + 'Thank you for your order from ' . $order->getStore()->getFrontendName() + ), + new StringContains( + "Your Order <span class=\"no-link\">#{$order->getIncrementId()}</span>" + ) + ); + + $this->assertEquals($message->getSubject(), $subject); + $this->assertThat($message->getRawMessage(), $assert); + } + + /** + * @inheritdoc + */ + public function testAclHasAccess() + { + $this->prepareRequest(); + + parent::testAclHasAccess(); + } + + /** + * @inheritdoc + */ + public function testAclNoAccess() + { + $this->prepareRequest(); + + parent::testAclNoAccess(); + } + + /** + * @param string $incrementalId + * @return OrderInterface|null + */ + private function getOrder(string $incrementalId) + { + /** @var SearchCriteria $searchCriteria */ + $searchCriteria = $this->_objectManager->create(SearchCriteriaBuilder::class) + ->addFilter(OrderInterface::INCREMENT_ID, $incrementalId) + ->create(); + + $orders = $this->orderRepository->getList($searchCriteria)->getItems(); + /** @var OrderInterface|null $order */ + $order = reset($orders); + + return $order; + } + + /** + * @return OrderInterface|null + */ + private function prepareRequest() + { + $order = $this->getOrder('100000001'); + $this->getRequest()->setParams(['order_id' => $order->getEntityId()]); + + return $order; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Invoice/AbstractInvoiceControllerTest.php b/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Invoice/AbstractInvoiceControllerTest.php new file mode 100644 index 0000000000000..3ba54418b6c26 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Invoice/AbstractInvoiceControllerTest.php @@ -0,0 +1,92 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Sales\Controller\Adminhtml\Order\Invoice; + +use Magento\Framework\Api\SearchCriteria; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\Data\Form\FormKey; +use Magento\Sales\Api\Data\InvoiceInterface; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Model\OrderRepository; +use Magento\TestFramework\Mail\Template\TransportBuilderMock; +use Magento\TestFramework\TestCase\AbstractBackendController; + +/** + * Abstract backend invoice test. + */ +class AbstractInvoiceControllerTest extends AbstractBackendController +{ + /** + * @var TransportBuilderMock + */ + protected $transportBuilder; + + /** + * @var OrderRepository + */ + protected $orderRepository; + + /** + * @var FormKey + */ + protected $formKey; + + /** + * @var string + */ + protected $resource = 'Magento_Sales::sales_invoice'; + + /** + * @inheritdoc + */ + protected function setUp() + { + parent::setUp(); + $this->transportBuilder = $this->_objectManager->get(TransportBuilderMock::class); + $this->orderRepository = $this->_objectManager->get(OrderRepository::class); + $this->formKey = $this->_objectManager->get(FormKey::class); + } + + /** + * @param string $incrementalId + * @return OrderInterface|null + */ + protected function getOrder(string $incrementalId) + { + /** @var SearchCriteria $searchCriteria */ + $searchCriteria = $this->_objectManager->create(SearchCriteriaBuilder::class) + ->addFilter(OrderInterface::INCREMENT_ID, $incrementalId) + ->create(); + + $orders = $this->orderRepository->getList($searchCriteria)->getItems(); + /** @var OrderInterface $order */ + $order = reset($orders); + + return $order; + } + + /** + * @param OrderInterface $order + * @return InvoiceInterface + */ + protected function getInvoiceByOrder(OrderInterface $order): InvoiceInterface + { + /** @var \Magento\Sales\Model\ResourceModel\Order\Invoice\Collection $invoiceCollection */ + $invoiceCollection = $this->_objectManager->create( + \Magento\Sales\Model\ResourceModel\Order\Invoice\CollectionFactory::class + )->create(); + + /** @var InvoiceInterface $invoice */ + $invoice = $invoiceCollection + ->setOrderFilter($order) + ->setPageSize(1) + ->getFirstItem(); + + return $invoice; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Invoice/AddCommentTest.php b/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Invoice/AddCommentTest.php new file mode 100644 index 0000000000000..8643dfc66f1b9 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Invoice/AddCommentTest.php @@ -0,0 +1,103 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Sales\Controller\Adminhtml\Order\Invoice; + +use PHPUnit\Framework\Constraint\RegularExpression; +use PHPUnit\Framework\Constraint\StringContains; + +/** + * Class verifies invoice add comment functionality. + * + * @magentoDbIsolation enabled + * @magentoAppArea adminhtml + * @magentoDataFixture Magento/Sales/_files/invoice.php + */ +class AddCommentTest extends AbstractInvoiceControllerTest +{ + /** + * @var string + */ + protected $uri = 'backend/sales/order_invoice/addComment'; + + /** + * @return void + */ + public function testSendEmailOnAddInvoiceComment() + { + $comment = 'Test Invoice Comment'; + $order = $this->prepareRequest( + [ + 'comment' => ['comment' => $comment, 'is_customer_notified' => true], + ] + ); + $this->dispatch('backend/sales/order_invoice/addComment'); + + $html = $this->getResponse()->getBody(); + $this->assertContains($comment, $html); + + $message = $this->transportBuilder->getSentMessage(); + $subject = __('Update to your %1 invoice', $order->getStore()->getFrontendName())->render(); + $messageConstraint = $this->logicalAnd( + new StringContains($order->getBillingAddress()->getName()), + new RegularExpression( + sprintf( + "/Your order #%s has been updated with a status of.*%s/", + $order->getIncrementId(), + $order->getFrontendStatusLabel() + ) + ), + new StringContains($comment) + ); + + $this->assertEquals($message->getSubject(), $subject); + $this->assertThat($message->getRawMessage(), $messageConstraint); + } + + /** + * @inheritdoc + */ + public function testAclHasAccess() + { + $this->prepareRequest(['comment' => ['comment' => 'Comment']]); + + parent::testAclHasAccess(); + } + + /** + * @inheritdoc + */ + public function testAclNoAccess() + { + $this->prepareRequest(['comment' => ['comment' => 'Comment']]); + + parent::testAclNoAccess(); + } + + /** + * @param array $params + * @return \Magento\Sales\Api\Data\OrderInterface|null + */ + private function prepareRequest(array $params = []) + { + $order = $this->getOrder('100000001'); + $invoice = $this->getInvoiceByOrder($order); + + $this->getRequest()->setMethod('POST'); + $this->getRequest()->setParams( + [ + 'id' => $invoice->getEntityId(), + 'form_key' => $this->formKey->getFormKey(), + ] + ); + + $data = $params ?? []; + $this->getRequest()->setPostValue($data); + + return $order; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Invoice/EmailTest.php b/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Invoice/EmailTest.php new file mode 100644 index 0000000000000..39b7fc8ef0267 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Invoice/EmailTest.php @@ -0,0 +1,88 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Sales\Controller\Adminhtml\Order\Invoice; + +use PHPUnit\Framework\Constraint\StringContains; + +/** + * Class verifies invoice send email functionality. + * + * @magentoDbIsolation enabled + * @magentoAppArea adminhtml + * @magentoDataFixture Magento/Sales/_files/invoice.php + */ +class EmailTest extends AbstractInvoiceControllerTest +{ + /** + * @var string + */ + protected $uri = 'backend/sales/order_invoice/email'; + + /** + * @return void + */ + public function testSendInvoiceEmail() + { + $order = $this->getOrder('100000001'); + $invoice = $this->getInvoiceByOrder($order); + + $this->getRequest()->setParams(['invoice_id' => $invoice->getEntityId()]); + $this->dispatch('backend/sales/order_invoice/email'); + + $this->assertSessionMessages( + $this->equalTo([(string)__('You sent the message.')]), + \Magento\Framework\Message\MessageInterface::TYPE_SUCCESS + ); + + $redirectUrl = sprintf( + 'sales/invoice/view/order_id/%s/invoice_id/%s', + $order->getEntityId(), + $invoice->getEntityId() + ); + $this->assertRedirect($this->stringContains($redirectUrl)); + + $message = $this->transportBuilder->getSentMessage(); + $subject = __('Invoice for your %1 order', $order->getStore()->getFrontendName())->render(); + $messageConstraint = $this->logicalAnd( + new StringContains($invoice->getBillingAddress()->getName()), + new StringContains( + 'Thank you for your order from ' . $invoice->getStore()->getFrontendName() + ), + new StringContains( + "Your Invoice #{$invoice->getIncrementId()} for Order #{$order->getIncrementId()}" + ) + ); + + $this->assertEquals($message->getSubject(), $subject); + $this->assertThat($message->getRawMessage(), $messageConstraint); + } + + /** + * @inheritdoc + */ + public function testAclHasAccess() + { + $order = $this->getOrder('100000001'); + $invoice = $this->getInvoiceByOrder($order); + $this->uri .= '/invoice_id/' . $invoice->getEntityId(); + + parent::testAclHasAccess(); + } + + /** + * @inheritdoc + */ + public function testAclNoAccess() + { + $order = $this->getOrder('100000001'); + $invoice = $this->getInvoiceByOrder($order); + $this->uri .= '/invoice_id/' . $invoice->getEntityId(); + + parent::testAclNoAccess(); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Invoice/SaveTest.php b/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Invoice/SaveTest.php new file mode 100644 index 0000000000000..d451bdcb287cf --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/Controller/Adminhtml/Order/Invoice/SaveTest.php @@ -0,0 +1,97 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Sales\Controller\Adminhtml\Order\Invoice; + +use PHPUnit\Framework\Constraint\StringContains; + +/** + * Class tests invoice creation in backend. + * + * @magentoDbIsolation enabled + * @magentoAppArea adminhtml + * @magentoDataFixture Magento/Sales/_files/order.php + */ +class SaveTest extends AbstractInvoiceControllerTest +{ + /** + * @var string + */ + protected $uri = 'backend/sales/order_invoice/save'; + + /** + * @return void + */ + public function testSendEmailOnInvoiceSave() + { + $order = $this->prepareRequest(['invoice' => ['send_email' => true]]); + $this->dispatch('backend/sales/order_invoice/save'); + + $this->assertSessionMessages( + $this->equalTo([(string)__('The invoice has been created.')]), + \Magento\Framework\Message\MessageInterface::TYPE_SUCCESS + ); + $this->assertRedirect($this->stringContains('sales/order/view/order_id/' . $order->getEntityId())); + + $invoice = $this->getInvoiceByOrder($order); + $message = $this->transportBuilder->getSentMessage(); + $subject = __('Invoice for your %1 order', $order->getStore()->getFrontendName())->render(); + $messageConstraint = $this->logicalAnd( + new StringContains($invoice->getBillingAddress()->getName()), + new StringContains( + 'Thank you for your order from ' . $invoice->getStore()->getFrontendName() + ), + new StringContains( + "Your Invoice #{$invoice->getIncrementId()} for Order #{$order->getIncrementId()}" + ) + ); + + $this->assertEquals($message->getSubject(), $subject); + $this->assertThat($message->getRawMessage(), $messageConstraint); + } + + /** + * @inheritdoc + */ + public function testAclHasAccess() + { + $this->prepareRequest(); + + parent::testAclHasAccess(); + } + + /** + * @inheritdoc + */ + public function testAclNoAccess() + { + $this->prepareRequest(); + + parent::testAclNoAccess(); + } + + /** + * @param array $params + * @return \Magento\Sales\Api\Data\OrderInterface|null + */ + private function prepareRequest(array $params = []) + { + $order = $this->getOrder('100000001'); + $this->getRequest()->setMethod('POST'); + $this->getRequest()->setParams( + [ + 'order_id' => $order->getEntityId(), + 'form_key' => $this->formKey->getFormKey(), + ] + ); + + $data = $params ?? []; + $this->getRequest()->setPostValue($data); + + return $order; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Sales/CustomerData/LastOrderedItemsTest.php b/dev/tests/integration/testsuite/Magento/Sales/CustomerData/LastOrderedItemsTest.php new file mode 100644 index 0000000000000..adeff2c89a241 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/CustomerData/LastOrderedItemsTest.php @@ -0,0 +1,55 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\Sales\CustomerData; + +use Magento\Framework\ObjectManagerInterface; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; +use Magento\Customer\Model\Session; + +/** + * Test for LastOrderedItems. + * + * @magentoAppIsolation enabled + */ +class LastOrderedItemsTest extends TestCase +{ + /** + * @var ObjectManagerInterface + */ + private $objectManager; + + /** + * @inheritdoc + */ + public function setUp() + { + $this->objectManager = Bootstrap::getObjectManager(); + } + + /** + * Test to check count in items collection. + * + * @magentoDataFixture Magento/Sales/_files/order_with_customer_and_multiple_order_items.php + */ + public function testDefaultFormatterIsAppliedWhenBasicIntegration() + { + /** @var Session $customerSession */ + $customerSession = $this->objectManager->get(Session::class); + $customerSession->loginById(1); + + /** @var LastOrderedItems $customerDataSectionSource */ + $customerDataSectionSource = $this->objectManager->get(LastOrderedItems::class); + $data = $customerDataSectionSource->getSectionData(); + + $this->assertEquals( + LastOrderedItems::SIDEBAR_ORDER_LIMIT, + count($data['items']), + 'Section items count should not be greater then ' . LastOrderedItems::SIDEBAR_ORDER_LIMIT + ); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Sales/Model/Order/CreateTest.php b/dev/tests/integration/testsuite/Magento/Sales/Model/Order/CreateTest.php new file mode 100644 index 0000000000000..4ff4ad384d3e4 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/Model/Order/CreateTest.php @@ -0,0 +1,100 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Sales\Model\Order; + +use Magento\Checkout\Model\Session as CheckoutSession; +use Magento\Framework\Data\Form\FormKey; +use Magento\Framework\ObjectManagerInterface; +use Magento\Quote\Api\GuestCartManagementInterface; +use Magento\Quote\Model\Quote; +use Magento\Quote\Model\QuoteIdMask; +use Magento\Quote\Model\QuoteIdMaskFactory; +use Magento\Sales\Model\OrderRepository; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\Mail\Template\TransportBuilderMock; +use PHPUnit\Framework\Constraint\StringContains; + +/** + * Class verifies order creation. + * + * @magentoDbIsolation enabled + * @magentoAppArea frontend + */ +class CreateTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var ObjectManagerInterface + */ + private $objectManager; + + /** + * @var TransportBuilderMock + */ + private $transportBuilder; + + /** + * @var QuoteIdMaskFactory + */ + private $quoteIdMaskFactory; + + /** + * @var FormKey + */ + private $formKey; + + /** + * @inheritdoc + */ + protected function setUp() + { + parent::setUp(); + $this->objectManager = Bootstrap::getObjectManager(); + $this->transportBuilder = $this->objectManager->get(TransportBuilderMock::class); + $this->quoteIdMaskFactory = $this->objectManager->get(QuoteIdMaskFactory::class); + $this->formKey = $this->objectManager->get(FormKey::class); + } + + /** + * @magentoDataFixture Magento/Sales/_files/guest_quote_with_addresses.php + * @return void + */ + public function testSendEmailOnOrderPlace() + { + /** @var Quote $quote */ + $quote = $this->objectManager->create(Quote::class); + $quote->load('guest_quote', 'reserved_order_id'); + + $checkoutSession = $this->objectManager->get(CheckoutSession::class); + $checkoutSession->setQuoteId($quote->getId()); + + /** @var QuoteIdMask $quoteIdMask */ + $quoteIdMask = $this->quoteIdMaskFactory->create(); + $quoteIdMask->load($quote->getId(), 'quote_id'); + $cartId = $quoteIdMask->getMaskedId(); + + /** @var GuestCartManagementInterface $cartManagement */ + $cartManagement = $this->objectManager->get(GuestCartManagementInterface::class); + $orderId = $cartManagement->placeOrder($cartId); + $order = $this->objectManager->get(OrderRepository::class)->get($orderId); + + $message = $this->transportBuilder->getSentMessage(); + $subject = __('Your %1 order confirmation', $order->getStore()->getFrontendName())->render(); + $assert = $this->logicalAnd( + new StringContains($order->getBillingAddress()->getName()), + new StringContains( + 'Thank you for your order from ' . $order->getStore()->getFrontendName() + ), + new StringContains( + "Your Order <span class=\"no-link\">#{$order->getIncrementId()}</span>" + ) + ); + + $this->assertEquals($message->getSubject(), $subject); + $this->assertThat($message->getRawMessage(), $assert); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/guest_quote_with_addresses.php b/dev/tests/integration/testsuite/Magento/Sales/_files/guest_quote_with_addresses.php new file mode 100644 index 0000000000000..601c500c18429 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/guest_quote_with_addresses.php @@ -0,0 +1,68 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +require __DIR__ . '/address_list.php'; + +\Magento\TestFramework\Helper\Bootstrap::getInstance()->loadArea('frontend'); + +$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); +/** @var \Magento\Catalog\Model\Product $product */ +$product = $objectManager->create(\Magento\Catalog\Model\Product::class); +$product->setTypeId('simple') + ->setAttributeSetId($product->getDefaultAttributeSetId()) + ->setName('Simple Product') + ->setSku('simple-product-guest-quote') + ->setPrice(10) + ->setTaxClassId(0) + ->setMetaTitle('meta title') + ->setMetaKeyword('meta keyword') + ->setMetaDescription('meta description') + ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) + ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) + ->setStockData( + [ + 'qty' => 100, + 'is_in_stock' => 1, + ] + )->save(); + +$productRepository = $objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); +$product = $productRepository->get('simple-product-guest-quote'); + +$addressData = reset($addresses); + +$billingAddress = $objectManager->create( + \Magento\Quote\Model\Quote\Address::class, + ['data' => $addressData] +); +$billingAddress->setAddressType('billing'); + +$shippingAddress = clone $billingAddress; +$shippingAddress->setId(null)->setAddressType('shipping'); + +$store = $objectManager->get(\Magento\Store\Model\StoreManagerInterface::class)->getStore(); + +/** @var \Magento\Quote\Model\Quote $quote */ +$quote = $objectManager->create(\Magento\Quote\Model\Quote::class); +$quote->setCustomerIsGuest(true) + ->setStoreId($store->getId()) + ->setReservedOrderId('guest_quote') + ->setBillingAddress($billingAddress) + ->setShippingAddress($shippingAddress) + ->addProduct($product); +$quote->getPayment()->setMethod('checkmo'); +$quote->getShippingAddress()->setShippingMethod('flatrate_flatrate')->setCollectShippingRates(1); +$quote->collectTotals(); + +$quoteRepository = $objectManager->create(\Magento\Quote\Api\CartRepositoryInterface::class); +$quoteRepository->save($quote); + +/** @var \Magento\Quote\Model\QuoteIdMask $quoteIdMask */ +$quoteIdMask = $objectManager->create(\Magento\Quote\Model\QuoteIdMaskFactory::class)->create(); +$quoteIdMask->setQuoteId($quote->getId()); +$quoteIdMask->setDataChanges(true); +$quoteIdMask->save(); diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/guest_quote_with_addresses_rollback.php b/dev/tests/integration/testsuite/Magento/Sales/_files/guest_quote_with_addresses_rollback.php new file mode 100644 index 0000000000000..02c42153b72c3 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/guest_quote_with_addresses_rollback.php @@ -0,0 +1,32 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +/** @var \Magento\Framework\Registry $registry */ +$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); +$registry = $objectManager->get(\Magento\Framework\Registry::class); +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +/** @var $quote \Magento\Quote\Model\Quote */ +$quote = $objectManager->create(\Magento\Quote\Model\Quote::class); +$quote->load('guest_quote', 'reserved_order_id'); +if ($quote->getId()) { + $quote->delete(); +} + +/** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); + +try { + $product = $productRepository->get('simple-product-guest-quote', false, null, true); + $productRepository->delete($product); +} catch (\Magento\Framework\Exception\NoSuchEntityException $exception) { + //Product already removed +} + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/order.php b/dev/tests/integration/testsuite/Magento/Sales/_files/order.php index 9f9f2f740c84e..65b143d9d68f7 100644 --- a/dev/tests/integration/testsuite/Magento/Sales/_files/order.php +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/order.php @@ -43,7 +43,8 @@ ->setBasePrice($product->getPrice()) ->setPrice($product->getPrice()) ->setRowTotal($product->getPrice()) - ->setProductType('simple'); + ->setProductType('simple') + ->setName($product->getName()); /** @var Order $order */ $order = $objectManager->create(Order::class); diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/order_list.php b/dev/tests/integration/testsuite/Magento/Sales/_files/order_list.php index 43e98419798a8..221f5b559c9f2 100644 --- a/dev/tests/integration/testsuite/Magento/Sales/_files/order_list.php +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/order_list.php @@ -5,6 +5,9 @@ */ use Magento\Sales\Model\Order; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order\Address as OrderAddress; +use Magento\Sales\Model\Order\Payment; require 'order.php'; /** @var Order $order */ @@ -48,16 +51,43 @@ ], ]; +$orderList = []; +/** @var OrderRepositoryInterface $orderRepository */ +$orderRepository = $objectManager->create(OrderRepositoryInterface::class); /** @var array $orderData */ foreach ($orders as $orderData) { - /** @var $order \Magento\Sales\Model\Order */ - $order = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Sales\Model\Order::class - ); + /** @var Order $order */ + $order = $objectManager->create(Order::class); + + // Reset addresses + /** @var Order\Address $billingAddress */ + $billingAddress = $objectManager->create(OrderAddress::class, ['data' => $addressData]); + $billingAddress->setAddressType('billing'); + + $shippingAddress = clone $billingAddress; + $shippingAddress->setId(null)->setAddressType('shipping'); + + /** @var Payment $payment */ + $payment = $objectManager->create(Payment::class); + $payment->setMethod('checkmo') + ->setAdditionalInformation('last_trans_id', '11122') + ->setAdditionalInformation( + 'metadata', + [ + 'type' => 'free', + 'fraudulent' => false, + ] + ); + $order ->setData($orderData) ->addItem($orderItem) + ->setCustomerIsGuest(true) + ->setCustomerEmail('customer@null.com') ->setBillingAddress($billingAddress) - ->setBillingAddress($shippingAddress) - ->save(); + ->setShippingAddress($shippingAddress) + ->setPayment($payment); + + $orderRepository->save($order); + $orderList[] = $order; } diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/order_list_with_tax.php b/dev/tests/integration/testsuite/Magento/Sales/_files/order_list_with_tax.php new file mode 100644 index 0000000000000..48f6ccfead297 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/order_list_with_tax.php @@ -0,0 +1,55 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Sales\Model\Order\Tax\ItemFactory; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Tax\Model\Sales\Order\TaxFactory; + +require 'default_rollback.php'; +require 'order_list.php'; + +/** @var array $orderList */ +foreach ($orderList as $order) { + $amount = 45; + $taxFactory = $objectManager->create(TaxFactory::class); + + /** @var \Magento\Tax\Model\Sales\Order\Tax $tax */ + $tax = $taxFactory->create(); + $tax->setOrderId($order->getId()) + ->setCode('US-NY-*-Rate 1') + ->setTitle('US-NY-*-Rate 1') + ->setPercent(8.37) + ->setAmount($amount) + ->setBaseAmount($amount) + ->setBaseRealAmount($amount); + $tax->save(); + + $salesOrderFactory = $objectManager->create(ItemFactory::class); + + /** @var \Magento\Sales\Model\Order\Tax\Item $salesOrderItem */ + $salesOrderItem = $salesOrderFactory->create(); + $salesOrderItem->setOrderId($order->getId()) + ->setStoreId($objectManager->get(StoreManagerInterface::class)->getStore()->getId()) + ->setProductOptions([]); + $salesCollection = $objectManager->create(\Magento\Sales\Model\ResourceModel\Order\Item::class); + $salesCollection->save($salesOrderItem); + + /** @var \Magento\Sales\Model\Order\Tax\Item $salesOrderItem */ + $salesOrderTaxItem = $salesOrderFactory->create(); + $salesOrderTaxItem->setTaxId($tax->getId()) + ->setTaxPercent(8.37) + ->setTaxAmount($amount) + ->setBaseAmount($amount) + ->setRealAmount($amount) + ->setRealBaseAmount($amount) + ->setAppliedTaxes([$tax]) + ->setTaxableItemType('shipping') + ->setItemId($salesOrderItem->getId()); + + $taxItemCollection = $objectManager->create(\Magento\Sales\Model\ResourceModel\Order\Tax\Item::class); + $taxItemCollection->save($salesOrderTaxItem); +} diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/order_list_with_tax_rollback.php b/dev/tests/integration/testsuite/Magento/Sales/_files/order_list_with_tax_rollback.php new file mode 100644 index 0000000000000..dd52deab825cb --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/order_list_with_tax_rollback.php @@ -0,0 +1,8 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +require 'order_rollback.php'; diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_customer_and_multiple_order_items.php b/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_customer_and_multiple_order_items.php new file mode 100644 index 0000000000000..586863dd07696 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_customer_and_multiple_order_items.php @@ -0,0 +1,12 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +require __DIR__ . '/order_with_multiple_items.php'; +require __DIR__ . '/../../../Magento/Customer/_files/customer.php'; + +$customerIdFromFixture = 1; +/** @var $order \Magento\Sales\Model\Order */ +$order->setCustomerId($customerIdFromFixture)->setCustomerIsGuest(false)->save(); diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_discount.php b/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_discount.php new file mode 100644 index 0000000000000..29a7aa4d90334 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_discount.php @@ -0,0 +1,73 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Address as OrderAddress; +use Magento\Sales\Model\Order\Item as OrderItem; +use Magento\Sales\Model\Order\Payment; +use Magento\Store\Model\StoreManagerInterface; + +require __DIR__ . '/../../../Magento/Catalog/_files/product_simple.php'; +/** @var \Magento\Catalog\Model\Product $product */ + +$addressData = include __DIR__ . '/address_data.php'; + +$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + +$billingAddress = $objectManager->create(OrderAddress::class, ['data' => $addressData]); +$billingAddress->setAddressType('billing'); + +$shippingAddress = clone $billingAddress; +$shippingAddress->setId(null)->setAddressType('shipping'); + +/** @var Payment $payment */ +$payment = $objectManager->create(Payment::class); +$payment->setMethod('checkmo') + ->setAdditionalInformation('last_trans_id', '11122') + ->setAdditionalInformation( + 'metadata', + [ + 'type' => 'free', + 'fraudulent' => false, + ] + ); + +/** @var OrderItem $orderItem */ +$orderItem = $objectManager->create(OrderItem::class); +$orderItem->setProductId($product->getId()) + ->setQtyOrdered(2) + ->setBasePrice($product->getPrice()) + ->setPrice($product->getPrice()) + ->setRowTotal($product->getPrice()) + ->setProductType('simple') + ->setDiscountAmount(2) + ->setBaseRowTotal($product->getPrice()) + ->setBaseDiscountAmount(2) + ->setTaxAmount(1) + ->setBaseTaxAmount(1); + +/** @var Order $order */ +$order = $objectManager->create(Order::class); +$order->setIncrementId('100000001') + ->setState(Order::STATE_PROCESSING) + ->setStatus($order->getConfig()->getStateDefaultStatus(Order::STATE_PROCESSING)) + ->setSubtotal(100) + ->setGrandTotal(100) + ->setBaseSubtotal(100) + ->setBaseGrandTotal(100) + ->setCustomerIsGuest(true) + ->setCustomerEmail('customer@null.com') + ->setBillingAddress($billingAddress) + ->setShippingAddress($shippingAddress) + ->setStoreId($objectManager->get(StoreManagerInterface::class)->getStore()->getId()) + ->addItem($orderItem) + ->setPayment($payment); + +/** @var OrderRepositoryInterface $orderRepository */ +$orderRepository = $objectManager->create(OrderRepositoryInterface::class); +$orderRepository->save($order); diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_discount_rollback.php b/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_discount_rollback.php new file mode 100644 index 0000000000000..1fb4b4636ab29 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_discount_rollback.php @@ -0,0 +1,8 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +require 'default_rollback.php'; diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_multiple_items.php b/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_multiple_items.php new file mode 100644 index 0000000000000..8375ae71fd10b --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_multiple_items.php @@ -0,0 +1,81 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +require 'order.php'; +/** @var \Magento\Catalog\Model\Product $product */ +/** @var \Magento\Sales\Model\Order $order */ +$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + +require __DIR__ . '/../../../Magento/Catalog/_files/product_simple.php'; +$orderItems[] = [ + 'product_id' => $product->getId(), + 'base_price' => 123, + 'order_id' => $order->getId(), + 'price' => 123, + 'row_total' => 126, + 'product_type' => 'simple' +]; + +require __DIR__ . '/../../../Magento/Catalog/_files/product_simple_duplicated.php'; +$orderItems[] = [ + 'product_id' => $product->getId(), + 'base_price' => 123, + 'order_id' => $order->getId(), + 'price' => 123, + 'row_total' => 126, + 'product_type' => 'simple' +]; + +require __DIR__ . '/../../../Magento/Catalog/_files/product_simple_with_decimal_qty.php'; +$orderItems[] = [ + 'product_id' => $product->getId(), + 'base_price' => 123, + 'order_id' => $order->getId(), + 'price' => 123, + 'row_total' => 126, + 'product_type' => 'simple' +]; + +require __DIR__ . '/../../../Magento/Catalog/_files/product_simple_with_url_key.php'; +$orderItems[] = [ + 'product_id' => $product->getId(), + 'base_price' => 123, + 'order_id' => $order->getId(), + 'price' => 123, + 'row_total' => 126, + 'product_type' => 'simple' +]; + +require __DIR__ . '/../../../Magento/Catalog/_files/product_simple_sku_with_slash.php'; +$orderItems[] = [ + 'product_id' => $product->getId(), + 'base_price' => 123, + 'order_id' => $order->getId(), + 'price' => 123, + 'row_total' => 126, + 'product_type' => 'simple' +]; + +require __DIR__ . '/../../../Magento/Catalog/_files/product_simple_xss.php'; +$orderItems[] = [ + 'product_id' => $product->getId(), + 'base_price' => 123, + 'order_id' => $order->getId(), + 'price' => 123, + 'row_total' => 126, + 'product_type' => 'simple' +]; + +/** @var array $orderItemData */ +foreach ($orderItems as $orderItemData) { + /** @var $orderItem \Magento\Sales\Model\Order\Item */ + $orderItem = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( + \Magento\Sales\Model\Order\Item::class + ); + $orderItem + ->setData($orderItemData) + ->save(); +} diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_shipping_and_invoice.php b/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_shipping_and_invoice.php index a889235ea1862..61d8be98bdd22 100644 --- a/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_shipping_and_invoice.php +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_shipping_and_invoice.php @@ -3,6 +3,9 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + +use Magento\Sales\Model\Order\ShipmentFactory; + require 'order.php'; $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); @@ -36,4 +39,11 @@ $order->setIsInProcess(true); -$transaction->addObject($invoice)->addObject($order)->save(); +$items = []; +foreach ($order->getItems() as $orderItem) { + $items[$orderItem->getId()] = $orderItem->getQtyOrdered(); +} +$shipment = $objectManager->get(ShipmentFactory::class)->create($order, $items); +$shipment->register(); + +$transaction->addObject($invoice)->addObject($shipment)->addObject($order)->save(); diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_tax.php b/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_tax.php new file mode 100644 index 0000000000000..0c7dc522f5759 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_tax.php @@ -0,0 +1,52 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Sales\Model\Order\Tax\ItemFactory; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Tax\Model\Sales\Order\TaxFactory; + +require 'default_rollback.php'; +require 'order.php'; + +$amount = 45; +$taxFactory = $objectManager->create(TaxFactory::class); + +/** @var \Magento\Tax\Model\Sales\Order\Tax $tax */ +$tax = $taxFactory->create(); +$tax->setOrderId($order->getId()) + ->setCode('US-NY-*-Rate 1') + ->setTitle('US-NY-*-Rate 1') + ->setPercent(8.37) + ->setAmount($amount) + ->setBaseAmount($amount) + ->setBaseRealAmount($amount); +$tax->save(); + +$salesOrderFactory = $objectManager->create(ItemFactory::class); + +/** @var \Magento\Sales\Model\Order\Tax\Item $salesOrderItem */ +$salesOrderItem = $salesOrderFactory->create(); +$salesOrderItem->setOrderId($order->getId()) + ->setStoreId($objectManager->get(StoreManagerInterface::class)->getStore()->getId()) + ->setProductOptions([]); +$salesCollection = $objectManager->create(\Magento\Sales\Model\ResourceModel\Order\Item::class); +$salesCollection->save($salesOrderItem); + +/** @var \Magento\Sales\Model\Order\Tax\Item $salesOrderTaxItem */ +$salesOrderTaxItem = $salesOrderFactory->create(); +$salesOrderTaxItem->setTaxId($tax->getId()) + ->setTaxPercent(8.37) + ->setTaxAmount($amount) + ->setBaseAmount($amount) + ->setRealAmount($amount) + ->setRealBaseAmount($amount) + ->setAppliedTaxes([$tax]) + ->setTaxableItemType('shipping') + ->setItemId($salesOrderItem->getId()); + +$taxItemCollection = $objectManager->create(\Magento\Sales\Model\ResourceModel\Order\Tax\Item::class); +$taxItemCollection->save($salesOrderTaxItem); diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_tax_rollback.php b/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_tax_rollback.php new file mode 100644 index 0000000000000..dd52deab825cb --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_tax_rollback.php @@ -0,0 +1,8 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +require 'order_rollback.php'; diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/quote_with_custom_price.php b/dev/tests/integration/testsuite/Magento/Sales/_files/quote_with_custom_price.php new file mode 100644 index 0000000000000..3ed44bfd69528 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/quote_with_custom_price.php @@ -0,0 +1,73 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\TestFramework\Helper\Bootstrap; + +Bootstrap::getInstance()->loadArea('frontend'); +$product = Bootstrap::getObjectManager()->create(\Magento\Catalog\Model\Product::class); +$product->setTypeId('simple') + ->setAttributeSetId(4) + ->setName('Simple Product') + ->setSku('simple_quote_custom_price') + ->setPrice(10) + ->setTaxClassId(0) + ->setMetaTitle('meta title') + ->setMetaKeyword('meta keyword') + ->setMetaDescription('meta description') + ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) + ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) + ->setStockData( + [ + 'qty' => 100, + 'is_in_stock' => 1, + ] + )->save(); + +$productRepository = Bootstrap::getObjectManager()->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); +$product = $productRepository->get('simple_quote_custom_price'); + +$addressData = include __DIR__ . '/address_data.php'; +$billingAddress = Bootstrap::getObjectManager()->create( + \Magento\Quote\Model\Quote\Address::class, + ['data' => $addressData] +); +$billingAddress->setAddressType('billing'); + +$shippingAddress = clone $billingAddress; +$shippingAddress->setId(null)->setAddressType('shipping'); + +$store = Bootstrap::getObjectManager() + ->get(\Magento\Store\Model\StoreManagerInterface::class) + ->getStore(); + +$requestData = [ + 'qty' => 1, + 'custom_price' => 12, +]; +$request = Bootstrap::getObjectManager()->create(\Magento\Framework\DataObject::class, ['data' => $requestData]); + +/** @var \Magento\Quote\Model\Quote $quote */ +$quote = Bootstrap::getObjectManager()->create(\Magento\Quote\Model\Quote::class); +$quote->setCustomerIsGuest(true) + ->setStoreId($store->getId()) + ->setReservedOrderId('test01') + ->setBillingAddress($billingAddress) + ->setShippingAddress($shippingAddress) + ->addProduct($product, $request); +$quote->getPayment()->setMethod('checkmo'); +$quote->setIsMultiShipping('1'); +$quote->collectTotals(); + +$quoteRepository = Bootstrap::getObjectManager()->create(\Magento\Quote\Api\CartRepositoryInterface::class); +$quoteRepository->save($quote); + +/** @var \Magento\Quote\Model\QuoteIdMask $quoteIdMask */ +$quoteIdMask = Bootstrap::getObjectManager() + ->create(\Magento\Quote\Model\QuoteIdMaskFactory::class) + ->create(); +$quoteIdMask->setQuoteId($quote->getId()); +$quoteIdMask->setDataChanges(true); +$quoteIdMask->save(); diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/quote_with_custom_price_rollback.php b/dev/tests/integration/testsuite/Magento/Sales/_files/quote_with_custom_price_rollback.php new file mode 100644 index 0000000000000..d17ec28063543 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/quote_with_custom_price_rollback.php @@ -0,0 +1,24 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +include __DIR__ . '/quote_rollback.php'; + +/** @var \Magento\Framework\Registry $registry */ +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +/** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ +$productRepository = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + ->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); + +try { + $product = $productRepository->get('simple_quote_custom_price', false, null, true); + $productRepository->delete($product); +} catch (\Magento\Framework\Exception\NoSuchEntityException $exception) { + //Product already removed +} + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/SalesRule/Model/Observer/AssignCouponDataAfterOrderCustomerAssignTest.php b/dev/tests/integration/testsuite/Magento/SalesRule/Model/Observer/AssignCouponDataAfterOrderCustomerAssignTest.php new file mode 100644 index 0000000000000..397650df416e9 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/SalesRule/Model/Observer/AssignCouponDataAfterOrderCustomerAssignTest.php @@ -0,0 +1,291 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\SalesRule\Model\Observer; + +use Magento\Sales\Model\Order; +use Magento\Customer\Model\GroupManagement; +use Magento\SalesRule\Api\CouponRepositoryInterface; +use Magento\SalesRule\Model\Coupon; +use Magento\SalesRule\Model\Rule; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Customer\Model\Data\Customer; +use Magento\TestFramework\Helper\Bootstrap; + +/** + * Class AssignCouponDataAfterOrderCustomerAssignTest + * + * @magentoAppIsolation enabled + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class AssignCouponDataAfterOrderCustomerAssignTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var \Magento\Framework\ObjectManagerInterface + */ + private $objectManager; + + /** + * @var \Magento\Quote\Api\GuestCartManagementInterface + */ + private $assignCouponToCustomerObserver; + + /** + * @var Magento\Sales\Model\OrderRepository + */ + private $orderRepository; + + /** + * @var \Magento\Framework\Event\ManagerInterface + */ + protected $eventManager; + + /** + * @var Magento\Customer\Api\CustomerRepositoryInterface + */ + private $customerRepository; + + /** + * @var Order\OrderCustomerDelegate + */ + private $delegateCustomerService; + + /** + * @var Magento\SalesRule\Model\Rule\CustomerFactory + */ + private $ruleCustomerFactory; + + /** + * @var Rule + */ + private $salesRule; + + /** + * @var Coupon + */ + private $coupon; + + /** + * @var Order + */ + private $order; + + /** + * @var Customer + */ + private $customer; + + /** + * @inheritdoc + */ + protected function setUp() + { + $this->objectManager = Bootstrap::getObjectManager(); + $this->eventManager = $this->createMock(\Magento\Framework\Event\ManagerInterface::class); + $this->orderRepository = $this->objectManager->get(\Magento\Sales\Model\OrderRepository::class); + $this->delegateCustomerService = $this->objectManager->get(Order\OrderCustomerDelegate::class); + $this->customerRepository = $this->objectManager->get(\Magento\Customer\Api\CustomerRepositoryInterface::class); + $this->ruleCustomerFactory = $this->objectManager->get(\Magento\SalesRule\Model\Rule\CustomerFactory::class); + $this->assignCouponToCustomerObserver = $this->objectManager->get( + \Magento\SalesRule\Observer\AssignCouponDataAfterOrderCustomerAssignObserver::class + ); + + $this->salesRule = $this->prepareSalesRule(); + $this->coupon = $this->attachSalesruleCoupon($this->salesRule); + $this->order = $this->makeOrderWithCouponAsGuest($this->coupon); + $this->delegateOrderToBeAssigned($this->order); + $this->customer = $this->registerNewCustomer(); + $this->order->setCustomerId($this->customer->getId()); + } + + /** + * @inheritdoc + */ + protected function tearDown() + { + $this->salesRule = null; + $this->customer = null; + $this->coupon = null; + $this->order = null; + } + + /** + * @magentoDataFixture Magento/Sales/_files/order.php + */ + public function testCouponDataHasBeenAssignedTest() + { + $ruleCustomer = $this->getSalesruleCustomerUsage($this->customer, $this->salesRule); + + // Assert, that rule customer model has been created for specific customer + $this->assertEquals( + $ruleCustomer->getCustomerId(), + $this->customer->getId() + ); + + // Assert, that customer has increased coupon usage of specific rule + $this->assertEquals( + 1, + $ruleCustomer->getTimesUsed() + ); + } + + /** + * @magentoDataFixture Magento/Sales/_files/order.php + */ + public function testOrderCancelingDecreasesCouponUsages() + { + $this->processOrder($this->order); + + // Should not throw exception as bux is fixed now + $this->order->cancel(); + $ruleCustomer = $this->getSalesruleCustomerUsage($this->customer, $this->salesRule); + + // Assert, that rule customer model has been created for specific customer + $this->assertEquals( + $ruleCustomer->getCustomerId(), + $this->customer->getId() + ); + + // Assert, that customer has increased coupon usage of specific rule + $this->assertEquals( + 0, + $ruleCustomer->getTimesUsed() + ); + } + + /** + * @param Order $order + * @return \Magento\Sales\Api\Data\OrderInterface + */ + private function processOrder(Order $order) + { + $order->setState(Order::STATE_PROCESSING); + $order->setStatus(Order::STATE_PROCESSING); + return $this->orderRepository->save($order); + } + + /** + * @param Customer $customer + * @param Rule $rule + * @return Rule\Customer + */ + private function getSalesruleCustomerUsage(Customer $customer, Rule $rule) : \Magento\SalesRule\Model\Rule\Customer + { + $ruleCustomer = $this->ruleCustomerFactory->create(); + return $ruleCustomer->loadByCustomerRule($customer->getId(), $rule->getRuleId()); + } + + /** + * @return Rule + */ + private function prepareSalesRule() : Rule + { + /** @var Rule $salesRule */ + $salesRule = $this->objectManager->create(Rule::class); + $salesRule->setData( + [ + 'name' => '15$ fixed discount on whole cart', + 'is_active' => 1, + 'customer_group_ids' => [GroupManagement::NOT_LOGGED_IN_ID], + 'coupon_type' => Rule::COUPON_TYPE_SPECIFIC, + 'conditions' => [ + [ + 'type' => \Magento\SalesRule\Model\Rule\Condition\Address::class, + 'attribute' => 'base_subtotal', + 'operator' => '>', + 'value' => 45, + ], + ], + 'simple_action' => Rule::CART_FIXED_ACTION, + 'discount_amount' => 15, + 'discount_step' => 0, + 'stop_rules_processing' => 1, + 'website_ids' => [ + $this->objectManager->get(StoreManagerInterface::class)->getWebsite()->getId(), + ], + ] + ); + Bootstrap::getObjectManager()->get( + \Magento\SalesRule\Model\ResourceModel\Rule::class + )->save($salesRule); + + return $salesRule; + } + + /** + * @param Rule $salesRule + * @return Coupon + */ + private function attachSalesruleCoupon(Rule $salesRule) : Coupon + { + $coupon = $this->objectManager->create(Coupon::class); + $coupon->setRuleId($salesRule->getId()) + ->setCode('CART_FIXED_DISCOUNT_15') + ->setType(0); + + Bootstrap::getObjectManager()->get(CouponRepositoryInterface::class)->save($coupon); + + return $coupon; + } + + /** + * @param Coupon $coupon + * @return Order + */ + private function makeOrderWithCouponAsGuest(Coupon $coupon) : Order + { + $order = Bootstrap::getObjectManager()->create(\Magento\Sales\Model\Order::class); + $order->loadByIncrementId('100000001') + ->setCustomerIsGuest(true) + ->setCouponCode($coupon->getCode()) + ->setCreatedAt('2014-10-25 10:10:10') + ->setAppliedRuleIds($coupon->getRuleId()) + ->save(); + + return $order; + } + + /** + * @param Order $order + */ + private function delegateOrderToBeAssigned(Order $order) + { + $this->delegateCustomerService->delegateNew($order->getId()); + } + + /** + * @return Customer + * @throws \Magento\Framework\Exception\InputException + * @throws \Magento\Framework\Exception\LocalizedException + * @throws \Magento\Framework\Exception\State\InputMismatchException + */ + private function registerNewCustomer() : Customer + { + $customer = Bootstrap::getObjectManager()->create( + \Magento\Customer\Api\Data\CustomerInterface::class + ); + + /** @var Magento\Customer\Api\Data\CustomerInterface $customer */ + $customer->setWebsiteId(1) + ->setEmail('customer@example.com') + ->setGroupId(1) + ->setStoreId(1) + ->setPrefix('Mr.') + ->setFirstname('John') + ->setMiddlename('A') + ->setLastname('Smith') + ->setSuffix('Esq.') + ->setDefaultBilling(1) + ->setDefaultShipping(1) + ->setTaxvat('12') + ->setGender(0); + + $customer = $this->customerRepository->save($customer, 'password'); + + return $customer; + } +} diff --git a/dev/tests/integration/testsuite/Magento/SendFriend/Controller/SendmailTest.php b/dev/tests/integration/testsuite/Magento/SendFriend/Controller/SendmailTest.php new file mode 100644 index 0000000000000..a075398e9cdb7 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/SendFriend/Controller/SendmailTest.php @@ -0,0 +1,143 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\SendFriend\Controller; + +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Customer\Model\Session; +use Magento\Framework\Data\Form\FormKey; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Framework\Message\MessageInterface; +use Magento\TestFramework\Request; +use Magento\TestFramework\TestCase\AbstractController; + +/** + * Class SendmailTest + */ +class SendmailTest extends AbstractController +{ + /** + * Share the product to friend as logged in customer + * + * @magentoDbIsolation enabled + * @magentoAppIsolation enabled + * @magentoDataFixture Magento/SendFriend/_files/disable_allow_guest_config.php + * @magentoDataFixture Magento/Customer/_files/customer.php + * @magentoDataFixture Magento/Catalog/_files/products.php + */ + public function testSendActionAsLoggedIn() + { + $product = $this->getProduct(); + $this->login(1); + $this->prepareRequestData(); + + $this->dispatch('sendfriend/product/sendmail/id/' . $product->getId()); + $this->assertSessionMessages( + $this->equalTo(['The link to a friend was sent.']), + MessageInterface::TYPE_SUCCESS + ); + } + + /** + * Share the product to friend as guest customer + * + * @magentoDbIsolation enabled + * @magentoAppIsolation enabled + * @magentoConfigFixture default_store sendfriend/email/enabled 1 + * @magentoConfigFixture default_store sendfriend/email/allow_guest 1 + * @magentoDataFixture Magento/Catalog/_files/products.php + */ + public function testSendActionAsGuest() + { + $product = $this->getProduct(); + $this->prepareRequestData(); + + $this->dispatch('sendfriend/product/sendmail/id/' . $product->getId()); + $this->assertSessionMessages( + $this->equalTo(['The link to a friend was sent.']), + MessageInterface::TYPE_SUCCESS + ); + } + + /** + * Share the product to friend as guest customer with invalid post data + * + * @magentoDbIsolation enabled + * @magentoAppIsolation enabled + * @magentoConfigFixture default_store sendfriend/email/enabled 1 + * @magentoConfigFixture default_store sendfriend/email/allow_guest 1 + * @magentoDataFixture Magento/Catalog/_files/products.php + */ + public function testSendActionAsGuestWithInvalidData() + { + $product = $this->getProduct(); + $this->prepareRequestData(true); + + $this->dispatch('sendfriend/product/sendmail/id/' . $product->getId()); + $this->assertSessionMessages( + $this->equalTo(['Invalid Sender Email']), + MessageInterface::TYPE_ERROR + ); + } + + /** + * @return ProductInterface + */ + private function getProduct() + { + return $this->_objectManager->get(ProductRepositoryInterface::class)->get('custom-design-simple-product'); + } + + /** + * Login the user + * + * @param string $customerId Customer to mark as logged in for the session + * @return void + */ + protected function login($customerId) + { + /** @var Session $session */ + $session = Bootstrap::getObjectManager() + ->get(Session::class); + $session->loginById($customerId); + } + + /** + * @param bool $invalidData + * @return void + */ + private function prepareRequestData($invalidData = false) + { + /** @var FormKey $formKey */ + $formKey = $this->_objectManager->get(FormKey::class); + $post = [ + 'sender' => [ + 'name' => 'Test', + 'email' => 'test@example.com', + 'message' => 'Message', + ], + 'recipients' => [ + 'name' => [ + 'Recipient 1', + 'Recipient 2' + ], + 'email' => [ + 'r1@example.com', + 'r2@example.com' + ] + ], + 'form_key' => $formKey->getFormKey(), + ]; + if ($invalidData) { + unset($post['sender']['email']); + } + + $this->getRequest()->setMethod(Request::METHOD_POST); + $this->getRequest()->setPostValue($post); + } +} diff --git a/dev/tests/integration/testsuite/Magento/SendFriend/_files/disable_allow_guest_config.php b/dev/tests/integration/testsuite/Magento/SendFriend/_files/disable_allow_guest_config.php new file mode 100644 index 0000000000000..202a396132485 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/SendFriend/_files/disable_allow_guest_config.php @@ -0,0 +1,24 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\Framework\App\Config\Value; +use Magento\TestFramework\Helper\Bootstrap; + +/** @var Value $config */ +$config = Bootstrap::getObjectManager()->create(Value::class); +$config->setPath('sendfriend/email/enabled'); +$config->setScope('default'); +$config->setScopeId(0); +$config->setValue(1); +$config->save(); + +/** @var Value $config */ +$config = Bootstrap::getObjectManager()->create(Value::class); +$config->setPath('sendfriend/email/allow_guest'); +$config->setScope('default'); +$config->setScopeId(0); +$config->setValue(0); +$config->save(); diff --git a/dev/tests/integration/testsuite/Magento/Shipping/Controller/Adminhtml/Order/Shipment/AbstractShipmentControllerTest.php b/dev/tests/integration/testsuite/Magento/Shipping/Controller/Adminhtml/Order/Shipment/AbstractShipmentControllerTest.php new file mode 100644 index 0000000000000..0a1926d58624c --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Shipping/Controller/Adminhtml/Order/Shipment/AbstractShipmentControllerTest.php @@ -0,0 +1,92 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Shipping\Controller\Adminhtml\Order\Shipment; + +use Magento\Framework\Api\SearchCriteria; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\Data\Form\FormKey; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\Data\ShipmentInterface; +use Magento\Sales\Model\OrderRepository; +use Magento\TestFramework\Mail\Template\TransportBuilderMock; +use Magento\TestFramework\TestCase\AbstractBackendController; + +/** + * Abstract backend shipment test. + */ +class AbstractShipmentControllerTest extends AbstractBackendController +{ + /** + * @var TransportBuilderMock + */ + protected $transportBuilder; + + /** + * @var OrderRepository + */ + protected $orderRepository; + + /** + * @var FormKey + */ + protected $formKey; + + /** + * @var string + */ + protected $resource = 'Magento_Sales::shipment'; + + /** + * @inheritdoc + */ + protected function setUp() + { + parent::setUp(); + $this->transportBuilder = $this->_objectManager->get(TransportBuilderMock::class); + $this->orderRepository = $this->_objectManager->get(OrderRepository::class); + $this->formKey = $this->_objectManager->get(FormKey::class); + } + + /** + * @param string $incrementalId + * @return OrderInterface|null + */ + protected function getOrder(string $incrementalId) + { + /** @var SearchCriteria $searchCriteria */ + $searchCriteria = $this->_objectManager->create(SearchCriteriaBuilder::class) + ->addFilter(OrderInterface::INCREMENT_ID, $incrementalId) + ->create(); + + $orders = $this->orderRepository->getList($searchCriteria)->getItems(); + /** @var OrderInterface|null $order */ + $order = reset($orders); + + return $order; + } + + /** + * @param OrderInterface $order + * @return ShipmentInterface + */ + protected function getShipment(OrderInterface $order): ShipmentInterface + { + /** @var \Magento\Sales\Model\ResourceModel\Order\Shipment\Collection $shipmentCollection */ + $shipmentCollection = $this->_objectManager->create( + \Magento\Sales\Model\ResourceModel\Order\Shipment\CollectionFactory::class + )->create(); + + /** @var ShipmentInterface $shipment */ + $shipment = $shipmentCollection + ->setOrderFilter($order) + ->setPageSize(1) + ->getFirstItem(); + + return $shipment; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Shipping/Controller/Adminhtml/Order/Shipment/AddCommentTest.php b/dev/tests/integration/testsuite/Magento/Shipping/Controller/Adminhtml/Order/Shipment/AddCommentTest.php new file mode 100644 index 0000000000000..c86ad71e7d5ca --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Shipping/Controller/Adminhtml/Order/Shipment/AddCommentTest.php @@ -0,0 +1,102 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Shipping\Controller\Adminhtml\Order\Shipment; + +use PHPUnit\Framework\Constraint\RegularExpression; +use PHPUnit\Framework\Constraint\StringContains; + +/** + * Class verifies shipment add comment functionality. + * + * @magentoDbIsolation enabled + * @magentoAppArea adminhtml + * @magentoDataFixture Magento/Sales/_files/shipment.php + */ +class AddCommentTest extends AbstractShipmentControllerTest +{ + /** + * @var string + */ + protected $uri = 'backend/admin/order_shipment/addComment'; + + /** + * @return void + */ + public function testSendEmailOnShipmentCommentAdd() + { + $comment = 'Test Shipment Comment'; + $order = $this->prepareRequest( + [ + 'comment' => ['comment' => $comment, 'is_customer_notified' => true], + ] + ); + $this->dispatch('backend/admin/order_shipment/addComment'); + $html = $this->getResponse()->getBody(); + $this->assertContains($comment, $html); + + $message = $this->transportBuilder->getSentMessage(); + $subject =__('Update to your %1 shipment', $order->getStore()->getFrontendName())->render(); + $messageConstraint = $this->logicalAnd( + new StringContains($order->getBillingAddress()->getName()), + new RegularExpression( + sprintf( + "/Your order #%s has been updated with a status of.*%s/", + $order->getIncrementId(), + $order->getFrontendStatusLabel() + ) + ), + new StringContains($comment) + ); + + $this->assertEquals($message->getSubject(), $subject); + $this->assertThat($message->getRawMessage(), $messageConstraint); + } + + /** + * @inheritdoc + */ + public function testAclHasAccess() + { + $this->prepareRequest(['comment', ['comment' => 'Comment']]); + + parent::testAclHasAccess(); + } + + /** + * @inheritdoc + */ + public function testAclNoAccess() + { + $this->prepareRequest(['comment', ['comment' => 'Comment']]); + + parent::testAclNoAccess(); + } + + /** + * @param array $params + * @return \Magento\Sales\Api\Data\OrderInterface|null + */ + private function prepareRequest(array $params = []) + { + $order = $this->getOrder('100000001'); + $shipment = $this->getShipment($order); + + $this->getRequest()->setMethod('POST'); + $this->getRequest()->setParams( + [ + 'id' => $shipment->getEntityId(), + 'form_key' => $this->formKey->getFormKey(), + ] + ); + + $data = $params ?? []; + $this->getRequest()->setPostValue($data); + + return $order; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Shipping/Controller/Adminhtml/Order/Shipment/SaveTest.php b/dev/tests/integration/testsuite/Magento/Shipping/Controller/Adminhtml/Order/Shipment/SaveTest.php new file mode 100644 index 0000000000000..4eb65678583aa --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Shipping/Controller/Adminhtml/Order/Shipment/SaveTest.php @@ -0,0 +1,97 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Shipping\Controller\Adminhtml\Order\Shipment; + +use PHPUnit\Framework\Constraint\StringContains; + +/** + * Class verifies shipment creation functionality. + * + * @magentoDbIsolation enabled + * @magentoAppArea adminhtml + * @magentoDataFixture Magento/Sales/_files/order.php + */ +class SaveTest extends AbstractShipmentControllerTest +{ + /** + * @var string + */ + protected $uri = 'backend/admin/order_shipment/save'; + + /** + * @return void + */ + public function testSendEmailOnShipmentSave() + { + $order = $this->prepareRequest(['shipment' => ['send_email' => true]]); + $this->dispatch('backend/admin/order_shipment/save'); + + $this->assertSessionMessages( + $this->equalTo([(string)__('The shipment has been created.')]), + \Magento\Framework\Message\MessageInterface::TYPE_SUCCESS + ); + $this->assertRedirect($this->stringContains('sales/order/view/order_id/' . $order->getEntityId())); + + $shipment = $this->getShipment($order); + $message = $this->transportBuilder->getSentMessage(); + $subject = __('Your %1 order has shipped', $order->getStore()->getFrontendName())->render(); + $messageConstraint = $this->logicalAnd( + new StringContains($order->getBillingAddress()->getName()), + new StringContains( + 'Thank you for your order from ' . $shipment->getStore()->getFrontendName() + ), + new StringContains( + "Your Shipment #{$shipment->getIncrementId()} for Order #{$order->getIncrementId()}" + ) + ); + + $this->assertEquals($message->getSubject(), $subject); + $this->assertThat($message->getRawMessage(), $messageConstraint); + } + + /** + * @inheritdoc + */ + public function testAclHasAccess() + { + $this->prepareRequest(); + + parent::testAclHasAccess(); + } + + /** + * @inheritdoc + */ + public function testAclNoAccess() + { + $this->prepareRequest(); + + parent::testAclNoAccess(); + } + + /** + * @param array $params + * @return \Magento\Sales\Api\Data\OrderInterface|null + */ + private function prepareRequest(array $params = []) + { + $order = $this->getOrder('100000001'); + $this->getRequest()->setMethod('POST'); + $this->getRequest()->setParams( + [ + 'order_id' => $order->getEntityId(), + 'form_key' => $this->formKey->getFormKey(), + ] + ); + + $data = $params ?? []; + $this->getRequest()->setPostValue($data); + + return $order; + } +} diff --git a/dev/tests/integration/testsuite/Magento/Store/Model/StoreResolver/WebsiteTest.php b/dev/tests/integration/testsuite/Magento/Store/Model/StoreResolver/WebsiteTest.php new file mode 100644 index 0000000000000..cc10b91a031cc --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Store/Model/StoreResolver/WebsiteTest.php @@ -0,0 +1,62 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Store\Model\StoreResolver; + +use Magento\TestFramework\Helper\Bootstrap; + +class WebsiteTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var Website + */ + private $reader; + + protected function setUp() + { + $this->reader = Bootstrap::getObjectManager()->create(Website::class); + } + + /** + * Tests retrieving of stores id by passed scope. + * + * @param string|null $scopeCode website code + * @param int $storesCount + * @magentoDataFixture Magento/Store/_files/second_website_with_two_stores.php + * @magentoDbIsolation disabled + * @magentoAppIsolation enabled + * @dataProvider scopeDataProvider + */ + public function testGetAllowedStoreIds($scopeCode, $storesCount) + { + $this->assertCount($storesCount, $this->reader->getAllowedStoreIds($scopeCode)); + } + + /** + * Provides scopes and corresponding count of resolved stores. + * + * @return array + */ + public function scopeDataProvider(): array + { + return [ + [null, 4], + ['test', 2] + ]; + } + + /** + * Tests retrieving of stores id by passing incorrect scope. + * + * @expectedException \Magento\Framework\Exception\NoSuchEntityException + * @expectedExceptionMessage The website with code not_exists that was requested wasn't found. + */ + public function testIncorrectScope() + { + $this->reader->getAllowedStoreIds('not_exists'); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Store/Model/StoreTest.php b/dev/tests/integration/testsuite/Magento/Store/Model/StoreTest.php index e62436460998f..9150ffb12b82d 100644 --- a/dev/tests/integration/testsuite/Magento/Store/Model/StoreTest.php +++ b/dev/tests/integration/testsuite/Magento/Store/Model/StoreTest.php @@ -4,13 +4,13 @@ * See COPYING.txt for license details. */ -// @codingStandardsIgnoreFile - namespace Magento\Store\Model; +use Magento\Catalog\Model\ProductRepository; use Magento\Framework\App\Bootstrap; use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\UrlInterface; +use Magento\Store\Api\StoreRepositoryInterface; use Zend\Stdlib\Parameters; /** @@ -200,7 +200,7 @@ public function testGetBaseUrlInPub() */ public function testGetBaseUrlForCustomEntryPoint($type, $useCustomEntryPoint, $useStoreCode, $expected) { - /* config operations require store to be loaded */ + /* config operations require store to be loaded */ $this->model->load('default'); \Magento\TestFramework\Helper\Bootstrap::getObjectManager() ->get(\Magento\Framework\App\Config\MutableScopeConfigInterface::class) @@ -269,12 +269,89 @@ public function testIsCanDelete() $this->assertFalse($this->model->isCanDelete()); } + /** + * @magentoDataFixture Magento/Store/_files/core_second_third_fixturestore.php + * @magentoDataFixture Magento/Catalog/_files/product_simple.php + * @magentoDbIsolation disabled + */ public function testGetCurrentUrl() { + $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + $objectManager->get(\Magento\Framework\App\Config\MutableScopeConfigInterface::class) + ->setValue('web/url/use_store', true, ScopeInterface::SCOPE_STORE, 'secondstore'); + $this->model->load('admin'); - $this->model->expects($this->any())->method('getUrl')->will($this->returnValue('http://localhost/index.php')); + $this->model + ->expects($this->any())->method('getUrl') + ->will($this->returnValue('http://localhost/index.php')); $this->assertStringEndsWith('default', $this->model->getCurrentUrl()); $this->assertStringEndsNotWith('default', $this->model->getCurrentUrl(false)); + + /** @var \Magento\Store\Model\Store $secondStore */ + $secondStore = $objectManager->get(StoreRepositoryInterface::class)->get('secondstore'); + + /** @var \Magento\Catalog\Model\ProductRepository $productRepository */ + $productRepository = $objectManager->create(ProductRepository::class); + $product = $productRepository->get('simple'); + $product->setStoreId($secondStore->getId()); + $url = $product->getUrlInStore(); + + $this->assertEquals( + $secondStore->getBaseUrl().'catalog/product/view/id/1/s/simple-product/', + $url + ); + $this->assertEquals( + $secondStore->getBaseUrl().'?___from_store=default', + $secondStore->getCurrentUrl() + ); + $this->assertEquals( + $secondStore->getBaseUrl(), + $secondStore->getCurrentUrl(false) + ); + } + + /** + * @magentoDataFixture Magento/Store/_files/second_store.php + * @magentoDataFixture Magento/Catalog/_files/category_product.php + * @magentoDbIsolation disabled + */ + public function testGetCurrentUrlWithUseStoreInUrlFalse() + { + $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + $objectManager->get(\Magento\Framework\App\Config\ReinitableConfigInterface::class) + ->setValue('web/url/use_store', false, ScopeInterface::SCOPE_STORE, 'default'); + + /** @var \Magento\Store\Model\Store $secondStore */ + $secondStore = $objectManager->get(StoreRepositoryInterface::class)->get('fixture_second_store'); + + /** @var \Magento\Catalog\Model\ProductRepository $productRepository */ + $productRepository = $objectManager->create(ProductRepository::class); + $product = $productRepository->get('simple333'); + + $product->setStoreId($secondStore->getId()); + $url = $product->getUrlInStore(); + + /** @var \Magento\Catalog\Model\CategoryRepository $categoryRepository */ + $categoryRepository = $objectManager->get(\Magento\Catalog\Model\CategoryRepository::class); + $category = $categoryRepository->get(333, $secondStore->getStoreId()); + + $this->assertEquals( + $secondStore->getBaseUrl().'catalog/category/view/s/category-1/id/333/', + $category->getUrl() + ); + $this->assertEquals( + $secondStore->getBaseUrl(). + 'catalog/product/view/id/333/s/simple-product-three/?___store=fixture_second_store', + $url + ); + $this->assertEquals( + $secondStore->getBaseUrl().'?___store=fixture_second_store&___from_store=default', + $secondStore->getCurrentUrl() + ); + $this->assertEquals( + $secondStore->getBaseUrl().'?___store=fixture_second_store', + $secondStore->getCurrentUrl(false) + ); } /** @@ -292,7 +369,11 @@ public function testCRUD() 'sort_order' => 0, 'is_active' => 1, ]); - $crud = new \Magento\TestFramework\Entity($this->model, ['name' => 'new name'], \Magento\Store\Model\Store::class); + $crud = new \Magento\TestFramework\Entity( + $this->model, + ['name' => 'new name'], + \Magento\Store\Model\Store::class + ); $crud->testCrud(); } diff --git a/dev/tests/integration/testsuite/Magento/Swatches/Controller/Adminhtml/Product/AttributeTest.php b/dev/tests/integration/testsuite/Magento/Swatches/Controller/Adminhtml/Product/AttributeTest.php index be9fd96d75892..493c4dcadc1e4 100644 --- a/dev/tests/integration/testsuite/Magento/Swatches/Controller/Adminhtml/Product/AttributeTest.php +++ b/dev/tests/integration/testsuite/Magento/Swatches/Controller/Adminhtml/Product/AttributeTest.php @@ -7,6 +7,8 @@ namespace Magento\Swatches\Controller\Adminhtml\Product; +use Magento\Framework\App\Request\Http as HttpRequest; +use Magento\Framework\Data\Form\FormKey; use Magento\Framework\Exception\LocalizedException; /** @@ -17,6 +19,21 @@ */ class AttributeTest extends \Magento\TestFramework\TestCase\AbstractBackendController { + /** + * @var FormKey + */ + private $formKey; + + /** + * @inheritDoc + */ + protected function setUp() + { + parent::setUp(); + + $this->formKey = $this->_objectManager->get(FormKey::class); + } + /** * Generate random hex color. * @@ -38,22 +55,27 @@ private function getSwatchVisualDataSet(int $optionsCount): array $optionsData = []; $expectedOptionsLabels = []; for ($i = 0; $i < $optionsCount; $i++) { - $order = $i + 1; - $expectedOptionLabelOnStoreView = "value_{$i}_store_1"; + $expectedOptionLabelOnStoreView = 'value_' . $i .'_store_1'; $expectedOptionsLabels[$i+1] = $expectedOptionLabelOnStoreView; - $optionsData []= "optionvisual[order][option_{$i}]={$order}"; - $optionsData []= "defaultvisual[]=option_{$i}"; - $optionsData []= "swatchvisual[value][option_{$i}]={$this->getRandomColor()}"; - $optionsData []= "optionvisual[value][option_{$i}][0]=value_{$i}_admin"; - $optionsData []= "optionvisual[value][option_{$i}][1]={$expectedOptionLabelOnStoreView}"; - $optionsData []= "optionvisual[delete][option_{$i}]="; + $optionId = 'option_' .$i; + $optionRowData = []; + $optionRowData['optionvisual']['order'][$optionId] = $i + 1; + $optionRowData['defaultvisual'][] = $optionId; + $optionRowData['swatchvisual']['value'][$optionId] = $this->getRandomColor(); + $optionRowData['optionvisual']['value'][$optionId][0] = 'value_' . $i .'_admin'; + $optionRowData['optionvisual']['value'][$optionId][1] = $expectedOptionLabelOnStoreView; + $optionRowData['optionvisual']['delete'][$optionId] = ''; + $optionsData[] = http_build_query($optionRowData); } - $optionsData []= "visual_swatch_validation="; - $optionsData []= "visual_swatch_validation_unique="; + return [ 'attribute_data' => array_merge_recursive( [ - 'serialized_swatch_values' => json_encode($optionsData), + 'serialized_options' => json_encode($optionsData), + ], + [ + 'visual_swatch_validation' => '', + 'visual_swatch_validation_unique' => '', ], $this->getAttributePreset(), [ @@ -76,22 +98,27 @@ private function getSwatchTextDataSet(int $optionsCount): array $optionsData = []; $expectedOptionsLabels = []; for ($i = 0; $i < $optionsCount; $i++) { - $order = $i + 1; - $expectedOptionLabelOnStoreView = "value_{$i}_store_1"; + $expectedOptionLabelOnStoreView = 'value_' . $i . '_store_1'; $expectedOptionsLabels[$i+1] = $expectedOptionLabelOnStoreView; - $optionsData []= "optiontext[order][option_{$i}]={$order}"; - $optionsData []= "defaulttext[]=option_{$i}"; - $optionsData []= "swatchtext[value][option_{$i}]=x{$i}"; - $optionsData []= "optiontext[value][option_{$i}][0]=value_{$i}_admin"; - $optionsData []= "optiontext[value][option_{$i}][1]={$expectedOptionLabelOnStoreView}"; - $optionsData []= "optiontext[delete][option_{$i}]="; + $optionId = 'option_' . $i; + $optionRowData = []; + $optionRowData['optiontext']['order'][$optionId] = $i + 1; + $optionRowData['defaulttext'][] = $optionId; + $optionRowData['swatchtext']['value'][$optionId] = 'x' . $i ; + $optionRowData['optiontext']['value'][$optionId][0] = 'value_' . $i . '_admin'; + $optionRowData['optiontext']['value'][$optionId][1]= $expectedOptionLabelOnStoreView; + $optionRowData['optiontext']['delete'][$optionId]=''; + $optionsData[] = http_build_query($optionRowData); } - $optionsData []= "text_swatch_validation="; - $optionsData []= "text_swatch_validation_unique="; + return [ 'attribute_data' => array_merge_recursive( [ - 'serialized_swatch_values' => json_encode($optionsData), + 'serialized_options' => json_encode($optionsData), + ], + [ + 'text_swatch_validation' => '', + 'text_swatch_validation_unique' => '', ], $this->getAttributePreset(), [ @@ -111,7 +138,6 @@ private function getSwatchTextDataSet(int $optionsCount): array private function getAttributePreset(): array { return [ - 'serialized_options' => '[]', 'form_key' => 'XxtpPYjm2YPYUlAt', 'frontend_label' => [ 0 => 'asdasd', @@ -176,7 +202,9 @@ public function testLargeOptionsDataSet( int $expectedOptionsCount, array $expectedLabels ) { + $this->getRequest()->setMethod(HttpRequest::METHOD_POST); $this->getRequest()->setPostValue($attributeData); + $this->getRequest()->setPostValue('form_key', $this->formKey->getFormKey()); $this->dispatch('backend/catalog/product_attribute/save'); $entityTypeId = $this->_objectManager->create( \Magento\Eav\Model\Entity::class diff --git a/dev/tests/integration/testsuite/Magento/Ui/Controller/Adminhtml/Index/Renderer/HandleTest.php b/dev/tests/integration/testsuite/Magento/Ui/Controller/Adminhtml/Index/Renderer/HandleTest.php new file mode 100644 index 0000000000000..3f941dc3664c5 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Ui/Controller/Adminhtml/Index/Renderer/HandleTest.php @@ -0,0 +1,54 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Ui\Controller\Adminhtml\Index\Renderer; + +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Framework\AuthorizationInterface; + +/** + * Test for \Magento\Ui\Controller\Adminhtml\Index\Render\Handle. + * + * @magentoAppArea adminhtml + */ +class HandleTest extends \Magento\TestFramework\TestCase\AbstractBackendController +{ + /** + * @magentoDataFixture Magento/Customer/_files/customer.php + */ + public function testExecuteWhenUserDoesNotHavePermission() + { + Bootstrap::getObjectManager()->configure([ + 'preferences' => [ + AuthorizationInterface::class => \Magento\Ui\Model\AuthorizationMock::class, + ], + ]); + $this->getRequest()->setParam('handle', 'customer_index_index'); + $this->getRequest()->setParam('namespace', 'customer_listing'); + $this->getRequest()->setParam('sorting%5Bfield%5D', 'entity_id'); + $this->getRequest()->setParam('paging%5BpageSize%5D', 20); + $this->getRequest()->setParam('isAjax', 1); + $this->dispatch('backend/mui/index/render_handle'); + $output = $this->getResponse()->getBody(); + $this->assertEmpty($output, 'The acl restriction wasn\'t applied properly'); + } + + /** + * @magentoDataFixture Magento/Customer/_files/customer.php + */ + public function testExecuteWhenUserHasPermission() + { + $this->getRequest()->setParam('handle', 'customer_index_index'); + $this->getRequest()->setParam('namespace', 'customer_listing'); + $this->getRequest()->setParam('sorting%5Bfield%5D', 'entity_id'); + $this->getRequest()->setParam('paging%5BpageSize%5D', 20); + $this->getRequest()->setParam('isAjax', 1); + $this->dispatch('backend/mui/index/render_handle'); + $output = $this->getResponse()->getBody(); + $this->assertNotEmpty($output, 'The acl restriction wasn\'t applied properly'); + } +} diff --git a/dev/tests/integration/testsuite/Magento/Ui/Model/AuthorizationMock.php b/dev/tests/integration/testsuite/Magento/Ui/Model/AuthorizationMock.php new file mode 100644 index 0000000000000..7f92edf96f424 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Ui/Model/AuthorizationMock.php @@ -0,0 +1,27 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Ui\Model; + +/** + * Check current user permission on resource and privilege. + */ +class AuthorizationMock extends \Magento\Framework\Authorization +{ + /** + * Check current user permission on resource and privilege + * + * @param string $resource + * @param string $privilege + * @return boolean + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function isAllowed($resource, $privilege = null) + { + return $resource !== 'Magento_Customer::manage'; + } +} diff --git a/dev/tests/integration/testsuite/Magento/UrlRewrite/Model/StoreSwitcher/RewriteUrlTest.php b/dev/tests/integration/testsuite/Magento/UrlRewrite/Model/StoreSwitcher/RewriteUrlTest.php index d23389718e2c2..c3c8a6536fee9 100644 --- a/dev/tests/integration/testsuite/Magento/UrlRewrite/Model/StoreSwitcher/RewriteUrlTest.php +++ b/dev/tests/integration/testsuite/Magento/UrlRewrite/Model/StoreSwitcher/RewriteUrlTest.php @@ -90,7 +90,8 @@ public function testSwitchToExistingPage() $storeRepository = $this->objectManager->create(\Magento\Store\Api\StoreRepositoryInterface::class); $toStore = $storeRepository->get($toStoreCode); - $redirectUrl = $expectedUrl = "http://localhost/page-c"; + $redirectUrl = "http://localhost/index.php/page-c/"; + $expectedUrl = "http://localhost/index.php/page-c-on-2nd-store"; $this->assertEquals($expectedUrl, $this->storeSwitcher->switch($fromStore, $toStore, $redirectUrl)); } diff --git a/dev/tests/js/jasmine/tests/lib/mage/validation.test.js b/dev/tests/js/jasmine/tests/lib/mage/validation.test.js index ccf3591be0dfe..2a3775ac538fc 100644 --- a/dev/tests/js/jasmine/tests/lib/mage/validation.test.js +++ b/dev/tests/js/jasmine/tests/lib/mage/validation.test.js @@ -1142,4 +1142,24 @@ define([ .call($.validator.prototype, '30', el1, null)).toEqual(false); }); }); + + describe('Testing validate-forbidden-extensions', function () { + it('validate-forbidden-extensions', function () { + var el1 = $('<input type="text" value="" ' + + 'class="validate-extensions" data-validation-params="php,phtml">').get(0); + + expect($.validator.methods['validate-forbidden-extensions'] + .call($.validator.prototype, 'php', el1, null)).toEqual(false); + expect($.validator.methods['validate-forbidden-extensions'] + .call($.validator.prototype, 'php,phtml', el1, null)).toEqual(false); + expect($.validator.methods['validate-forbidden-extensions'] + .call($.validator.prototype, 'html', el1, null)).toEqual(true); + expect($.validator.methods['validate-forbidden-extensions'] + .call($.validator.prototype, 'html,png', el1, null)).toEqual(true); + expect($.validator.methods['validate-forbidden-extensions'] + .call($.validator.prototype, 'php,html', el1, null)).toEqual(false); + expect($.validator.methods['validate-forbidden-extensions'] + .call($.validator.prototype, 'html,php', el1, null)).toEqual(false); + }); + }); }); diff --git a/dev/tests/static/framework/Magento/CodeMessDetector/Rule/Design/RequestAwareBlockMethod.php b/dev/tests/static/framework/Magento/CodeMessDetector/Rule/Design/RequestAwareBlockMethod.php new file mode 100644 index 0000000000000..9ce891da718b4 --- /dev/null +++ b/dev/tests/static/framework/Magento/CodeMessDetector/Rule/Design/RequestAwareBlockMethod.php @@ -0,0 +1,53 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\CodeMessDetector\Rule\Design; + +use PHPMD\AbstractNode; +use PHPMD\AbstractRule; +use PHPMD\Node\ClassNode; +use PHPMD\Node\MethodNode; +use PDepend\Source\AST\ASTMethod; +use PHPMD\Rule\MethodAware; + +/** + * Detect direct request usages. + */ +class RequestAwareBlockMethod extends AbstractRule implements MethodAware +{ + /** + * @inheritDoc + * + * @param ASTMethod|MethodNode $method + */ + public function apply(AbstractNode $method) + { + $definedIn = $method->getParentType(); + try { + $isBlock = ($definedIn instanceof ClassNode) + && is_subclass_of( + $definedIn->getFullQualifiedName(), + \Magento\Framework\View\Element\AbstractBlock::class + ); + } catch (\Throwable $exception) { + //Failed to load classes. + return; + } + + if ($isBlock) { + $nodes = $method->findChildrenOfType('PropertyPostfix') + $method->findChildrenOfType('MethodPostfix'); + foreach ($nodes as $node) { + $name = mb_strtolower($node->getFirstChildOfType('Identifier')->getImage()); + if ($name === '_request' || $name === 'getrequest') { + $this->addViolation($method, [$method->getFullQualifiedName()]); + break; + } + } + } + } +} diff --git a/dev/tests/static/framework/Magento/CodeMessDetector/resources/rulesets/design.xml b/dev/tests/static/framework/Magento/CodeMessDetector/resources/rulesets/design.xml index 61d4f7b3be81d..b527b26784f98 100644 --- a/dev/tests/static/framework/Magento/CodeMessDetector/resources/rulesets/design.xml +++ b/dev/tests/static/framework/Magento/CodeMessDetector/resources/rulesets/design.xml @@ -29,6 +29,37 @@ final class Foo } class Baz { final public function bad() {} +} + ]]> + </example> + </rule> + <rule name="RequestAwareBlockMethod" + class="Magento\CodeMessDetector\Rule\Design\RequestAwareBlockMethod" + message="{0} uses request object directly. Add user input validation and suppress this warning."> + <description> + <![CDATA[ +Blocks must not depend on being used with certain controllers. +If you use request object in a block directly you must validate all user input inside the block. + ]]> + </description> + <priority>2</priority> + <properties /> + <example> + <![CDATA[ +class MyOrder extends AbstractBlock +{ + + ....... + + public function getOrder() + { + $orderId = $this->getRequest()->getParam('order_id'); + //Validate customer having such order. + if (!$this->hasOrder($this->getCustomerId(), $orderId)) { + ...deny access... + } + ..... + } } ]]> </example> diff --git a/dev/tests/static/testsuite/Magento/Test/Legacy/ObsoleteCodeTest.php b/dev/tests/static/testsuite/Magento/Test/Legacy/ObsoleteCodeTest.php index 8a00037d2513b..2a6079d619d4c 100644 --- a/dev/tests/static/testsuite/Magento/Test/Legacy/ObsoleteCodeTest.php +++ b/dev/tests/static/testsuite/Magento/Test/Legacy/ObsoleteCodeTest.php @@ -112,7 +112,7 @@ public function testPhpFiles() $changedFiles = ChangedFiles::getPhpFiles(__DIR__ . '/../_files/changed_files*'); $blacklistFiles = $this->getBlacklistFiles(); foreach ($blacklistFiles as $blacklistFile) { - unset($changedFiles[BP . $blacklistFile]); + unset($changedFiles[$blacklistFile]); } $invoker( function ($file) { @@ -920,7 +920,7 @@ private function processPattern($appPath, $pattern) $fileSet = glob($appPath . DIRECTORY_SEPARATOR . $pattern, GLOB_NOSORT); foreach ($fileSet as $file) { - $files[] = substr($file, $relativePathStart); + $files[] = ltrim(substr($file, $relativePathStart), '/'); } return $files; diff --git a/dev/tests/static/testsuite/Magento/Test/Less/_files/blacklist/old.txt b/dev/tests/static/testsuite/Magento/Test/Less/_files/blacklist/old.txt index 70764344de69b..7e29ef51964bc 100644 --- a/dev/tests/static/testsuite/Magento/Test/Less/_files/blacklist/old.txt +++ b/dev/tests/static/testsuite/Magento/Test/Less/_files/blacklist/old.txt @@ -4,6 +4,7 @@ app/design/adminhtml/Magento/backend/Magento_ConfigurableProduct/web/css/source/ app/design/adminhtml/Magento/backend/Magento_Developer/web/css/source/_module-old.less app/design/adminhtml/Magento/backend/Magento_Enterprise/web/css/source/_module-old.less app/design/adminhtml/Magento/backend/Magento_Msrp/web/css/source/_module-old.less +app/design/adminhtml/Magento/backend/Magento_Sales/web/css/source/module/_order.less app/design/adminhtml/Magento/backend/Magento_Tax/web/css/source/_module-old.less app/design/adminhtml/Magento/backend/Magento_Theme/web/css/source/_module-old.less app/design/adminhtml/Magento/backend/Magento_Ui/web/css/source/_module-old.less diff --git a/dev/tests/static/testsuite/Magento/Test/Php/_files/phpcpd/blacklist/common.txt b/dev/tests/static/testsuite/Magento/Test/Php/_files/phpcpd/blacklist/common.txt index 0ab8e0216c249..c3a66f0c33f0f 100644 --- a/dev/tests/static/testsuite/Magento/Test/Php/_files/phpcpd/blacklist/common.txt +++ b/dev/tests/static/testsuite/Magento/Test/Php/_files/phpcpd/blacklist/common.txt @@ -199,3 +199,5 @@ Magento/Framework/MessageQueue/Topology/Config/ExchangeConfigItem IntegrationConfig.php *Test.php setup/performance-toolkit/aggregate-report +Magento/Customer/Model/FileUploaderDataResolver.php +Magento/Customer/Model/Customer/DataProvider.php diff --git a/dev/tests/static/testsuite/Magento/Test/Php/_files/phpmd/ruleset.xml b/dev/tests/static/testsuite/Magento/Test/Php/_files/phpmd/ruleset.xml index 76f76e2b23c56..7fad5c3a3670b 100644 --- a/dev/tests/static/testsuite/Magento/Test/Php/_files/phpmd/ruleset.xml +++ b/dev/tests/static/testsuite/Magento/Test/Php/_files/phpmd/ruleset.xml @@ -47,5 +47,5 @@ <!-- Magento Specific Rules --> <rule ref="Magento/CodeMessDetector/resources/rulesets/design.xml/FinalImplementation" /> - + <rule ref="Magento/CodeMessDetector/resources/rulesets/design.xml/RequestAwareBlockMethod" /> </ruleset> diff --git a/lib/internal/Magento/Framework/App/DeploymentConfig.php b/lib/internal/Magento/Framework/App/DeploymentConfig.php index 615c295675adc..beb4f98ae76bd 100644 --- a/lib/internal/Magento/Framework/App/DeploymentConfig.php +++ b/lib/internal/Magento/Framework/App/DeploymentConfig.php @@ -70,6 +70,11 @@ public function get($key = null, $defaultValue = null) if ($key === null) { return $this->flatData; } + + if (array_key_exists($key, $this->flatData) && $this->flatData[$key] === null) { + return ''; + } + return $this->flatData[$key] ?? $defaultValue; } diff --git a/lib/internal/Magento/Framework/App/ScopeDefault.php b/lib/internal/Magento/Framework/App/ScopeDefault.php index 2ea62387145bf..e62d19f9ffbb4 100644 --- a/lib/internal/Magento/Framework/App/ScopeDefault.php +++ b/lib/internal/Magento/Framework/App/ScopeDefault.php @@ -17,7 +17,7 @@ class ScopeDefault implements ScopeInterface */ public function getCode() { - return 'default'; + return ''; } /** @@ -27,7 +27,7 @@ public function getCode() */ public function getId() { - return 1; + return 0; } /** diff --git a/lib/internal/Magento/Framework/App/Test/Unit/DeploymentConfigTest.php b/lib/internal/Magento/Framework/App/Test/Unit/DeploymentConfigTest.php index 80ab2302dc91c..3508cfed0777b 100644 --- a/lib/internal/Magento/Framework/App/Test/Unit/DeploymentConfigTest.php +++ b/lib/internal/Magento/Framework/App/Test/Unit/DeploymentConfigTest.php @@ -140,4 +140,43 @@ public function keyCollisionDataProvider() ] ]; } + + /** + * @param string $key + * @param string|null $expectedFlattenData + * @return void + * @dataProvider getDataProvider + */ + public function testGet(string $key, $expectedFlattenData) + { + $flatData = [ + 'key1' => 'value', + 'key2' => null, + ]; + + $this->reader->expects($this->once())->method('load')->willReturn($flatData); + + $this->assertEquals($expectedFlattenData, $this->_deploymentConfig->get($key)); + } + + /** + * @return array + */ + public function getDataProvider(): array + { + return [ + [ + 'key' => 'key1', + 'expectedFlattenData' => 'value', + ], + [ + 'key' => 'key2', + 'expectedFlattenData' => '', + ], + [ + 'key' => 'key3', + 'expectedFlattenData' => null, + ], + ]; + } } diff --git a/lib/internal/Magento/Framework/Archive/Zip.php b/lib/internal/Magento/Framework/Archive/Zip.php index f33ad8700f056..c41f8b28ce348 100644 --- a/lib/internal/Magento/Framework/Archive/Zip.php +++ b/lib/internal/Magento/Framework/Archive/Zip.php @@ -4,13 +4,11 @@ * See COPYING.txt for license details. */ -/** - * Class to work with zip archives - * - * @author Magento Core Team <core@magentocommerce.com> - */ namespace Magento\Framework\Archive; +/** + * Zip compressed file archive. + */ class Zip extends AbstractArchive implements ArchiveInterface { /** @@ -54,11 +52,34 @@ public function pack($source, $destination) public function unpack($source, $destination) { $zip = new \ZipArchive(); - $zip->open($source); - $filename = $zip->getNameIndex(0); - $zip->extractTo(dirname($destination), $filename); - rename(dirname($destination).'/'.$filename, $destination); - $zip->close(); + if ($zip->open($source) === true) { + $filename = $this->filterRelativePaths($zip->getNameIndex(0) ?: ''); + if ($filename) { + $zip->extractTo(dirname($destination), $filename); + rename(dirname($destination).'/'.$filename, $destination); + } else { + $destination = ''; + } + $zip->close(); + } else { + $destination = ''; + } + return $destination; } + + /** + * Filter file names with relative paths. + * + * @param string $path + * @return string + */ + private function filterRelativePaths(string $path): string + { + if ($path && preg_match('#^\s*(../)|(/../)#i', $path)) { + $path = ''; + } + + return $path; + } } diff --git a/lib/internal/Magento/Framework/Backup/BackupInterface.php b/lib/internal/Magento/Framework/Backup/BackupInterface.php index 3d054bdbd1a9c..16aada9689c11 100644 --- a/lib/internal/Magento/Framework/Backup/BackupInterface.php +++ b/lib/internal/Magento/Framework/Backup/BackupInterface.php @@ -13,6 +13,8 @@ /** * @api + * + * @deprecated Backups should be done using other means. */ interface BackupInterface { diff --git a/lib/internal/Magento/Framework/Backup/Db/BackupDbInterface.php b/lib/internal/Magento/Framework/Backup/Db/BackupDbInterface.php index 13e3c562bb527..a019ccac06b66 100644 --- a/lib/internal/Magento/Framework/Backup/Db/BackupDbInterface.php +++ b/lib/internal/Magento/Framework/Backup/Db/BackupDbInterface.php @@ -7,6 +7,8 @@ /** * @api + * + * @deprecated Backups should be done using other means. */ interface BackupDbInterface { diff --git a/lib/internal/Magento/Framework/Backup/Db/BackupInterface.php b/lib/internal/Magento/Framework/Backup/Db/BackupInterface.php index f7459f629cb4a..ae5879290eb20 100644 --- a/lib/internal/Magento/Framework/Backup/Db/BackupInterface.php +++ b/lib/internal/Magento/Framework/Backup/Db/BackupInterface.php @@ -7,6 +7,8 @@ /** * @api + * + * @deprecated Backups should be done using other means. */ interface BackupInterface { diff --git a/lib/internal/Magento/Framework/Cache/Backend/Database.php b/lib/internal/Magento/Framework/Cache/Backend/Database.php index 291078383014a..231a8584cc8a5 100644 --- a/lib/internal/Magento/Framework/Cache/Backend/Database.php +++ b/lib/internal/Magento/Framework/Cache/Backend/Database.php @@ -27,11 +27,11 @@ * ) ENGINE=InnoDB DEFAULT CHARSET=utf8; */ -/** - * Database cache backend - */ namespace Magento\Framework\Cache\Backend; +/** + * Database cache backend. + */ class Database extends \Zend_Cache_Backend implements \Zend_Cache_Backend_ExtendedInterface { /** @@ -139,7 +139,7 @@ protected function _getTagsTable() * * Note : return value is always "string" (unserialization is done by the core not by the backend) * - * @param string $id Cache id + * @param string $id Cache id * @param boolean $doNotTestCacheValidity If set to true, the cache validity won't be tested * @return string|false cached datas */ @@ -432,7 +432,7 @@ public function touch($id, $extraLifetime) return $this->_getConnection()->update( $this->_getDataTable(), ['expire_time' => new \Zend_Db_Expr('expire_time+' . $extraLifetime)], - ['id=?' => $id, 'expire_time = 0 OR expire_time>' => time()] + ['id=?' => $id, 'expire_time = 0 OR expire_time>?' => time()] ); } else { return true; diff --git a/lib/internal/Magento/Framework/DB/Adapter/Pdo/Mysql.php b/lib/internal/Magento/Framework/DB/Adapter/Pdo/Mysql.php index 3d06e27542f07..3689aeef81e0b 100644 --- a/lib/internal/Magento/Framework/DB/Adapter/Pdo/Mysql.php +++ b/lib/internal/Magento/Framework/DB/Adapter/Pdo/Mysql.php @@ -2854,6 +2854,7 @@ public function endSetup() * - array("gteq" => $greaterOrEqualValue) * - array("lteq" => $lessOrEqualValue) * - array("finset" => $valueInSet) + * - array("nfinset" => $valueNotInSet) * - array("regexp" => $regularExpression) * - array("seq" => $stringValue) * - array("sneq" => $stringValue) @@ -2883,6 +2884,7 @@ public function prepareSqlCondition($fieldName, $condition) 'gteq' => "{{fieldName}} >= ?", 'lteq' => "{{fieldName}} <= ?", 'finset' => "FIND_IN_SET(?, {{fieldName}})", + 'nfinset' => "NOT FIND_IN_SET(?, {{fieldName}})", 'regexp' => "{{fieldName}} REGEXP ?", 'from' => "{{fieldName}} >= ?", 'to' => "{{fieldName}} <= ?", diff --git a/lib/internal/Magento/Framework/DB/Query/BatchRangeIterator.php b/lib/internal/Magento/Framework/DB/Query/BatchRangeIterator.php index cc2f5a91f73fd..af0ddc9b18b9f 100644 --- a/lib/internal/Magento/Framework/DB/Query/BatchRangeIterator.php +++ b/lib/internal/Magento/Framework/DB/Query/BatchRangeIterator.php @@ -107,7 +107,7 @@ public function __construct( public function current() { if (null === $this->currentSelect) { - $this->isValid = ($this->currentOffset + $this->batchSize) <= $this->totalItemCount; + $this->isValid = $this->currentOffset < $this->totalItemCount; $this->currentSelect = $this->initSelectObject(); } return $this->currentSelect; @@ -138,7 +138,7 @@ public function next() if (null === $this->currentSelect) { $this->current(); } - $this->isValid = ($this->batchSize + $this->currentOffset) <= $this->totalItemCount; + $this->isValid = $this->currentOffset < $this->totalItemCount; $select = $this->initSelectObject(); if ($this->isValid) { $this->iteration++; diff --git a/lib/internal/Magento/Framework/DB/Statement/Pdo/Mysql.php b/lib/internal/Magento/Framework/DB/Statement/Pdo/Mysql.php index 7b8314a76f32e..d24bc5fef6ef6 100644 --- a/lib/internal/Magento/Framework/DB/Statement/Pdo/Mysql.php +++ b/lib/internal/Magento/Framework/DB/Statement/Pdo/Mysql.php @@ -3,21 +3,20 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +namespace Magento\Framework\DB\Statement\Pdo; + +use Magento\Framework\DB\Statement\Parameter; /** * Mysql DB Statement * * @author Magento Core Team <core@magentocommerce.com> */ -namespace Magento\Framework\DB\Statement\Pdo; - -use Magento\Framework\DB\Statement\Parameter; - class Mysql extends \Zend_Db_Statement_Pdo { + /** - * Executes statement with binding values to it. - * Allows transferring specific options to DB driver. + * Executes statement with binding values to it. Allows transferring specific options to DB driver. * * @param array $params Array of values to bind to parameter placeholders. * @return bool @@ -61,11 +60,9 @@ public function _executeWithBinding(array $params) $statement->bindParam($paramName, $bindValues[$name], $dataType, $length, $driverOptions); } - try { + return $this->tryExecute(function () use ($statement) { return $statement->execute(); - } catch (\PDOException $e) { - throw new \Zend_Db_Statement_Exception($e->getMessage(), (int)$e->getCode(), $e); - } + }); } /** @@ -90,7 +87,29 @@ public function _execute(array $params = null) if ($specialExecute) { return $this->_executeWithBinding($params); } else { - return parent::_execute($params); + return $this->tryExecute(function () use ($params) { + return $params !== null ? $this->_stmt->execute($params) : $this->_stmt->execute(); + }); + } + } + + /** + * Executes query and avoid warnings. + * + * @param callable $callback + * @return bool + * @throws \Zend_Db_Statement_Exception + */ + private function tryExecute($callback) + { + $previousLevel = error_reporting(\E_ERROR); // disable warnings for PDO bugs #63812, #74401 + try { + return $callback(); + } catch (\PDOException $e) { + $message = sprintf('%s, query was: %s', $e->getMessage(), $this->_stmt->queryString); + throw new \Zend_Db_Statement_Exception($message, (int)$e->getCode(), $e); + } finally { + error_reporting($previousLevel); } } } diff --git a/lib/internal/Magento/Framework/DB/Test/Unit/DB/Statement/MysqlTest.php b/lib/internal/Magento/Framework/DB/Test/Unit/DB/Statement/MysqlTest.php new file mode 100644 index 0000000000000..714dfe6bb1059 --- /dev/null +++ b/lib/internal/Magento/Framework/DB/Test/Unit/DB/Statement/MysqlTest.php @@ -0,0 +1,154 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\Framework\DB\Test\Unit\DB\Statement; + +use Magento\Framework\DB\Statement\Parameter; +use Magento\Framework\DB\Statement\Pdo\Mysql; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * @inheritdoc + */ +class MysqlTest extends TestCase +{ + /** + * @var \Zend_Db_Adapter_Abstract|MockObject + */ + private $adapterMock; + + /** + * @var \PDO|MockObject + */ + private $pdoMock; + + /** + * @var \Zend_Db_Profiler|MockObject + */ + private $zendDbProfilerMock; + + /** + * @var \PDOStatement|MockObject + */ + private $pdoStatementMock; + + /** + * @inheritdoc + */ + public function setUp() + { + $this->adapterMock = $this->getMockForAbstractClass( + \Zend_Db_Adapter_Abstract::class, + [], + '', + false, + true, + true, + ['getConnection', 'getProfiler'] + ); + $this->pdoMock = $this->createMock(\PDO::class); + $this->adapterMock->expects($this->once()) + ->method('getConnection') + ->willReturn($this->pdoMock); + $this->zendDbProfilerMock = $this->createMock(\Zend_Db_Profiler::class); + $this->adapterMock->expects($this->once()) + ->method('getProfiler') + ->willReturn($this->zendDbProfilerMock); + $this->pdoStatementMock = $this->createMock(\PDOStatement::class); + } + + public function testExecuteWithoutParams() + { + $query = 'SET @a=1;'; + $this->pdoMock->expects($this->once()) + ->method('prepare') + ->with($query) + ->willReturn($this->pdoStatementMock); + $this->pdoStatementMock->expects($this->once()) + ->method('execute'); + (new Mysql($this->adapterMock, $query))->_execute(); + } + + public function testExecuteWhenThrowPDOException() + { + $this->expectException(\Zend_Db_Statement_Exception::class); + $this->expectExceptionMessage('test message, query was:'); + $errorReporting = error_reporting(); + $query = 'SET @a=1;'; + $this->pdoMock->expects($this->once()) + ->method('prepare') + ->with($query) + ->willReturn($this->pdoStatementMock); + $this->pdoStatementMock->expects($this->once()) + ->method('execute') + ->willThrowException(new \PDOException('test message')); + + $this->assertEquals($errorReporting, error_reporting(), 'Error report level was\'t restored'); + + (new Mysql($this->adapterMock, $query))->_execute(); + } + + public function testExecuteWhenParamsAsPrimitives() + { + $params = [':param1' => 'value1', ':param2' => 'value2']; + $query = 'UPDATE `some_table1` SET `col1`=\'val1\' WHERE `param1`=\':param1\' AND `param2`=\':param2\';'; + $this->pdoMock->expects($this->once()) + ->method('prepare') + ->with($query) + ->willReturn($this->pdoStatementMock); + $this->pdoStatementMock->expects($this->never()) + ->method('bindParam'); + $this->pdoStatementMock->expects($this->once()) + ->method('execute') + ->with($params); + + (new Mysql($this->adapterMock, $query))->_execute($params); + } + + public function testExecuteWhenParamsAsParameterObject() + { + $param1 = $this->createMock(Parameter::class); + $param1Value = 'SomeValue'; + $param1DataType = 'dataType'; + $param1Length = '9'; + $param1DriverOptions = 'some driver options'; + $param1->expects($this->once()) + ->method('getIsBlob') + ->willReturn(false); + $param1->expects($this->once()) + ->method('getDataType') + ->willReturn($param1DataType); + $param1->expects($this->once()) + ->method('getLength') + ->willReturn($param1Length); + $param1->expects($this->once()) + ->method('getDriverOptions') + ->willReturn($param1DriverOptions); + $param1->expects($this->once()) + ->method('getValue') + ->willReturn($param1Value); + $params = [ + ':param1' => $param1, + ':param2' => 'value2', + ]; + $query = 'UPDATE `some_table1` SET `col1`=\'val1\' WHERE `param1`=\':param1\' AND `param2`=\':param2\';'; + $this->pdoMock->expects($this->once()) + ->method('prepare') + ->with($query) + ->willReturn($this->pdoStatementMock); + $this->pdoStatementMock->expects($this->exactly(2)) + ->method('bindParam') + ->withConsecutive( + [':param1', $param1Value, $param1DataType, $param1Length, $param1DriverOptions], + [':param2', 'value2', \PDO::PARAM_STR, null, null] + ); + $this->pdoStatementMock->expects($this->once()) + ->method('execute'); + + (new Mysql($this->adapterMock, $query))->_execute($params); + } +} diff --git a/lib/internal/Magento/Framework/Data/Collection.php b/lib/internal/Magento/Framework/Data/Collection.php index 099753ac1b56f..4e9132d49a2e2 100644 --- a/lib/internal/Magento/Framework/Data/Collection.php +++ b/lib/internal/Magento/Framework/Data/Collection.php @@ -7,6 +7,7 @@ use Magento\Framework\Data\Collection\EntityFactoryInterface; use Magento\Framework\Option\ArrayInterface; +use Magento\Framework\Exception\InputException; /** * Data collection @@ -234,12 +235,20 @@ protected function _setIsLoaded($flag = true) * Get current collection page * * @param int $displacement + * @throws \Magento\Framework\Exception\InputException * @return int */ public function getCurPage($displacement = 0) { if ($this->_curPage + $displacement < 1) { return 1; + } elseif ($this->_curPage > $this->getLastPageNumber() && $displacement === 0) { + throw new InputException( + __( + 'currentPage value %1 specified is greater than the %2 page(s) available.', + [$this->_curPage, $this->getLastPageNumber()] + ) + ); } elseif ($this->_curPage + $displacement > $this->getLastPageNumber()) { return $this->getLastPageNumber(); } else { diff --git a/lib/internal/Magento/Framework/Data/Form/Element/Date.php b/lib/internal/Magento/Framework/Data/Form/Element/Date.php index 1920115ff0a3a..e762a641bfdcc 100644 --- a/lib/internal/Magento/Framework/Data/Form/Element/Date.php +++ b/lib/internal/Magento/Framework/Data/Form/Element/Date.php @@ -82,13 +82,13 @@ public function setValue($value) $this->_value = $value; return $this; } - if (preg_match('/^[0-9]+$/', $value)) { - $this->_value = (new \DateTime())->setTimestamp($this->_toTimestamp($value)); - return $this; - } - try { - $this->_value = new \DateTime($value, new \DateTimeZone($this->localeDate->getConfigTimezone())); + if (preg_match('/^[0-9]+$/', $value)) { + $this->_value = (new \DateTime())->setTimestamp($this->_toTimestamp($value)); + } else { + $this->_value = new \DateTime($value); + $this->_value->setTimezone(new \DateTimeZone($this->localeDate->getConfigTimezone())); + } } catch (\Exception $e) { $this->_value = ''; } diff --git a/lib/internal/Magento/Framework/Data/Form/Element/Editor.php b/lib/internal/Magento/Framework/Data/Form/Element/Editor.php index 6ed2d8293e61d..412fedaec3831 100644 --- a/lib/internal/Magento/Framework/Data/Form/Element/Editor.php +++ b/lib/internal/Magento/Framework/Data/Form/Element/Editor.php @@ -487,4 +487,13 @@ protected function isToggleButtonVisible() { return !$this->getConfig()->hasData('toggle_button') || $this->getConfig('toggle_button'); } + + /** + * @inheritdoc + */ + public function getHtmlId() + { + $suffix = $this->getConfig('dynamic_id') ? '${ $.wysiwygUniqueSuffix }' : ''; + return parent::getHtmlId() . $suffix; + } } diff --git a/lib/internal/Magento/Framework/Data/Form/Element/Text.php b/lib/internal/Magento/Framework/Data/Form/Element/Text.php index eb157c7279a71..1b0946db796cb 100644 --- a/lib/internal/Magento/Framework/Data/Form/Element/Text.php +++ b/lib/internal/Magento/Framework/Data/Form/Element/Text.php @@ -4,15 +4,13 @@ * See COPYING.txt for license details. */ -/** - * Form text element - * - * @author Magento Core Team <core@magentocommerce.com> - */ namespace Magento\Framework\Data\Form\Element; use Magento\Framework\Escaper; +/** + * Form text element + */ class Text extends AbstractElement { /** @@ -65,7 +63,8 @@ public function getHtmlAttributes() 'placeholder', 'data-form-part', 'data-role', - 'data-action' + 'data-validation-params', + 'data-action', ]; } } diff --git a/lib/internal/Magento/Framework/Data/Test/Unit/CollectionTest.php b/lib/internal/Magento/Framework/Data/Test/Unit/CollectionTest.php index 2ecc67e1eb70e..0b6ddd6999d3e 100644 --- a/lib/internal/Magento/Framework/Data/Test/Unit/CollectionTest.php +++ b/lib/internal/Magento/Framework/Data/Test/Unit/CollectionTest.php @@ -7,6 +7,9 @@ // @codingStandardsIgnoreFile +/** + * Class for Collection test. + */ class CollectionTest extends \PHPUnit\Framework\TestCase { /** @@ -14,6 +17,9 @@ class CollectionTest extends \PHPUnit\Framework\TestCase */ protected $_model; + /** + * @inheritdoc + */ protected function setUp() { $this->_model = new \Magento\Framework\Data\Collection( @@ -21,6 +27,11 @@ protected function setUp() ); } + /** + * Test for method removeAllItems. + * + * @return void + */ public function testRemoveAllItems() { $this->_model->addItem(new \Magento\Framework\DataObject()); @@ -32,6 +43,7 @@ public function testRemoveAllItems() /** * Test loadWithFilter() + * * @return void */ public function testLoadWithFilter() @@ -44,6 +56,8 @@ public function testLoadWithFilter() } /** + * Test for method etItemObjectClass. + * * @dataProvider setItemObjectClassDataProvider */ public function testSetItemObjectClass($class) @@ -53,6 +67,8 @@ public function testSetItemObjectClass($class) } /** + * Data provider. + * * @return array */ public function setItemObjectClassDataProvider() @@ -61,6 +77,8 @@ public function setItemObjectClassDataProvider() } /** + * Test for method setItemObjectClass with exception. + * * @expectedException \InvalidArgumentException * @expectedExceptionMessage Incorrect_ClassName does not extend \Magento\Framework\DataObject */ @@ -69,12 +87,22 @@ public function testSetItemObjectClassException() $this->_model->setItemObjectClass('Incorrect_ClassName'); } + /** + * Test for method addFilter. + * + * @return void + */ public function testAddFilter() { $this->_model->addFilter('field1', 'value'); $this->assertEquals('field1', $this->_model->getFilter('field1')->getData('field')); } + /** + * Test for method getFilters. + * + * @return void + */ public function testGetFilters() { $this->_model->addFilter('field1', 'value'); @@ -83,12 +111,22 @@ public function testGetFilters() $this->assertEquals('field2', $this->_model->getFilter(['field1', 'field2'])[1]->getData('field')); } + /** + * Test for method get non existion filters. + * + * @return void + */ public function testGetNonExistingFilters() { $this->assertEmpty($this->_model->getFilter([])); $this->assertEmpty($this->_model->getFilter('non_existing_filter')); } + /** + * Test for lag. + * + * @return void + */ public function testFlag() { $this->_model->setFlag('flag_name', 'flag_value'); @@ -97,12 +135,35 @@ public function testFlag() $this->assertNull($this->_model->getFlag('non_existing_flag')); } + /** + * Test for method getCurPage. + * + * @return void + */ public function testGetCurPage() { - $this->_model->setCurPage(10); + $this->_model->setCurPage(1); $this->assertEquals(1, $this->_model->getCurPage()); } + /** + * Test for getCurPage with exception. + * + * @expectedException \Magento\Framework\Exception\InputException + * @expectedExceptionMessage currentPage value 10 specified is greater than the 1 page(s) available. + * @return void + */ + public function testGetCurPageWithException() + { + $this->_model->setCurPage(10); + $this->_model->getCurPage(); + } + + /** + * Test for method possibleFlowWithItem. + * + * @return void + */ public function testPossibleFlowWithItem() { $firstItemMock = $this->createPartialMock(\Magento\Framework\DataObject::class, ['getId', 'getData', 'toArray']); @@ -164,6 +225,11 @@ public function testPossibleFlowWithItem() $this->assertEquals([], $this->_model->getItems()); } + /** + * Test for method eachCallsMethodOnEachItemWithNoArgs. + * + * @return void + */ public function testEachCallsMethodOnEachItemWithNoArgs() { for ($i = 0; $i < 3; $i++) { @@ -173,7 +239,12 @@ public function testEachCallsMethodOnEachItemWithNoArgs() } $this->_model->each('testCallback'); } - + + /** + * Test for method eachCallsMethodOnEachItemWithArgs. + * + * @return void + */ public function testEachCallsMethodOnEachItemWithArgs() { for ($i = 0; $i < 3; $i++) { @@ -184,6 +255,11 @@ public function testEachCallsMethodOnEachItemWithArgs() $this->_model->each('testCallback', ['a', 'b', 'c']); } + /** + * Test for method callsClosureWithEachItemAndNoArgs. + * + * @return void + */ public function testCallsClosureWithEachItemAndNoArgs() { for ($i = 0; $i < 3; $i++) { @@ -196,6 +272,11 @@ public function testCallsClosureWithEachItemAndNoArgs() }); } + /** + * Test for method callsClosureWithEachItemAndArgs. + * + * @return void + */ public function testCallsClosureWithEachItemAndArgs() { for ($i = 0; $i < 3; $i++) { @@ -208,6 +289,11 @@ public function testCallsClosureWithEachItemAndArgs() }, ['a', 'b', 'c']); } + /** + * Test for method callsCallableArrayWithEachItemNoArgs. + * + * @return void + */ public function testCallsCallableArrayWithEachItemNoArgs() { $mockCallbackObject = $this->getMockBuilder('DummyEachCallbackInstance') @@ -226,6 +312,11 @@ public function testCallsCallableArrayWithEachItemNoArgs() $this->_model->each([$mockCallbackObject, 'testObjCallback']); } + /** + * Test for method callsCallableArrayWithEachItemAndArgs. + * + * @return void + */ public function testCallsCallableArrayWithEachItemAndArgs() { $mockCallbackObject = $this->getMockBuilder('DummyEachCallbackInstance') diff --git a/lib/internal/Magento/Framework/Escaper.php b/lib/internal/Magento/Framework/Escaper.php index fec64378189eb..f3eefaed50d18 100644 --- a/lib/internal/Magento/Framework/Escaper.php +++ b/lib/internal/Magento/Framework/Escaper.php @@ -3,6 +3,7 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + namespace Magento\Framework; /** @@ -32,14 +33,24 @@ class Escaper */ private $allowedAttributes = ['id', 'class', 'href', 'target', 'title', 'style']; + /** + * @var string + */ + private static $xssFiltrationPattern = + '/((javascript(\\\\x3a|:|%3A))|(data(\\\\x3a|:|%3A))|(vbscript:))|' + . '((\\\\x6A\\\\x61\\\\x76\\\\x61\\\\x73\\\\x63\\\\x72\\\\x69\\\\x70\\\\x74(\\\\x3a|:|%3A))|' + . '(\\\\x64\\\\x61\\\\x74\\\\x61(\\\\x3a|:|%3A)))/i'; + /** * @var string[] */ private $escapeAsUrlAttributes = ['href']; /** - * Escape string for HTML context. allowedTags will not be escaped, except the following: script, img, embed, - * iframe, video, source, object, audio + * Escape string for HTML context. + * + * AllowedTags will not be escaped, except the following: script, img, embed, + * iframe, video, source, object, audio. * * @param string|array $data * @param array|null $allowedTags @@ -47,6 +58,10 @@ class Escaper */ public function escapeHtml($data, $allowedTags = null) { + if (!is_array($data)) { + $data = (string)$data; + } + if (is_array($data)) { $result = []; foreach ($data as $item) { @@ -54,16 +69,7 @@ public function escapeHtml($data, $allowedTags = null) } } elseif (strlen($data)) { if (is_array($allowedTags) && !empty($allowedTags)) { - $notAllowedTags = array_intersect( - array_map('strtolower', $allowedTags), - $this->notAllowedTags - ); - if (!empty($notAllowedTags)) { - $this->getLogger()->critical( - 'The following tag(s) are not allowed: ' . implode(', ', $notAllowedTags) - ); - $allowedTags = array_diff($allowedTags, $this->notAllowedTags); - } + $allowedTags = $this->filterProhibitedTags($allowedTags); $wrapperElementId = uniqid(); $domDocument = new \DOMDocument('1.0', 'UTF-8'); set_error_handler( @@ -71,6 +77,7 @@ function ($errorNumber, $errorString) { throw new \Exception($errorString, $errorNumber); } ); + $data = $this->prepareUnescapedCharacters($data); $string = mb_convert_encoding($data, 'HTML-ENTITIES', 'UTF-8'); try { $domDocument->loadHTML( @@ -99,6 +106,19 @@ function ($errorNumber, $errorString) { return $result; } + /** + * Used to replace characters, that mb_convert_encoding will not process + * + * @param string $data + * @return string|null + */ + private function prepareUnescapedCharacters(string $data) + { + $patterns = ['/\&/u']; + $replacements = ['&']; + return \preg_replace($patterns, $replacements, $data); + } + /** * Remove not allowed tags * @@ -197,7 +217,7 @@ public function escapeHtmlAttr($string, $escapeSingleQuote = true) if ($escapeSingleQuote) { return $this->getEscaper()->escapeHtmlAttr((string) $string); } - return htmlspecialchars($string, ENT_COMPAT, 'UTF-8', false); + return htmlspecialchars((string)$string, ENT_COMPAT, 'UTF-8', false); } /** @@ -278,14 +298,13 @@ public function escapeJsQuote($data, $quote = '\'') $result[] = $this->escapeJsQuote($item, $quote); } } else { - $result = str_replace($quote, '\\' . $quote, $data); + $result = str_replace($quote, '\\' . $quote, (string)$data); } return $result; } /** * Escape xss in urls - * Remove `javascript:`, `vbscript:`, `data:` words from url * * @param string $data * @return string @@ -293,15 +312,33 @@ public function escapeJsQuote($data, $quote = '\'') */ public function escapeXssInUrl($data) { - $pattern = '/((javascript(\\\\x3a|:|%3A))|(data(\\\\x3a|:|%3A))|(vbscript:))|' - . '((\\\\x6A\\\\x61\\\\x76\\\\x61\\\\x73\\\\x63\\\\x72\\\\x69\\\\x70\\\\x74(\\\\x3a|:|%3A))|' - . '(\\\\x64\\\\x61\\\\x74\\\\x61(\\\\x3a|:|%3A)))/i'; - $result = preg_replace($pattern, ':', $data); - return htmlspecialchars($result, ENT_COMPAT | ENT_HTML5 | ENT_HTML401, 'UTF-8', false); + return htmlspecialchars( + $this->escapeScriptIdentifiers((string)$data), + ENT_COMPAT | ENT_HTML5 | ENT_HTML401, + 'UTF-8', + false + ); + } + + /** + * Remove `javascript:`, `vbscript:`, `data:` words from the string. + * + * @param string $data + * @return string + */ + private function escapeScriptIdentifiers(string $data): string + { + $filteredData = preg_replace(self::$xssFiltrationPattern, ':', $data) ?: ''; + if (preg_match(self::$xssFiltrationPattern, $filteredData)) { + $filteredData = $this->escapeScriptIdentifiers($filteredData); + } + + return $filteredData; } /** * Escape quotes inside html attributes + * * Use $addSlashes = false for escaping js that inside html attribute (onClick, onSubmit etc) * * @param string $data @@ -346,4 +383,27 @@ private function getLogger() } return $this->logger; } + + /** + * Filter prohibited tags. + * + * @param string[] $allowedTags + * @return string[] + */ + private function filterProhibitedTags(array $allowedTags): array + { + $notAllowedTags = array_intersect( + array_map('strtolower', $allowedTags), + $this->notAllowedTags + ); + + if (!empty($notAllowedTags)) { + $this->getLogger()->critical( + 'The following tag(s) are not allowed: ' . implode(', ', $notAllowedTags) + ); + $allowedTags = array_diff($allowedTags, $this->notAllowedTags); + } + + return $allowedTags; + } } diff --git a/lib/internal/Magento/Framework/Event/Test/Unit/Config/_files/invalidEventsXmlArray.php b/lib/internal/Magento/Framework/Event/Test/Unit/Config/_files/invalidEventsXmlArray.php index 33007b7295bca..e0dc7494cca19 100644 --- a/lib/internal/Magento/Framework/Event/Test/Unit/Config/_files/invalidEventsXmlArray.php +++ b/lib/internal/Magento/Framework/Event/Test/Unit/Config/_files/invalidEventsXmlArray.php @@ -4,10 +4,6 @@ * See COPYING.txt for license details. */ return [ - 'without_event_handle' => [ - '<?xml version="1.0"?><config></config>', - ["Element 'config': Missing child element(s). Expected is ( event ).\nLine: 1\n"], - ], 'event_without_required_name_attribute' => [ '<?xml version="1.0"?><config><event name="some_name"></event></config>', ["Element 'event': Missing child element(s). Expected is ( observer ).\nLine: 1\n"], diff --git a/lib/internal/Magento/Framework/Event/etc/events.xsd b/lib/internal/Magento/Framework/Event/etc/events.xsd index d656b7fdb6ed6..cac62af356760 100644 --- a/lib/internal/Magento/Framework/Event/etc/events.xsd +++ b/lib/internal/Magento/Framework/Event/etc/events.xsd @@ -9,7 +9,7 @@ <xs:element name="config"> <xs:complexType> <xs:sequence> - <xs:element name="event" type="eventDeclaration" minOccurs="1" maxOccurs="unbounded"> + <xs:element name="event" type="eventDeclaration" minOccurs="0" maxOccurs="unbounded"> <xs:unique name="uniqueObserverName"> <xs:annotation> <xs:documentation> diff --git a/lib/internal/Magento/Framework/File/Uploader.php b/lib/internal/Magento/Framework/File/Uploader.php index 6a1351c0feffd..b53887cc0836a 100644 --- a/lib/internal/Magento/Framework/File/Uploader.php +++ b/lib/internal/Magento/Framework/File/Uploader.php @@ -210,20 +210,22 @@ public function save($destinationFolder, $newFileName = null) $this->_result = false; $destinationFile = $destinationFolder; $fileName = isset($newFileName) ? $newFileName : $this->_file['name']; - $fileName = self::getCorrectFileName($fileName); + $fileName = static::getCorrectFileName($fileName); if ($this->_enableFilesDispersion) { $fileName = $this->correctFileNameCase($fileName); $this->setAllowCreateFolders(true); - $this->_dispretionPath = self::getDispersionPath($fileName); + $this->_dispretionPath = static::getDispersionPath($fileName); $destinationFile .= $this->_dispretionPath; $this->_createDestinationFolder($destinationFile); } if ($this->_allowRenameFiles) { - $fileName = self::getNewFileName(self::_addDirSeparator($destinationFile) . $fileName); + $fileName = static::getNewFileName( + static::_addDirSeparator($destinationFile) . $fileName + ); } - $destinationFile = self::_addDirSeparator($destinationFile) . $fileName; + $destinationFile = static::_addDirSeparator($destinationFile) . $fileName; try { $this->_result = $this->_moveFile($this->_file['tmp_name'], $destinationFile); diff --git a/lib/internal/Magento/Framework/Filesystem/Directory/PathValidator.php b/lib/internal/Magento/Framework/Filesystem/Directory/PathValidator.php new file mode 100644 index 0000000000000..8791fe88ab65f --- /dev/null +++ b/lib/internal/Magento/Framework/Filesystem/Directory/PathValidator.php @@ -0,0 +1,69 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\Framework\Filesystem\Directory; + +use Magento\Framework\Exception\ValidatorException; +use Magento\Framework\Filesystem\DriverInterface; +use Magento\Framework\Phrase; + +/** + * {@inheritdoc} + * + * Validates paths using driver. + */ +class PathValidator implements PathValidatorInterface +{ + /** + * @var DriverInterface + */ + private $driver; + + /** + * @param DriverInterface $driver + */ + public function __construct(DriverInterface $driver) + { + $this->driver = $driver; + } + + /** + * @inheritdoc + */ + public function validate( + string $directoryPath, + string $path, + $scheme = null, + $absolutePath = false + ) { + $realDirectoryPath = $this->driver->getRealPathSafety($directoryPath); + if (mb_substr($realDirectoryPath, -1) !== DIRECTORY_SEPARATOR) { + $realDirectoryPath .= DIRECTORY_SEPARATOR; + } + if (!$absolutePath) { + $actualPath = $this->driver->getRealPathSafety( + $this->driver->getAbsolutePath( + $realDirectoryPath, + $path, + $scheme + ) + ); + } else { + $actualPath = $this->driver->getRealPathSafety($path); + } + + if (mb_strpos($actualPath, $realDirectoryPath) !== 0 + && $path . DIRECTORY_SEPARATOR !== $realDirectoryPath + ) { + throw new ValidatorException( + new Phrase( + 'Path "%1" cannot be used with directory "%2"', + [$path, $directoryPath] + ) + ); + } + } +} diff --git a/lib/internal/Magento/Framework/Filesystem/Directory/PathValidatorInterface.php b/lib/internal/Magento/Framework/Filesystem/Directory/PathValidatorInterface.php new file mode 100644 index 0000000000000..46f4efd2467fe --- /dev/null +++ b/lib/internal/Magento/Framework/Filesystem/Directory/PathValidatorInterface.php @@ -0,0 +1,33 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\Framework\Filesystem\Directory; + +use Magento\Framework\Exception\ValidatorException; + +/** + * Validate paths to be used with directories. + */ +interface PathValidatorInterface +{ + /** + * Validate if path can be used with a directory. + * + * @param string $directoryPath + * @param string $path + * @param string|null $scheme + * @param bool $absolutePath + * @throws ValidatorException + * + * @return void + */ + public function validate( + string $directoryPath, + string $path, + $scheme = null, + $absolutePath = false + ); +} diff --git a/lib/internal/Magento/Framework/Filesystem/Directory/Read.php b/lib/internal/Magento/Framework/Filesystem/Directory/Read.php index 18c93f2f4e1c8..d5de745b8fc09 100644 --- a/lib/internal/Magento/Framework/Filesystem/Directory/Read.php +++ b/lib/internal/Magento/Framework/Filesystem/Directory/Read.php @@ -6,6 +6,8 @@ namespace Magento\Framework\Filesystem\Directory; use Magento\Framework\Exception\FileSystemException; +use Magento\Framework\Exception\ValidatorException; +use Magento\Framework\Filesystem\File\ReadFactoryInterface; /** * @api @@ -33,21 +35,52 @@ class Read implements ReadInterface */ protected $driver; + /** + * @var PathValidatorInterface + */ + private $pathValidator; + /** * Constructor. Set properties. * * @param \Magento\Framework\Filesystem\File\ReadFactory $fileFactory * @param \Magento\Framework\Filesystem\DriverInterface $driver * @param string $path + * @param PathValidatorInterface|null $pathValidator */ public function __construct( \Magento\Framework\Filesystem\File\ReadFactory $fileFactory, \Magento\Framework\Filesystem\DriverInterface $driver, - $path + $path, + PathValidatorInterface $pathValidator = null ) { $this->fileFactory = $fileFactory; $this->driver = $driver; $this->setPath($path); + $this->pathValidator = $pathValidator; + } + + /** + * @param string|null $path + * @param string|null $scheme + * @param bool $absolutePath + * + * @return void + * @throws ValidatorException + */ + protected function validatePath( + $path = null, + $scheme = null, + $absolutePath = false + ) { + if ($path && $this->pathValidator) { + $this->pathValidator->validate( + $this->path, + $path, + $scheme, + $absolutePath + ); + } } /** @@ -70,9 +103,12 @@ protected function setPath($path) * @param string $path * @param string $scheme * @return string + * @throws ValidatorException */ public function getAbsolutePath($path = null, $scheme = null) { + $this->validatePath($path, $scheme); + return $this->driver->getAbsolutePath($this->path, $path, $scheme); } @@ -81,9 +117,16 @@ public function getAbsolutePath($path = null, $scheme = null) * * @param string $path * @return string + * @throws ValidatorException */ public function getRelativePath($path = null) { + $this->validatePath( + $path, + null, + $path && $path[0] === DIRECTORY_SEPARATOR + ); + return $this->driver->getRelativePath($this->path, $path); } @@ -92,14 +135,18 @@ public function getRelativePath($path = null) * * @param string|null $path * @return string[] + * @throws ValidatorException */ public function read($path = null) { + $this->validatePath($path); + $files = $this->driver->readDirectory($this->driver->getAbsolutePath($this->path, $path)); $result = []; foreach ($files as $file) { $result[] = $this->getRelativePath($file); } + return $result; } @@ -108,9 +155,12 @@ public function read($path = null) * * @param null $path * @return string[] + * @throws ValidatorException */ public function readRecursively($path = null) { + $this->validatePath($path); + $result = []; $paths = $this->driver->readDirectoryRecursively($this->driver->getAbsolutePath($this->path, $path)); /** @var \FilesystemIterator $file */ @@ -118,6 +168,7 @@ public function readRecursively($path = null) $result[] = $this->getRelativePath($file); } sort($result); + return $result; } @@ -127,9 +178,12 @@ public function readRecursively($path = null) * @param string $pattern * @param string $path [optional] * @return string[] + * @throws ValidatorException */ public function search($pattern, $path = null) { + $this->validatePath($path); + if ($path) { $absolutePath = $this->driver->getAbsolutePath($this->path, $this->getRelativePath($path)); } else { @@ -141,6 +195,7 @@ public function search($pattern, $path = null) foreach ($files as $file) { $result[] = $this->getRelativePath($file); } + return $result; } @@ -150,9 +205,12 @@ public function search($pattern, $path = null) * @param string $path [optional] * @return bool * @throws \Magento\Framework\Exception\FileSystemException + * @throws ValidatorException */ public function isExist($path = null) { + $this->validatePath($path); + return $this->driver->isExists($this->driver->getAbsolutePath($this->path, $path)); } @@ -162,9 +220,12 @@ public function isExist($path = null) * @param string $path * @return array * @throws \Magento\Framework\Exception\FileSystemException + * @throws ValidatorException */ public function stat($path) { + $this->validatePath($path); + return $this->driver->stat($this->driver->getAbsolutePath($this->path, $path)); } @@ -174,9 +235,12 @@ public function stat($path) * @param string $path [optional] * @return bool * @throws \Magento\Framework\Exception\FileSystemException + * @throws ValidatorException */ public function isReadable($path = null) { + $this->validatePath($path); + return $this->driver->isReadable($this->driver->getAbsolutePath($this->path, $path)); } @@ -186,9 +250,12 @@ public function isReadable($path = null) * @param string $path * * @return \Magento\Framework\Filesystem\File\ReadInterface + * @throws ValidatorException */ public function openFile($path) { + $this->validatePath($path); + return $this->fileFactory->create( $this->driver->getAbsolutePath($this->path, $path), $this->driver @@ -203,10 +270,14 @@ public function openFile($path) * @param resource|null $context * @return string * @throws FileSystemException + * @throws ValidatorException */ public function readFile($path, $flag = null, $context = null) { + $this->validatePath($path); + $absolutePath = $this->driver->getAbsolutePath($this->path, $path); + return $this->driver->fileGetContents($absolutePath, $flag, $context); } @@ -215,9 +286,12 @@ public function readFile($path, $flag = null, $context = null) * * @param string $path * @return bool + * @throws ValidatorException */ public function isFile($path) { + $this->validatePath($path); + return $this->driver->isFile($this->driver->getAbsolutePath($this->path, $path)); } @@ -226,9 +300,12 @@ public function isFile($path) * * @param string $path [optional] * @return bool + * @throws ValidatorException */ public function isDirectory($path = null) { + $this->validatePath($path); + return $this->driver->isDirectory($this->driver->getAbsolutePath($this->path, $path)); } } diff --git a/lib/internal/Magento/Framework/Filesystem/Directory/ReadFactory.php b/lib/internal/Magento/Framework/Filesystem/Directory/ReadFactory.php index a9fe7dcb7bf06..25a290455dc46 100644 --- a/lib/internal/Magento/Framework/Filesystem/Directory/ReadFactory.php +++ b/lib/internal/Magento/Framework/Filesystem/Directory/ReadFactory.php @@ -36,7 +36,15 @@ public function __construct(DriverPool $driverPool) public function create($path, $driverCode = DriverPool::FILE) { $driver = $this->driverPool->getDriver($driverCode); - $factory = new \Magento\Framework\Filesystem\File\ReadFactory($this->driverPool); - return new Read($factory, $driver, $path); + $factory = new \Magento\Framework\Filesystem\File\ReadFactory( + $this->driverPool + ); + + return new Read( + $factory, + $driver, + $path, + new PathValidator($driver) + ); } } diff --git a/lib/internal/Magento/Framework/Filesystem/Directory/Write.php b/lib/internal/Magento/Framework/Filesystem/Directory/Write.php index 8be0fd9ae230a..1774731718f01 100644 --- a/lib/internal/Magento/Framework/Filesystem/Directory/Write.php +++ b/lib/internal/Magento/Framework/Filesystem/Directory/Write.php @@ -7,6 +7,8 @@ namespace Magento\Framework\Filesystem\Directory; use Magento\Framework\Exception\FileSystemException; +use Magento\Framework\Exception\ValidatorException; +use Magento\Framework\Filesystem\File\WriteFactoryInterface; /** * Write Interface implementation @@ -27,16 +29,16 @@ class Write extends Read implements WriteInterface * @param \Magento\Framework\Filesystem\DriverInterface $driver * @param string $path * @param int $createPermissions + * @param PathValidatorInterface|null $pathValidator */ public function __construct( \Magento\Framework\Filesystem\File\WriteFactory $fileFactory, \Magento\Framework\Filesystem\DriverInterface $driver, $path, - $createPermissions = null + $createPermissions = null, + PathValidatorInterface $pathValidator = null ) { - $this->fileFactory = $fileFactory; - $this->driver = $driver; - $this->setPath($path); + parent::__construct($fileFactory, $driver, $path, $pathValidator); if (null !== $createPermissions) { $this->permissions = $createPermissions; } @@ -83,13 +85,16 @@ protected function assertIsFile($path) * @param string $path * @return bool * @throws FileSystemException + * @throws ValidatorException */ public function create($path = null) { + $this->validatePath($path); $absolutePath = $this->driver->getAbsolutePath($this->path, $path); if ($this->driver->isDirectory($absolutePath)) { return true; } + return $this->driver->createDirectory($absolutePath, $this->permissions); } @@ -101,9 +106,11 @@ public function create($path = null) * @param WriteInterface $targetDirectory * @return bool * @throws FileSystemException + * @throws ValidatorException */ public function renameFile($path, $newPath, WriteInterface $targetDirectory = null) { + $this->validatePath($path); $this->assertIsFile($path); $targetDirectory = $targetDirectory ?: $this; if (!$targetDirectory->isExist($this->driver->getParentDirectory($newPath))) { @@ -111,6 +118,7 @@ public function renameFile($path, $newPath, WriteInterface $targetDirectory = nu } $absolutePath = $this->driver->getAbsolutePath($this->path, $path); $absoluteNewPath = $targetDirectory->getAbsolutePath($newPath); + return $this->driver->rename($absolutePath, $absoluteNewPath, $targetDirectory->driver); } @@ -122,9 +130,11 @@ public function renameFile($path, $newPath, WriteInterface $targetDirectory = nu * @param WriteInterface $targetDirectory * @return bool * @throws FileSystemException + * @throws ValidatorException */ public function copyFile($path, $destination, WriteInterface $targetDirectory = null) { + $this->validatePath($path); $this->assertIsFile($path); $targetDirectory = $targetDirectory ?: $this; @@ -145,9 +155,11 @@ public function copyFile($path, $destination, WriteInterface $targetDirectory = * @param WriteInterface $targetDirectory [optional] * @return bool * @throws \Magento\Framework\Exception\FileSystemException + * @throws ValidatorException */ public function createSymlink($path, $destination, WriteInterface $targetDirectory = null) { + $this->validatePath($path); $targetDirectory = $targetDirectory ?: $this; $parentDirectory = $this->driver->getParentDirectory($destination); if (!$targetDirectory->isExist($parentDirectory)) { @@ -165,10 +177,12 @@ public function createSymlink($path, $destination, WriteInterface $targetDirecto * @param string $path * @return bool * @throws FileSystemException + * @throws ValidatorException */ public function delete($path = null) { $exceptionMessages = []; + $this->validatePath($path); if (!$this->isExist($path)) { return true; } @@ -195,6 +209,7 @@ public function delete($path = null) ); } } + return true; } @@ -236,10 +251,13 @@ private function deleteFilesRecursively(string $path) * @param int $permissions * @return bool * @throws FileSystemException + * @throws ValidatorException */ public function changePermissions($path, $permissions) { + $this->validatePath($path); $absolutePath = $this->driver->getAbsolutePath($this->path, $path); + return $this->driver->changePermissions($absolutePath, $permissions); } @@ -251,10 +269,13 @@ public function changePermissions($path, $permissions) * @param int $filePermissions * @return bool * @throws FileSystemException + * @throws ValidatorException */ public function changePermissionsRecursively($path, $dirPermissions, $filePermissions) { + $this->validatePath($path); $absolutePath = $this->driver->getAbsolutePath($this->path, $path); + return $this->driver->changePermissionsRecursively($absolutePath, $dirPermissions, $filePermissions); } @@ -265,9 +286,12 @@ public function changePermissionsRecursively($path, $dirPermissions, $filePermis * @param int|null $modificationTime * @return bool * @throws FileSystemException + * @throws ValidatorException */ public function touch($path, $modificationTime = null) { + $this->validatePath($path); + $folder = $this->driver->getParentDirectory($path); $this->create($folder); $this->assertWritable($folder); @@ -280,9 +304,12 @@ public function touch($path, $modificationTime = null) * @param string|null $path * @return bool * @throws \Magento\Framework\Exception\FileSystemException + * @throws ValidatorException */ public function isWritable($path = null) { + $this->validatePath($path); + return $this->driver->isWritable($this->driver->getAbsolutePath($this->path, $path)); } @@ -293,13 +320,16 @@ public function isWritable($path = null) * @param string $mode * @return \Magento\Framework\Filesystem\File\WriteInterface * @throws \Magento\Framework\Exception\FileSystemException + * @throws ValidatorException */ public function openFile($path, $mode = 'w') { + $this->validatePath($path); $folder = dirname($path); $this->create($folder); $this->assertWritable($this->isExist($path) ? $path : $folder); $absolutePath = $this->driver->getAbsolutePath($this->path, $path); + return $this->fileFactory->create($absolutePath, $this->driver, $mode); } diff --git a/lib/internal/Magento/Framework/Filesystem/Directory/WriteFactory.php b/lib/internal/Magento/Framework/Filesystem/Directory/WriteFactory.php index a723ed6a7bea6..ff14b12f62047 100644 --- a/lib/internal/Magento/Framework/Filesystem/Directory/WriteFactory.php +++ b/lib/internal/Magento/Framework/Filesystem/Directory/WriteFactory.php @@ -37,7 +37,16 @@ public function __construct(DriverPool $driverPool) public function create($path, $driverCode = DriverPool::FILE, $createPermissions = null) { $driver = $this->driverPool->getDriver($driverCode); - $factory = new \Magento\Framework\Filesystem\File\WriteFactory($this->driverPool); - return new Write($factory, $driver, $path, $createPermissions); + $factory = new \Magento\Framework\Filesystem\File\WriteFactory( + $this->driverPool + ); + + return new Write( + $factory, + $driver, + $path, + $createPermissions, + new PathValidator($driver) + ); } } diff --git a/lib/internal/Magento/Framework/Filesystem/Driver/File.php b/lib/internal/Magento/Framework/Filesystem/Driver/File.php index 53322f06bbc6f..03fb0dc9f0c4d 100644 --- a/lib/internal/Magento/Framework/Filesystem/Driver/File.php +++ b/lib/internal/Magento/Framework/Filesystem/Driver/File.php @@ -967,6 +967,13 @@ public function getRealPathSafety($path) if (strpos($path, DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR) === false) { return $path; } + + //Removing redundant directory separators. + $path = preg_replace( + '/\\' . DIRECTORY_SEPARATOR . '\\' . DIRECTORY_SEPARATOR . '+/', + DIRECTORY_SEPARATOR, + $path + ); $pathParts = explode(DIRECTORY_SEPARATOR, $path); $realPath = []; foreach ($pathParts as $pathPart) { diff --git a/lib/internal/Magento/Framework/Filesystem/File/WriteFactory.php b/lib/internal/Magento/Framework/Filesystem/File/WriteFactory.php index 64683a104784d..a45d6a62488f6 100644 --- a/lib/internal/Magento/Framework/Filesystem/File/WriteFactory.php +++ b/lib/internal/Magento/Framework/Filesystem/File/WriteFactory.php @@ -8,7 +8,7 @@ use Magento\Framework\Filesystem\DriverInterface; use Magento\Framework\Filesystem\DriverPool; -class WriteFactory +class WriteFactory extends ReadFactory { /** * Pool of filesystem drivers @@ -24,6 +24,7 @@ class WriteFactory */ public function __construct(DriverPool $driverPool) { + parent::__construct($driverPool); $this->driverPool = $driverPool; } diff --git a/lib/internal/Magento/Framework/HTTP/Client/Curl.php b/lib/internal/Magento/Framework/HTTP/Client/Curl.php index 57a5535751f09..375d85806a7d1 100644 --- a/lib/internal/Magento/Framework/HTTP/Client/Curl.php +++ b/lib/internal/Magento/Framework/HTTP/Client/Curl.php @@ -432,7 +432,7 @@ protected function parseHeaders($ch, $data) { if ($this->_headerCount == 0) { $line = explode(" ", trim($data), 3); - if (count($line) != 3) { + if (count($line) < 2) { $this->doError("Invalid response line returned from server: " . $data); } $this->_responseStatus = (int)$line[1]; diff --git a/lib/internal/Magento/Framework/Interception/Config/Config.php b/lib/internal/Magento/Framework/Interception/Config/Config.php index 7c80051537baa..ba1a0f9685eac 100644 --- a/lib/internal/Magento/Framework/Interception/Config/Config.php +++ b/lib/internal/Magento/Framework/Interception/Config/Config.php @@ -125,7 +125,7 @@ public function __construct( */ public function initialize($classDefinitions = []) { - $this->_cache->clean(\Zend_Cache::CLEANING_MODE_MATCHING_TAG, [$this->_cacheId]); + $this->_cache->remove($this->_cacheId); $config = []; foreach ($this->_scopeList->getAllScopes() as $scope) { $config = array_replace_recursive($config, $this->_reader->read($scope)); diff --git a/lib/internal/Magento/Framework/Lock/Backend/Cache.php b/lib/internal/Magento/Framework/Lock/Backend/Cache.php new file mode 100644 index 0000000000000..61818cbb8c53c --- /dev/null +++ b/lib/internal/Magento/Framework/Lock/Backend/Cache.php @@ -0,0 +1,52 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Framework\Lock\Backend; + +use Magento\Framework\Cache\FrontendInterface; + +/** + * Implementation of the lock manager on the basis of the caching system. + */ +class Cache implements \Magento\Framework\Lock\LockManagerInterface +{ + /** + * @var FrontendInterface + */ + private $cache; + + /** + * @param FrontendInterface $cache + */ + public function __construct(FrontendInterface $cache) + { + $this->cache = $cache; + } + /** + * @inheritdoc + */ + public function lock(string $name, int $timeout = -1): bool + { + return $this->cache->save('1', $name, [], $timeout); + } + + /** + * @inheritdoc + */ + public function unlock(string $name): bool + { + return $this->cache->remove($name); + } + + /** + * @inheritdoc + */ + public function isLocked(string $name): bool + { + return (bool)$this->cache->test($name); + } +} diff --git a/lib/internal/Magento/Framework/Lock/Backend/Database.php b/lib/internal/Magento/Framework/Lock/Backend/Database.php index 61857685a7bb4..a9dbecedab238 100644 --- a/lib/internal/Magento/Framework/Lock/Backend/Database.php +++ b/lib/internal/Magento/Framework/Lock/Backend/Database.php @@ -3,8 +3,8 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ - declare(strict_types=1); + namespace Magento\Framework\Lock\Backend; use Magento\Framework\App\DeploymentConfig; @@ -14,6 +14,9 @@ use Magento\Framework\Exception\InputException; use Magento\Framework\Phrase; +/** + * Implementation of the lock manager on the basis of MySQL. + */ class Database implements \Magento\Framework\Lock\LockManagerInterface { /** @var ResourceConnection */ @@ -53,9 +56,13 @@ public function __construct( * @return bool * @throws InputException * @throws AlreadyExistsException + * @throws \Zend_Db_Statement_Exception */ public function lock(string $name, int $timeout = -1): bool { + if (!$this->deploymentConfig->isDbAvailable()) { + return true; + }; $name = $this->addPrefix($name); /** @@ -66,7 +73,7 @@ public function lock(string $name, int $timeout = -1): bool if ($this->currentLock) { throw new AlreadyExistsException( new Phrase( - 'Current connection is already holding lock for $1, only single lock allowed', + 'Current connection is already holding lock for %1, only single lock allowed', [$this->currentLock] ) ); @@ -90,9 +97,13 @@ public function lock(string $name, int $timeout = -1): bool * @param string $name lock name * @return bool * @throws InputException + * @throws \Zend_Db_Statement_Exception */ public function unlock(string $name): bool { + if (!$this->deploymentConfig->isDbAvailable()) { + return true; + }; $name = $this->addPrefix($name); $result = (bool)$this->resource->getConnection()->query( @@ -113,9 +124,13 @@ public function unlock(string $name): bool * @param string $name lock name * @return bool * @throws InputException + * @throws \Zend_Db_Statement_Exception */ public function isLocked(string $name): bool { + if (!$this->deploymentConfig->isDbAvailable()) { + return false; + }; $name = $this->addPrefix($name); return (bool)$this->resource->getConnection()->query( diff --git a/lib/internal/Magento/Framework/Lock/Test/Unit/Backend/DatabaseTest.php b/lib/internal/Magento/Framework/Lock/Test/Unit/Backend/DatabaseTest.php index a31b686dfacd8..e853d272aa372 100644 --- a/lib/internal/Magento/Framework/Lock/Test/Unit/Backend/DatabaseTest.php +++ b/lib/internal/Magento/Framework/Lock/Test/Unit/Backend/DatabaseTest.php @@ -5,8 +5,13 @@ */ namespace Magento\Framework\Lock\Test\Unit\Backend; +use Magento\Framework\App\DeploymentConfig; use Magento\Framework\Lock\Backend\Database; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +/** + * @inheritdoc + */ class DatabaseTest extends \PHPUnit\Framework\TestCase { /** @@ -25,13 +30,21 @@ class DatabaseTest extends \PHPUnit\Framework\TestCase private $statement; /** - * @var \Magento\Framework\TestFramework\Unit\Helper\ObjectManager + * @var ObjectManager */ private $objectManager; /** @var Database $database */ private $database; + /** + * @var DeploymentConfig|\PHPUnit_Framework_MockObject_MockObject + */ + private $deploymentConfig; + + /** + * @inheritdoc + */ protected function setUp() { $this->connection = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class) @@ -52,17 +65,33 @@ protected function setUp() ->method('query') ->willReturn($this->statement); - $this->objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->objectManager = new ObjectManager($this); + + $this->deploymentConfig = $this->getMockBuilder(DeploymentConfig::class) + ->disableOriginalConstructor() + ->getMock(); /** @var Database $database */ $this->database = $this->objectManager->getObject( Database::class, - ['resource' => $this->resource] + [ + 'resource' => $this->resource, + 'deploymentConfig' => $this->deploymentConfig, + ] ); } + /** + * @return void + * @throws \Magento\Framework\Exception\AlreadyExistsException + * @throws \Magento\Framework\Exception\InputException + */ public function testLock() { + $this->deploymentConfig + ->method('isDbAvailable') + ->with() + ->willReturn(true); $this->statement->expects($this->once()) ->method('fetchColumn') ->willReturn(true); @@ -75,14 +104,23 @@ public function testLock() */ public function testlockWithTooLongName() { + $this->deploymentConfig + ->method('isDbAvailable') + ->with() + ->willReturn(true); $this->database->lock('BbXbyf9rIY5xuAVdviQJmh76FyoeeVHTDpcjmcImNtgpO4Hnz4xk76ZGEyYALvrQu'); } /** * @expectedException \Magento\Framework\Exception\AlreadyExistsException + * @throws \Magento\Framework\Exception\InputException */ public function testlockWithAlreadyAcquiredLockInSameSession() { + $this->deploymentConfig + ->method('isDbAvailable') + ->with() + ->willReturn(true); $this->statement->expects($this->any()) ->method('fetchColumn') ->willReturn(true); @@ -90,4 +128,47 @@ public function testlockWithAlreadyAcquiredLockInSameSession() $this->database->lock('testLock'); $this->database->lock('differentLock'); } + + /** + * @return void + * @throws \Magento\Framework\Exception\AlreadyExistsException + * @throws \Magento\Framework\Exception\InputException + */ + public function testLockWithUnavailableDeploymentConfig() + { + $this->deploymentConfig + ->expects($this->atLeast(1)) + ->method('isDbAvailable') + ->with() + ->willReturn(false); + $this->assertTrue($this->database->lock('testLock')); + } + + /** + * @return void + * @throws \Magento\Framework\Exception\InputException + */ + public function testUnlockWithUnavailableDeploymentConfig() + { + $this->deploymentConfig + ->expects($this->atLeast(1)) + ->method('isDbAvailable') + ->with() + ->willReturn(false); + $this->assertTrue($this->database->unlock('testLock')); + } + + /** + * @return void + * @throws \Magento\Framework\Exception\InputException + */ + public function testIsLockedWithUnavailableDB() + { + $this->deploymentConfig + ->expects($this->atLeast(1)) + ->method('isDbAvailable') + ->with() + ->willReturn(false); + $this->assertFalse($this->database->isLocked('testLock')); + } } diff --git a/lib/internal/Magento/Framework/Mail/Template/TransportBuilder.php b/lib/internal/Magento/Framework/Mail/Template/TransportBuilder.php index a1c7333f41245..5e54a9441d1a1 100644 --- a/lib/internal/Magento/Framework/Mail/Template/TransportBuilder.php +++ b/lib/internal/Magento/Framework/Mail/Template/TransportBuilder.php @@ -48,6 +48,13 @@ class TransportBuilder */ protected $templateOptions; + /** + * Mail from address + * + * @var string|array + */ + private $from; + /** * Mail Transport * @@ -178,8 +185,7 @@ public function setReplyTo($email, $name = null) */ public function setFrom($from) { - $result = $this->_senderResolver->resolve($from); - $this->message->setFrom($result['email'], $result['name']); + $this->from = $from; return $this; } @@ -256,6 +262,7 @@ protected function reset() $this->templateIdentifier = null; $this->templateVars = null; $this->templateOptions = null; + $this->from = null; return $this; } @@ -289,6 +296,14 @@ protected function prepareMessage() ->setBody($body) ->setSubject(html_entity_decode($template->getSubject(), ENT_QUOTES)); + if ($this->from) { + $from = $this->_senderResolver->resolve( + $this->from, + $template->getDesignConfig()->getStore() + ); + $this->message->setFrom($from['email'], $from['name']); + } + return $this; } } diff --git a/lib/internal/Magento/Framework/Mail/Test/Unit/Template/TransportBuilderTest.php b/lib/internal/Magento/Framework/Mail/Test/Unit/Template/TransportBuilderTest.php index c3759bc43f81f..0bbfb37356caa 100644 --- a/lib/internal/Magento/Framework/Mail/Test/Unit/Template/TransportBuilderTest.php +++ b/lib/internal/Magento/Framework/Mail/Test/Unit/Template/TransportBuilderTest.php @@ -7,6 +7,7 @@ namespace Magento\Framework\Mail\Test\Unit\Template; use Magento\Framework\App\TemplateTypesInterface; +use Magento\Framework\DataObject; use Magento\Framework\Mail\MessageInterface; /** @@ -99,17 +100,37 @@ protected function setUp() */ public function testGetTransport($templateType, $messageType, $bodyText, $templateNamespace) { - $this->builder->setTemplateModel($templateNamespace); - $vars = ['reason' => 'Reason', 'customer' => 'Customer']; $options = ['area' => 'frontend', 'store' => 1]; + $from = 'email_from'; + $sender = ['email' => 'from@example.com', 'name' => 'name']; - $template = $this->createMock(\Magento\Framework\Mail\TemplateInterface::class); + $this->builder->setTemplateModel($templateNamespace); + $this->builder->setFrom($from); + + $template = $this->createPartialMock( + \Magento\Framework\Mail\TemplateInterface::class, + [ + 'setVars', + 'isPlain', + 'setOptions', + 'getSubject', + 'getType', + 'processTemplate', + 'getDesignConfig', + ] + ); $template->expects($this->once())->method('setVars')->with($this->equalTo($vars))->willReturnSelf(); $template->expects($this->once())->method('setOptions')->with($this->equalTo($options))->willReturnSelf(); $template->expects($this->once())->method('getSubject')->willReturn('Email Subject'); $template->expects($this->once())->method('getType')->willReturn($templateType); $template->expects($this->once())->method('processTemplate')->willReturn($bodyText); + $template->method('getDesignConfig')->willReturn(new DataObject($options)); + + $this->senderResolverMock->expects($this->once()) + ->method('resolve') + ->with($from, 1) + ->willReturn($sender); $this->templateFactoryMock->expects($this->once()) ->method('get') @@ -128,6 +149,9 @@ public function testGetTransport($templateType, $messageType, $bodyText, $templa ->method('setBody') ->with($this->equalTo($bodyText)) ->willReturnSelf(); + $this->messageMock->method('setFrom') + ->with($sender['email'], $sender['name']) + ->willReturnSelf(); $transport = $this->createMock(\Magento\Framework\Mail\TransportInterface::class); @@ -161,24 +185,6 @@ public function getTransportDataProvider() ]; } - /** - * @return void - */ - public function testSetFrom() - { - $sender = ['email' => 'from@example.com', 'name' => 'name']; - $this->senderResolverMock->expects($this->once()) - ->method('resolve') - ->with($sender) - ->willReturn($sender); - $this->messageMock->expects($this->once()) - ->method('setFrom') - ->with('from@example.com', 'name') - ->willReturnSelf(); - - $this->builder->setFrom($sender); - } - /** * @return void */ diff --git a/lib/internal/Magento/Framework/Reflection/DataObjectProcessor.php b/lib/internal/Magento/Framework/Reflection/DataObjectProcessor.php index 6311003bd2ad5..2f3caf08c534e 100644 --- a/lib/internal/Magento/Framework/Reflection/DataObjectProcessor.php +++ b/lib/internal/Magento/Framework/Reflection/DataObjectProcessor.php @@ -40,25 +40,33 @@ class DataObjectProcessor */ private $customAttributesProcessor; + /** + * @var array + */ + private $processors; + /** * @param MethodsMap $methodsMapProcessor * @param TypeCaster $typeCaster * @param FieldNamer $fieldNamer * @param CustomAttributesProcessor $customAttributesProcessor * @param ExtensionAttributesProcessor $extensionAttributesProcessor + * @param array $processors */ public function __construct( MethodsMap $methodsMapProcessor, TypeCaster $typeCaster, FieldNamer $fieldNamer, CustomAttributesProcessor $customAttributesProcessor, - ExtensionAttributesProcessor $extensionAttributesProcessor + ExtensionAttributesProcessor $extensionAttributesProcessor, + array $processors = [] ) { $this->methodsMapProcessor = $methodsMapProcessor; $this->typeCaster = $typeCaster; $this->fieldNamer = $fieldNamer; $this->extensionAttributesProcessor = $extensionAttributesProcessor; $this->customAttributesProcessor = $customAttributesProcessor; + $this->processors = $processors; } /** @@ -121,6 +129,27 @@ public function buildOutputDataArray($dataObject, $dataObjectType) $outputData[$key] = $value; } + + $outputData = $this->changeOutputArray($dataObject, $outputData); + + return $outputData; + } + + /** + * Change output array if needed. + * + * @param mixed $dataObject + * @param array $outputData + * @return array + */ + private function changeOutputArray($dataObject, array $outputData): array + { + foreach ($this->processors as $dataObjectClassName => $processor) { + if ($dataObject instanceof $dataObjectClassName) { + $outputData = $processor->execute($dataObject, $outputData); + } + } + return $outputData; } } diff --git a/lib/internal/Magento/Framework/Search/Adapter/Mysql/Query/Builder/Match.php b/lib/internal/Magento/Framework/Search/Adapter/Mysql/Query/Builder/Match.php index 8e3758817adf0..ffd5b41f7933d 100644 --- a/lib/internal/Magento/Framework/Search/Adapter/Mysql/Query/Builder/Match.php +++ b/lib/internal/Magento/Framework/Search/Adapter/Mysql/Query/Builder/Match.php @@ -15,10 +15,15 @@ use Magento\Framework\Search\Adapter\Preprocessor\PreprocessorInterface; /** + * Class for building select where condition. + * * @api */ class Match implements QueryInterface { + /** + * @var string + */ const SPECIAL_CHARACTERS = '-+~/\\<>\'":*$#@()!,.?`=%&^'; const MINIMAL_CHARACTER_LENGTH = 3; @@ -69,7 +74,7 @@ public function __construct( } /** - * {@inheritdoc} + * @inheritdoc */ public function build( ScoreBuilder $scoreBuilder, @@ -113,6 +118,8 @@ public function build( } /** + * Prepare query value for build function. + * * @param string $queryValue * @param string $conditionType * @return string diff --git a/lib/internal/Magento/Framework/Serialize/README.md b/lib/internal/Magento/Framework/Serialize/README.md index 5af8fb7f71b6b..d900f89208a54 100644 --- a/lib/internal/Magento/Framework/Serialize/README.md +++ b/lib/internal/Magento/Framework/Serialize/README.md @@ -3,6 +3,7 @@ **Serialize** library provides interface *SerializerInterface* and multiple implementations: * *Json* - default implementation. Uses PHP native json_encode/json_decode functions; + * *JsonHexTag* - default implementation. Uses PHP native json_encode/json_decode functions with `JSON_HEX_TAG` option enabled; * *Serialize* - less secure than *Json*, but gives higher performance on big arrays. Uses PHP native serialize/unserialize functions, does not unserialize objects on PHP 7. Using *Serialize* implementation directly is discouraged, always use *SerializerInterface*, using *Serialize* implementation may lead to security vulnerabilities. \ No newline at end of file diff --git a/lib/internal/Magento/Framework/Serialize/Serializer/FormData.php b/lib/internal/Magento/Framework/Serialize/Serializer/FormData.php new file mode 100644 index 0000000000000..077e91797d2b5 --- /dev/null +++ b/lib/internal/Magento/Framework/Serialize/Serializer/FormData.php @@ -0,0 +1,55 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\Framework\Serialize\Serializer; + +use Magento\Framework\Serialize\Serializer\Json; + +/** + * Class for processing of serialized form data. + */ +class FormData +{ + /** + * @var Json + */ + private $serializer; + + /** + * @param Json $serializer + */ + public function __construct(Json $serializer) + { + $this->serializer = $serializer; + } + + /** + * Provides form data from the serialized data. + * + * @param string $serializedData + * @return array + * @throws \InvalidArgumentException + */ + public function unserialize(string $serializedData): array + { + $encodedFields = $this->serializer->unserialize($serializedData); + + if (!is_array($encodedFields)) { + throw new \InvalidArgumentException('Unable to unserialize value.'); + } + + $formData = []; + foreach ($encodedFields as $item) { + $decodedFieldData = []; + parse_str($item, $decodedFieldData); + $formData = array_replace_recursive($formData, $decodedFieldData); + } + + return $formData; + } +} diff --git a/lib/internal/Magento/Framework/Serialize/Serializer/Json.php b/lib/internal/Magento/Framework/Serialize/Serializer/Json.php index e352d0c2d7124..7ce9756ff243d 100644 --- a/lib/internal/Magento/Framework/Serialize/Serializer/Json.php +++ b/lib/internal/Magento/Framework/Serialize/Serializer/Json.php @@ -16,27 +16,27 @@ class Json implements SerializerInterface { /** - * {@inheritDoc} + * @inheritDoc * @since 100.2.0 */ public function serialize($data) { $result = json_encode($data); if (false === $result) { - throw new \InvalidArgumentException('Unable to serialize value.'); + throw new \InvalidArgumentException("Unable to serialize value. Error: " . json_last_error_msg()); } return $result; } /** - * {@inheritDoc} + * @inheritDoc * @since 100.2.0 */ public function unserialize($string) { $result = json_decode($string, true); if (json_last_error() !== JSON_ERROR_NONE) { - throw new \InvalidArgumentException('Unable to unserialize value.'); + throw new \InvalidArgumentException("Unable to unserialize value. Error: " . json_last_error_msg()); } return $result; } diff --git a/lib/internal/Magento/Framework/Serialize/Serializer/JsonHexTag.php b/lib/internal/Magento/Framework/Serialize/Serializer/JsonHexTag.php new file mode 100644 index 0000000000000..4a5406ff3fd99 --- /dev/null +++ b/lib/internal/Magento/Framework/Serialize/Serializer/JsonHexTag.php @@ -0,0 +1,35 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\Framework\Serialize\Serializer; + +use Magento\Framework\Serialize\SerializerInterface; + +/** + * Serialize data to JSON with the JSON_HEX_TAG option enabled + * (All < and > are converted to \u003C and \u003E), + * unserialize JSON encoded data + * + * @api + * @since 100.2.0 + */ +class JsonHexTag extends Json implements SerializerInterface +{ + /** + * @inheritDoc + * @since 100.2.0 + */ + public function serialize($data): string + { + $result = json_encode($data, JSON_HEX_TAG); + if (false === $result) { + throw new \InvalidArgumentException('Unable to serialize value.'); + } + return $result; + } +} diff --git a/lib/internal/Magento/Framework/Serialize/Test/Unit/Serializer/FormDataTest.php b/lib/internal/Magento/Framework/Serialize/Test/Unit/Serializer/FormDataTest.php new file mode 100644 index 0000000000000..7729a2da97efb --- /dev/null +++ b/lib/internal/Magento/Framework/Serialize/Test/Unit/Serializer/FormDataTest.php @@ -0,0 +1,106 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\Framework\Serialize\Test\Unit\Serializer; + +use Magento\Framework\Serialize\Serializer\FormData; +use Magento\Framework\Serialize\Serializer\Json; +use Psr\Log\InvalidArgumentException; + +/** + * Test for Magento\Framework\Serialize\Serializer\FormData class. + */ +class FormDataTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var Json|\PHPUnit_Framework_MockObject_MockObject + */ + private $jsonSerializerMock; + + /** + * @var FormData + */ + private $formDataSerializer; + + /** + * @inheritdoc + */ + protected function setUp() + { + $this->jsonSerializerMock = $this->createMock(Json::class); + $this->formDataSerializer = new FormData($this->jsonSerializerMock); + } + + /** + * @param string $serializedData + * @param array $encodedFields + * @param array $expectedFormData + * @return void + * @dataProvider unserializeDataProvider + */ + public function testUnserialize(string $serializedData, array $encodedFields, array $expectedFormData) + { + $this->jsonSerializerMock->expects($this->once()) + ->method('unserialize') + ->with($serializedData) + ->willReturn($encodedFields); + + $this->assertEquals($expectedFormData, $this->formDataSerializer->unserialize($serializedData)); + } + + /** + * @return array + */ + public function unserializeDataProvider(): array + { + return [ + [ + 'serializedData' => + '["option[order][option_0]=1","option[value][option_0]=1","option[delete][option_0]="]', + 'encodedFields' => [ + 'option[order][option_0]=1', + 'option[value][option_0]=1', + 'option[delete][option_0]=', + ], + 'expectedFormData' => [ + 'option' => [ + 'order' => [ + 'option_0' => '1', + ], + 'value' => [ + 'option_0' => '1', + ], + 'delete' => [ + 'option_0' => '', + ], + ], + ], + ], + [ + 'serializedData' => '[]', + 'encodedFields' => [], + 'expectedFormData' => [], + ], + ]; + } + + /** + * @return void + * @expectedException InvalidArgumentException + * @expectedExceptionMessage Unable to unserialize value. + */ + public function testUnserializeWithWrongSerializedData() + { + $serializedData = 'test'; + + $this->jsonSerializerMock->expects($this->once()) + ->method('unserialize') + ->with($serializedData) + ->willReturn('test'); + + $this->formDataSerializer->unserialize($serializedData); + } +} diff --git a/lib/internal/Magento/Framework/Serialize/Test/Unit/Serializer/JsonHexTagTest.php b/lib/internal/Magento/Framework/Serialize/Test/Unit/Serializer/JsonHexTagTest.php new file mode 100644 index 0000000000000..c867dced0fc6e --- /dev/null +++ b/lib/internal/Magento/Framework/Serialize/Test/Unit/Serializer/JsonHexTagTest.php @@ -0,0 +1,118 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +declare(strict_types=1); + +namespace Magento\Framework\Serialize\Test\Unit\Serializer; + +use Magento\Framework\DataObject; +use Magento\Framework\Serialize\Serializer\JsonHexTag; + +class JsonHexTagTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var \Magento\Framework\Serialize\Serializer\Json + */ + private $json; + + protected function setUp() + { + $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $this->json = $objectManager->getObject(JsonHexTag::class); + } + + /** + * @param string|int|float|bool|array|null $value + * @param string $expected + * @dataProvider serializeDataProvider + */ + public function testSerialize($value, $expected) + { + $this->assertEquals( + $expected, + $this->json->serialize($value) + ); + } + + public function serializeDataProvider() + { + $dataObject = new DataObject(['something']); + return [ + ['', '""'], + ['string', '"string"'], + [null, 'null'], + [false, 'false'], + [['a' => 'b', 'd' => 123], '{"a":"b","d":123}'], + [123, '123'], + [10.56, '10.56'], + [$dataObject, '{}'], + ['< >', '"\u003C \u003E"'], + ]; + } + + /** + * @param string $value + * @param string|int|float|bool|array|null $expected + * @dataProvider unserializeDataProvider + */ + public function testUnserialize($value, $expected) + { + $this->assertEquals( + $expected, + $this->json->unserialize($value) + ); + } + + /** + * @return array + */ + public function unserializeDataProvider(): array + { + return [ + ['""', ''], + ['"string"', 'string'], + ['null', null], + ['false', false], + ['{"a":"b","d":123}', ['a' => 'b', 'd' => 123]], + ['123', 123], + ['10.56', 10.56], + ['{}', []], + ['"\u003C \u003E"', '< >'], + ]; + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage Unable to serialize value. + */ + public function testSerializeException() + { + $this->json->serialize(STDOUT); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage Unable to unserialize value. + * @dataProvider unserializeExceptionDataProvider + */ + public function testUnserializeException($value) + { + $this->json->unserialize($value); + } + + /** + * @return array + */ + public function unserializeExceptionDataProvider(): array + { + return [ + [''], + [false], + [null], + ['{'] + ]; + } +} diff --git a/lib/internal/Magento/Framework/Test/Unit/DB/Query/BatchRangeIteratorTest.php b/lib/internal/Magento/Framework/Test/Unit/DB/Query/BatchRangeIteratorTest.php index 22fdf0a05686a..9e2014c1b070a 100644 --- a/lib/internal/Magento/Framework/Test/Unit/DB/Query/BatchRangeIteratorTest.php +++ b/lib/internal/Magento/Framework/Test/Unit/DB/Query/BatchRangeIteratorTest.php @@ -116,6 +116,6 @@ public function testIterations() $iterations++; } - $this->assertEquals(10, $iterations); + $this->assertEquals(11, $iterations); } } diff --git a/lib/internal/Magento/Framework/Test/Unit/EscaperTest.php b/lib/internal/Magento/Framework/Test/Unit/EscaperTest.php index ec05478b90db9..e406994b54c17 100644 --- a/lib/internal/Magento/Framework/Test/Unit/EscaperTest.php +++ b/lib/internal/Magento/Framework/Test/Unit/EscaperTest.php @@ -72,9 +72,9 @@ public function testEscapeJsEscapesOwaspRecommendedRanges() // Exceptions to escaping ranges $immune = [',', '.', '_']; for ($chr = 0; $chr < 0xFF; $chr++) { - if ($chr >= 0x30 && $chr <= 0x39 - || $chr >= 0x41 && $chr <= 0x5A - || $chr >= 0x61 && $chr <= 0x7A + if (($chr >= 0x30 && $chr <= 0x39) + || ($chr >= 0x41 && $chr <= 0x5A) + || ($chr >= 0x61 && $chr <= 0x7A) ) { $literal = $this->codepointToUtf8($chr); $this->assertEquals($literal, $this->escaper->escapeJs($literal)); @@ -174,6 +174,11 @@ public function escapeHtmlDataProvider() 'data' => '&<>"\'&<>"' ', 'expected' => '&<>"'&<>"' ' ], + 'text with special characters and allowed tag' => [ + 'data' => '&<br/>"\'&<>"' ', + 'expected' => '&<br>"'&<>"' ', + 'allowedTags' => ['br'], + ], 'text with multiple allowed tags, includes self closing tag' => [ 'data' => '<span>some text in tags<br /></span>', 'expected' => '<span>some text in tags<br></span>', diff --git a/lib/internal/Magento/Framework/Test/Unit/Filesystem/Directory/PathValidatorTest.php b/lib/internal/Magento/Framework/Test/Unit/Filesystem/Directory/PathValidatorTest.php new file mode 100644 index 0000000000000..d19dbde27a472 --- /dev/null +++ b/lib/internal/Magento/Framework/Test/Unit/Filesystem/Directory/PathValidatorTest.php @@ -0,0 +1,105 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +namespace Magento\Framework\Test\Unit\Filesystem\Directory; + +use Magento\Framework\Filesystem\Directory\PathValidator; +use Magento\Framework\Filesystem\DriverInterface; + +/** + * Test for Magento\Framework\Filesystem\Directory\PathValidator class. + */ +class PathValidatorTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var DriverInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $driverMock; + + /** + * @var PathValidator + */ + private $pathValidator; + + protected function setUp() + { + $this->driverMock = $this->getMockForAbstractClass(DriverInterface::class); + + $this->pathValidator = new PathValidator($this->driverMock); + } + + /** + * @return void + */ + public function testValidateWithAbsolutePath() + { + $directoryPath = __DIR__ . '/pub/static/'; + $path = '/pub/static/testFile.txt'; + + $this->driverMock->expects($this->at(0)) + ->method('getRealPathSafety') + ->with($directoryPath) + ->willReturn($directoryPath); + $this->driverMock->expects($this->at(1)) + ->method('getRealPathSafety') + ->with($path) + ->willReturn($directoryPath); + + $this->pathValidator->validate($directoryPath, $path, null, true); + } + + /** + * @return void + */ + public function testValidateWithoutAbsolutePath() + { + $directoryPath = __DIR__ . '/pub/static/'; + $path = '/pub/static/testFile.txt'; + $actualPath = __DIR__ . '/pub/static/'; + + $this->driverMock->expects($this->at(0)) + ->method('getRealPathSafety') + ->with($directoryPath) + ->willReturn($directoryPath); + $this->driverMock->expects($this->once()) + ->method('getAbsolutePath') + ->with($directoryPath, $path, null) + ->willReturn($actualPath); + $this->driverMock->expects($this->at(2)) + ->method('getRealPathSafety') + ->with($actualPath) + ->willReturn($actualPath); + + $this->pathValidator->validate($directoryPath, $path); + } + + /** + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + * @expectedExceptionMessageRegExp #^Path ".+/static/testFile.txt" cannot be used with directory ".+/pub/static/"$# + */ + public function testValidateWithWrongPath() + { + $directoryPath = __DIR__ . '/pub/static/'; + $path = '../../../pub/static/testFile.txt'; + $actualPath = __DIR__ . '/../../app/etc/'; + + $this->driverMock->expects($this->at(0)) + ->method('getRealPathSafety') + ->with($directoryPath) + ->willReturn($directoryPath); + $this->driverMock->expects($this->once()) + ->method('getAbsolutePath') + ->with($directoryPath, $path, null) + ->willReturn($actualPath); + $this->driverMock->expects($this->at(2)) + ->method('getRealPathSafety') + ->with($actualPath) + ->willReturn($actualPath); + + $this->pathValidator->validate($directoryPath, $path); + } +} diff --git a/lib/internal/Magento/Framework/Test/Unit/TranslateTest.php b/lib/internal/Magento/Framework/Test/Unit/TranslateTest.php index 5c634a1bca078..0480f05ca6ab4 100644 --- a/lib/internal/Magento/Framework/Test/Unit/TranslateTest.php +++ b/lib/internal/Magento/Framework/Test/Unit/TranslateTest.php @@ -228,11 +228,8 @@ public function testLoadData($area, $forceReload) $this->translate->loadData($area, $forceReload); $expected = [ - 'module original' => 'module translated', - 'module theme' => 'theme translated overwrite', - 'module pack' => 'theme-pack translated overwrite', + 'module pack' => 'pack translated overwrite', 'module db' => 'db translated overwrite', - 'theme original' => 'theme translated', 'pack original' => 'pack translated', 'db original' => 'db translated', ]; diff --git a/lib/internal/Magento/Framework/Translate.php b/lib/internal/Magento/Framework/Translate.php index 82296d776a73d..3682cfc88c8d4 100644 --- a/lib/internal/Magento/Framework/Translate.php +++ b/lib/internal/Magento/Framework/Translate.php @@ -8,6 +8,9 @@ namespace Magento\Framework; use Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Filesystem\Driver\File; +use Magento\Framework\Filesystem\DriverInterface; /** * Translate library @@ -116,6 +119,11 @@ class Translate implements \Magento\Framework\TranslateInterface */ protected $packDictionary; + /** + * @var DriverInterface + */ + private $fileDriver; + /** * @var \Magento\Framework\Serialize\SerializerInterface */ @@ -135,6 +143,7 @@ class Translate implements \Magento\Framework\TranslateInterface * @param \Magento\Framework\App\RequestInterface $request * @param \Magento\Framework\File\Csv $csvParser * @param \Magento\Framework\App\Language\Dictionary $packDictionary + * @param DriverInterface|null $fileDriver * * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ @@ -151,7 +160,8 @@ public function __construct( \Magento\Framework\Filesystem $filesystem, \Magento\Framework\App\RequestInterface $request, \Magento\Framework\File\Csv $csvParser, - \Magento\Framework\App\Language\Dictionary $packDictionary + \Magento\Framework\App\Language\Dictionary $packDictionary, + DriverInterface $fileDriver = null ) { $this->_viewDesign = $viewDesign; $this->_cache = $cache; @@ -166,6 +176,7 @@ public function __construct( $this->directory = $filesystem->getDirectoryRead(DirectoryList::ROOT); $this->_csvParser = $csvParser; $this->packDictionary = $packDictionary; + $this->fileDriver = $fileDriver ?: ObjectManager::getInstance()->get(File::class); $this->_config = [ self::CONFIG_AREA_KEY => null, @@ -469,7 +480,7 @@ protected function _getThemeTranslationFile($locale) protected function _getFileData($file) { $data = []; - if ($this->directory->isExist($this->directory->getRelativePath($file))) { + if ($this->fileDriver->isExists($file)) { $this->_csvParser->setDelimiter(','); $data = $this->_csvParser->getDataPairs($file); } diff --git a/lib/internal/Magento/Framework/View/Element/AbstractBlock.php b/lib/internal/Magento/Framework/View/Element/AbstractBlock.php index 84dd4270c59cf..901c0e59b1949 100644 --- a/lib/internal/Magento/Framework/View/Element/AbstractBlock.php +++ b/lib/internal/Magento/Framework/View/Element/AbstractBlock.php @@ -952,7 +952,7 @@ public function stripTags($data, $allowableTags = null, $allowHtmlEntities = fal */ public function escapeUrl($string) { - return $this->_escaper->escapeUrl($string); + return $this->_escaper->escapeUrl((string)$string); } /** @@ -1152,7 +1152,8 @@ public function getVar($name, $module = null) /** * Determine if the block scope is private or public. - * Returns true if scope is private, false otherwise + * + * Returns true if scope is private, false otherwise. * * @return bool */ diff --git a/lib/internal/Magento/Framework/View/Element/Html/Link/Current.php b/lib/internal/Magento/Framework/View/Element/Html/Link/Current.php index 43bfd46c1193a..7aac210dcab89 100644 --- a/lib/internal/Magento/Framework/View/Element/Html/Link/Current.php +++ b/lib/internal/Magento/Framework/View/Element/Html/Link/Current.php @@ -5,6 +5,10 @@ */ namespace Magento\Framework\View\Element\Html\Link; +use Magento\Framework\App\DefaultPathInterface; +use Magento\Framework\View\Element\Template; +use Magento\Framework\View\Element\Template\Context; + /** * Block representing link with two possible states. * "Current" state means link leads to URL equivalent to URL of currently displayed page. @@ -17,25 +21,25 @@ * @method null|bool getCurrent() * @method \Magento\Framework\View\Element\Html\Link\Current setCurrent(bool $value) */ -class Current extends \Magento\Framework\View\Element\Template +class Current extends Template { /** * Default path * - * @var \Magento\Framework\App\DefaultPathInterface + * @var DefaultPathInterface */ protected $_defaultPath; /** * Constructor * - * @param \Magento\Framework\View\Element\Template\Context $context - * @param \Magento\Framework\App\DefaultPathInterface $defaultPath + * @param Context $context + * @param DefaultPathInterface $defaultPath * @param array $data */ public function __construct( - \Magento\Framework\View\Element\Template\Context $context, - \Magento\Framework\App\DefaultPathInterface $defaultPath, + Context $context, + DefaultPathInterface $defaultPath, array $data = [] ) { parent::__construct($context, $data); @@ -56,18 +60,20 @@ public function getHref() * Get current mca * * @return string + * @SuppressWarnings(PHPMD.RequestAwareBlockMethod) */ private function getMca() { $routeParts = [ - 'module' => $this->_request->getModuleName(), - 'controller' => $this->_request->getControllerName(), - 'action' => $this->_request->getActionName(), + (string)$this->_request->getModuleName(), + (string)$this->_request->getControllerName(), + (string)$this->_request->getActionName(), ]; $parts = []; + $pathParts = explode('/', trim($this->_request->getPathInfo(), '/')); foreach ($routeParts as $key => $value) { - if (!empty($value) && $value != $this->_defaultPath->getPart($key)) { + if (isset($pathParts[$key]) && $pathParts[$key] === $value) { $parts[] = $value; } } diff --git a/lib/internal/Magento/Framework/View/Element/Template/File/Validator.php b/lib/internal/Magento/Framework/View/Element/Template/File/Validator.php index 4b4e7e1bad467..9354aef1c57f7 100644 --- a/lib/internal/Magento/Framework/View/Element/Template/File/Validator.php +++ b/lib/internal/Magento/Framework/View/Element/Template/File/Validator.php @@ -5,12 +5,12 @@ */ namespace Magento\Framework\View\Element\Template\File; -use \Magento\Framework\App\Filesystem\DirectoryList; +use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\Component\ComponentRegistrar; +use Magento\Framework\Filesystem\Driver\File as FileDriver; /** - * Class Validator - * @package Magento\Framework\View\Element\Template\File + * Class Validator. */ class Validator { @@ -68,6 +68,11 @@ class Validator */ protected $_compiledDir; + /** + * @var FileDriver + */ + private $fileDriver; + /** * Class constructor * @@ -75,12 +80,14 @@ class Validator * @param \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfigInterface * @param ComponentRegistrar $componentRegistrar * @param string|null $scope + * @param FileDriver|null $fileDriver */ public function __construct( \Magento\Framework\Filesystem $filesystem, \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfigInterface, ComponentRegistrar $componentRegistrar, - $scope = null + $scope = null, + FileDriver $fileDriver = null ) { $this->_filesystem = $filesystem; $this->_isAllowSymlinks = $scopeConfigInterface->getValue(self::XML_PATH_TEMPLATE_ALLOW_SYMLINK, $scope); @@ -88,6 +95,7 @@ public function __construct( $this->moduleDirs = $componentRegistrar->getPaths(ComponentRegistrar::MODULE); $this->_compiledDir = $this->_filesystem->getDirectoryRead(DirectoryList::TMP_MATERIALIZATION_DIR) ->getAbsolutePath(); + $this->fileDriver = $fileDriver ?: \Magento\Framework\App\ObjectManager::getInstance()->get(FileDriver::class); } /** @@ -128,7 +136,7 @@ protected function isPathInDirectories($path, $directories) $directories = (array)$directories; } foreach ($directories as $directory) { - if (0 === strpos($path, $directory)) { + if (0 === strpos($this->fileDriver->getRealPath($path), $directory)) { return true; } } diff --git a/lib/internal/Magento/Framework/View/Test/Unit/Element/Html/Link/CurrentTest.php b/lib/internal/Magento/Framework/View/Test/Unit/Element/Html/Link/CurrentTest.php index 909748722a081..7070ec9d48c11 100644 --- a/lib/internal/Magento/Framework/View/Test/Unit/Element/Html/Link/CurrentTest.php +++ b/lib/internal/Magento/Framework/View/Test/Unit/Element/Html/Link/CurrentTest.php @@ -17,11 +17,6 @@ class CurrentTest extends \PHPUnit\Framework\TestCase */ protected $_requestMock; - /** - * @var \PHPUnit_Framework_MockObject_MockObject - */ - protected $_defaultPathMock; - /** * @var \Magento\Framework\TestFramework\Unit\Helper\ObjectManager */ @@ -32,7 +27,6 @@ protected function setUp() $this->_objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); $this->_urlBuilderMock = $this->createMock(\Magento\Framework\UrlInterface::class); $this->_requestMock = $this->createMock(\Magento\Framework\App\Request\Http::class); - $this->_defaultPathMock = $this->createMock(\Magento\Framework\App\DefaultPathInterface::class); } public function testGetUrl() @@ -60,29 +54,46 @@ public function testIsCurrentIfIsset() $this->assertTrue($link->isCurrent()); } + /** + * Test if the current url is the same as link path + * + * @return void + */ public function testIsCurrent() { - $path = 'test/path'; - $url = 'http://example.com/a/b'; - - $this->_requestMock->expects($this->once())->method('getModuleName')->will($this->returnValue('a')); - $this->_requestMock->expects($this->once())->method('getControllerName')->will($this->returnValue('b')); - $this->_requestMock->expects($this->once())->method('getActionName')->will($this->returnValue('d')); - $this->_defaultPathMock->expects($this->atLeastOnce())->method('getPart')->will($this->returnValue('d')); + $path = 'test/index'; + $url = 'http://example.com/test/index'; + + $this->_requestMock->expects($this->once()) + ->method('getPathInfo') + ->will($this->returnValue('/test/index/')); + $this->_requestMock->expects($this->once()) + ->method('getModuleName') + ->will($this->returnValue('test')); + $this->_requestMock->expects($this->once()) + ->method('getControllerName') + ->will($this->returnValue('index')); + $this->_requestMock->expects($this->once()) + ->method('getActionName') + ->will($this->returnValue('index')); + $this->_urlBuilderMock->expects($this->at(0)) + ->method('getUrl') + ->with($path) + ->will($this->returnValue($url)); + $this->_urlBuilderMock->expects($this->at(1)) + ->method('getUrl') + ->with('test/index') + ->will($this->returnValue($url)); - $this->_urlBuilderMock->expects($this->at(0))->method('getUrl')->with($path)->will($this->returnValue($url)); - $this->_urlBuilderMock->expects($this->at(1))->method('getUrl')->with('a/b')->will($this->returnValue($url)); - - $this->_requestMock->expects($this->once())->method('getControllerName')->will($this->returnValue('b')); /** @var \Magento\Framework\View\Element\Html\Link\Current $link */ $link = $this->_objectManager->getObject( \Magento\Framework\View\Element\Html\Link\Current::class, [ 'urlBuilder' => $this->_urlBuilderMock, - 'request' => $this->_requestMock, - 'defaultPath' => $this->_defaultPathMock + 'request' => $this->_requestMock ] ); + $link->setPath($path); $this->assertTrue($link->isCurrent()); } diff --git a/lib/internal/Magento/Framework/View/Test/Unit/Element/Template/File/ValidatorTest.php b/lib/internal/Magento/Framework/View/Test/Unit/Element/Template/File/ValidatorTest.php index 28d58f4685e80..696dca4eef75e 100644 --- a/lib/internal/Magento/Framework/View/Test/Unit/Element/Template/File/ValidatorTest.php +++ b/lib/internal/Magento/Framework/View/Test/Unit/Element/Template/File/ValidatorTest.php @@ -10,41 +10,40 @@ use \Magento\Framework\Filesystem\DriverPool; /** - * Class ValidatorTest - * @package Magento\Framework\View\Test\Unit\Element\Template\File + * Tests for Magento\Framework\View\Element\Template\File\Validator class. */ class ValidatorTest extends \PHPUnit\Framework\TestCase { /** - * Resolver object + * Resolver object. * * @var \Magento\Framework\View\Element\Template\File\Validator */ - private $_validator; + private $validator; /** - * Mock for view file system + * Mock for view file system. * * @var \Magento\Framework\FileSystem|\PHPUnit_Framework_MockObject_MockObject */ - private $_fileSystemMock; + private $fileSystemMock; /** - * Mock for scope config + * Mock for scope config. * * @var \Magento\Framework\App\Config\ScopeConfigInterface|\PHPUnit_Framework_MockObject_MockObject */ - private $_scopeConfigMock; + private $scopeConfigMock; /** - * Mock for root directory reader + * Mock for root directory reader. * * @var \Magento\Framework\Filesystem\Directory\ReadInterface|\PHPUnit_Framework_MockObject_MockObject */ private $rootDirectoryMock; /** - * Mock for compiled directory reader + * Mock for compiled directory reader. * * @var \Magento\Framework\Filesystem\Directory\ReadInterface|\PHPUnit_Framework_MockObject_MockObject */ @@ -56,18 +55,16 @@ class ValidatorTest extends \PHPUnit\Framework\TestCase private $componentRegistrar; /** - * Test Setup - * - * @return void + * @inheritdoc */ protected function setUp() { - $this->_fileSystemMock = $this->createMock(\Magento\Framework\Filesystem::class); - $this->_scopeConfigMock = $this->createMock(\Magento\Framework\App\Config\ScopeConfigInterface::class); + $this->fileSystemMock = $this->createMock(\Magento\Framework\Filesystem::class); + $this->scopeConfigMock = $this->createMock(\Magento\Framework\App\Config\ScopeConfigInterface::class); $this->rootDirectoryMock = $this->createMock(\Magento\Framework\Filesystem\Directory\ReadInterface::class); $this->compiledDirectoryMock = $this->createMock(\Magento\Framework\Filesystem\Directory\ReadInterface::class); - $this->_fileSystemMock->expects($this->any()) + $this->fileSystemMock->expects($this->any()) ->method('getDirectoryRead') ->will($this->returnValueMap( [ @@ -87,39 +84,46 @@ protected function setUp() $this->returnValueMap( [ [ComponentRegistrar::MODULE, ['/magento/app/code/Some/Module']], - [ComponentRegistrar::THEME, ['/magento/themes/default']] + [ComponentRegistrar::THEME, ['/magento/themes/default']], ] ) ); - $this->_validator = new \Magento\Framework\View\Element\Template\File\Validator( - $this->_fileSystemMock, - $this->_scopeConfigMock, - $this->componentRegistrar + + $fileDriverMock = $this->createMock(\Magento\Framework\Filesystem\Driver\File::class); + $fileDriverMock->expects($this->any()) + ->method('getRealPath') + ->willReturnArgument(0); + + $this->validator = new \Magento\Framework\View\Element\Template\File\Validator( + $this->fileSystemMock, + $this->scopeConfigMock, + $this->componentRegistrar, + null, + $fileDriverMock ); } /** - * Test is file valid + * Test is file valid. * * @param string $file * @param bool $expectedResult - * - * @dataProvider testIsValidDataProvider - * * @return void + * + * @dataProvider isValidDataProvider */ - public function testIsValid($file, $expectedResult) + public function testIsValid(string $file, bool $expectedResult) { $this->rootDirectoryMock->expects($this->any())->method('isFile')->will($this->returnValue(true)); - $this->assertEquals($expectedResult, $this->_validator->isValid($file)); + $this->assertEquals($expectedResult, $this->validator->isValid($file)); } /** - * Data provider for testIsValid + * Data provider for testIsValid. * - * @return [] + * @return array */ - public function testIsValidDataProvider() + public function isValidDataProvider() : array { return [ 'empty' => ['', false], diff --git a/lib/internal/Magento/Framework/Webapi/Rest/Response/Renderer/Xml.php b/lib/internal/Magento/Framework/Webapi/Rest/Response/Renderer/Xml.php index b4cfc61611a93..f25cd219e3eae 100644 --- a/lib/internal/Magento/Framework/Webapi/Rest/Response/Renderer/Xml.php +++ b/lib/internal/Magento/Framework/Webapi/Rest/Response/Renderer/Xml.php @@ -7,6 +7,9 @@ */ namespace Magento\Framework\Webapi\Rest\Response\Renderer; +/** + * Renders response data in Xml format. + */ class Xml implements \Magento\Framework\Webapi\Rest\Response\RendererInterface { /** @@ -111,8 +114,7 @@ protected function _formatValue($value) /** Without the following transformation boolean values are rendered incorrectly */ $value = $value ? 'true' : 'false'; } - $replacementMap = ['&' => '&']; - return str_replace(array_keys($replacementMap), array_values($replacementMap), $value); + return (string) $value; } /** diff --git a/lib/internal/Magento/Framework/Webapi/Test/Unit/Rest/Response/Renderer/XmlTest.php b/lib/internal/Magento/Framework/Webapi/Test/Unit/Rest/Response/Renderer/XmlTest.php index 396fbcdb1978b..71fb41491cc74 100644 --- a/lib/internal/Magento/Framework/Webapi/Test/Unit/Rest/Response/Renderer/XmlTest.php +++ b/lib/internal/Magento/Framework/Webapi/Test/Unit/Rest/Response/Renderer/XmlTest.php @@ -76,6 +76,11 @@ public function providerXmlRender() '<?xml version="1.0"?><response><item_7key>value</item_7key></response>', 'Invalid XML render with numeric symbol in data index.' ], + [ + ['key' => 'test & foo'], + '<?xml version="1.0"?><response><key>test & foo</key></response>', + 'Invalid XML render with ampersand symbol in data index.' + ], [ ['.key' => 'value'], '<?xml version="1.0"?><response><item_key>value</item_key></response>', diff --git a/lib/internal/Magento/Framework/composer.json b/lib/internal/Magento/Framework/composer.json index ededfebaf94ab..105c3f0721819 100644 --- a/lib/internal/Magento/Framework/composer.json +++ b/lib/internal/Magento/Framework/composer.json @@ -2,7 +2,7 @@ "name": "magento/framework", "description": "N/A", "type": "magento2-library", - "version": "101.0.6", + "version": "101.0.7", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/lib/web/css/docs/forms.html b/lib/web/css/docs/forms.html index 211a9bd650ba0..dc08ddffd2066 100644 --- a/lib/web/css/docs/forms.html +++ b/lib/web/css/docs/forms.html @@ -713,7 +713,7 @@ <h2 id="simple-form-with-required-fields-message">Simple form with "require <th>@_type</th> <td class="vars_value">@form-element-input-type</td> <td class="vars_value">'' [input-text | select | textarea | input-radio | input-checkbox]</td> - <td>Form control type.<br/><b>@form-element-input__[]</b> global variables are used to set up all form elements style. Control-specific global variables use these <b>@form-element-input__[]</b> variables by default. Control-specific global variables can be set up separately.<br/><b>@input-text__[]</b> is used to set up input-text controls style<br/><b>@select__[]</b> is used to set up selects style<br/><b>@textarea__[]</b> is used to set up textarea style</td> + <td>Form control type.<br/><strong>@form-element-input__[]</strong> global variables are used to set up all form elements style. Control-specific global variables use these <strong>@form-element-input__[]</strong> variables by default. Control-specific global variables can be set up separately.<br/><strong>@input-text__[]</strong> is used to set up input-text controls style<br/><strong>@select__[]</strong> is used to set up selects style<br/><strong>@textarea__[]</strong> is used to set up textarea style</td> </tr> <tr> <th>@_background</th> diff --git a/lib/web/css/docs/variables.html b/lib/web/css/docs/variables.html index 4f353dc1554a4..ebbf2122ab209 100644 --- a/lib/web/css/docs/variables.html +++ b/lib/web/css/docs/variables.html @@ -3507,7 +3507,7 @@ <h4 id="the-codelibformelementinputcoed-mixin-variables">The <code>.lib-form-ele <th>@_type</th> <td class="vars_value">@form-element-input-type</td> <td class="vars_value">'' [input-text | select | textarea | input-radio | input-checkbox]</td> - <td>Form control type.<br/><b>@form-element-input__[]</b> global variables are used to set up all form elements style. Control-specific global variables use these <b>@form-element-input__[]</b> variables by default. Control-specific global variables can be set up separately.<br/><b>@input-text__[]</b> is used to set up input-text controls style<br/><b>@select__[]</b> is used to set up selects style<br/><b>@textarea__[]</b> is used to set up textarea style</td> + <td>Form control type.<br/><strong>@form-element-input__[]</strong> global variables are used to set up all form elements style. Control-specific global variables use these <strong>@form-element-input__[]</strong> variables by default. Control-specific global variables can be set up separately.<br/><strong>@input-text__[]</strong> is used to set up input-text controls style<br/><strong>@select__[]</strong> is used to set up selects style<br/><strong>@textarea__[]</strong> is used to set up textarea style</td> </tr> <tr> <th>@_background</th> diff --git a/lib/web/css/source/lib/_forms.less b/lib/web/css/source/lib/_forms.less index b1c7a49da4a7a..5b6ff81bb4a65 100644 --- a/lib/web/css/source/lib/_forms.less +++ b/lib/web/css/source/lib/_forms.less @@ -300,6 +300,8 @@ input[type="checkbox"] { .lib-form-element-choice(@_type: input-checkbox); + position: relative; + top: 2px; } input[type="radio"] { diff --git a/lib/web/css/source/lib/_navigation.less b/lib/web/css/source/lib/_navigation.less index acae3c629500e..6232333fca33f 100644 --- a/lib/web/css/source/lib/_navigation.less +++ b/lib/web/css/source/lib/_navigation.less @@ -330,6 +330,19 @@ padding-right: 0; } + &:hover { + &:after { + content: ''; + display: block; + position: absolute; + top: 0; + left: 100%; + width: 10px; + height: calc(100% + 3px); + z-index: 1; + } + } + > .level-top { .lib-css(background, @_nav-level0-item-background-color); .lib-css(border, @_nav-level0-item-border); @@ -408,6 +421,17 @@ @_left: @_submenu-arrow-left ); + &:before { + content: ''; + display: block; + position: absolute; + width: 100%; + height: 4px; + left: 0; + top: -4px; + z-index: 1; + } + a { display: block; line-height: inherit; diff --git a/lib/web/fotorama/fotorama.js b/lib/web/fotorama/fotorama.js index 4062c501cb798..4f323e4312f6b 100644 --- a/lib/web/fotorama/fotorama.js +++ b/lib/web/fotorama/fotorama.js @@ -2481,11 +2481,11 @@ fotoramaVersion = '4.6.4'; .append($videoPlay.clone()); // This solves tabbing problems - addFocus(frame, function () { + addFocus(frame, function (e) { setTimeout(function () { lockScroll($stage); }, 0); - clickToShow({index: frameData.eq, user: true}); + clickToShow({index: frameData.eq, user: true}, e); }); $stageFrame = $stageFrame.add($frame); @@ -3034,7 +3034,10 @@ fotoramaVersion = '4.6.4'; return time; } - that.showStage = function (silent, options, time) { + that.showStage = function (silent, options, time, e) { + if (e !== undefined && e.target.tagName == 'IFRAME') { + return; + } unloadVideo($videoPlaying, activeFrame.i !== data[normalizeIndex(repositionIndex)].i); frameDraw(activeIndexes, 'stage'); stageFramePosition(SLOW ? [dirtyIndex] : [dirtyIndex, getPrevIndex(dirtyIndex), getNextIndex(dirtyIndex)]); @@ -3122,7 +3125,7 @@ fotoramaVersion = '4.6.4'; } }; - that.show = function (options) { + that.show = function (options, e) { that.longPress.singlePressInProgress = true; var index = calcActiveIndex(options); @@ -3133,7 +3136,7 @@ fotoramaVersion = '4.6.4'; var silent = _activeFrame === activeFrame && !options.user; - that.showStage(silent, options, time); + that.showStage(silent, options, time, e); that.showNav(silent, options, time); showedFLAG = typeof lastActiveIndex !== 'undefined' && lastActiveIndex !== activeIndex; @@ -3493,7 +3496,7 @@ fotoramaVersion = '4.6.4'; $stage.on('mousemove', stageCursor); - function clickToShow(showOptions) { + function clickToShow(showOptions, e) { clearTimeout(clickToShow.t); if (opts.clicktransition && opts.clicktransition !== opts.transition) { @@ -3510,7 +3513,7 @@ fotoramaVersion = '4.6.4'; }, 10); }, 0); } else { - that.show(showOptions); + that.show(showOptions, e); } } diff --git a/lib/web/images/logo.svg b/lib/web/images/logo.svg index 013d6e7c5a107..0f29d4e3eef21 100644 --- a/lib/web/images/logo.svg +++ b/lib/web/images/logo.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" height="55.139px" viewBox="0 0 189 55.139" width="189px" version="1.1" y="0px" x="0px" xmlns:xlink="http://www.w3.org/1999/xlink" enable-background="new 0 0 189 55.139"><path d="m23.333 0l-23.333 14.135v26.865l6.06 3.568v-26.865l17.278-10.504 17.292 10.488 0.074 0.042-0.008 26.798 6.001-3.527v-26.865l-23.364-14.135zm3.088 16.538v31.407l-3.088 1.889-3.091-1.896v-31.376l-8.003 4.928v26.86l11.094 6.789 11.189-6.837v-26.829l-8.101-4.935z" fill="#ED7402"/><path d="m12.239 21.491l8.003 4.886v-9.814l-8.003 4.928zm14.182-4.953v9.902l8.101-4.967-8.101-4.935zm20.276-2.403l-23.364-14.135-23.333 14.135 6.06 3.568 17.278-10.504 17.365 10.53 5.994-3.594z" fill="#F8B97F"/><path d="m82.328 39.984l-1.608-20.54-8.156 20.651h-2.656l-8.156-20.651-1.571 20.541h-3.293l2.058-25.814h4.34l8.043 21.174 8.044-21.174h4.301l2.021 25.814h-3.367z" fill="#131108"/><path d="m99.91 39.984l-0.375-2.396c-1.421 1.458-3.366 2.769-6.284 2.769-3.218 0-5.237-1.945-5.237-4.977 0-4.452 3.815-6.209 11.261-6.996v-0.748c0-2.244-1.348-3.03-3.406-3.03-2.17 0-4.227 0.673-6.172 1.533l-0.449-2.88c2.133-0.861 4.153-1.496 6.922-1.496 4.339 0 6.436 1.757 6.436 5.723v12.498h-2.7zm-0.635-9.055c-6.586 0.636-7.97 2.432-7.97 4.266 0 1.457 0.973 2.394 2.657 2.394 1.945 0 3.815-0.974 5.312-2.508v-4.152h0.001z" fill="#131108"/><path d="m121.72 21.876l0.485 2.992-3.404 0.336c0.486 0.824 0.712 1.76 0.712 2.769 0 3.817-3.219 6.134-6.848 6.134-0.449 0-0.898-0.037-1.348-0.111-0.523 0.338-0.897 0.75-0.897 1.085 0 0.636 0.634 0.787 3.777 1.349l1.272 0.223c3.778 0.673 6.135 1.871 6.135 4.639 0 3.741-4.078 5.5-8.715 5.5-4.64 0-8.343-1.458-8.343-4.6 0-1.834 1.271-3.256 3.777-4.603-0.784-0.562-1.121-1.198-1.121-1.872 0-0.861 0.672-1.722 1.87-2.432-1.982-0.973-3.33-2.879-3.33-5.312 0-3.852 3.217-6.208 6.846-6.208 1.795 0 3.368 0.522 4.604 1.496l4.53-1.385zm-13.99 20.052c0 1.424 1.834 2.471 5.312 2.471 3.48 0 5.424-1.197 5.424-2.693 0-1.086-0.822-1.832-3.365-2.282l-2.134-0.375c-0.972-0.187-1.495-0.299-2.207-0.448-2.1 1.045-3.03 2.094-3.03 3.327zm4.86-17.733c-2.244 0-3.629 1.722-3.629 3.891 0 2.058 1.421 3.665 3.629 3.665 2.283 0 3.704-1.682 3.704-3.815 0.01-2.132-1.49-3.741-3.7-3.741z" fill="#131108"/><path d="m137.58 31.416h-12.122c0.112 4.15 2.094 6.098 5.2 6.098 2.583 0 4.453-1.009 6.397-2.544l0.484 2.994c-1.907 1.497-4.188 2.394-7.143 2.394-4.642 0-8.271-2.807-8.271-9.354 0-5.724 3.368-9.239 7.856-9.239 5.199 0 7.595 4.002 7.595 8.94v0.711zm-7.63-7.033c-2.059 0-3.817 1.459-4.34 4.526h8.604c-0.41-2.881-1.68-4.526-4.26-4.526z" fill="#131108"/><path d="m151.61 39.984v-12.16c0-1.832-0.786-3.067-2.73-3.067-1.759 0-3.555 1.161-5.163 2.88v12.347h-3.329v-17.846h2.655l0.412 2.582c1.683-1.533 3.78-2.955 6.321-2.955 3.369 0 5.166 2.019 5.166 5.236v12.983h-3.33z" fill="#131108"/><path d="m164.78 40.284c-3.143 0-5.199-1.124-5.199-4.716v-10.624h-2.694v-2.806h2.694v-5.949l3.255-0.485v6.434h3.853l0.449 2.806h-4.302v10.025c0 1.461 0.599 2.357 2.47 2.357 0.598 0 1.121-0.035 1.531-0.112l0.45 2.843c-0.57 0.112-1.35 0.227-2.51 0.227z" fill="#131108"/><path d="m175.82 40.357c-4.752 0-8.194-3.403-8.194-9.278 0-5.874 3.442-9.314 8.194-9.314 4.787 0 8.305 3.44 8.305 9.314 0 5.875-3.52 9.278-8.3 9.278zm0-15.788c-3.217 0-4.826 2.769-4.826 6.51 0 3.668 1.683 6.511 4.826 6.511 3.291 0 4.938-2.77 4.938-6.511-0.01-3.666-1.73-6.51-4.94-6.51z" fill="#131108"/><path d="m186.48 24.81c-1.338 0-2.268-0.929-2.268-2.318 0-1.379 0.95-2.328 2.268-2.328 1.34 0 2.27 0.939 2.27 2.328 0 1.378-0.95 2.318-2.27 2.318zm0-4.376c-1.078 0-1.938 0.739-1.938 2.058 0 1.31 0.859 2.049 1.938 2.049 1.09 0 1.949-0.739 1.949-2.049 0-1.319-0.87-2.058-1.95-2.058zm0.67 3.297l-0.768-1.099h-0.249v1.059h-0.44v-2.568h0.779c0.54 0 0.898 0.27 0.898 0.75 0 0.37-0.201 0.609-0.52 0.709l0.74 1.049-0.44 0.1zm-0.68-2.209h-0.34v0.759h0.319c0.29 0 0.471-0.12 0.471-0.379 0-0.25-0.16-0.38-0.45-0.38z" fill="#131108"/></svg> \ No newline at end of file +<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 179.073 50"><style>.st0{fill:#f26322}.st1{fill:#4d4d4d}</style><path class="st0" d="M21.432 0L0 12.373v24.713l6.117 3.533-.041-24.713 15.315-8.842 15.313 8.842v24.706l6.118-3.526V12.35z"/><path class="st0" d="M24.47 40.618l-3.058 1.772-3.071-1.759V15.906l-6.116 3.532.01 24.712 9.172 5.298 9.18-5.298V19.438l-6.117-3.532z"/><path class="st1" d="M56.838 12.522l8.415 21.258h.068l8.21-21.258h3.203V36.88h-2.215V15.656h-.068c-.114.386-.239.772-.374 1.158-.115.318-.246.67-.393 1.055-.147.387-.278.75-.391 1.09l-7.052 17.919h-2.01L57.11 18.96a19.913 19.913 0 0 1-.408-1.039 43.558 43.558 0 0 1-.375-1.073 68.067 68.067 0 0 0-.408-1.192h-.069v21.223h-2.112V12.522h3.1zM83.17 36.982a5.205 5.205 0 0 1-1.823-.92 4.327 4.327 0 0 1-1.209-1.533 4.881 4.881 0 0 1-.443-2.145 5.018 5.018 0 0 1 .579-2.556 4.472 4.472 0 0 1 1.568-1.584 7.927 7.927 0 0 1 2.299-.903 24.732 24.732 0 0 1 2.811-.477 36 36 0 0 0 2.198-.29 6.689 6.689 0 0 0 1.465-.392c.329-.123.614-.343.817-.63.187-.325.276-.698.256-1.073v-.34a3.212 3.212 0 0 0-1.09-2.674 4.93 4.93 0 0 0-3.134-.87c-3.135 0-4.781 1.306-4.941 3.918h-2.077a5.748 5.748 0 0 1 1.891-4.089 7.5 7.5 0 0 1 5.127-1.533 7.335 7.335 0 0 1 4.564 1.278 4.923 4.923 0 0 1 1.67 4.173v9.573c-.034.402.069.804.29 1.141.219.251.536.394.868.392.12-.001.24-.012.358-.034.124-.022.266-.057.425-.102h.103v1.533c-.188.077-.382.14-.58.188-.28.062-.566.091-.852.086a2.694 2.694 0 0 1-1.839-.597 2.575 2.575 0 0 1-.75-1.891v-.374h-.102c-.277.372-.578.725-.903 1.056-.38.385-.81.717-1.278.988a7.14 7.14 0 0 1-1.737.715 8.443 8.443 0 0 1-2.249.272 8.186 8.186 0 0 1-2.282-.306m5.195-1.857a5.971 5.971 0 0 0 1.857-1.175 4.767 4.767 0 0 0 1.499-3.441V27.34a7.295 7.295 0 0 1-2.061.732 33.21 33.21 0 0 1-2.504.426c-.749.114-1.441.233-2.077.358a5.237 5.237 0 0 0-1.652.596 3.055 3.055 0 0 0-1.108 1.107 3.579 3.579 0 0 0-.408 1.824c-.018.53.093 1.056.324 1.533.201.392.493.73.851.987a3.35 3.35 0 0 0 1.244.529c.493.104.995.155 1.499.153a6.555 6.555 0 0 0 2.536-.46M99.35 41.734a4.687 4.687 0 0 1-2.009-3.287h2.043c.14.966.763 1.794 1.652 2.197a7.522 7.522 0 0 0 3.288.664 5.688 5.688 0 0 0 4.173-1.345 4.997 4.997 0 0 0 1.345-3.697v-2.793h-.102a7.293 7.293 0 0 1-2.283 2.282 6.297 6.297 0 0 1-3.304.784 7.375 7.375 0 0 1-3.135-.647 6.921 6.921 0 0 1-2.385-1.806 8.095 8.095 0 0 1-1.516-2.777 11.429 11.429 0 0 1-.528-3.56 10.89 10.89 0 0 1 .613-3.799 8.483 8.483 0 0 1 1.636-2.777 6.75 6.75 0 0 1 2.402-1.703 7.45 7.45 0 0 1 2.913-.58 6.234 6.234 0 0 1 3.372.835 6.938 6.938 0 0 1 2.215 2.265h.102v-2.725h2.078v16.931a6.78 6.78 0 0 1-1.636 4.735 7.751 7.751 0 0 1-5.893 2.112 8.337 8.337 0 0 1-5.041-1.309m9.232-8.908a8.565 8.565 0 0 0 1.397-5.11 11.22 11.22 0 0 0-.34-2.862 6.239 6.239 0 0 0-1.056-2.231 4.828 4.828 0 0 0-1.789-1.448 5.772 5.772 0 0 0-2.503-.511 4.782 4.782 0 0 0-4.071 1.941 8.464 8.464 0 0 0-1.448 5.179c-.008.936.107 1.87.34 2.777a6.64 6.64 0 0 0 1.022 2.214 4.81 4.81 0 0 0 1.703 1.465 5.208 5.208 0 0 0 2.42.528 4.967 4.967 0 0 0 4.325-1.942M119.244 36.624a7.19 7.19 0 0 1-2.572-1.941 8.66 8.66 0 0 1-1.583-2.93 11.839 11.839 0 0 1-.546-3.662 11.179 11.179 0 0 1 .58-3.663 9.138 9.138 0 0 1 1.617-2.929 7.307 7.307 0 0 1 2.522-1.942 7.684 7.684 0 0 1 3.321-.698 7.275 7.275 0 0 1 3.56.8 6.678 6.678 0 0 1 2.351 2.146 8.806 8.806 0 0 1 1.278 3.083 16.87 16.87 0 0 1 .374 3.577h-13.422c.013.941.157 1.875.426 2.777a6.968 6.968 0 0 0 1.124 2.231 5.108 5.108 0 0 0 1.857 1.499 5.948 5.948 0 0 0 2.623.546 4.985 4.985 0 0 0 3.424-1.074 5.875 5.875 0 0 0 1.719-2.878h2.044a7.51 7.51 0 0 1-2.385 4.19 7.072 7.072 0 0 1-4.803 1.567 8.386 8.386 0 0 1-3.509-.699m8.312-12.264a5.986 5.986 0 0 0-.988-1.976 4.525 4.525 0 0 0-1.635-1.311 5.362 5.362 0 0 0-2.351-.478 5.623 5.623 0 0 0-2.368.478 5.064 5.064 0 0 0-1.754 1.311 6.566 6.566 0 0 0-1.141 1.96 9.615 9.615 0 0 0-.562 2.453h11.174a9.268 9.268 0 0 0-.375-2.437M134.879 19.267v2.691h.068a7.237 7.237 0 0 1 2.333-2.197 6.798 6.798 0 0 1 3.561-.868 5.834 5.834 0 0 1 4.037 1.413 5.174 5.174 0 0 1 1.584 4.071V36.88h-2.112V24.581a3.716 3.716 0 0 0-1.073-2.947 4.334 4.334 0 0 0-2.948-.937 5.896 5.896 0 0 0-2.111.375 5.558 5.558 0 0 0-1.738 1.039 4.717 4.717 0 0 0-1.601 3.593V36.88h-2.112V19.267h2.112zM151.912 36.284a2.934 2.934 0 0 1-.92-2.436V21.005h-2.657v-1.738h2.657v-5.416h2.112v5.416h3.271v1.738h-3.271v12.502a1.65 1.65 0 0 0 .426 1.312c.371.265.823.391 1.277.357.258-.001.515-.029.766-.085.215-.043.426-.106.63-.188h.103v1.806a5.907 5.907 0 0 1-1.942.306 3.819 3.819 0 0 1-2.452-.731M162.625 36.624a7.368 7.368 0 0 1-2.571-1.942 8.732 8.732 0 0 1-1.618-2.929 12.217 12.217 0 0 1 0-7.324 8.744 8.744 0 0 1 1.618-2.93 7.386 7.386 0 0 1 2.571-1.942 8.106 8.106 0 0 1 3.424-.698 7.989 7.989 0 0 1 3.406.698 7.424 7.424 0 0 1 2.556 1.942 8.504 8.504 0 0 1 1.601 2.93 12.57 12.57 0 0 1 0 7.324 8.5 8.5 0 0 1-1.601 2.929 7.415 7.415 0 0 1-2.556 1.942 7.959 7.959 0 0 1-3.406.698 8.075 8.075 0 0 1-3.424-.698m6.013-1.652a5.308 5.308 0 0 0 1.873-1.601 7.215 7.215 0 0 0 1.124-2.385 11.348 11.348 0 0 0 0-5.792 7.215 7.215 0 0 0-1.124-2.385 5.289 5.289 0 0 0-1.873-1.601 6.109 6.109 0 0 0-5.195 0 5.497 5.497 0 0 0-1.874 1.601 7.046 7.046 0 0 0-1.141 2.385 11.392 11.392 0 0 0 0 5.792c.227.86.614 1.669 1.141 2.385a5.817 5.817 0 0 0 7.069 1.601M176.856 22.191a2.128 2.128 0 0 1-2.213-2.265 2.216 2.216 0 1 1 4.431 0 2.146 2.146 0 0 1-2.218 2.265m0-4.277a1.845 1.845 0 0 0-1.892 2.012 1.9 1.9 0 1 0 3.797 0 1.854 1.854 0 0 0-1.905-2.012m.653 3.222l-.751-1.073h-.243v1.035h-.43v-2.509h.763c.526 0 .877.264.877.732a.681.681 0 0 1-.508.693l.724 1.025-.432.097zm-.661-2.157h-.333v.741h.312c.283 0 .46-.117.46-.371.001-.244-.158-.37-.439-.37"/></svg> diff --git a/lib/web/images/magento-logo.svg b/lib/web/images/magento-logo.svg index 0d5cc0e6233d6..0f29d4e3eef21 100644 --- a/lib/web/images/magento-logo.svg +++ b/lib/web/images/magento-logo.svg @@ -1 +1 @@ -<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" width="214px" xml:space="preserve" height="62px" viewBox="0 0 214 62" baseProfile="tiny" version="1.1" y="0px" x="0px" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="m93.166 44.96l-1.809-23.096-9.17 23.221h-2.988l-9.17-23.221-1.767 23.096h-3.702l2.314-29.026h4.88l9.045 23.809 9.045-23.809h4.836l2.271 29.026h-3.785z" fill="#131108"/><path d="m112.94 44.96l-0.421-2.692c-1.597 1.639-3.785 3.112-7.066 3.112-3.619 0-5.889-2.188-5.889-5.596 0-5.006 4.29-6.981 12.663-7.867v-0.841c0-2.523-1.515-3.407-3.83-3.407-2.439 0-4.754 0.757-6.94 1.725l-0.505-3.238c2.398-0.969 4.67-1.682 7.783-1.682 4.88 0 7.236 1.976 7.236 6.435v14.051h-3.02zm-0.72-10.182c-7.406 0.715-8.963 2.735-8.963 4.796 0 1.642 1.095 2.693 2.989 2.693 2.187 0 4.291-1.095 5.974-2.82v-4.669z" fill="#131108"/><path d="m137.46 24.599l0.546 3.364-3.826 0.378c0.546 0.926 0.799 1.979 0.799 3.113 0 4.292-3.618 6.899-7.699 6.899-0.504 0-1.011-0.042-1.514-0.126-0.589 0.38-1.01 0.844-1.01 1.22 0 0.716 0.714 0.886 4.248 1.517l1.432 0.252c4.249 0.757 6.898 2.102 6.898 5.216 0 4.206-4.586 6.183-9.802 6.183s-9.381-1.64-9.381-5.173c0-2.062 1.431-3.66 4.248-5.174-0.882-0.631-1.26-1.348-1.26-2.104 0-0.969 0.756-1.936 2.103-2.734-2.229-1.095-3.744-3.238-3.744-5.974 0-4.332 3.616-6.981 7.697-6.981 2.019 0 3.786 0.587 5.175 1.682l5.08-1.558zm-15.73 22.547c0 1.599 2.06 2.775 5.972 2.775 3.913 0 6.099-1.345 6.099-3.027 0-1.222-0.924-2.061-3.784-2.566l-2.397-0.422c-1.095-0.208-1.682-0.336-2.481-0.502-2.36 1.177-3.41 2.356-3.41 3.742zm5.47-19.939c-2.522 0-4.081 1.936-4.081 4.375 0 2.313 1.6 4.12 4.081 4.12 2.566 0 4.165-1.892 4.165-4.29 0-2.397-1.68-4.205-4.16-4.205z" fill="#131108"/><path d="m155.3 35.325h-13.631c0.125 4.669 2.354 6.856 5.847 6.856 2.904 0 5.007-1.135 7.193-2.86l0.546 3.367c-2.144 1.682-4.709 2.691-8.031 2.691-5.219 0-9.299-3.155-9.299-10.519 0-6.435 3.787-10.388 8.835-10.388 5.846 0 8.54 4.5 8.54 10.052v0.801zm-8.58-7.908c-2.313 0-4.291 1.641-4.879 5.09h9.675c-0.47-3.239-1.9-5.09-4.8-5.09z" fill="#131108"/><path d="m171.07 44.96v-13.673c0-2.06-0.883-3.449-3.07-3.449-1.977 0-3.996 1.305-5.807 3.239v13.883h-3.743v-20.067h2.986l0.463 2.903c1.893-1.724 4.251-3.323 7.108-3.323 3.786 0 5.808 2.271 5.808 5.888v14.599h-3.75z" fill="#131108"/><path d="m185.88 45.298c-3.532 0-5.846-1.265-5.846-5.304v-11.946h-3.03v-3.156h3.03v-6.688l3.66-0.546v7.234h4.332l0.505 3.156h-4.837v11.273c0 1.643 0.675 2.651 2.776 2.651 0.673 0 1.262-0.041 1.724-0.127l0.506 3.196c-0.63 0.128-1.51 0.257-2.81 0.257z" fill="#131108"/><path d="m198.29 45.38c-5.342 0-9.213-3.827-9.213-10.434 0-6.605 3.871-10.473 9.213-10.473 5.383 0 9.339 3.868 9.339 10.473 0 6.607-3.96 10.434-9.34 10.434zm0-17.753c-3.617 0-5.426 3.113-5.426 7.319 0 4.125 1.892 7.321 5.426 7.321 3.702 0 5.553-3.114 5.553-7.321 0-4.122-1.93-7.319-5.55-7.319z" fill="#131108"/><path d="m210.28 27.897c-1.505 0-2.551-1.045-2.551-2.606 0-1.55 1.067-2.618 2.551-2.618 1.505 0 2.55 1.056 2.55 2.618 0 1.55-1.07 2.606-2.55 2.606zm0-4.92c-1.214 0-2.18 0.831-2.18 2.314 0 1.472 0.966 2.303 2.18 2.303 1.225 0 2.191-0.832 2.191-2.303 0-1.483-0.98-2.314-2.19-2.314zm0.75 3.708l-0.863-1.237h-0.281v1.191h-0.495v-2.888h0.878c0.606 0 1.01 0.303 1.01 0.843 0 0.416-0.225 0.686-0.585 0.798l0.833 1.18-0.5 0.113zm-0.76-2.484h-0.383v0.854h0.359c0.325 0 0.53-0.135 0.53-0.427 0-0.281-0.18-0.427-0.51-0.427z" fill="#131108"/><g fill="#E85D22"><path d="m26.845 8.857"/><polygon points="53.692 15.5 53.692 46.5 46.021 50.929 46.021 19.929 26.845 8.857 7.67 19.928 7.67 50.929 0 46.5 0 15.5 26.845 0"/><polygon points="26.847 62 15.341 55.356 15.341 24.357 23.011 19.928 23.011 50.929 26.845 53.257 30.682 50.929 30.682 19.929 38.353 24.357 38.353 55.356"/></g></svg> \ No newline at end of file +<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 179.073 50"><style>.st0{fill:#f26322}.st1{fill:#4d4d4d}</style><path class="st0" d="M21.432 0L0 12.373v24.713l6.117 3.533-.041-24.713 15.315-8.842 15.313 8.842v24.706l6.118-3.526V12.35z"/><path class="st0" d="M24.47 40.618l-3.058 1.772-3.071-1.759V15.906l-6.116 3.532.01 24.712 9.172 5.298 9.18-5.298V19.438l-6.117-3.532z"/><path class="st1" d="M56.838 12.522l8.415 21.258h.068l8.21-21.258h3.203V36.88h-2.215V15.656h-.068c-.114.386-.239.772-.374 1.158-.115.318-.246.67-.393 1.055-.147.387-.278.75-.391 1.09l-7.052 17.919h-2.01L57.11 18.96a19.913 19.913 0 0 1-.408-1.039 43.558 43.558 0 0 1-.375-1.073 68.067 68.067 0 0 0-.408-1.192h-.069v21.223h-2.112V12.522h3.1zM83.17 36.982a5.205 5.205 0 0 1-1.823-.92 4.327 4.327 0 0 1-1.209-1.533 4.881 4.881 0 0 1-.443-2.145 5.018 5.018 0 0 1 .579-2.556 4.472 4.472 0 0 1 1.568-1.584 7.927 7.927 0 0 1 2.299-.903 24.732 24.732 0 0 1 2.811-.477 36 36 0 0 0 2.198-.29 6.689 6.689 0 0 0 1.465-.392c.329-.123.614-.343.817-.63.187-.325.276-.698.256-1.073v-.34a3.212 3.212 0 0 0-1.09-2.674 4.93 4.93 0 0 0-3.134-.87c-3.135 0-4.781 1.306-4.941 3.918h-2.077a5.748 5.748 0 0 1 1.891-4.089 7.5 7.5 0 0 1 5.127-1.533 7.335 7.335 0 0 1 4.564 1.278 4.923 4.923 0 0 1 1.67 4.173v9.573c-.034.402.069.804.29 1.141.219.251.536.394.868.392.12-.001.24-.012.358-.034.124-.022.266-.057.425-.102h.103v1.533c-.188.077-.382.14-.58.188-.28.062-.566.091-.852.086a2.694 2.694 0 0 1-1.839-.597 2.575 2.575 0 0 1-.75-1.891v-.374h-.102c-.277.372-.578.725-.903 1.056-.38.385-.81.717-1.278.988a7.14 7.14 0 0 1-1.737.715 8.443 8.443 0 0 1-2.249.272 8.186 8.186 0 0 1-2.282-.306m5.195-1.857a5.971 5.971 0 0 0 1.857-1.175 4.767 4.767 0 0 0 1.499-3.441V27.34a7.295 7.295 0 0 1-2.061.732 33.21 33.21 0 0 1-2.504.426c-.749.114-1.441.233-2.077.358a5.237 5.237 0 0 0-1.652.596 3.055 3.055 0 0 0-1.108 1.107 3.579 3.579 0 0 0-.408 1.824c-.018.53.093 1.056.324 1.533.201.392.493.73.851.987a3.35 3.35 0 0 0 1.244.529c.493.104.995.155 1.499.153a6.555 6.555 0 0 0 2.536-.46M99.35 41.734a4.687 4.687 0 0 1-2.009-3.287h2.043c.14.966.763 1.794 1.652 2.197a7.522 7.522 0 0 0 3.288.664 5.688 5.688 0 0 0 4.173-1.345 4.997 4.997 0 0 0 1.345-3.697v-2.793h-.102a7.293 7.293 0 0 1-2.283 2.282 6.297 6.297 0 0 1-3.304.784 7.375 7.375 0 0 1-3.135-.647 6.921 6.921 0 0 1-2.385-1.806 8.095 8.095 0 0 1-1.516-2.777 11.429 11.429 0 0 1-.528-3.56 10.89 10.89 0 0 1 .613-3.799 8.483 8.483 0 0 1 1.636-2.777 6.75 6.75 0 0 1 2.402-1.703 7.45 7.45 0 0 1 2.913-.58 6.234 6.234 0 0 1 3.372.835 6.938 6.938 0 0 1 2.215 2.265h.102v-2.725h2.078v16.931a6.78 6.78 0 0 1-1.636 4.735 7.751 7.751 0 0 1-5.893 2.112 8.337 8.337 0 0 1-5.041-1.309m9.232-8.908a8.565 8.565 0 0 0 1.397-5.11 11.22 11.22 0 0 0-.34-2.862 6.239 6.239 0 0 0-1.056-2.231 4.828 4.828 0 0 0-1.789-1.448 5.772 5.772 0 0 0-2.503-.511 4.782 4.782 0 0 0-4.071 1.941 8.464 8.464 0 0 0-1.448 5.179c-.008.936.107 1.87.34 2.777a6.64 6.64 0 0 0 1.022 2.214 4.81 4.81 0 0 0 1.703 1.465 5.208 5.208 0 0 0 2.42.528 4.967 4.967 0 0 0 4.325-1.942M119.244 36.624a7.19 7.19 0 0 1-2.572-1.941 8.66 8.66 0 0 1-1.583-2.93 11.839 11.839 0 0 1-.546-3.662 11.179 11.179 0 0 1 .58-3.663 9.138 9.138 0 0 1 1.617-2.929 7.307 7.307 0 0 1 2.522-1.942 7.684 7.684 0 0 1 3.321-.698 7.275 7.275 0 0 1 3.56.8 6.678 6.678 0 0 1 2.351 2.146 8.806 8.806 0 0 1 1.278 3.083 16.87 16.87 0 0 1 .374 3.577h-13.422c.013.941.157 1.875.426 2.777a6.968 6.968 0 0 0 1.124 2.231 5.108 5.108 0 0 0 1.857 1.499 5.948 5.948 0 0 0 2.623.546 4.985 4.985 0 0 0 3.424-1.074 5.875 5.875 0 0 0 1.719-2.878h2.044a7.51 7.51 0 0 1-2.385 4.19 7.072 7.072 0 0 1-4.803 1.567 8.386 8.386 0 0 1-3.509-.699m8.312-12.264a5.986 5.986 0 0 0-.988-1.976 4.525 4.525 0 0 0-1.635-1.311 5.362 5.362 0 0 0-2.351-.478 5.623 5.623 0 0 0-2.368.478 5.064 5.064 0 0 0-1.754 1.311 6.566 6.566 0 0 0-1.141 1.96 9.615 9.615 0 0 0-.562 2.453h11.174a9.268 9.268 0 0 0-.375-2.437M134.879 19.267v2.691h.068a7.237 7.237 0 0 1 2.333-2.197 6.798 6.798 0 0 1 3.561-.868 5.834 5.834 0 0 1 4.037 1.413 5.174 5.174 0 0 1 1.584 4.071V36.88h-2.112V24.581a3.716 3.716 0 0 0-1.073-2.947 4.334 4.334 0 0 0-2.948-.937 5.896 5.896 0 0 0-2.111.375 5.558 5.558 0 0 0-1.738 1.039 4.717 4.717 0 0 0-1.601 3.593V36.88h-2.112V19.267h2.112zM151.912 36.284a2.934 2.934 0 0 1-.92-2.436V21.005h-2.657v-1.738h2.657v-5.416h2.112v5.416h3.271v1.738h-3.271v12.502a1.65 1.65 0 0 0 .426 1.312c.371.265.823.391 1.277.357.258-.001.515-.029.766-.085.215-.043.426-.106.63-.188h.103v1.806a5.907 5.907 0 0 1-1.942.306 3.819 3.819 0 0 1-2.452-.731M162.625 36.624a7.368 7.368 0 0 1-2.571-1.942 8.732 8.732 0 0 1-1.618-2.929 12.217 12.217 0 0 1 0-7.324 8.744 8.744 0 0 1 1.618-2.93 7.386 7.386 0 0 1 2.571-1.942 8.106 8.106 0 0 1 3.424-.698 7.989 7.989 0 0 1 3.406.698 7.424 7.424 0 0 1 2.556 1.942 8.504 8.504 0 0 1 1.601 2.93 12.57 12.57 0 0 1 0 7.324 8.5 8.5 0 0 1-1.601 2.929 7.415 7.415 0 0 1-2.556 1.942 7.959 7.959 0 0 1-3.406.698 8.075 8.075 0 0 1-3.424-.698m6.013-1.652a5.308 5.308 0 0 0 1.873-1.601 7.215 7.215 0 0 0 1.124-2.385 11.348 11.348 0 0 0 0-5.792 7.215 7.215 0 0 0-1.124-2.385 5.289 5.289 0 0 0-1.873-1.601 6.109 6.109 0 0 0-5.195 0 5.497 5.497 0 0 0-1.874 1.601 7.046 7.046 0 0 0-1.141 2.385 11.392 11.392 0 0 0 0 5.792c.227.86.614 1.669 1.141 2.385a5.817 5.817 0 0 0 7.069 1.601M176.856 22.191a2.128 2.128 0 0 1-2.213-2.265 2.216 2.216 0 1 1 4.431 0 2.146 2.146 0 0 1-2.218 2.265m0-4.277a1.845 1.845 0 0 0-1.892 2.012 1.9 1.9 0 1 0 3.797 0 1.854 1.854 0 0 0-1.905-2.012m.653 3.222l-.751-1.073h-.243v1.035h-.43v-2.509h.763c.526 0 .877.264.877.732a.681.681 0 0 1-.508.693l.724 1.025-.432.097zm-.661-2.157h-.333v.741h.312c.283 0 .46-.117.46-.371.001-.244-.158-.37-.439-.37"/></svg> diff --git a/lib/web/jquery/patches/jquery-ui.js b/lib/web/jquery/patches/jquery-ui.js new file mode 100644 index 0000000000000..ae2d8da7ece66 --- /dev/null +++ b/lib/web/jquery/patches/jquery-ui.js @@ -0,0 +1,46 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'jquery' +], function ($) { + 'use strict'; + + /** + * Patch for CVE-2016-7103 (XSS vulnerability). + * Can safely remove only when jQuery UI is upgraded to >= 1.12.x. + * https://www.cvedetails.com/cve/CVE-2016-7103/ + */ + function dialogPatch() { + $.widget('ui.dialog', $.ui.dialog, { + /** @inheritdoc */ + _createTitlebar: function () { + this.options.closeText = $('<a>').text('' + this.options.closeText).html(); + + this._superApply(); + }, + + /** @inheritdoc */ + _setOption: function (key, value) { + if (key === 'closeText') { + value = $('<a>').text('' + value).html(); + } + + this._super(key, value); + } + }); + } + + return function () { + var majorVersion = $.ui.version.split('.')[0], + minorVersion = $.ui.version.split('.')[1]; + + if (majorVersion === 1 && minorVersion >= 12 || majorVersion >= 2) { + console.warn('jQuery patch for CVE-2016-7103 is no longer necessary, and should be removed'); + } + + dialogPatch(); + }; +}); diff --git a/lib/web/jquery/patches/jquery.js b/lib/web/jquery/patches/jquery.js new file mode 100644 index 0000000000000..9d733a92159c6 --- /dev/null +++ b/lib/web/jquery/patches/jquery.js @@ -0,0 +1,35 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([], function () { + 'use strict'; + + /** + * Patch for CVE-2015-9251 (XSS vulnerability). + * Can safely remove only when jQuery UI is upgraded to >= 3.3.x. + * https://www.cvedetails.com/cve/CVE-2015-9251/ + */ + function ajaxResponsePatch(jQuery) { + jQuery.ajaxPrefilter(function (s) { + if (s.crossDomain) { + s.contents.script = false; + } + }); + } + + return function ($) { + var majorVersion = $.fn.jquery.split('.')[0]; + + $.noConflict(); + + if (majorVersion >= 3) { + console.warn('jQuery patch for CVE-2015-9251 is no longer necessary, and should be removed'); + } + + ajaxResponsePatch(jQuery); + + return jQuery; + }; +}); diff --git a/lib/web/mage/adminhtml/wysiwyg/tiny_mce/html5-schema.js b/lib/web/mage/adminhtml/wysiwyg/tiny_mce/html5-schema.js index 98ce6a005db04..41def246989db 100644 --- a/lib/web/mage/adminhtml/wysiwyg/tiny_mce/html5-schema.js +++ b/lib/web/mage/adminhtml/wysiwyg/tiny_mce/html5-schema.js @@ -149,7 +149,8 @@ define([ ['col', 'width align char charoff valign'], ['input button select textarea', 'autofocus'], ['input textarea', 'placeholder onselect onchange onfocus onblur'], - ['link script img', 'crossorigin'] + ['link script img', 'crossorigin'], + ['div','aria-role'] ]; rawData.forEach(function (data) { diff --git a/lib/web/mage/backend/validation.js b/lib/web/mage/backend/validation.js index d3ab7dd086a43..f141fb3eeb8d0 100644 --- a/lib/web/mage/backend/validation.js +++ b/lib/web/mage/backend/validation.js @@ -171,6 +171,7 @@ this._submit(); } else { this._showErrors(response); + $(this.element[0]).trigger('afterValidate.error'); $('body').trigger('processStop'); } }, diff --git a/lib/web/mage/dataPost.js b/lib/web/mage/dataPost.js index 5d052f12db8fb..cc56ee266e08a 100644 --- a/lib/web/mage/dataPost.js +++ b/lib/web/mage/dataPost.js @@ -57,7 +57,7 @@ define([ */ postData: function (params) { var formKey = $(this.options.formKeyInputSelector).val(), - $form; + $form, input; if (formKey) { params.data['form_key'] = formKey; @@ -67,6 +67,19 @@ define([ data: params })); + if (params.files) { + $form[0].enctype = 'multipart/form-data'; + $.each(params.files, function (key, files) { + if (files instanceof FileList) { + input = document.createElement('input'); + input.type = 'file'; + input.name = key; + input.files = files; + $form[0].appendChild(input); + } + }); + } + if (params.data.confirmation) { uiConfirm({ content: params.data.confirmationMessage, diff --git a/lib/web/mage/gallery/gallery.js b/lib/web/mage/gallery/gallery.js index db98a1cc39a30..15c3d01cf2be3 100644 --- a/lib/web/mage/gallery/gallery.js +++ b/lib/web/mage/gallery/gallery.js @@ -472,8 +472,18 @@ define([ * @param {Array.<Object>} data - Set of gallery items to update. */ updateData: function (data) { + var mainImageIndex; + if (_.isArray(data)) { settings.fotoramaApi.load(data); + mainImageIndex = getMainImageIndex(data); + + if (mainImageIndex) { + settings.fotoramaApi.show({ + index: mainImageIndex, + time: 0 + }); + } $.extend(false, settings, { data: data, diff --git a/lib/web/mage/gallery/gallery.less b/lib/web/mage/gallery/gallery.less index f551157210692..fb95d3d1ed98c 100644 --- a/lib/web/mage/gallery/gallery.less +++ b/lib/web/mage/gallery/gallery.less @@ -768,6 +768,7 @@ max-width: inherit; position: absolute; top: 0; + object-fit: scale-down; } } diff --git a/lib/web/mage/menu.js b/lib/web/mage/menu.js index e4ccbdf325ecc..7533edd57b4a6 100644 --- a/lib/web/mage/menu.js +++ b/lib/web/mage/menu.js @@ -21,7 +21,7 @@ define([ expanded: false, showDelay: 42, hideDelay: 300, - delay: 300, + delay: 0, mediaBreakpoint: '(max-width: 768px)' }, @@ -439,6 +439,10 @@ define([ event.preventDefault(); target = $(event.target).closest('.ui-menu-item'); + if (target.length) { + target.get(0).scrollIntoView(); + } + if (!target.hasClass('level-top') || !target.has('.ui-menu').length) { window.location.href = target.find('> a').attr('href'); } diff --git a/lib/web/mage/translate-inline.js b/lib/web/mage/translate-inline.js index bc3a190a9f712..141af6e141c39 100644 --- a/lib/web/mage/translate-inline.js +++ b/lib/web/mage/translate-inline.js @@ -200,32 +200,5 @@ } }); - $.widget('ui.button', $.ui.button, { - /** - * @private - */ - _create: function () { - this._super(); - // Decode HTML entities to prevent incorrect rendering of dialog button label - this.options.label = this.options.label ? - jQuery('<div/>').html(this.options.label).text() : this.options.label; - //Reset button to make decoded label visible - this._resetButton(); - } - }); - - $.widget('ui.dialog', $.ui.dialog, { - /** - * Prevent rendering of dialog title as escaped HTML - */ - _title: function (title) { - this._super(title); - - if (this.options.title) { - title.html(this.options.title); - } - } - }); - return $.mage.translateInline; })); diff --git a/lib/web/mage/validation.js b/lib/web/mage/validation.js index e14ecf982bc96..a742b8e6bbb27 100644 --- a/lib/web/mage/validation.js +++ b/lib/web/mage/validation.js @@ -887,6 +887,27 @@ }, $.mage.__('Please enter a valid number in this field.') ], + 'validate-forbidden-extensions': [ + function (v, elem) { + var forbiddenExtensions = $(elem).attr('data-validation-params'), + forbiddenExtensionsArray = forbiddenExtensions.split(','), + extensionsArray = v.split(','), + result = true; + + this.validateExtensionsMessage = $.mage.__('Forbidden extensions has been used. Avoid usage of ') + + forbiddenExtensions; + + $.each(extensionsArray, function (key, extension) { + if (forbiddenExtensionsArray.indexOf(extension) !== -1) { + result = false; + } + }); + + return result; + }, function () { + return this.validateExtensionsMessage; + } + ], 'validate-digits-range': [ function (v, elm, param) { var numValue, dataAttrRange, classNameRange, result, range, m, classes, ii; @@ -1105,9 +1126,9 @@ ovId; if (!result) { - ovId = $(elm).attr('id') + '_value'; + ovId = $('#' + $(elm).attr('id') + '_value'); - if ($(ovId)) { + if (ovId.length > 0) { result = !$.mage.isEmptyNoTrim($(ovId).val()); } } @@ -1923,6 +1944,9 @@ } if (firstActive.length) { + $('html, body').animate({ + scrollTop: firstActive.offset().top + }); firstActive.focus(); } } diff --git a/lib/web/magnifier/magnifier.js b/lib/web/magnifier/magnifier.js index 0807a4c394995..f6ea1300e77ea 100644 --- a/lib/web/magnifier/magnifier.js +++ b/lib/web/magnifier/magnifier.js @@ -542,7 +542,11 @@ showWrapper = true; bindEvents(eventType, thumb); data[idx].status = 2; - data[idx].zoom = largeObj.height / largeWrapper.height(); + if (largeObj.width > largeObj.height) { + data[idx].zoom = largeObj.width / largeWrapper.width(); + } else { + data[idx].zoom = largeObj.height / largeWrapper.height(); + } setThumbData(thumb, data[idx]); updateLensOnLoad(idx, thumb, largeObj, largeWrapper); } diff --git a/lib/web/varien/js.js b/lib/web/varien/js.js index 55e41a1652cb8..45032829f2fd8 100644 --- a/lib/web/varien/js.js +++ b/lib/web/varien/js.js @@ -607,17 +607,11 @@ if (!("console" in window) || !("firebug" in console)) * @example fireEvent($('my-input', 'click')); */ function fireEvent(element, event) { - if (document.createEvent) { - // dispatch for all browsers except IE before version 9 - var evt = document.createEvent('HTMLEvents'); + // dispatch event + var evt = document.createEvent('HTMLEvents'); - evt.initEvent(event, true, true); // event type, bubbling, cancelable - return element.dispatchEvent(evt); - } - // dispatch for IE before version 9 - var evt = document.createEventObject(); - - return element.fireEvent('on' + event, evt); + evt.initEvent(event, true, true); // event type, bubbling, cancelable + return element.dispatchEvent(evt); } diff --git a/nginx.conf.sample b/nginx.conf.sample index 6f87a9a076666..90604808f6ec0 100644 --- a/nginx.conf.sample +++ b/nginx.conf.sample @@ -161,7 +161,7 @@ location /media/import/ { } # PHP entry point for main application -location ~ (index|get|static|report|404|503|health_check)\.php$ { +location ~ ^/(index|get|static|errors/report|errors/404|errors/503|health_check)\.php$ { try_files $uri =404; fastcgi_pass fastcgi_backend; fastcgi_buffers 1024 4k; diff --git a/pub/errors/default/images/logo.gif b/pub/errors/default/images/logo.gif index f1f7fcaf4f020..0cca183e08da2 100644 Binary files a/pub/errors/default/images/logo.gif and b/pub/errors/default/images/logo.gif differ diff --git a/pub/static/.htaccess b/pub/static/.htaccess index f49dce25d433e..8253efa80bed8 100644 --- a/pub/static/.htaccess +++ b/pub/static/.htaccess @@ -22,6 +22,11 @@ Options -MultiViews RewriteCond %{REQUEST_FILENAME} !-l RewriteRule .* ../static.php?resource=$0 [L] + # Detects if moxieplayer request with uri params and redirects to uri without params + <Files moxieplayer.swf> + RewriteCond %{QUERY_STRING} !^$ + RewriteRule ^(.*)$ %{REQUEST_URI}? [R=301,L] + </Files> </IfModule> ############################################ diff --git a/setup/performance-toolkit/benchmark.jmx b/setup/performance-toolkit/benchmark.jmx index 80abe6da87f3c..64ff3bc9cba60 100644 --- a/setup/performance-toolkit/benchmark.jmx +++ b/setup/performance-toolkit/benchmark.jmx @@ -25803,6 +25803,39 @@ catch (java.lang.Exception e) { </ResponseAssertion> <hashTree/> </hashTree> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Filled Order Page" enabled="true"> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true"> + <collectionProp name="Arguments.arguments"/> + </elementProp> + <stringProp name="HTTPSampler.domain"/> + <stringProp name="HTTPSampler.port"/> + <stringProp name="HTTPSampler.connect_timeout">60000</stringProp> + <stringProp name="HTTPSampler.response_timeout">200000</stringProp> + <stringProp name="HTTPSampler.protocol">${request_protocol}</stringProp> + <stringProp name="HTTPSampler.contentEncoding"/> + <stringProp name="HTTPSampler.path">${base_path}${admin_path}/sales/order_create/index/</stringProp> + <stringProp name="HTTPSampler.method">GET</stringProp> + <boolProp name="HTTPSampler.follow_redirects">true</boolProp> + <boolProp name="HTTPSampler.auto_redirects">false</boolProp> + <boolProp name="HTTPSampler.use_keepalive">true</boolProp> + <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> + <boolProp name="HTTPSampler.monitor">false</boolProp> + <stringProp name="HTTPSampler.embedded_url_re"/> + <stringProp name="TestPlan.comments">Detected the start of a redirect chain</stringProp> + </HTTPSamplerProxy> + <hashTree> + <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Assert Filled Order Page" enabled="true"> + <collectionProp name="Asserion.test_strings"> + <stringProp name="-37823069">Select from existing customer addresses</stringProp> + <stringProp name="-13185722">Submit Order</stringProp> + <stringProp name="-209419315">Items Ordered</stringProp> + </collectionProp> + <stringProp name="Assertion.test_field">Assertion.response_data</stringProp> + <boolProp name="Assertion.assume_success">false</boolProp> + <intProp name="Assertion.test_type">2</intProp> + </ResponseAssertion> + <hashTree/> + </hashTree> <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Save Order" enabled="true"> <elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true"> <collectionProp name="Arguments.arguments"> diff --git a/setup/pub/angular-ng-storage/angular-ng-storage.min.js b/setup/pub/angular-ng-storage/angular-ng-storage.min.js index f5526bbace8ef..54891ebb4087f 100644 --- a/setup/pub/angular-ng-storage/angular-ng-storage.min.js +++ b/setup/pub/angular-ng-storage/angular-ng-storage.min.js @@ -1 +1 @@ -/*! ngStorage 0.3.0 | Copyright (c) 2013 Gias Kay Lee | MIT License */"use strict";!function(){function a(a){return["$rootScope","$window",function(b,c){for(var d,e,f,g=c[a]||(console.warn("This browser does not support Web Storage!"),{}),h={$default:function(a){for(var b in a)angular.isDefined(h[b])||(h[b]=a[b]);return h},$reset:function(a){for(var b in h)"$"===b[0]||delete h[b];return h.$default(a)}},i=0;i<g.length;i++)(f=g.key(i))&&"ngStorage-"===f.slice(0,10)&&(h[f.slice(10)]=angular.fromJson(g.getItem(f)));return d=angular.copy(h),b.$watch(function(){e||(e=setTimeout(function(){if(e=null,!angular.equals(h,d)){angular.forEach(h,function(a,b){angular.isDefined(a)&&"$"!==b[0]&&g.setItem("ngStorage-"+b,angular.toJson(a)),delete d[b]});for(var a in d)g.removeItem("ngStorage-"+a);d=angular.copy(h)}},100))}),"localStorage"===a&&c.addEventListener&&c.addEventListener("storage",function(a){"ngStorage-"===a.key.slice(0,10)&&(a.newValue?h[a.key.slice(10)]=angular.fromJson(a.newValue):delete h[a.key.slice(10)],d=angular.copy(h),b.$apply())}),h}]}angular.module("ngStorage",[]).factory("$localStorage",a("localStorage")).factory("$sessionStorage",a("sessionStorage"))}(); \ No newline at end of file +/*! ngstorage 0.3.10 | Copyright (c) 2016 Gias Kay Lee | MIT License */!function(a,b){"use strict";"function"==typeof define&&define.amd?define(["angular"],b):a.hasOwnProperty("angular")?b(a.angular):"object"==typeof exports&&(module.exports=b(require("angular")))}(this,function(a){"use strict";function b(a,b){var c;try{c=a[b]}catch(d){c=!1}if(c){var e="__"+Math.round(1e7*Math.random());try{a[b].setItem(e,e),a[b].removeItem(e,e)}catch(d){c=!1}}return c}function c(c){var d=b(window,c);return function(){var e="ngStorage-";this.setKeyPrefix=function(a){if("string"!=typeof a)throw new TypeError("[ngStorage] - "+c+"Provider.setKeyPrefix() expects a String.");e=a};var f=a.toJson,g=a.fromJson;this.setSerializer=function(a){if("function"!=typeof a)throw new TypeError("[ngStorage] - "+c+"Provider.setSerializer expects a function.");f=a},this.setDeserializer=function(a){if("function"!=typeof a)throw new TypeError("[ngStorage] - "+c+"Provider.setDeserializer expects a function.");g=a},this.supported=function(){return!!d},this.get=function(a){return d&&g(d.getItem(e+a))},this.set=function(a,b){return d&&d.setItem(e+a,f(b))},this.remove=function(a){d&&d.removeItem(e+a)},this.$get=["$rootScope","$window","$log","$timeout","$document",function(d,h,i,j,k){var l,m,n=e.length,o=b(h,c),p=o||(i.warn("This browser does not support Web Storage!"),{setItem:a.noop,getItem:a.noop,removeItem:a.noop}),q={$default:function(b){for(var c in b)a.isDefined(q[c])||(q[c]=a.copy(b[c]));return q.$sync(),q},$reset:function(a){for(var b in q)"$"===b[0]||delete q[b]&&p.removeItem(e+b);return q.$default(a)},$sync:function(){for(var a,b=0,c=p.length;c>b;b++)(a=p.key(b))&&e===a.slice(0,n)&&(q[a.slice(n)]=g(p.getItem(a)))},$apply:function(){var b;if(m=null,!a.equals(q,l)){b=a.copy(l),a.forEach(q,function(c,d){a.isDefined(c)&&"$"!==d[0]&&(p.setItem(e+d,f(c)),delete b[d])});for(var c in b)p.removeItem(e+c);l=a.copy(q)}},$supported:function(){return!!o}};return q.$sync(),l=a.copy(q),d.$watch(function(){m||(m=j(q.$apply,100,!1))}),h.addEventListener&&h.addEventListener("storage",function(b){if(b.key){var c=k[0];c.hasFocus&&c.hasFocus()||e!==b.key.slice(0,n)||(b.newValue?q[b.key.slice(n)]=g(b.newValue):delete q[b.key.slice(n)],l=a.copy(q),d.$apply())}}),h.addEventListener&&h.addEventListener("beforeunload",function(){q.$apply()}),q}]}}return a=a&&a.module?a:window.angular,a.module("ngStorage",[]).provider("$localStorage",c("localStorage")).provider("$sessionStorage",c("sessionStorage"))}); \ No newline at end of file diff --git a/setup/pub/angular-sanitize/angular-sanitize.js b/setup/pub/angular-sanitize/angular-sanitize.js index 6004460cd17eb..8faa84315009f 100644 --- a/setup/pub/angular-sanitize/angular-sanitize.js +++ b/setup/pub/angular-sanitize/angular-sanitize.js @@ -1,76 +1,79 @@ /** - * @license AngularJS v1.2.14 - * (c) 2010-2014 Google, Inc. http://angularjs.org + * @license AngularJS v1.6.9 + * (c) 2010-2018 Google, Inc. http://angularjs.org * License: MIT */ -(function(window, angular, undefined) {'use strict'; +(function(window, angular) {'use strict'; + + /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * Any commits to this file should be reviewed with security in mind. * + * Changes to this file can potentially create security vulnerabilities. * + * An approval from 2 Core members with history of modifying * + * this file is required. * + * * + * Does the change somehow allow for arbitrary javascript to be executed? * + * Or allows for someone to change the prototype of built-in objects? * + * Or gives undesired access to variables likes document or window? * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ var $sanitizeMinErr = angular.$$minErr('$sanitize'); + var bind; + var extend; + var forEach; + var isDefined; + var lowercase; + var noop; + var nodeContains; + var htmlParser; + var htmlSanitizeWriter; /** * @ngdoc module * @name ngSanitize * @description * - * # ngSanitize - * * The `ngSanitize` module provides functionality to sanitize HTML. * - * - * <div doc-module-components="ngSanitize"></div> - * * See {@link ngSanitize.$sanitize `$sanitize`} for usage. */ - /* - * HTML Parser By Misko Hevery (misko@hevery.com) - * based on: HTML Parser By John Resig (ejohn.org) - * Original code by Erik Arvidsson, Mozilla Public License - * http://erik.eae.net/simplehtmlparser/simplehtmlparser.js - * - * // Use like so: - * htmlParser(htmlString, { - * start: function(tag, attrs, unary) {}, - * end: function(tag) {}, - * chars: function(text) {}, - * comment: function(text) {} - * }); - * - */ - - /** * @ngdoc service * @name $sanitize - * @function + * @kind function * * @description - * The input is sanitized by parsing the html into tokens. All safe tokens (from a whitelist) are + * Sanitizes an html string by stripping all potentially dangerous tokens. + * + * The input is sanitized by parsing the HTML into tokens. All safe tokens (from a whitelist) are * then serialized back to properly escaped html string. This means that no unsafe input can make - * it into the returned string, however, since our parser is more strict than a typical browser - * parser, it's possible that some obscure input, which would be recognized as valid HTML by a - * browser, won't make it through the sanitizer. - * The whitelist is configured using the functions `aHrefSanitizationWhitelist` and - * `imgSrcSanitizationWhitelist` of {@link ng.$compileProvider `$compileProvider`}. + * it into the returned string. * - * @param {string} html Html input. - * @returns {string} Sanitized html. + * The whitelist for URL sanitization of attribute values is configured using the functions + * `aHrefSanitizationWhitelist` and `imgSrcSanitizationWhitelist` of {@link ng.$compileProvider + * `$compileProvider`}. + * + * The input may also contain SVG markup if this is enabled via {@link $sanitizeProvider}. + * + * @param {string} html HTML input. + * @returns {string} Sanitized HTML. * * @example - <example module="ngSanitize" deps="angular-sanitize.js"> + <example module="sanitizeExample" deps="angular-sanitize.js" name="sanitize-service"> <file name="index.html"> <script> - function Ctrl($scope, $sce) { - $scope.snippet = - '<p style="color:blue">an html\n' + - '<em onmouseover="this.textContent=\'PWN3D!\'">click here</em>\n' + - 'snippet</p>'; - $scope.deliberatelyTrustDangerousSnippet = function() { - return $sce.trustAsHtml($scope.snippet); - }; - } + angular.module('sanitizeExample', ['ngSanitize']) + .controller('ExampleController', ['$scope', '$sce', function($scope, $sce) { + $scope.snippet = + '<p style="color:blue">an html\n' + + '<em onmouseover="this.textContent=\'PWN3D!\'">click here</em>\n' + + 'snippet</p>'; + $scope.deliberatelyTrustDangerousSnippet = function() { + return $sce.trustAsHtml($scope.snippet); + }; + }]); </script> - <div ng-controller="Ctrl"> + <div ng-controller="ExampleController"> Snippet: <textarea ng-model="snippet" cols="60" rows="3"></textarea> <table> <tr> @@ -105,410 +108,538 @@ </file> <file name="protractor.js" type="protractor"> it('should sanitize the html snippet by default', function() { - expect(element(by.css('#bind-html-with-sanitize div')).getInnerHtml()). + expect(element(by.css('#bind-html-with-sanitize div')).getAttribute('innerHTML')). toBe('<p>an html\n<em>click here</em>\nsnippet</p>'); }); + it('should inline raw snippet if bound to a trusted value', function() { - expect(element(by.css('#bind-html-with-trust div')).getInnerHtml()). + expect(element(by.css('#bind-html-with-trust div')).getAttribute('innerHTML')). toBe("<p style=\"color:blue\">an html\n" + "<em onmouseover=\"this.textContent='PWN3D!'\">click here</em>\n" + "snippet</p>"); }); + it('should escape snippet without any filter', function() { - expect(element(by.css('#bind-default div')).getInnerHtml()). + expect(element(by.css('#bind-default div')).getAttribute('innerHTML')). toBe("<p style=\"color:blue\">an html\n" + "<em onmouseover=\"this.textContent='PWN3D!'\">click here</em>\n" + "snippet</p>"); }); + it('should update', function() { element(by.model('snippet')).clear(); element(by.model('snippet')).sendKeys('new <b onclick="alert(1)">text</b>'); - expect(element(by.css('#bind-html-with-sanitize div')).getInnerHtml()). + expect(element(by.css('#bind-html-with-sanitize div')).getAttribute('innerHTML')). toBe('new <b>text</b>'); - expect(element(by.css('#bind-html-with-trust div')).getInnerHtml()).toBe( + expect(element(by.css('#bind-html-with-trust div')).getAttribute('innerHTML')).toBe( 'new <b onclick="alert(1)">text</b>'); - expect(element(by.css('#bind-default div')).getInnerHtml()).toBe( + expect(element(by.css('#bind-default div')).getAttribute('innerHTML')).toBe( "new <b onclick=\"alert(1)\">text</b>"); }); </file> </example> */ + + + /** + * @ngdoc provider + * @name $sanitizeProvider + * @this + * + * @description + * Creates and configures {@link $sanitize} instance. + */ function $SanitizeProvider() { + var svgEnabled = false; + this.$get = ['$$sanitizeUri', function($$sanitizeUri) { + if (svgEnabled) { + extend(validElements, svgElements); + } return function(html) { var buf = []; htmlParser(html, htmlSanitizeWriter(buf, function(uri, isImage) { - return !/^unsafe/.test($$sanitizeUri(uri, isImage)); + return !/^unsafe:/.test($$sanitizeUri(uri, isImage)); })); return buf.join(''); }; }]; - } - function sanitizeText(chars) { - var buf = []; - var writer = htmlSanitizeWriter(buf, angular.noop); - writer.chars(chars); - return buf.join(''); - } - - -// Regular Expressions for parsing tags and attributes - var START_TAG_REGEXP = - /^<\s*([\w:-]+)((?:\s+[\w:-]+(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)\s*>/, - END_TAG_REGEXP = /^<\s*\/\s*([\w:-]+)[^>]*>/, - ATTR_REGEXP = /([\w:-]+)(?:\s*=\s*(?:(?:"((?:[^"])*)")|(?:'((?:[^'])*)')|([^>\s]+)))?/g, - BEGIN_TAG_REGEXP = /^</, - BEGIN_END_TAGE_REGEXP = /^<\s*\//, - COMMENT_REGEXP = /<!--(.*?)-->/g, - DOCTYPE_REGEXP = /<!DOCTYPE([^>]*?)>/i, - CDATA_REGEXP = /<!\[CDATA\[(.*?)]]>/g, - // Match everything outside of normal chars and " (quote character) - NON_ALPHANUMERIC_REGEXP = /([^\#-~| |!])/g; - - -// Good source of info about elements and attributes -// http://dev.w3.org/html5/spec/Overview.html#semantics -// http://simon.html5.org/html-elements - -// Safe Void Elements - HTML5 -// http://dev.w3.org/html5/spec/Overview.html#void-elements - var voidElements = makeMap("area,br,col,hr,img,wbr"); - -// Elements that you can, intentionally, leave open (and which close themselves) -// http://dev.w3.org/html5/spec/Overview.html#optional-tags - var optionalEndTagBlockElements = makeMap("colgroup,dd,dt,li,p,tbody,td,tfoot,th,thead,tr"), - optionalEndTagInlineElements = makeMap("rp,rt"), - optionalEndTagElements = angular.extend({}, - optionalEndTagInlineElements, - optionalEndTagBlockElements); - -// Safe Block Elements - HTML5 - var blockElements = angular.extend({}, optionalEndTagBlockElements, makeMap("address,article," + - "aside,blockquote,caption,center,del,dir,div,dl,figure,figcaption,footer,h1,h2,h3,h4,h5," + - "h6,header,hgroup,hr,ins,map,menu,nav,ol,pre,script,section,table,ul")); - -// Inline Elements - HTML5 - var inlineElements = angular.extend({}, optionalEndTagInlineElements, makeMap("a,abbr,acronym,b," + - "bdi,bdo,big,br,cite,code,del,dfn,em,font,i,img,ins,kbd,label,map,mark,q,ruby,rp,rt,s," + - "samp,small,span,strike,strong,sub,sup,time,tt,u,var")); - - -// Special Elements (can contain anything) - var specialElements = makeMap("script,style"); - - var validElements = angular.extend({}, - voidElements, - blockElements, - inlineElements, - optionalEndTagElements); - -//Attributes that have href and hence need to be sanitized - var uriAttrs = makeMap("background,cite,href,longdesc,src,usemap"); - var validAttrs = angular.extend({}, uriAttrs, makeMap( - 'abbr,align,alt,axis,bgcolor,border,cellpadding,cellspacing,class,clear,'+ - 'color,cols,colspan,compact,coords,dir,face,headers,height,hreflang,hspace,'+ - 'ismap,lang,language,nohref,nowrap,rel,rev,rows,rowspan,rules,'+ - 'scope,scrolling,shape,size,span,start,summary,target,title,type,'+ - 'valign,value,vspace,width')); - - function makeMap(str) { - var obj = {}, items = str.split(','), i; - for (i = 0; i < items.length; i++) obj[items[i]] = true; - return obj; - } + /** + * @ngdoc method + * @name $sanitizeProvider#enableSvg + * @kind function + * + * @description + * Enables a subset of svg to be supported by the sanitizer. + * + * <div class="alert alert-warning"> + * <p>By enabling this setting without taking other precautions, you might expose your + * application to click-hijacking attacks. In these attacks, sanitized svg elements could be positioned + * outside of the containing element and be rendered over other elements on the page (e.g. a login + * link). Such behavior can then result in phishing incidents.</p> + * + * <p>To protect against these, explicitly setup `overflow: hidden` css rule for all potential svg + * tags within the sanitized content:</p> + * + * <br> + * + * <pre><code> + * .rootOfTheIncludedContent svg { + * overflow: hidden !important; + * } + * </code></pre> + * </div> + * + * @param {boolean=} flag Enable or disable SVG support in the sanitizer. + * @returns {boolean|ng.$sanitizeProvider} Returns the currently configured value if called + * without an argument or self for chaining otherwise. + */ + this.enableSvg = function(enableSvg) { + if (isDefined(enableSvg)) { + svgEnabled = enableSvg; + return this; + } else { + return svgEnabled; + } + }; - /** - * @example - * htmlParser(htmlString, { - * start: function(tag, attrs, unary) {}, - * end: function(tag) {}, - * chars: function(text) {}, - * comment: function(text) {} - * }); - * - * @param {string} html string - * @param {object} handler - */ - function htmlParser( html, handler ) { - var index, chars, match, stack = [], last = html; - stack.last = function() { return stack[ stack.length - 1 ]; }; - - while ( html ) { - chars = true; + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Private stuff + ////////////////////////////////////////////////////////////////////////////////////////////////// - // Make sure we're not in a script or style element - if ( !stack.last() || !specialElements[ stack.last() ] ) { + bind = angular.bind; + extend = angular.extend; + forEach = angular.forEach; + isDefined = angular.isDefined; + lowercase = angular.lowercase; + noop = angular.noop; - // Comment - if ( html.indexOf("<!--") === 0 ) { - // comments containing -- are not allowed unless they terminate the comment - index = html.indexOf("--", 4); + htmlParser = htmlParserImpl; + htmlSanitizeWriter = htmlSanitizeWriterImpl; - if ( index >= 0 && html.lastIndexOf("-->", index) === index) { - if (handler.comment) handler.comment( html.substring( 4, index ) ); - html = html.substring( index + 3 ); - chars = false; - } - // DOCTYPE - } else if ( DOCTYPE_REGEXP.test(html) ) { - match = html.match( DOCTYPE_REGEXP ); + nodeContains = window.Node.prototype.contains || /** @this */ function(arg) { + // eslint-disable-next-line no-bitwise + return !!(this.compareDocumentPosition(arg) & 16); + }; - if ( match ) { - html = html.replace( match[0] , ''); - chars = false; - } - // end tag - } else if ( BEGIN_END_TAGE_REGEXP.test(html) ) { - match = html.match( END_TAG_REGEXP ); - - if ( match ) { - html = html.substring( match[0].length ); - match[0].replace( END_TAG_REGEXP, parseEndTag ); - chars = false; - } + // Regular Expressions for parsing tags and attributes + var SURROGATE_PAIR_REGEXP = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g, + // Match everything outside of normal chars and " (quote character) + NON_ALPHANUMERIC_REGEXP = /([^#-~ |!])/g; + + + // Good source of info about elements and attributes + // http://dev.w3.org/html5/spec/Overview.html#semantics + // http://simon.html5.org/html-elements + + // Safe Void Elements - HTML5 + // http://dev.w3.org/html5/spec/Overview.html#void-elements + var voidElements = toMap('area,br,col,hr,img,wbr'); + + // Elements that you can, intentionally, leave open (and which close themselves) + // http://dev.w3.org/html5/spec/Overview.html#optional-tags + var optionalEndTagBlockElements = toMap('colgroup,dd,dt,li,p,tbody,td,tfoot,th,thead,tr'), + optionalEndTagInlineElements = toMap('rp,rt'), + optionalEndTagElements = extend({}, + optionalEndTagInlineElements, + optionalEndTagBlockElements); + + // Safe Block Elements - HTML5 + var blockElements = extend({}, optionalEndTagBlockElements, toMap('address,article,' + + 'aside,blockquote,caption,center,del,dir,div,dl,figure,figcaption,footer,h1,h2,h3,h4,h5,' + + 'h6,header,hgroup,hr,ins,map,menu,nav,ol,pre,section,table,ul')); + + // Inline Elements - HTML5 + var inlineElements = extend({}, optionalEndTagInlineElements, toMap('a,abbr,acronym,b,' + + 'bdi,bdo,big,br,cite,code,del,dfn,em,font,i,img,ins,kbd,label,map,mark,q,ruby,rp,rt,s,' + + 'samp,small,span,strike,strong,sub,sup,time,tt,u,var')); + + // SVG Elements + // https://wiki.whatwg.org/wiki/Sanitization_rules#svg_Elements + // Note: the elements animate,animateColor,animateMotion,animateTransform,set are intentionally omitted. + // They can potentially allow for arbitrary javascript to be executed. See #11290 + var svgElements = toMap('circle,defs,desc,ellipse,font-face,font-face-name,font-face-src,g,glyph,' + + 'hkern,image,linearGradient,line,marker,metadata,missing-glyph,mpath,path,polygon,polyline,' + + 'radialGradient,rect,stop,svg,switch,text,title,tspan'); + + // Blocked Elements (will be stripped) + var blockedElements = toMap('script,style'); + + var validElements = extend({}, + voidElements, + blockElements, + inlineElements, + optionalEndTagElements); + + //Attributes that have href and hence need to be sanitized + var uriAttrs = toMap('background,cite,href,longdesc,src,xlink:href,xml:base'); + + var htmlAttrs = toMap('abbr,align,alt,axis,bgcolor,border,cellpadding,cellspacing,class,clear,' + + 'color,cols,colspan,compact,coords,dir,face,headers,height,hreflang,hspace,' + + 'ismap,lang,language,nohref,nowrap,rel,rev,rows,rowspan,rules,' + + 'scope,scrolling,shape,size,span,start,summary,tabindex,target,title,type,' + + 'valign,value,vspace,width'); + + // SVG attributes (without "id" and "name" attributes) + // https://wiki.whatwg.org/wiki/Sanitization_rules#svg_Attributes + var svgAttrs = toMap('accent-height,accumulate,additive,alphabetic,arabic-form,ascent,' + + 'baseProfile,bbox,begin,by,calcMode,cap-height,class,color,color-rendering,content,' + + 'cx,cy,d,dx,dy,descent,display,dur,end,fill,fill-rule,font-family,font-size,font-stretch,' + + 'font-style,font-variant,font-weight,from,fx,fy,g1,g2,glyph-name,gradientUnits,hanging,' + + 'height,horiz-adv-x,horiz-origin-x,ideographic,k,keyPoints,keySplines,keyTimes,lang,' + + 'marker-end,marker-mid,marker-start,markerHeight,markerUnits,markerWidth,mathematical,' + + 'max,min,offset,opacity,orient,origin,overline-position,overline-thickness,panose-1,' + + 'path,pathLength,points,preserveAspectRatio,r,refX,refY,repeatCount,repeatDur,' + + 'requiredExtensions,requiredFeatures,restart,rotate,rx,ry,slope,stemh,stemv,stop-color,' + + 'stop-opacity,strikethrough-position,strikethrough-thickness,stroke,stroke-dasharray,' + + 'stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-miterlimit,stroke-opacity,' + + 'stroke-width,systemLanguage,target,text-anchor,to,transform,type,u1,u2,underline-position,' + + 'underline-thickness,unicode,unicode-range,units-per-em,values,version,viewBox,visibility,' + + 'width,widths,x,x-height,x1,x2,xlink:actuate,xlink:arcrole,xlink:role,xlink:show,xlink:title,' + + 'xlink:type,xml:base,xml:lang,xml:space,xmlns,xmlns:xlink,y,y1,y2,zoomAndPan', true); + + var validAttrs = extend({}, + uriAttrs, + svgAttrs, + htmlAttrs); + + function toMap(str, lowercaseKeys) { + var obj = {}, items = str.split(','), i; + for (i = 0; i < items.length; i++) { + obj[lowercaseKeys ? lowercase(items[i]) : items[i]] = true; + } + return obj; + } - // start tag - } else if ( BEGIN_TAG_REGEXP.test(html) ) { - match = html.match( START_TAG_REGEXP ); + /** + * Create an inert document that contains the dirty HTML that needs sanitizing + * Depending upon browser support we use one of three strategies for doing this. + * Support: Safari 10.x -> XHR strategy + * Support: Firefox -> DomParser strategy + */ + var getInertBodyElement /* function(html: string): HTMLBodyElement */ = (function(window, document) { + var inertDocument; + if (document && document.implementation) { + inertDocument = document.implementation.createHTMLDocument('inert'); + } else { + throw $sanitizeMinErr('noinert', 'Can\'t create an inert html document'); + } + var inertBodyElement = (inertDocument.documentElement || inertDocument.getDocumentElement()).querySelector('body'); - if ( match ) { - html = html.substring( match[0].length ); - match[0].replace( START_TAG_REGEXP, parseStartTag ); - chars = false; - } + // Check for the Safari 10.1 bug - which allows JS to run inside the SVG G element + inertBodyElement.innerHTML = '<svg><g onload="this.parentNode.remove()"></g></svg>'; + if (!inertBodyElement.querySelector('svg')) { + return getInertBodyElement_XHR; + } else { + // Check for the Firefox bug - which prevents the inner img JS from being sanitized + inertBodyElement.innerHTML = '<svg><p><style><img src="</style><img src=x onerror=alert(1)//">'; + if (inertBodyElement.querySelector('svg img')) { + return getInertBodyElement_DOMParser; + } else { + return getInertBodyElement_InertDocument; } + } - if ( chars ) { - index = html.indexOf("<"); - - var text = index < 0 ? html : html.substring( 0, index ); - html = index < 0 ? "" : html.substring( index ); - - if (handler.chars) handler.chars( decodeEntities(text) ); + function getInertBodyElement_XHR(html) { + // We add this dummy element to ensure that the rest of the content is parsed as expected + // e.g. leading whitespace is maintained and tags like `<meta>` do not get hoisted to the `<head>` tag. + html = '<remove></remove>' + html; + try { + html = encodeURI(html); + } catch (e) { + return undefined; } + var xhr = new window.XMLHttpRequest(); + xhr.responseType = 'document'; + xhr.open('GET', 'data:text/html;charset=utf-8,' + html, false); + xhr.send(null); + var body = xhr.response.body; + body.firstChild.remove(); + return body; + } - } else { - html = html.replace(new RegExp("(.*)<\\s*\\/\\s*" + stack.last() + "[^>]*>", 'i'), - function(all, text){ - text = text.replace(COMMENT_REGEXP, "$1").replace(CDATA_REGEXP, "$1"); + function getInertBodyElement_DOMParser(html) { + // We add this dummy element to ensure that the rest of the content is parsed as expected + // e.g. leading whitespace is maintained and tags like `<meta>` do not get hoisted to the `<head>` tag. + html = '<remove></remove>' + html; + try { + var body = new window.DOMParser().parseFromString(html, 'text/html').body; + body.firstChild.remove(); + return body; + } catch (e) { + return undefined; + } + } - if (handler.chars) handler.chars( decodeEntities(text) ); + function getInertBodyElement_InertDocument(html) { + inertBodyElement.innerHTML = html; - return ""; - }); + // Support: IE 9-11 only + // strip custom-namespaced attributes on IE<=11 + if (document.documentMode) { + stripCustomNsAttrs(inertBodyElement); + } - parseEndTag( "", stack.last() ); + return inertBodyElement; } - - if ( html == last ) { - throw $sanitizeMinErr('badparse', "The sanitizer was unable to parse the following block " + - "of html: {0}", html); + })(window, window.document); + + /** + * @example + * htmlParser(htmlString, { + * start: function(tag, attrs) {}, + * end: function(tag) {}, + * chars: function(text) {}, + * comment: function(text) {} + * }); + * + * @param {string} html string + * @param {object} handler + */ + function htmlParserImpl(html, handler) { + if (html === null || html === undefined) { + html = ''; + } else if (typeof html !== 'string') { + html = '' + html; } - last = html; - } - // Clean up any remaining tags - parseEndTag(); + var inertBodyElement = getInertBodyElement(html); + if (!inertBodyElement) return ''; - function parseStartTag( tag, tagName, rest, unary ) { - tagName = angular.lowercase(tagName); - if ( blockElements[ tagName ] ) { - while ( stack.last() && inlineElements[ stack.last() ] ) { - parseEndTag( "", stack.last() ); + //mXSS protection + var mXSSAttempts = 5; + do { + if (mXSSAttempts === 0) { + throw $sanitizeMinErr('uinput', 'Failed to sanitize html because the input is unstable'); + } + mXSSAttempts--; + + // trigger mXSS if it is going to happen by reading and writing the innerHTML + html = inertBodyElement.innerHTML; + inertBodyElement = getInertBodyElement(html); + } while (html !== inertBodyElement.innerHTML); + + var node = inertBodyElement.firstChild; + while (node) { + switch (node.nodeType) { + case 1: // ELEMENT_NODE + handler.start(node.nodeName.toLowerCase(), attrToMap(node.attributes)); + break; + case 3: // TEXT NODE + handler.chars(node.textContent); + break; } - } - if ( optionalEndTagElements[ tagName ] && stack.last() == tagName ) { - parseEndTag( "", tagName ); + var nextNode; + if (!(nextNode = node.firstChild)) { + if (node.nodeType === 1) { + handler.end(node.nodeName.toLowerCase()); + } + nextNode = getNonDescendant('nextSibling', node); + if (!nextNode) { + while (nextNode == null) { + node = getNonDescendant('parentNode', node); + if (node === inertBodyElement) break; + nextNode = getNonDescendant('nextSibling', node); + if (node.nodeType === 1) { + handler.end(node.nodeName.toLowerCase()); + } + } + } + } + node = nextNode; } - unary = voidElements[ tagName ] || !!unary; + while ((node = inertBodyElement.firstChild)) { + inertBodyElement.removeChild(node); + } + } - if ( !unary ) - stack.push( tagName ); + function attrToMap(attrs) { + var map = {}; + for (var i = 0, ii = attrs.length; i < ii; i++) { + var attr = attrs[i]; + map[attr.name] = attr.value; + } + return map; + } - var attrs = {}; - rest.replace(ATTR_REGEXP, - function(match, name, doubleQuotedValue, singleQuotedValue, unquotedValue) { - var value = doubleQuotedValue - || singleQuotedValue - || unquotedValue - || ''; + /** + * Escapes all potentially dangerous characters, so that the + * resulting string can be safely inserted into attribute or + * element text. + * @param value + * @returns {string} escaped text + */ + function encodeEntities(value) { + return value. + replace(/&/g, '&'). + replace(SURROGATE_PAIR_REGEXP, function(value) { + var hi = value.charCodeAt(0); + var low = value.charCodeAt(1); + return '&#' + (((hi - 0xD800) * 0x400) + (low - 0xDC00) + 0x10000) + ';'; + }). + replace(NON_ALPHANUMERIC_REGEXP, function(value) { + return '&#' + value.charCodeAt(0) + ';'; + }). + replace(/</g, '<'). + replace(/>/g, '>'); + } - attrs[name] = decodeEntities(value); - }); - if (handler.start) handler.start( tagName, attrs, unary ); + /** + * create an HTML/XML writer which writes to buffer + * @param {Array} buf use buf.join('') to get out sanitized html string + * @returns {object} in the form of { + * start: function(tag, attrs) {}, + * end: function(tag) {}, + * chars: function(text) {}, + * comment: function(text) {} + * } + */ + function htmlSanitizeWriterImpl(buf, uriValidator) { + var ignoreCurrentElement = false; + var out = bind(buf, buf.push); + return { + start: function(tag, attrs) { + tag = lowercase(tag); + if (!ignoreCurrentElement && blockedElements[tag]) { + ignoreCurrentElement = tag; + } + if (!ignoreCurrentElement && validElements[tag] === true) { + out('<'); + out(tag); + forEach(attrs, function(value, key) { + var lkey = lowercase(key); + var isImage = (tag === 'img' && lkey === 'src') || (lkey === 'background'); + if (validAttrs[lkey] === true && + (uriAttrs[lkey] !== true || uriValidator(value, isImage))) { + out(' '); + out(key); + out('="'); + out(encodeEntities(value)); + out('"'); + } + }); + out('>'); + } + }, + end: function(tag) { + tag = lowercase(tag); + if (!ignoreCurrentElement && validElements[tag] === true && voidElements[tag] !== true) { + out('</'); + out(tag); + out('>'); + } + // eslint-disable-next-line eqeqeq + if (tag == ignoreCurrentElement) { + ignoreCurrentElement = false; + } + }, + chars: function(chars) { + if (!ignoreCurrentElement) { + out(encodeEntities(chars)); + } + } + }; } - function parseEndTag( tag, tagName ) { - var pos = 0, i; - tagName = angular.lowercase(tagName); - if ( tagName ) - // Find the closest opened tag of the same type - for ( pos = stack.length - 1; pos >= 0; pos-- ) - if ( stack[ pos ] == tagName ) - break; - if ( pos >= 0 ) { - // Close all the open elements, up the stack - for ( i = stack.length - 1; i >= pos; i-- ) - if (handler.end) handler.end( stack[ i ] ); + /** + * When IE9-11 comes across an unknown namespaced attribute e.g. 'xlink:foo' it adds 'xmlns:ns1' attribute to declare + * ns1 namespace and prefixes the attribute with 'ns1' (e.g. 'ns1:xlink:foo'). This is undesirable since we don't want + * to allow any of these custom attributes. This method strips them all. + * + * @param node Root element to process + */ + function stripCustomNsAttrs(node) { + while (node) { + if (node.nodeType === window.Node.ELEMENT_NODE) { + var attrs = node.attributes; + for (var i = 0, l = attrs.length; i < l; i++) { + var attrNode = attrs[i]; + var attrName = attrNode.name.toLowerCase(); + if (attrName === 'xmlns:ns1' || attrName.lastIndexOf('ns1:', 0) === 0) { + node.removeAttributeNode(attrNode); + i--; + l--; + } + } + } - // Remove the open elements from the stack - stack.length = pos; + var nextNode = node.firstChild; + if (nextNode) { + stripCustomNsAttrs(nextNode); + } + + node = getNonDescendant('nextSibling', node); } } - } - var hiddenPre=document.createElement("pre"); - var spaceRe = /^(\s*)([\s\S]*?)(\s*)$/; - /** - * decodes all entities into regular string - * @param value - * @returns {string} A string with decoded entities. - */ - function decodeEntities(value) { - if (!value) { return ''; } - - // Note: IE8 does not preserve spaces at the start/end of innerHTML - // so we must capture them and reattach them afterward - var parts = spaceRe.exec(value); - var spaceBefore = parts[1]; - var spaceAfter = parts[3]; - var content = parts[2]; - if (content) { - hiddenPre.innerHTML=content.replace(/</g,"<"); - // innerText depends on styling as it doesn't display hidden elements. - // Therefore, it's better to use textContent not to cause unnecessary - // reflows. However, IE<9 don't support textContent so the innerText - // fallback is necessary. - content = 'textContent' in hiddenPre ? - hiddenPre.textContent : hiddenPre.innerText; + function getNonDescendant(propName, node) { + // An element is clobbered if its `propName` property points to one of its descendants + var nextNode = node[propName]; + if (nextNode && nodeContains.call(node, nextNode)) { + throw $sanitizeMinErr('elclob', 'Failed to sanitize html because the element is clobbered: {0}', node.outerHTML || node.outerText); + } + return nextNode; } - return spaceBefore + content + spaceAfter; - } - - /** - * Escapes all potentially dangerous characters, so that the - * resulting string can be safely inserted into attribute or - * element text. - * @param value - * @returns {string} escaped text - */ - function encodeEntities(value) { - return value. - replace(/&/g, '&'). - replace(NON_ALPHANUMERIC_REGEXP, function(value){ - return '&#' + value.charCodeAt(0) + ';'; - }). - replace(/</g, '<'). - replace(/>/g, '>'); } - /** - * create an HTML/XML writer which writes to buffer - * @param {Array} buf use buf.jain('') to get out sanitized html string - * @returns {object} in the form of { - * start: function(tag, attrs, unary) {}, - * end: function(tag) {}, - * chars: function(text) {}, - * comment: function(text) {} - * } - */ - function htmlSanitizeWriter(buf, uriValidator){ - var ignore = false; - var out = angular.bind(buf, buf.push); - return { - start: function(tag, attrs, unary){ - tag = angular.lowercase(tag); - if (!ignore && specialElements[tag]) { - ignore = tag; - } - if (!ignore && validElements[tag] === true) { - out('<'); - out(tag); - angular.forEach(attrs, function(value, key){ - var lkey=angular.lowercase(key); - var isImage = (tag === 'img' && lkey === 'src') || (lkey === 'background'); - if (validAttrs[lkey] === true && - (uriAttrs[lkey] !== true || uriValidator(value, isImage))) { - out(' '); - out(key); - out('="'); - out(encodeEntities(value)); - out('"'); - } - }); - out(unary ? '/>' : '>'); - } - }, - end: function(tag){ - tag = angular.lowercase(tag); - if (!ignore && validElements[tag] === true) { - out('</'); - out(tag); - out('>'); - } - if (tag == ignore) { - ignore = false; - } - }, - chars: function(chars){ - if (!ignore) { - out(encodeEntities(chars)); - } - } - }; + function sanitizeText(chars) { + var buf = []; + var writer = htmlSanitizeWriter(buf, noop); + writer.chars(chars); + return buf.join(''); } // define ngSanitize module and register $sanitize service - angular.module('ngSanitize', []).provider('$sanitize', $SanitizeProvider); - - /* global sanitizeText: false */ + angular.module('ngSanitize', []) + .provider('$sanitize', $SanitizeProvider) + .info({ angularVersion: '1.6.9' }); /** * @ngdoc filter * @name linky - * @function + * @kind function * * @description - * Finds links in text input and turns them into html links. Supports http/https/ftp/mailto and + * Finds links in text input and turns them into html links. Supports `http/https/ftp/sftp/mailto` and * plain email address links. * * Requires the {@link ngSanitize `ngSanitize`} module to be installed. * * @param {string} text Input text. - * @param {string} target Window (_blank|_self|_parent|_top) or named frame to open links in. - * @returns {string} Html-linkified text. + * @param {string} [target] Window (`_blank|_self|_parent|_top`) or named frame to open links in. + * @param {object|function(url)} [attributes] Add custom attributes to the link element. + * + * Can be one of: + * + * - `object`: A map of attributes + * - `function`: Takes the url as a parameter and returns a map of attributes + * + * If the map of attributes contains a value for `target`, it overrides the value of + * the target parameter. + * + * + * @returns {string} Html-linkified and {@link $sanitize sanitized} text. * * @usage <span ng-bind-html="linky_expression | linky"></span> * * @example - <example module="ngSanitize" deps="angular-sanitize.js"> + <example module="linkyExample" deps="angular-sanitize.js" name="linky-filter"> <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.snippet = - 'Pretty text with some links:\n'+ - 'http://angularjs.org/,\n'+ - 'mailto:us@somewhere.org,\n'+ - 'another@somewhere.org,\n'+ - 'and one more: ftp://127.0.0.1/.'; - $scope.snippetWithTarget = 'http://angularjs.org/'; - } - </script> - <div ng-controller="Ctrl"> + <div ng-controller="ExampleController"> Snippet: <textarea ng-model="snippet" cols="60" rows="3"></textarea> <table> <tr> - <td>Filter</td> - <td>Source</td> - <td>Rendered</td> + <th>Filter</th> + <th>Source</th> + <th>Rendered</th> </tr> <tr id="linky-filter"> <td>linky filter</td> @@ -522,10 +653,19 @@ <tr id="linky-target"> <td>linky target</td> <td> - <pre><div ng-bind-html="snippetWithTarget | linky:'_blank'"><br></div></pre> + <pre><div ng-bind-html="snippetWithSingleURL | linky:'_blank'"><br></div></pre> </td> <td> - <div ng-bind-html="snippetWithTarget | linky:'_blank'"></div> + <div ng-bind-html="snippetWithSingleURL | linky:'_blank'"></div> + </td> + </tr> + <tr id="linky-custom-attributes"> + <td>linky custom attributes</td> + <td> + <pre><div ng-bind-html="snippetWithSingleURL | linky:'_self':{rel: 'nofollow'}"><br></div></pre> + </td> + <td> + <div ng-bind-html="snippetWithSingleURL | linky:'_self':{rel: 'nofollow'}"></div> </td> </tr> <tr id="escaped-html"> @@ -535,6 +675,18 @@ </tr> </table> </file> + <file name="script.js"> + angular.module('linkyExample', ['ngSanitize']) + .controller('ExampleController', ['$scope', function($scope) { + $scope.snippet = + 'Pretty text with some links:\n' + + 'http://angularjs.org/,\n' + + 'mailto:us@somewhere.org,\n' + + 'another@somewhere.org,\n' + + 'and one more: ftp://127.0.0.1/.'; + $scope.snippetWithSingleURL = 'http://angularjs.org/'; + }]); + </file> <file name="protractor.js" type="protractor"> it('should linkify the snippet with urls', function() { expect(element(by.id('linky-filter')).element(by.binding('snippet | linky')).getText()). @@ -542,12 +694,14 @@ 'another@somewhere.org, and one more: ftp://127.0.0.1/.'); expect(element.all(by.css('#linky-filter a')).count()).toEqual(4); }); + it('should not linkify snippet without the linky filter', function() { expect(element(by.id('escaped-html')).element(by.binding('snippet')).getText()). toBe('Pretty text with some links: http://angularjs.org/, mailto:us@somewhere.org, ' + 'another@somewhere.org, and one more: ftp://127.0.0.1/.'); expect(element.all(by.css('#escaped-html a')).count()).toEqual(0); }); + it('should update', function() { element(by.model('snippet')).clear(); element(by.model('snippet')).sendKeys('new http://link.'); @@ -557,22 +711,43 @@ expect(element(by.id('escaped-html')).element(by.binding('snippet')).getText()) .toBe('new http://link.'); }); + it('should work with the target property', function() { expect(element(by.id('linky-target')). - element(by.binding("snippetWithTarget | linky:'_blank'")).getText()). + element(by.binding("snippetWithSingleURL | linky:'_blank'")).getText()). toBe('http://angularjs.org/'); expect(element(by.css('#linky-target a')).getAttribute('target')).toEqual('_blank'); }); + + it('should optionally add custom attributes', function() { + expect(element(by.id('linky-custom-attributes')). + element(by.binding("snippetWithSingleURL | linky:'_self':{rel: 'nofollow'}")).getText()). + toBe('http://angularjs.org/'); + expect(element(by.css('#linky-custom-attributes a')).getAttribute('rel')).toEqual('nofollow'); + }); </file> </example> */ angular.module('ngSanitize').filter('linky', ['$sanitize', function($sanitize) { var LINKY_URL_REGEXP = - /((ftp|https?):\/\/|(mailto:)?[A-Za-z0-9._%+-]+@)\S*[^\s.;,(){}<>]/, - MAILTO_REGEXP = /^mailto:/; + /((s?ftp|https?):\/\/|(www\.)|(mailto:)?[A-Za-z0-9._%+-]+@)\S*[^\s.;,(){}<>"\u201d\u2019]/i, + MAILTO_REGEXP = /^mailto:/i; + + var linkyMinErr = angular.$$minErr('linky'); + var isDefined = angular.isDefined; + var isFunction = angular.isFunction; + var isObject = angular.isObject; + var isString = angular.isString; + + return function(text, target, attributes) { + if (text == null || text === '') return text; + if (!isString(text)) throw linkyMinErr('notstring', 'Expected string but received: {0}', text); + + var attributesFn = + isFunction(attributes) ? attributes : + isObject(attributes) ? function getAttributesObject() {return attributes;} : + function getEmptyAttributesObject() {return {};}; - return function(text, target) { - if (!text) return text; var match; var raw = text; var html = []; @@ -581,8 +756,10 @@ while ((match = raw.match(LINKY_URL_REGEXP))) { // We can not end in these as they are sometimes found at the end of the sentence url = match[0]; - // if we did not match ftp/http/mailto then assume mailto - if (match[2] == match[3]) url = 'mailto:' + url; + // if we did not match ftp/http/www/mailto then assume mailto + if (!match[2] && !match[4]) { + url = (match[3] ? 'http://' : 'mailto:') + url; + } i = match.index; addText(raw.substr(0, i)); addLink(url, match[0].replace(MAILTO_REGEXP, '')); @@ -599,15 +776,21 @@ } function addLink(url, text) { + var key, linkAttributes = attributesFn(url); html.push('<a '); - if (angular.isDefined(target)) { - html.push('target="'); - html.push(target); - html.push('" '); + + for (key in linkAttributes) { + html.push(key + '="' + linkAttributes[key] + '" '); + } + + if (isDefined(target) && !('target' in linkAttributes)) { + html.push('target="', + target, + '" '); } - html.push('href="'); - html.push(url); - html.push('">'); + html.push('href="', + url.replace(/"/g, '"'), + '">'); addText(text); html.push('</a>'); } diff --git a/setup/pub/angular-sanitize/angular-sanitize.min.js b/setup/pub/angular-sanitize/angular-sanitize.min.js index 4fc586065be2f..991dd00987a8c 100644 --- a/setup/pub/angular-sanitize/angular-sanitize.min.js +++ b/setup/pub/angular-sanitize/angular-sanitize.min.js @@ -1,14 +1,17 @@ /* - AngularJS v1.2.14 - (c) 2010-2014 Google, Inc. http://angularjs.org + AngularJS v1.6.9 + (c) 2010-2018 Google, Inc. http://angularjs.org License: MIT - */ -(function(p,h,q){'use strict';function E(a){var e=[];s(e,h.noop).chars(a);return e.join("")}function k(a){var e={};a=a.split(",");var d;for(d=0;d<a.length;d++)e[a[d]]=!0;return e}function F(a,e){function d(a,b,d,g){b=h.lowercase(b);if(t[b])for(;f.last()&&u[f.last()];)c("",f.last());v[b]&&f.last()==b&&c("",b);(g=w[b]||!!g)||f.push(b);var l={};d.replace(G,function(a,b,e,c,d){l[b]=r(e||c||d||"")});e.start&&e.start(b,l,g)}function c(a,b){var c=0,d;if(b=h.lowercase(b))for(c=f.length-1;0<=c&&f[c]!=b;c--); - if(0<=c){for(d=f.length-1;d>=c;d--)e.end&&e.end(f[d]);f.length=c}}var b,g,f=[],l=a;for(f.last=function(){return f[f.length-1]};a;){g=!0;if(f.last()&&x[f.last()])a=a.replace(RegExp("(.*)<\\s*\\/\\s*"+f.last()+"[^>]*>","i"),function(b,a){a=a.replace(H,"$1").replace(I,"$1");e.chars&&e.chars(r(a));return""}),c("",f.last());else{if(0===a.indexOf("\x3c!--"))b=a.indexOf("--",4),0<=b&&a.lastIndexOf("--\x3e",b)===b&&(e.comment&&e.comment(a.substring(4,b)),a=a.substring(b+3),g=!1);else if(y.test(a)){if(b=a.match(y))a= - a.replace(b[0],""),g=!1}else if(J.test(a)){if(b=a.match(z))a=a.substring(b[0].length),b[0].replace(z,c),g=!1}else K.test(a)&&(b=a.match(A))&&(a=a.substring(b[0].length),b[0].replace(A,d),g=!1);g&&(b=a.indexOf("<"),g=0>b?a:a.substring(0,b),a=0>b?"":a.substring(b),e.chars&&e.chars(r(g)))}if(a==l)throw L("badparse",a);l=a}c()}function r(a){if(!a)return"";var e=M.exec(a);a=e[1];var d=e[3];if(e=e[2])n.innerHTML=e.replace(/</g,"<"),e="textContent"in n?n.textContent:n.innerText;return a+e+d}function B(a){return a.replace(/&/g, - "&").replace(N,function(a){return"&#"+a.charCodeAt(0)+";"}).replace(/</g,"<").replace(/>/g,">")}function s(a,e){var d=!1,c=h.bind(a,a.push);return{start:function(a,g,f){a=h.lowercase(a);!d&&x[a]&&(d=a);d||!0!==C[a]||(c("<"),c(a),h.forEach(g,function(d,f){var g=h.lowercase(f),k="img"===a&&"src"===g||"background"===g;!0!==O[g]||!0===D[g]&&!e(d,k)||(c(" "),c(f),c('="'),c(B(d)),c('"'))}),c(f?"/>":">"))},end:function(a){a=h.lowercase(a);d||!0!==C[a]||(c("</"),c(a),c(">"));a==d&&(d=!1)},chars:function(a){d|| -c(B(a))}}}var L=h.$$minErr("$sanitize"),A=/^<\s*([\w:-]+)((?:\s+[\w:-]+(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)\s*>/,z=/^<\s*\/\s*([\w:-]+)[^>]*>/,G=/([\w:-]+)(?:\s*=\s*(?:(?:"((?:[^"])*)")|(?:'((?:[^'])*)')|([^>\s]+)))?/g,K=/^</,J=/^<\s*\//,H=/\x3c!--(.*?)--\x3e/g,y=/<!DOCTYPE([^>]*?)>/i,I=/<!\[CDATA\[(.*?)]]\x3e/g,N=/([^\#-~| |!])/g,w=k("area,br,col,hr,img,wbr");p=k("colgroup,dd,dt,li,p,tbody,td,tfoot,th,thead,tr");q=k("rp,rt");var v=h.extend({},q,p),t=h.extend({},p,k("address,article,aside,blockquote,caption,center,del,dir,div,dl,figure,figcaption,footer,h1,h2,h3,h4,h5,h6,header,hgroup,hr,ins,map,menu,nav,ol,pre,script,section,table,ul")), - u=h.extend({},q,k("a,abbr,acronym,b,bdi,bdo,big,br,cite,code,del,dfn,em,font,i,img,ins,kbd,label,map,mark,q,ruby,rp,rt,s,samp,small,span,strike,strong,sub,sup,time,tt,u,var")),x=k("script,style"),C=h.extend({},w,t,u,v),D=k("background,cite,href,longdesc,src,usemap"),O=h.extend({},D,k("abbr,align,alt,axis,bgcolor,border,cellpadding,cellspacing,class,clear,color,cols,colspan,compact,coords,dir,face,headers,height,hreflang,hspace,ismap,lang,language,nohref,nowrap,rel,rev,rows,rowspan,rules,scope,scrolling,shape,size,span,start,summary,target,title,type,valign,value,vspace,width")), - n=document.createElement("pre"),M=/^(\s*)([\s\S]*?)(\s*)$/;h.module("ngSanitize",[]).provider("$sanitize",function(){this.$get=["$$sanitizeUri",function(a){return function(e){var d=[];F(e,s(d,function(c,b){return!/^unsafe/.test(a(c,b))}));return d.join("")}}]});h.module("ngSanitize").filter("linky",["$sanitize",function(a){var e=/((ftp|https?):\/\/|(mailto:)?[A-Za-z0-9._%+-]+@)\S*[^\s.;,(){}<>]/,d=/^mailto:/;return function(c,b){function g(a){a&&m.push(E(a))}function f(a,c){m.push("<a ");h.isDefined(b)&& -(m.push('target="'),m.push(b),m.push('" '));m.push('href="');m.push(a);m.push('">');g(c);m.push("</a>")}if(!c)return c;for(var l,k=c,m=[],n,p;l=k.match(e);)n=l[0],l[2]==l[3]&&(n="mailto:"+n),p=l.index,g(k.substr(0,p)),f(n,l[0].replace(d,"")),k=k.substring(p+l[0].length);g(k);return a(m.join(""))}}])})(window,window.angular); +*/ +(function(s,d){'use strict';function J(d){var k=[];w(k,B).chars(d);return k.join("")}var x=d.$$minErr("$sanitize"),C,k,D,E,p,B,F,G,w;d.module("ngSanitize",[]).provider("$sanitize",function(){function g(a,e){var c={},b=a.split(","),f;for(f=0;f<b.length;f++)c[e?p(b[f]):b[f]]=!0;return c}function K(a){for(var e={},c=0,b=a.length;c<b;c++){var f=a[c];e[f.name]=f.value}return e}function H(a){return a.replace(/&/g,"&").replace(L,function(a){var c=a.charCodeAt(0);a=a.charCodeAt(1);return"&#"+(1024*(c- + 55296)+(a-56320)+65536)+";"}).replace(M,function(a){return"&#"+a.charCodeAt(0)+";"}).replace(/</g,"<").replace(/>/g,">")}function I(a){for(;a;){if(a.nodeType===s.Node.ELEMENT_NODE)for(var e=a.attributes,c=0,b=e.length;c<b;c++){var f=e[c],h=f.name.toLowerCase();if("xmlns:ns1"===h||0===h.lastIndexOf("ns1:",0))a.removeAttributeNode(f),c--,b--}(e=a.firstChild)&&I(e);a=t("nextSibling",a)}}function t(a,e){var c=e[a];if(c&&F.call(e,c))throw x("elclob",e.outerHTML||e.outerText);return c}var y=!1;this.$get= + ["$$sanitizeUri",function(a){y&&k(n,z);return function(e){var c=[];G(e,w(c,function(b,c){return!/^unsafe:/.test(a(b,c))}));return c.join("")}}];this.enableSvg=function(a){return E(a)?(y=a,this):y};C=d.bind;k=d.extend;D=d.forEach;E=d.isDefined;p=d.lowercase;B=d.noop;G=function(a,e){null===a||void 0===a?a="":"string"!==typeof a&&(a=""+a);var c=u(a);if(!c)return"";var b=5;do{if(0===b)throw x("uinput");b--;a=c.innerHTML;c=u(a)}while(a!==c.innerHTML);for(b=c.firstChild;b;){switch(b.nodeType){case 1:e.start(b.nodeName.toLowerCase(), + K(b.attributes));break;case 3:e.chars(b.textContent)}var f;if(!(f=b.firstChild)&&(1===b.nodeType&&e.end(b.nodeName.toLowerCase()),f=t("nextSibling",b),!f))for(;null==f;){b=t("parentNode",b);if(b===c)break;f=t("nextSibling",b);1===b.nodeType&&e.end(b.nodeName.toLowerCase())}b=f}for(;b=c.firstChild;)c.removeChild(b)};w=function(a,e){var c=!1,b=C(a,a.push);return{start:function(a,h){a=p(a);!c&&A[a]&&(c=a);c||!0!==n[a]||(b("<"),b(a),D(h,function(c,h){var d=p(h),g="img"===a&&"src"===d||"background"=== + d;!0!==v[d]||!0===m[d]&&!e(c,g)||(b(" "),b(h),b('="'),b(H(c)),b('"'))}),b(">"))},end:function(a){a=p(a);c||!0!==n[a]||!0===h[a]||(b("</"),b(a),b(">"));a==c&&(c=!1)},chars:function(a){c||b(H(a))}}};F=s.Node.prototype.contains||function(a){return!!(this.compareDocumentPosition(a)&16)};var L=/[\uD800-\uDBFF][\uDC00-\uDFFF]/g,M=/([^#-~ |!])/g,h=g("area,br,col,hr,img,wbr"),q=g("colgroup,dd,dt,li,p,tbody,td,tfoot,th,thead,tr"),l=g("rp,rt"),r=k({},l,q),q=k({},q,g("address,article,aside,blockquote,caption,center,del,dir,div,dl,figure,figcaption,footer,h1,h2,h3,h4,h5,h6,header,hgroup,hr,ins,map,menu,nav,ol,pre,section,table,ul")), + l=k({},l,g("a,abbr,acronym,b,bdi,bdo,big,br,cite,code,del,dfn,em,font,i,img,ins,kbd,label,map,mark,q,ruby,rp,rt,s,samp,small,span,strike,strong,sub,sup,time,tt,u,var")),z=g("circle,defs,desc,ellipse,font-face,font-face-name,font-face-src,g,glyph,hkern,image,linearGradient,line,marker,metadata,missing-glyph,mpath,path,polygon,polyline,radialGradient,rect,stop,svg,switch,text,title,tspan"),A=g("script,style"),n=k({},h,q,l,r),m=g("background,cite,href,longdesc,src,xlink:href,xml:base"),r=g("abbr,align,alt,axis,bgcolor,border,cellpadding,cellspacing,class,clear,color,cols,colspan,compact,coords,dir,face,headers,height,hreflang,hspace,ismap,lang,language,nohref,nowrap,rel,rev,rows,rowspan,rules,scope,scrolling,shape,size,span,start,summary,tabindex,target,title,type,valign,value,vspace,width"), + l=g("accent-height,accumulate,additive,alphabetic,arabic-form,ascent,baseProfile,bbox,begin,by,calcMode,cap-height,class,color,color-rendering,content,cx,cy,d,dx,dy,descent,display,dur,end,fill,fill-rule,font-family,font-size,font-stretch,font-style,font-variant,font-weight,from,fx,fy,g1,g2,glyph-name,gradientUnits,hanging,height,horiz-adv-x,horiz-origin-x,ideographic,k,keyPoints,keySplines,keyTimes,lang,marker-end,marker-mid,marker-start,markerHeight,markerUnits,markerWidth,mathematical,max,min,offset,opacity,orient,origin,overline-position,overline-thickness,panose-1,path,pathLength,points,preserveAspectRatio,r,refX,refY,repeatCount,repeatDur,requiredExtensions,requiredFeatures,restart,rotate,rx,ry,slope,stemh,stemv,stop-color,stop-opacity,strikethrough-position,strikethrough-thickness,stroke,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-miterlimit,stroke-opacity,stroke-width,systemLanguage,target,text-anchor,to,transform,type,u1,u2,underline-position,underline-thickness,unicode,unicode-range,units-per-em,values,version,viewBox,visibility,width,widths,x,x-height,x1,x2,xlink:actuate,xlink:arcrole,xlink:role,xlink:show,xlink:title,xlink:type,xml:base,xml:lang,xml:space,xmlns,xmlns:xlink,y,y1,y2,zoomAndPan", + !0),v=k({},m,l,r),u=function(a,e){function c(b){b="<remove></remove>"+b;try{var c=(new a.DOMParser).parseFromString(b,"text/html").body;c.firstChild.remove();return c}catch(e){}}function b(a){d.innerHTML=a;e.documentMode&&I(d);return d}var h;if(e&&e.implementation)h=e.implementation.createHTMLDocument("inert");else throw x("noinert");var d=(h.documentElement||h.getDocumentElement()).querySelector("body");d.innerHTML='<svg><g onload="this.parentNode.remove()"></g></svg>';return d.querySelector("svg")? + (d.innerHTML='<svg><p><style><img src="</style><img src=x onerror=alert(1)//">',d.querySelector("svg img")?c:b):function(b){b="<remove></remove>"+b;try{b=encodeURI(b)}catch(c){return}var e=new a.XMLHttpRequest;e.responseType="document";e.open("GET","data:text/html;charset=utf-8,"+b,!1);e.send(null);b=e.response.body;b.firstChild.remove();return b}}(s,s.document)}).info({angularVersion:"1.6.9"});d.module("ngSanitize").filter("linky",["$sanitize",function(g){var k=/((s?ftp|https?):\/\/|(www\.)|(mailto:)?[A-Za-z0-9._%+-]+@)\S*[^\s.;,(){}<>"\u201d\u2019]/i, + p=/^mailto:/i,s=d.$$minErr("linky"),t=d.isDefined,y=d.isFunction,w=d.isObject,x=d.isString;return function(d,q,l){function r(a){a&&m.push(J(a))}function z(a,d){var c,b=A(a);m.push("<a ");for(c in b)m.push(c+'="'+b[c]+'" ');!t(q)||"target"in b||m.push('target="',q,'" ');m.push('href="',a.replace(/"/g,"""),'">');r(d);m.push("</a>")}if(null==d||""===d)return d;if(!x(d))throw s("notstring",d);for(var A=y(l)?l:w(l)?function(){return l}:function(){return{}},n=d,m=[],v,u;d=n.match(k);)v=d[0],d[2]|| +d[4]||(v=(d[3]?"http://":"mailto:")+v),u=d.index,r(n.substr(0,u)),z(v,d[0].replace(p,"")),n=n.substring(u+d[0].length);r(n);return g(m.join(""))}}])})(window,window.angular); //# sourceMappingURL=angular-sanitize.min.js.map \ No newline at end of file diff --git a/setup/pub/angular-sanitize/angular-sanitize.min.js.map b/setup/pub/angular-sanitize/angular-sanitize.min.js.map index 0310ddce9c937..8ce8290b2b387 100644 --- a/setup/pub/angular-sanitize/angular-sanitize.min.js.map +++ b/setup/pub/angular-sanitize/angular-sanitize.min.js.map @@ -1,8 +1,8 @@ { -"version":3, -"file":"angular-sanitize.min.js", -"lineCount":13, -"mappings":"A;;;;;aAKC,SAAQ,CAACA,CAAD,CAASC,CAAT,CAAkBC,CAAlB,CAA6B,CAiJtCC,QAASA,EAAY,CAACC,CAAD,CAAQ,CAC3B,IAAIC,EAAM,EACGC,EAAAC,CAAmBF,CAAnBE,CAAwBN,CAAAO,KAAxBD,CACbH,MAAA,CAAaA,CAAb,CACA,OAAOC,EAAAI,KAAA,CAAS,EAAT,CAJoB,CAmE7BC,QAASA,EAAO,CAACC,CAAD,CAAM,CAAA,IAChBC,EAAM,EAAIC,EAAAA,CAAQF,CAAAG,MAAA,CAAU,GAAV,CAAtB,KAAsCC,CACtC,KAAKA,CAAL,CAAS,CAAT,CAAYA,CAAZ,CAAgBF,CAAAG,OAAhB,CAA8BD,CAAA,EAA9B,CAAmCH,CAAA,CAAIC,CAAA,CAAME,CAAN,CAAJ,CAAA,CAAgB,CAAA,CACnD,OAAOH,EAHa,CAmBtBK,QAASA,EAAU,CAAEC,CAAF,CAAQC,CAAR,CAAkB,CAiFnCC,QAASA,EAAa,CAAEC,CAAF,CAAOC,CAAP,CAAgBC,CAAhB,CAAsBC,CAAtB,CAA8B,CAClDF,CAAA,CAAUrB,CAAAwB,UAAA,CAAkBH,CAAlB,CACV,IAAKI,CAAA,CAAeJ,CAAf,CAAL,CACE,IAAA,CAAQK,CAAAC,KAAA,EAAR,EAAwBC,CAAA,CAAgBF,CAAAC,KAAA,EAAhB,CAAxB,CAAA,CACEE,CAAA,CAAa,EAAb,CAAiBH,CAAAC,KAAA,EAAjB,CAICG,EAAA,CAAwBT,CAAxB,CAAL,EAA0CK,CAAAC,KAAA,EAA1C,EAA0DN,CAA1D,EACEQ,CAAA,CAAa,EAAb,CAAiBR,CAAjB,CAKF,EAFAE,CAEA,CAFQQ,CAAA,CAAcV,CAAd,CAER,EAFmC,CAAC,CAACE,CAErC,GACEG,CAAAM,KAAA,CAAYX,CAAZ,CAEF,KAAIY,EAAQ,EAEZX,EAAAY,QAAA,CAAaC,CAAb,CACE,QAAQ,CAACC,CAAD,CAAQC,CAAR,CAAcC,CAAd,CAAiCC,CAAjC,CAAoDC,CAApD,CAAmE,CAMzEP,CAAA,CAAMI,CAAN,CAAA,CAAcI,CAAA,CALFH,CAKE,EAJTC,CAIS,EAHTC,CAGS,EAFT,EAES,CAN2D,CAD7E,CASItB,EAAAwB,MAAJ,EAAmBxB,CAAAwB,MAAA,CAAerB,CAAf,CAAwBY,CAAxB,CAA+BV,CAA/B,CA5B+B,CA+BpDM,QAASA,EAAW,CAAET,CAAF,CAAOC,CAAP,CAAiB,CAAA,IAC/BsB,EAAM,CADyB,CACtB7B,CAEb,IADAO,CACA,CADUrB,CAAAwB,UAAA,CAAkBH,CAAlB,CACV,CAEE,IAAMsB,CAAN,CAAYjB,CAAAX,OAAZ,CAA2B,CAA3B,CAAqC,CAArC,EAA8B4B,CAA9B,EACOjB,CAAA,CAAOiB,CAAP,CADP,EACuBtB,CADvB,CAAwCsB,CAAA,EAAxC;AAIF,GAAY,CAAZ,EAAKA,CAAL,CAAgB,CAEd,IAAM7B,CAAN,CAAUY,CAAAX,OAAV,CAAyB,CAAzB,CAA4BD,CAA5B,EAAiC6B,CAAjC,CAAsC7B,CAAA,EAAtC,CACMI,CAAA0B,IAAJ,EAAiB1B,CAAA0B,IAAA,CAAalB,CAAA,CAAOZ,CAAP,CAAb,CAGnBY,EAAAX,OAAA,CAAe4B,CAND,CATmB,CAhHF,IAC/BE,CAD+B,CACxB1C,CADwB,CACVuB,EAAQ,EADE,CACEC,EAAOV,CAG5C,KAFAS,CAAAC,KAEA,CAFamB,QAAQ,EAAG,CAAE,MAAOpB,EAAA,CAAOA,CAAAX,OAAP,CAAsB,CAAtB,CAAT,CAExB,CAAQE,CAAR,CAAA,CAAe,CACbd,CAAA,CAAQ,CAAA,CAGR,IAAMuB,CAAAC,KAAA,EAAN,EAAuBoB,CAAA,CAAiBrB,CAAAC,KAAA,EAAjB,CAAvB,CAmDEV,CASA,CATOA,CAAAiB,QAAA,CAAiBc,MAAJ,CAAW,kBAAX,CAAgCtB,CAAAC,KAAA,EAAhC,CAA+C,QAA/C,CAAyD,GAAzD,CAAb,CACL,QAAQ,CAACsB,CAAD,CAAMC,CAAN,CAAW,CACjBA,CAAA,CAAOA,CAAAhB,QAAA,CAAaiB,CAAb,CAA6B,IAA7B,CAAAjB,QAAA,CAA2CkB,CAA3C,CAAyD,IAAzD,CAEHlC,EAAAf,MAAJ,EAAmBe,CAAAf,MAAA,CAAesC,CAAA,CAAeS,CAAf,CAAf,CAEnB,OAAO,EALU,CADd,CASP,CAAArB,CAAA,CAAa,EAAb,CAAiBH,CAAAC,KAAA,EAAjB,CA5DF,KAAyD,CAGvD,GAA8B,CAA9B,GAAKV,CAAAoC,QAAA,CAAa,SAAb,CAAL,CAEER,CAEA,CAFQ5B,CAAAoC,QAAA,CAAa,IAAb,CAAmB,CAAnB,CAER,CAAc,CAAd,EAAKR,CAAL,EAAmB5B,CAAAqC,YAAA,CAAiB,QAAjB,CAAwBT,CAAxB,CAAnB,GAAsDA,CAAtD,GACM3B,CAAAqC,QAEJ,EAFqBrC,CAAAqC,QAAA,CAAiBtC,CAAAuC,UAAA,CAAgB,CAAhB,CAAmBX,CAAnB,CAAjB,CAErB,CADA5B,CACA,CADOA,CAAAuC,UAAA,CAAgBX,CAAhB,CAAwB,CAAxB,CACP,CAAA1C,CAAA,CAAQ,CAAA,CAHV,CAJF,KAUO,IAAKsD,CAAAC,KAAA,CAAoBzC,CAApB,CAAL,CAGL,IAFAmB,CAEA,CAFQnB,CAAAmB,MAAA,CAAYqB,CAAZ,CAER,CACExC,CACA;AADOA,CAAAiB,QAAA,CAAcE,CAAA,CAAM,CAAN,CAAd,CAAyB,EAAzB,CACP,CAAAjC,CAAA,CAAQ,CAAA,CAFV,CAHK,IAQA,IAAKwD,CAAAD,KAAA,CAA4BzC,CAA5B,CAAL,CAGL,IAFAmB,CAEA,CAFQnB,CAAAmB,MAAA,CAAYwB,CAAZ,CAER,CACE3C,CAEA,CAFOA,CAAAuC,UAAA,CAAgBpB,CAAA,CAAM,CAAN,CAAArB,OAAhB,CAEP,CADAqB,CAAA,CAAM,CAAN,CAAAF,QAAA,CAAkB0B,CAAlB,CAAkC/B,CAAlC,CACA,CAAA1B,CAAA,CAAQ,CAAA,CAHV,CAHK,IAUK0D,EAAAH,KAAA,CAAsBzC,CAAtB,CAAL,GACLmB,CADK,CACGnB,CAAAmB,MAAA,CAAY0B,CAAZ,CADH,IAIH7C,CAEA,CAFOA,CAAAuC,UAAA,CAAgBpB,CAAA,CAAM,CAAN,CAAArB,OAAhB,CAEP,CADAqB,CAAA,CAAM,CAAN,CAAAF,QAAA,CAAkB4B,CAAlB,CAAoC3C,CAApC,CACA,CAAAhB,CAAA,CAAQ,CAAA,CANL,CAUFA,EAAL,GACE0C,CAKA,CALQ5B,CAAAoC,QAAA,CAAa,GAAb,CAKR,CAHIH,CAGJ,CAHmB,CAAR,CAAAL,CAAA,CAAY5B,CAAZ,CAAmBA,CAAAuC,UAAA,CAAgB,CAAhB,CAAmBX,CAAnB,CAG9B,CAFA5B,CAEA,CAFe,CAAR,CAAA4B,CAAA,CAAY,EAAZ,CAAiB5B,CAAAuC,UAAA,CAAgBX,CAAhB,CAExB,CAAI3B,CAAAf,MAAJ,EAAmBe,CAAAf,MAAA,CAAesC,CAAA,CAAeS,CAAf,CAAf,CANrB,CAzCuD,CA+DzD,GAAKjC,CAAL,EAAaU,CAAb,CACE,KAAMoC,EAAA,CAAgB,UAAhB,CAC4C9C,CAD5C,CAAN,CAGFU,CAAA,CAAOV,CAvEM,CA2EfY,CAAA,EA/EmC,CA2IrCY,QAASA,EAAc,CAACuB,CAAD,CAAQ,CAC7B,GAAI,CAACA,CAAL,CAAc,MAAO,EAIrB,KAAIC,EAAQC,CAAAC,KAAA,CAAaH,CAAb,CACRI,EAAAA,CAAcH,CAAA,CAAM,CAAN,CAClB,KAAII,EAAaJ,CAAA,CAAM,CAAN,CAEjB,IADIK,CACJ,CADcL,CAAA,CAAM,CAAN,CACd,CACEM,CAAAC,UAKA,CALoBF,CAAApC,QAAA,CAAgB,IAAhB,CAAqB,MAArB,CAKpB,CAAAoC,CAAA,CAAU,aAAA,EAAiBC,EAAjB,CACRA,CAAAE,YADQ,CACgBF,CAAAG,UAE5B,OAAON,EAAP,CAAqBE,CAArB,CAA+BD,CAlBF,CA4B/BM,QAASA,EAAc,CAACX,CAAD,CAAQ,CAC7B,MAAOA,EAAA9B,QAAA,CACG,IADH;AACS,OADT,CAAAA,QAAA,CAEG0C,CAFH,CAE4B,QAAQ,CAACZ,CAAD,CAAO,CAC9C,MAAO,IAAP,CAAcA,CAAAa,WAAA,CAAiB,CAAjB,CAAd,CAAoC,GADU,CAF3C,CAAA3C,QAAA,CAKG,IALH,CAKS,MALT,CAAAA,QAAA,CAMG,IANH,CAMS,MANT,CADsB,CAoB/B7B,QAASA,EAAkB,CAACD,CAAD,CAAM0E,CAAN,CAAmB,CAC5C,IAAIC,EAAS,CAAA,CAAb,CACIC,EAAMhF,CAAAiF,KAAA,CAAa7E,CAAb,CAAkBA,CAAA4B,KAAlB,CACV,OAAO,OACEU,QAAQ,CAACtB,CAAD,CAAMa,CAAN,CAAaV,CAAb,CAAmB,CAChCH,CAAA,CAAMpB,CAAAwB,UAAA,CAAkBJ,CAAlB,CACD2D,EAAAA,CAAL,EAAehC,CAAA,CAAgB3B,CAAhB,CAAf,GACE2D,CADF,CACW3D,CADX,CAGK2D,EAAL,EAAsC,CAAA,CAAtC,GAAeG,CAAA,CAAc9D,CAAd,CAAf,GACE4D,CAAA,CAAI,GAAJ,CAcA,CAbAA,CAAA,CAAI5D,CAAJ,CAaA,CAZApB,CAAAmF,QAAA,CAAgBlD,CAAhB,CAAuB,QAAQ,CAAC+B,CAAD,CAAQoB,CAAR,CAAY,CACzC,IAAIC,EAAKrF,CAAAwB,UAAA,CAAkB4D,CAAlB,CAAT,CACIE,EAAmB,KAAnBA,GAAWlE,CAAXkE,EAAqC,KAArCA,GAA4BD,CAA5BC,EAAyD,YAAzDA,GAAgDD,CAC3B,EAAA,CAAzB,GAAIE,CAAA,CAAWF,CAAX,CAAJ,EACsB,CAAA,CADtB,GACGG,CAAA,CAASH,CAAT,CADH,EAC8B,CAAAP,CAAA,CAAad,CAAb,CAAoBsB,CAApB,CAD9B,GAEEN,CAAA,CAAI,GAAJ,CAIA,CAHAA,CAAA,CAAII,CAAJ,CAGA,CAFAJ,CAAA,CAAI,IAAJ,CAEA,CADAA,CAAA,CAAIL,CAAA,CAAeX,CAAf,CAAJ,CACA,CAAAgB,CAAA,CAAI,GAAJ,CANF,CAHyC,CAA3C,CAYA,CAAAA,CAAA,CAAIzD,CAAA,CAAQ,IAAR,CAAe,GAAnB,CAfF,CALgC,CAD7B,KAwBAqB,QAAQ,CAACxB,CAAD,CAAK,CACdA,CAAA,CAAMpB,CAAAwB,UAAA,CAAkBJ,CAAlB,CACD2D,EAAL,EAAsC,CAAA,CAAtC,GAAeG,CAAA,CAAc9D,CAAd,CAAf,GACE4D,CAAA,CAAI,IAAJ,CAEA,CADAA,CAAA,CAAI5D,CAAJ,CACA,CAAA4D,CAAA,CAAI,GAAJ,CAHF,CAKI5D,EAAJ,EAAW2D,CAAX,GACEA,CADF,CACW,CAAA,CADX,CAPc,CAxBb,OAmCE5E,QAAQ,CAACA,CAAD,CAAO,CACb4E,CAAL;AACEC,CAAA,CAAIL,CAAA,CAAexE,CAAf,CAAJ,CAFgB,CAnCjB,CAHqC,CAha9C,IAAI4D,EAAkB/D,CAAAyF,SAAA,CAAiB,WAAjB,CAAtB,CAwJI3B,EACG,4FAzJP,CA0JEF,EAAiB,2BA1JnB,CA2JEzB,EAAc,yEA3JhB,CA4JE0B,EAAmB,IA5JrB,CA6JEF,EAAyB,SA7J3B,CA8JER,EAAiB,qBA9JnB,CA+JEM,EAAiB,qBA/JnB,CAgKEL,EAAe,yBAhKjB,CAkKEwB,EAA0B,gBAlK5B,CA2KI7C,EAAetB,CAAA,CAAQ,wBAAR,CAIfiF,EAAAA,CAA8BjF,CAAA,CAAQ,gDAAR,CAC9BkF,EAAAA,CAA+BlF,CAAA,CAAQ,OAAR,CADnC,KAEIqB,EAAyB9B,CAAA4F,OAAA,CAAe,EAAf,CACeD,CADf,CAEeD,CAFf,CAF7B,CAOIjE,EAAgBzB,CAAA4F,OAAA,CAAe,EAAf,CAAmBF,CAAnB,CAAgDjF,CAAA,CAAQ,4KAAR,CAAhD,CAPpB;AAYImB,EAAiB5B,CAAA4F,OAAA,CAAe,EAAf,CAAmBD,CAAnB,CAAiDlF,CAAA,CAAQ,2JAAR,CAAjD,CAZrB,CAkBIsC,EAAkBtC,CAAA,CAAQ,cAAR,CAlBtB,CAoBIyE,EAAgBlF,CAAA4F,OAAA,CAAe,EAAf,CACe7D,CADf,CAEeN,CAFf,CAGeG,CAHf,CAIeE,CAJf,CApBpB,CA2BI0D,EAAW/E,CAAA,CAAQ,0CAAR,CA3Bf,CA4BI8E,EAAavF,CAAA4F,OAAA,CAAe,EAAf,CAAmBJ,CAAnB,CAA6B/E,CAAA,CAC1C,ySAD0C,CAA7B,CA5BjB;AA0LI8D,EAAUsB,QAAAC,cAAA,CAAuB,KAAvB,CA1Ld,CA2LI5B,EAAU,wBAsGdlE,EAAA+F,OAAA,CAAe,YAAf,CAA6B,EAA7B,CAAAC,SAAA,CAA0C,WAA1C,CA7UAC,QAA0B,EAAG,CAC3B,IAAAC,KAAA,CAAY,CAAC,eAAD,CAAkB,QAAQ,CAACC,CAAD,CAAgB,CACpD,MAAO,SAAQ,CAAClF,CAAD,CAAO,CACpB,IAAIb,EAAM,EACVY,EAAA,CAAWC,CAAX,CAAiBZ,CAAA,CAAmBD,CAAnB,CAAwB,QAAQ,CAACgG,CAAD,CAAMd,CAAN,CAAe,CAC9D,MAAO,CAAC,SAAA5B,KAAA,CAAeyC,CAAA,CAAcC,CAAd,CAAmBd,CAAnB,CAAf,CADsD,CAA/C,CAAjB,CAGA,OAAOlF,EAAAI,KAAA,CAAS,EAAT,CALa,CAD8B,CAA1C,CADe,CA6U7B,CAuGAR,EAAA+F,OAAA,CAAe,YAAf,CAAAM,OAAA,CAAoC,OAApC,CAA6C,CAAC,WAAD,CAAc,QAAQ,CAACC,CAAD,CAAY,CAAA,IACzEC,EACE,mEAFuE,CAGzEC,EAAgB,UAEpB,OAAO,SAAQ,CAACtD,CAAD,CAAOuD,CAAP,CAAe,CAoB5BC,QAASA,EAAO,CAACxD,CAAD,CAAO,CAChBA,CAAL,EAGAjC,CAAAe,KAAA,CAAU9B,CAAA,CAAagD,CAAb,CAAV,CAJqB,CAOvByD,QAASA,EAAO,CAACC,CAAD,CAAM1D,CAAN,CAAY,CAC1BjC,CAAAe,KAAA,CAAU,KAAV,CACIhC,EAAA6G,UAAA,CAAkBJ,CAAlB,CAAJ;CACExF,CAAAe,KAAA,CAAU,UAAV,CAEA,CADAf,CAAAe,KAAA,CAAUyE,CAAV,CACA,CAAAxF,CAAAe,KAAA,CAAU,IAAV,CAHF,CAKAf,EAAAe,KAAA,CAAU,QAAV,CACAf,EAAAe,KAAA,CAAU4E,CAAV,CACA3F,EAAAe,KAAA,CAAU,IAAV,CACA0E,EAAA,CAAQxD,CAAR,CACAjC,EAAAe,KAAA,CAAU,MAAV,CAX0B,CA1B5B,GAAI,CAACkB,CAAL,CAAW,MAAOA,EAMlB,KALA,IAAId,CAAJ,CACI0E,EAAM5D,CADV,CAEIjC,EAAO,EAFX,CAGI2F,CAHJ,CAII9F,CACJ,CAAQsB,CAAR,CAAgB0E,CAAA1E,MAAA,CAAUmE,CAAV,CAAhB,CAAA,CAEEK,CAMA,CANMxE,CAAA,CAAM,CAAN,CAMN,CAJIA,CAAA,CAAM,CAAN,CAIJ,EAJgBA,CAAA,CAAM,CAAN,CAIhB,GAJ0BwE,CAI1B,CAJgC,SAIhC,CAJ4CA,CAI5C,EAHA9F,CAGA,CAHIsB,CAAAS,MAGJ,CAFA6D,CAAA,CAAQI,CAAAC,OAAA,CAAW,CAAX,CAAcjG,CAAd,CAAR,CAEA,CADA6F,CAAA,CAAQC,CAAR,CAAaxE,CAAA,CAAM,CAAN,CAAAF,QAAA,CAAiBsE,CAAjB,CAAgC,EAAhC,CAAb,CACA,CAAAM,CAAA,CAAMA,CAAAtD,UAAA,CAAc1C,CAAd,CAAkBsB,CAAA,CAAM,CAAN,CAAArB,OAAlB,CAER2F,EAAA,CAAQI,CAAR,CACA,OAAOR,EAAA,CAAUrF,CAAAT,KAAA,CAAU,EAAV,CAAV,CAlBqB,CAL+C,CAAlC,CAA7C,CAzjBsC,CAArC,CAAA,CA0mBET,MA1mBF,CA0mBUA,MAAAC,QA1mBV;", -"sources":["angular-sanitize.js"], -"names":["window","angular","undefined","sanitizeText","chars","buf","htmlSanitizeWriter","writer","noop","join","makeMap","str","obj","items","split","i","length","htmlParser","html","handler","parseStartTag","tag","tagName","rest","unary","lowercase","blockElements","stack","last","inlineElements","parseEndTag","optionalEndTagElements","voidElements","push","attrs","replace","ATTR_REGEXP","match","name","doubleQuotedValue","singleQuotedValue","unquotedValue","decodeEntities","start","pos","end","index","stack.last","specialElements","RegExp","all","text","COMMENT_REGEXP","CDATA_REGEXP","indexOf","lastIndexOf","comment","substring","DOCTYPE_REGEXP","test","BEGIN_END_TAGE_REGEXP","END_TAG_REGEXP","BEGIN_TAG_REGEXP","START_TAG_REGEXP","$sanitizeMinErr","value","parts","spaceRe","exec","spaceBefore","spaceAfter","content","hiddenPre","innerHTML","textContent","innerText","encodeEntities","NON_ALPHANUMERIC_REGEXP","charCodeAt","uriValidator","ignore","out","bind","validElements","forEach","key","lkey","isImage","validAttrs","uriAttrs","$$minErr","optionalEndTagBlockElements","optionalEndTagInlineElements","extend","document","createElement","module","provider","$SanitizeProvider","$get","$$sanitizeUri","uri","filter","$sanitize","LINKY_URL_REGEXP","MAILTO_REGEXP","target","addText","addLink","url","isDefined","raw","substr"] + "version":3, + "file":"angular-sanitize.min.js", + "lineCount":16, + "mappings":"A;;;;;aAKC,SAAQ,CAACA,CAAD,CAASC,CAAT,CAAkB,CAykB3BC,QAASA,EAAY,CAACC,CAAD,CAAQ,CAC3B,IAAIC,EAAM,EACGC,EAAAC,CAAmBF,CAAnBE,CAAwBC,CAAxBD,CACbH,MAAA,CAAaA,CAAb,CACA,OAAOC,EAAAI,KAAA,CAAS,EAAT,CAJoB,CA5jB7B,IAAIC,EAAkBR,CAAAS,SAAA,CAAiB,WAAjB,CAAtB,CACIC,CADJ,CAEIC,CAFJ,CAGIC,CAHJ,CAIIC,CAJJ,CAKIC,CALJ,CAMIR,CANJ,CAOIS,CAPJ,CAQIC,CARJ,CASIZ,CA4jBJJ,EAAAiB,OAAA,CAAe,YAAf,CAA6B,EAA7B,CAAAC,SAAA,CACY,WADZ,CAhcAC,QAA0B,EAAG,CA4J3BC,QAASA,EAAK,CAACC,CAAD,CAAMC,CAAN,CAAqB,CAAA,IAC7BC,EAAM,EADuB,CACnBC,EAAQH,CAAAI,MAAA,CAAU,GAAV,CADW,CACKC,CACtC,KAAKA,CAAL,CAAS,CAAT,CAAYA,CAAZ,CAAgBF,CAAAG,OAAhB,CAA8BD,CAAA,EAA9B,CACEH,CAAA,CAAID,CAAA,CAAgBR,CAAA,CAAUU,CAAA,CAAME,CAAN,CAAV,CAAhB,CAAsCF,CAAA,CAAME,CAAN,CAA1C,CAAA,CAAsD,CAAA,CAExD,OAAOH,EAL0B,CAwJnCK,QAASA,EAAS,CAACC,CAAD,CAAQ,CAExB,IADA,IAAIC,EAAM,EAAV,CACSJ,EAAI,CADb,CACgBK,EAAKF,CAAAF,OAArB,CAAmCD,CAAnC,CAAuCK,CAAvC,CAA2CL,CAAA,EAA3C,CAAgD,CAC9C,IAAIM,EAAOH,CAAA,CAAMH,CAAN,CACXI,EAAA,CAAIE,CAAAC,KAAJ,CAAA,CAAiBD,CAAAE,MAF6B,CAIhD,MAAOJ,EANiB,CAiB1BK,QAASA,EAAc,CAACD,CAAD,CAAQ,CAC7B,MAAOA,EAAAE,QAAA,CACG,IADH,CACS,OADT,CAAAA,QAAA,CAEGC,CAFH,CAE0B,QAAQ,CAACH,CAAD,CAAQ,CAC7C,IAAII,EAAKJ,CAAAK,WAAA,CAAiB,CAAjB,CACLC,EAAAA,CAAMN,CAAAK,WAAA,CAAiB,CAAjB,CACV,OAAO,IAAP,EAAgC,IAAhC,EAAiBD,CAAjB;AAAsB,KAAtB,GAA0CE,CAA1C,CAAgD,KAAhD,EAA0D,KAA1D,EAAqE,GAHxB,CAF1C,CAAAJ,QAAA,CAOGK,CAPH,CAO4B,QAAQ,CAACP,CAAD,CAAQ,CAC/C,MAAO,IAAP,CAAcA,CAAAK,WAAA,CAAiB,CAAjB,CAAd,CAAoC,GADW,CAP5C,CAAAH,QAAA,CAUG,IAVH,CAUS,MAVT,CAAAA,QAAA,CAWG,IAXH,CAWS,MAXT,CADsB,CAgF/BM,QAASA,EAAkB,CAACC,CAAD,CAAO,CAChC,IAAA,CAAOA,CAAP,CAAA,CAAa,CACX,GAAIA,CAAAC,SAAJ,GAAsB7C,CAAA8C,KAAAC,aAAtB,CAEE,IADA,IAAIjB,EAAQc,CAAAI,WAAZ,CACSrB,EAAI,CADb,CACgBsB,EAAInB,CAAAF,OAApB,CAAkCD,CAAlC,CAAsCsB,CAAtC,CAAyCtB,CAAA,EAAzC,CAA8C,CAC5C,IAAIuB,EAAWpB,CAAA,CAAMH,CAAN,CAAf,CACIwB,EAAWD,CAAAhB,KAAAkB,YAAA,EACf,IAAiB,WAAjB,GAAID,CAAJ,EAAoE,CAApE,GAAgCA,CAAAE,YAAA,CAAqB,MAArB,CAA6B,CAA7B,CAAhC,CACET,CAAAU,oBAAA,CAAyBJ,CAAzB,CAEA,CADAvB,CAAA,EACA,CAAAsB,CAAA,EAN0C,CAYhD,CADIM,CACJ,CADeX,CAAAY,WACf,GACEb,CAAA,CAAmBY,CAAnB,CAGFX,EAAA,CAAOa,CAAA,CAAiB,aAAjB,CAAgCb,CAAhC,CAnBI,CADmB,CAwBlCa,QAASA,EAAgB,CAACC,CAAD,CAAWd,CAAX,CAAiB,CAExC,IAAIW,EAAWX,CAAA,CAAKc,CAAL,CACf,IAAIH,CAAJ,EAAgBvC,CAAA2C,KAAA,CAAkBf,CAAlB,CAAwBW,CAAxB,CAAhB,CACE,KAAM9C,EAAA,CAAgB,QAAhB,CAA2FmC,CAAAgB,UAA3F,EAA6GhB,CAAAiB,UAA7G,CAAN,CAEF,MAAON,EANiC,CA5a1C,IAAIO,EAAa,CAAA,CAEjB,KAAAC,KAAA;AAAY,CAAC,eAAD,CAAkB,QAAQ,CAACC,CAAD,CAAgB,CAChDF,CAAJ,EACElD,CAAA,CAAOqD,CAAP,CAAsBC,CAAtB,CAEF,OAAO,SAAQ,CAACC,CAAD,CAAO,CACpB,IAAI/D,EAAM,EACVa,EAAA,CAAWkD,CAAX,CAAiB9D,CAAA,CAAmBD,CAAnB,CAAwB,QAAQ,CAACgE,CAAD,CAAMC,CAAN,CAAe,CAC9D,MAAO,CAAC,UAAAC,KAAA,CAAgBN,CAAA,CAAcI,CAAd,CAAmBC,CAAnB,CAAhB,CADsD,CAA/C,CAAjB,CAGA,OAAOjE,EAAAI,KAAA,CAAS,EAAT,CALa,CAJ8B,CAA1C,CA4CZ,KAAA+D,UAAA,CAAiBC,QAAQ,CAACD,CAAD,CAAY,CACnC,MAAIzD,EAAA,CAAUyD,CAAV,CAAJ,EACET,CACO,CADMS,CACN,CAAA,IAFT,EAIST,CAL0B,CAarCnD,EAAA,CAAOV,CAAAU,KACPC,EAAA,CAASX,CAAAW,OACTC,EAAA,CAAUZ,CAAAY,QACVC,EAAA,CAAYb,CAAAa,UACZC,EAAA,CAAYd,CAAAc,UACZR,EAAA,CAAON,CAAAM,KAEPU,EAAA,CAsLAwD,QAAuB,CAACN,CAAD,CAAOO,CAAP,CAAgB,CACxB,IAAb,GAAIP,CAAJ,EAA8BQ,IAAAA,EAA9B,GAAqBR,CAArB,CACEA,CADF,CACS,EADT,CAE2B,QAF3B,GAEW,MAAOA,EAFlB,GAGEA,CAHF,CAGS,EAHT,CAGcA,CAHd,CAMA,KAAIS,EAAmBC,CAAA,CAAoBV,CAApB,CACvB,IAAKS,CAAAA,CAAL,CAAuB,MAAO,EAG9B,KAAIE,EAAe,CACnB,GAAG,CACD,GAAqB,CAArB,GAAIA,CAAJ,CACE,KAAMrE,EAAA,CAAgB,QAAhB,CAAN,CAEFqE,CAAA,EAGAX,EAAA,CAAOS,CAAAG,UACPH,EAAA,CAAmBC,CAAA,CAAoBV,CAApB,CARlB,CAAH,MASSA,CATT,GASkBS,CAAAG,UATlB,CAYA,KADInC,CACJ,CADWgC,CAAApB,WACX,CAAOZ,CAAP,CAAA,CAAa,CACX,OAAQA,CAAAC,SAAR,EACE,KAAK,CAAL,CACE6B,CAAAM,MAAA,CAAcpC,CAAAqC,SAAA7B,YAAA,EAAd;AAA2CvB,CAAA,CAAUe,CAAAI,WAAV,CAA3C,CACA,MACF,MAAK,CAAL,CACE0B,CAAAvE,MAAA,CAAcyC,CAAAsC,YAAd,CALJ,CASA,IAAI3B,CACJ,IAAM,EAAAA,CAAA,CAAWX,CAAAY,WAAX,CAAN,GACwB,CAIjBD,GAJDX,CAAAC,SAICU,EAHHmB,CAAAS,IAAA,CAAYvC,CAAAqC,SAAA7B,YAAA,EAAZ,CAGGG,CADLA,CACKA,CADME,CAAA,CAAiB,aAAjB,CAAgCb,CAAhC,CACNW,CAAAA,CAAAA,CALP,EAMI,IAAA,CAAmB,IAAnB,EAAOA,CAAP,CAAA,CAAyB,CACvBX,CAAA,CAAOa,CAAA,CAAiB,YAAjB,CAA+Bb,CAA/B,CACP,IAAIA,CAAJ,GAAagC,CAAb,CAA+B,KAC/BrB,EAAA,CAAWE,CAAA,CAAiB,aAAjB,CAAgCb,CAAhC,CACW,EAAtB,GAAIA,CAAAC,SAAJ,EACE6B,CAAAS,IAAA,CAAYvC,CAAAqC,SAAA7B,YAAA,EAAZ,CALqB,CAU7BR,CAAA,CAAOW,CA3BI,CA8Bb,IAAA,CAAQX,CAAR,CAAegC,CAAApB,WAAf,CAAA,CACEoB,CAAAQ,YAAA,CAA6BxC,CAA7B,CAvDmC,CArLvCvC,EAAA,CA0RAgF,QAA+B,CAACjF,CAAD,CAAMkF,CAAN,CAAoB,CACjD,IAAIC,EAAuB,CAAA,CAA3B,CACIC,EAAM7E,CAAA,CAAKP,CAAL,CAAUA,CAAAqF,KAAV,CACV,OAAO,CACLT,MAAOA,QAAQ,CAACU,CAAD,CAAM5D,CAAN,CAAa,CAC1B4D,CAAA,CAAM3E,CAAA,CAAU2E,CAAV,CACDH,EAAAA,CAAL,EAA6BI,CAAA,CAAgBD,CAAhB,CAA7B,GACEH,CADF,CACyBG,CADzB,CAGKH,EAAL,EAAoD,CAAA,CAApD,GAA6BtB,CAAA,CAAcyB,CAAd,CAA7B,GACEF,CAAA,CAAI,GAAJ,CAcA,CAbAA,CAAA,CAAIE,CAAJ,CAaA,CAZA7E,CAAA,CAAQiB,CAAR,CAAe,QAAQ,CAACK,CAAD,CAAQyD,CAAR,CAAa,CAClC,IAAIC,EAAO9E,CAAA,CAAU6E,CAAV,CAAX,CACIvB,EAAmB,KAAnBA,GAAWqB,CAAXrB,EAAqC,KAArCA,GAA4BwB,CAA5BxB,EAAyD,YAAzDA;AAAgDwB,CAC3B,EAAA,CAAzB,GAAIC,CAAA,CAAWD,CAAX,CAAJ,EACsB,CAAA,CADtB,GACGE,CAAA,CAASF,CAAT,CADH,EAC8B,CAAAP,CAAA,CAAanD,CAAb,CAAoBkC,CAApB,CAD9B,GAEEmB,CAAA,CAAI,GAAJ,CAIA,CAHAA,CAAA,CAAII,CAAJ,CAGA,CAFAJ,CAAA,CAAI,IAAJ,CAEA,CADAA,CAAA,CAAIpD,CAAA,CAAeD,CAAf,CAAJ,CACA,CAAAqD,CAAA,CAAI,GAAJ,CANF,CAHkC,CAApC,CAYA,CAAAA,CAAA,CAAI,GAAJ,CAfF,CAL0B,CADvB,CAwBLL,IAAKA,QAAQ,CAACO,CAAD,CAAM,CACjBA,CAAA,CAAM3E,CAAA,CAAU2E,CAAV,CACDH,EAAL,EAAoD,CAAA,CAApD,GAA6BtB,CAAA,CAAcyB,CAAd,CAA7B,EAAkF,CAAA,CAAlF,GAA4DM,CAAA,CAAaN,CAAb,CAA5D,GACEF,CAAA,CAAI,IAAJ,CAEA,CADAA,CAAA,CAAIE,CAAJ,CACA,CAAAF,CAAA,CAAI,GAAJ,CAHF,CAMIE,EAAJ,EAAWH,CAAX,GACEA,CADF,CACyB,CAAA,CADzB,CARiB,CAxBd,CAoCLpF,MAAOA,QAAQ,CAACA,CAAD,CAAQ,CAChBoF,CAAL,EACEC,CAAA,CAAIpD,CAAA,CAAejC,CAAf,CAAJ,CAFmB,CApClB,CAH0C,CAxRnDa,EAAA,CAAehB,CAAA8C,KAAAmD,UAAAC,SAAf,EAA8D,QAAQ,CAACC,CAAD,CAAM,CAE1E,MAAO,CAAG,EAAA,IAAAC,wBAAA,CAA6BD,CAA7B,CAAA,CAAoC,EAApC,CAFgE,CAtEjD,KA4EvB7D,EAAwB,iCA5ED,CA8EzBI,EAA0B,cA9ED,CAuFvBsD,EAAe3E,CAAA,CAAM,wBAAN,CAvFQ,CA2FvBgF,EAA8BhF,CAAA,CAAM,gDAAN,CA3FP,CA4FvBiF,EAA+BjF,CAAA,CAAM,OAAN,CA5FR,CA6FvBkF,EAAyB3F,CAAA,CAAO,EAAP,CACe0F,CADf,CAEeD,CAFf,CA7FF,CAkGvBG,EAAgB5F,CAAA,CAAO,EAAP,CAAWyF,CAAX,CAAwChF,CAAA,CAAM,qKAAN,CAAxC,CAlGO;AAuGvBoF,EAAiB7F,CAAA,CAAO,EAAP,CAAW0F,CAAX,CAAyCjF,CAAA,CAAM,2JAAN,CAAzC,CAvGM,CA+GvB6C,EAAc7C,CAAA,CAAM,wNAAN,CA/GS,CAoHvBsE,EAAkBtE,CAAA,CAAM,cAAN,CApHK,CAsHvB4C,EAAgBrD,CAAA,CAAO,EAAP,CACeoF,CADf,CAEeQ,CAFf,CAGeC,CAHf,CAIeF,CAJf,CAtHO,CA6HvBR,EAAW1E,CAAA,CAAM,uDAAN,CA7HY,CA+HvBqF,EAAYrF,CAAA,CAAM,kTAAN,CA/HW;AAuIvBsF,EAAWtF,CAAA,CAAM,guCAAN;AAcoE,CAAA,CAdpE,CAvIY,CAuJvByE,EAAalF,CAAA,CAAO,EAAP,CACemF,CADf,CAEeY,CAFf,CAGeD,CAHf,CAvJU,CA0KvB7B,EAAqE,QAAQ,CAAC7E,CAAD,CAAS4G,CAAT,CAAmB,CAyClGC,QAASA,EAA6B,CAAC1C,CAAD,CAAO,CAG3CA,CAAA,CAAO,mBAAP,CAA6BA,CAC7B,IAAI,CACF,IAAI2C,EAAOC,CAAA,IAAI/G,CAAAgH,UAAJD,iBAAA,CAAuC5C,CAAvC,CAA6C,WAA7C,CAAA2C,KACXA,EAAAtD,WAAAyD,OAAA,EACA,OAAOH,EAHL,CAIF,MAAOI,CAAP,CAAU,EAR+B,CAa7CC,QAASA,EAAiC,CAAChD,CAAD,CAAO,CAC/CS,CAAAG,UAAA,CAA6BZ,CAIzByC,EAAAQ,aAAJ,EACEzE,CAAA,CAAmBiC,CAAnB,CAGF,OAAOA,EATwC,CArDjD,IAAIyC,CACJ,IAAIT,CAAJ,EAAgBA,CAAAU,eAAhB,CACED,CAAA,CAAgBT,CAAAU,eAAAC,mBAAA,CAA2C,OAA3C,CADlB,KAGE,MAAM9G,EAAA,CAAgB,SAAhB,CAAN,CAEF,IAAImE,EAAmB4C,CAACH,CAAAI,gBAADD,EAAkCH,CAAAK,mBAAA,EAAlCF,eAAA,CAAoF,MAApF,CAGvB5C,EAAAG,UAAA,CAA6B,sDAC7B,OAAKH,EAAA4C,cAAA,CAA+B,KAA/B,CAAL;CAIE5C,CAAAG,UACA,CAD6B,kEAC7B,CAAIH,CAAA4C,cAAA,CAA+B,SAA/B,CAAJ,CACSX,CADT,CAGSM,CARX,EAYAQ,QAAgC,CAACxD,CAAD,CAAO,CAGrCA,CAAA,CAAO,mBAAP,CAA6BA,CAC7B,IAAI,CACFA,CAAA,CAAOyD,SAAA,CAAUzD,CAAV,CADL,CAEF,MAAO+C,CAAP,CAAU,CACV,MADU,CAGZ,IAAIW,EAAM,IAAI7H,CAAA8H,eACdD,EAAAE,aAAA,CAAmB,UACnBF,EAAAG,KAAA,CAAS,KAAT,CAAgB,+BAAhB,CAAkD7D,CAAlD,CAAwD,CAAA,CAAxD,CACA0D,EAAAI,KAAA,CAAS,IAAT,CACInB,EAAAA,CAAOe,CAAAK,SAAApB,KACXA,EAAAtD,WAAAyD,OAAA,EACA,OAAOH,EAf8B,CAvB2D,CAA5B,CAiErE9G,CAjEqE,CAiE7DA,CAAA4G,SAjE6D,CA1K7C,CAgc7B,CAAAuB,KAAA,CAEQ,CAAEC,eAAgB,OAAlB,CAFR,CAmIAnI,EAAAiB,OAAA,CAAe,YAAf,CAAAmH,OAAA,CAAoC,OAApC,CAA6C,CAAC,WAAD,CAAc,QAAQ,CAACC,CAAD,CAAY,CAAA,IACzEC,EACE,2FAFuE;AAGzEC,EAAgB,WAHyD,CAKzEC,EAAcxI,CAAAS,SAAA,CAAiB,OAAjB,CAL2D,CAMzEI,EAAYb,CAAAa,UAN6D,CAOzE4H,EAAazI,CAAAyI,WAP4D,CAQzEC,EAAW1I,CAAA0I,SAR8D,CASzEC,EAAW3I,CAAA2I,SAEf,OAAO,SAAQ,CAACC,CAAD,CAAOC,CAAP,CAAe9F,CAAf,CAA2B,CA6BxC+F,QAASA,EAAO,CAACF,CAAD,CAAO,CAChBA,CAAL,EAGA1E,CAAAsB,KAAA,CAAUvF,CAAA,CAAa2I,CAAb,CAAV,CAJqB,CAOvBG,QAASA,EAAO,CAACC,CAAD,CAAMJ,CAAN,CAAY,CAAA,IACtBjD,CADsB,CACjBsD,EAAiBC,CAAA,CAAaF,CAAb,CAC1B9E,EAAAsB,KAAA,CAAU,KAAV,CAEA,KAAKG,CAAL,GAAYsD,EAAZ,CACE/E,CAAAsB,KAAA,CAAUG,CAAV,CAAgB,IAAhB,CAAuBsD,CAAA,CAAetD,CAAf,CAAvB,CAA6C,IAA7C,CAGE,EAAA9E,CAAA,CAAUgI,CAAV,CAAJ,EAA2B,QAA3B,EAAuCI,EAAvC,EACE/E,CAAAsB,KAAA,CAAU,UAAV,CACUqD,CADV,CAEU,IAFV,CAIF3E,EAAAsB,KAAA,CAAU,QAAV,CACUwD,CAAA5G,QAAA,CAAY,IAAZ,CAAkB,QAAlB,CADV,CAEU,IAFV,CAGA0G,EAAA,CAAQF,CAAR,CACA1E,EAAAsB,KAAA,CAAU,MAAV,CAjB0B,CAnC5B,GAAY,IAAZ,EAAIoD,CAAJ,EAA6B,EAA7B,GAAoBA,CAApB,CAAiC,MAAOA,EACxC,IAAK,CAAAD,CAAA,CAASC,CAAT,CAAL,CAAqB,KAAMJ,EAAA,CAAY,WAAZ,CAA8DI,CAA9D,CAAN,CAYrB,IAVA,IAAIM,EACFT,CAAA,CAAW1F,CAAX,CAAA,CAAyBA,CAAzB,CACA2F,CAAA,CAAS3F,CAAT,CAAA,CAAuBoG,QAA4B,EAAG,CAAC,MAAOpG,EAAR,CAAtD,CACAqG,QAAiC,EAAG,CAAC,MAAO,EAAR,CAHtC,CAMIC,EAAMT,CANV,CAOI1E,EAAO,EAPX,CAQI8E,CARJ,CASItH,CACJ,CAAQ4H,CAAR,CAAgBD,CAAAC,MAAA,CAAUhB,CAAV,CAAhB,CAAA,CAEEU,CAQA,CARMM,CAAA,CAAM,CAAN,CAQN,CANKA,CAAA,CAAM,CAAN,CAML;AANkBA,CAAA,CAAM,CAAN,CAMlB,GALEN,CAKF,EALSM,CAAA,CAAM,CAAN,CAAA,CAAW,SAAX,CAAuB,SAKhC,EAL6CN,CAK7C,EAHAtH,CAGA,CAHI4H,CAAAC,MAGJ,CAFAT,CAAA,CAAQO,CAAAG,OAAA,CAAW,CAAX,CAAc9H,CAAd,CAAR,CAEA,CADAqH,CAAA,CAAQC,CAAR,CAAaM,CAAA,CAAM,CAAN,CAAAlH,QAAA,CAAiBmG,CAAjB,CAAgC,EAAhC,CAAb,CACA,CAAAc,CAAA,CAAMA,CAAAI,UAAA,CAAc/H,CAAd,CAAkB4H,CAAA,CAAM,CAAN,CAAA3H,OAAlB,CAERmH,EAAA,CAAQO,CAAR,CACA,OAAOhB,EAAA,CAAUnE,CAAA3D,KAAA,CAAU,EAAV,CAAV,CA3BiC,CAXmC,CAAlC,CAA7C,CArtB2B,CAA1B,CAAD,CA2xBGR,MA3xBH,CA2xBWA,MAAAC,QA3xBX;", + "sources":["angular-sanitize.js"], + "names":["window","angular","sanitizeText","chars","buf","htmlSanitizeWriter","writer","noop","join","$sanitizeMinErr","$$minErr","bind","extend","forEach","isDefined","lowercase","nodeContains","htmlParser","module","provider","$SanitizeProvider","toMap","str","lowercaseKeys","obj","items","split","i","length","attrToMap","attrs","map","ii","attr","name","value","encodeEntities","replace","SURROGATE_PAIR_REGEXP","hi","charCodeAt","low","NON_ALPHANUMERIC_REGEXP","stripCustomNsAttrs","node","nodeType","Node","ELEMENT_NODE","attributes","l","attrNode","attrName","toLowerCase","lastIndexOf","removeAttributeNode","nextNode","firstChild","getNonDescendant","propName","call","outerHTML","outerText","svgEnabled","$get","$$sanitizeUri","validElements","svgElements","html","uri","isImage","test","enableSvg","this.enableSvg","htmlParserImpl","handler","undefined","inertBodyElement","getInertBodyElement","mXSSAttempts","innerHTML","start","nodeName","textContent","end","removeChild","htmlSanitizeWriterImpl","uriValidator","ignoreCurrentElement","out","push","tag","blockedElements","key","lkey","validAttrs","uriAttrs","voidElements","prototype","contains","arg","compareDocumentPosition","optionalEndTagBlockElements","optionalEndTagInlineElements","optionalEndTagElements","blockElements","inlineElements","htmlAttrs","svgAttrs","document","getInertBodyElement_DOMParser","body","parseFromString","DOMParser","remove","e","getInertBodyElement_InertDocument","documentMode","inertDocument","implementation","createHTMLDocument","querySelector","documentElement","getDocumentElement","getInertBodyElement_XHR","encodeURI","xhr","XMLHttpRequest","responseType","open","send","response","info","angularVersion","filter","$sanitize","LINKY_URL_REGEXP","MAILTO_REGEXP","linkyMinErr","isFunction","isObject","isString","text","target","addText","addLink","url","linkAttributes","attributesFn","getAttributesObject","getEmptyAttributesObject","raw","match","index","substr","substring"] } \ No newline at end of file diff --git a/setup/pub/angular-ui-bootstrap/angular-ui-bootstrap.min.js b/setup/pub/angular-ui-bootstrap/angular-ui-bootstrap.min.js index fa6a8613174cf..2676e0ae9dd18 100644 --- a/setup/pub/angular-ui-bootstrap/angular-ui-bootstrap.min.js +++ b/setup/pub/angular-ui-bootstrap/angular-ui-bootstrap.min.js @@ -6,5 +6,5 @@ * License: MIT */ angular.module("ui.bootstrap",["ui.bootstrap.tpls","ui.bootstrap.transition","ui.bootstrap.collapse","ui.bootstrap.accordion","ui.bootstrap.alert","ui.bootstrap.bindHtml","ui.bootstrap.buttons","ui.bootstrap.carousel","ui.bootstrap.dateparser","ui.bootstrap.position","ui.bootstrap.datepicker","ui.bootstrap.dropdown","ui.bootstrap.modal","ui.bootstrap.pagination","ui.bootstrap.tooltip","ui.bootstrap.popover","ui.bootstrap.progressbar","ui.bootstrap.rating","ui.bootstrap.tabs","ui.bootstrap.timepicker","ui.bootstrap.typeahead"]),angular.module("ui.bootstrap.tpls",["template/accordion/accordion-group.html","template/accordion/accordion.html","template/alert/alert.html","template/carousel/carousel.html","template/carousel/slide.html","template/datepicker/datepicker.html","template/datepicker/day.html","template/datepicker/month.html","template/datepicker/popup.html","template/datepicker/year.html","template/modal/backdrop.html","template/modal/window.html","template/pagination/pager.html","template/pagination/pagination.html","template/tooltip/tooltip-html-unsafe-popup.html","template/tooltip/tooltip-popup.html","template/popover/popover.html","template/progressbar/bar.html","template/progressbar/progress.html","template/progressbar/progressbar.html","template/rating/rating.html","template/tabs/tab.html","template/tabs/tabset.html","template/timepicker/timepicker.html","template/typeahead/typeahead-match.html","template/typeahead/typeahead-popup.html"]),angular.module("ui.bootstrap.transition",[]).factory("$transition",["$q","$timeout","$rootScope",function(a,b,c){function d(a){for(var b in a)if(void 0!==f.style[b])return a[b]}var e=function(d,f,g){g=g||{};var h=a.defer(),i=e[g.animation?"animationEndEventName":"transitionEndEventName"],j=function(){c.$apply(function(){d.unbind(i,j),h.resolve(d)})};return i&&d.bind(i,j),b(function(){angular.isString(f)?d.addClass(f):angular.isFunction(f)?f(d):angular.isObject(f)&&d.css(f),i||h.resolve(d)}),h.promise.cancel=function(){i&&d.unbind(i,j),h.reject("Transition cancelled")},h.promise},f=document.createElement("trans"),g={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd",transition:"transitionend"},h={WebkitTransition:"webkitAnimationEnd",MozTransition:"animationend",OTransition:"oAnimationEnd",transition:"animationend"};return e.transitionEndEventName=d(g),e.animationEndEventName=d(h),e}]),angular.module("ui.bootstrap.collapse",["ui.bootstrap.transition"]).directive("collapse",["$transition",function(a){return{link:function(b,c,d){function e(b){function d(){j===e&&(j=void 0)}var e=a(c,b);return j&&j.cancel(),j=e,e.then(d,d),e}function f(){k?(k=!1,g()):(c.removeClass("collapse").addClass("collapsing"),e({height:c[0].scrollHeight+"px"}).then(g))}function g(){c.removeClass("collapsing"),c.addClass("collapse in"),c.css({height:"auto"})}function h(){if(k)k=!1,i(),c.css({height:0});else{c.css({height:c[0].scrollHeight+"px"});{c[0].offsetWidth}c.removeClass("collapse in").addClass("collapsing"),e({height:0}).then(i)}}function i(){c.removeClass("collapsing"),c.addClass("collapse")}var j,k=!0;b.$watch(d.collapse,function(a){a?h():f()})}}}]),angular.module("ui.bootstrap.accordion",["ui.bootstrap.collapse"]).constant("accordionConfig",{closeOthers:!0}).controller("AccordionController",["$scope","$attrs","accordionConfig",function(a,b,c){this.groups=[],this.closeOthers=function(d){var e=angular.isDefined(b.closeOthers)?a.$eval(b.closeOthers):c.closeOthers;e&&angular.forEach(this.groups,function(a){a!==d&&(a.isOpen=!1)})},this.addGroup=function(a){var b=this;this.groups.push(a),a.$on("$destroy",function(){b.removeGroup(a)})},this.removeGroup=function(a){var b=this.groups.indexOf(a);-1!==b&&this.groups.splice(b,1)}}]).directive("accordion",function(){return{restrict:"EA",controller:"AccordionController",transclude:!0,replace:!1,templateUrl:"template/accordion/accordion.html"}}).directive("accordionGroup",function(){return{require:"^accordion",restrict:"EA",transclude:!0,replace:!0,templateUrl:"template/accordion/accordion-group.html",scope:{heading:"@",isOpen:"=?",isDisabled:"=?"},controller:function(){this.setHeading=function(a){this.heading=a}},link:function(a,b,c,d){d.addGroup(a),a.$watch("isOpen",function(b){b&&d.closeOthers(a)}),a.toggleOpen=function(){a.isDisabled||(a.isOpen=!a.isOpen)}}}}).directive("accordionHeading",function(){return{restrict:"EA",transclude:!0,template:"",replace:!0,require:"^accordionGroup",link:function(a,b,c,d,e){d.setHeading(e(a,function(){}))}}}).directive("accordionTransclude",function(){return{require:"^accordionGroup",link:function(a,b,c,d){a.$watch(function(){return d[c.accordionTransclude]},function(a){a&&(b.html(""),b.append(a))})}}}),angular.module("ui.bootstrap.alert",[]).controller("AlertController",["$scope","$attrs",function(a,b){a.closeable="close"in b}]).directive("alert",function(){return{restrict:"EA",controller:"AlertController",templateUrl:"template/alert/alert.html",transclude:!0,replace:!0,scope:{type:"@",close:"&"}}}),angular.module("ui.bootstrap.bindHtml",[]).directive("bindHtmlUnsafe",function(){return function(a,b,c){b.addClass("ng-binding").data("$binding",c.bindHtmlUnsafe),a.$watch(c.bindHtmlUnsafe,function(a){b.html(a||"")})}}),angular.module("ui.bootstrap.buttons",[]).constant("buttonConfig",{activeClass:"active",toggleEvent:"click"}).controller("ButtonsController",["buttonConfig",function(a){this.activeClass=a.activeClass||"active",this.toggleEvent=a.toggleEvent||"click"}]).directive("btnRadio",function(){return{require:["btnRadio","ngModel"],controller:"ButtonsController",link:function(a,b,c,d){var e=d[0],f=d[1];f.$render=function(){b.toggleClass(e.activeClass,angular.equals(f.$modelValue,a.$eval(c.btnRadio)))},b.bind(e.toggleEvent,function(){var d=b.hasClass(e.activeClass);(!d||angular.isDefined(c.uncheckable))&&a.$apply(function(){f.$setViewValue(d?null:a.$eval(c.btnRadio)),f.$render()})})}}}).directive("btnCheckbox",function(){return{require:["btnCheckbox","ngModel"],controller:"ButtonsController",link:function(a,b,c,d){function e(){return g(c.btnCheckboxTrue,!0)}function f(){return g(c.btnCheckboxFalse,!1)}function g(b,c){var d=a.$eval(b);return angular.isDefined(d)?d:c}var h=d[0],i=d[1];i.$render=function(){b.toggleClass(h.activeClass,angular.equals(i.$modelValue,e()))},b.bind(h.toggleEvent,function(){a.$apply(function(){i.$setViewValue(b.hasClass(h.activeClass)?f():e()),i.$render()})})}}}),angular.module("ui.bootstrap.carousel",["ui.bootstrap.transition"]).controller("CarouselController",["$scope","$timeout","$transition",function(a,b,c){function d(){e();var c=+a.interval;!isNaN(c)&&c>=0&&(g=b(f,c))}function e(){g&&(b.cancel(g),g=null)}function f(){h?(a.next(),d()):a.pause()}var g,h,i=this,j=i.slides=a.slides=[],k=-1;i.currentSlide=null;var l=!1;i.select=a.select=function(e,f){function g(){if(!l){if(i.currentSlide&&angular.isString(f)&&!a.noTransition&&e.$element){e.$element.addClass(f);{e.$element[0].offsetWidth}angular.forEach(j,function(a){angular.extend(a,{direction:"",entering:!1,leaving:!1,active:!1})}),angular.extend(e,{direction:f,active:!0,entering:!0}),angular.extend(i.currentSlide||{},{direction:f,leaving:!0}),a.$currentTransition=c(e.$element,{}),function(b,c){a.$currentTransition.then(function(){h(b,c)},function(){h(b,c)})}(e,i.currentSlide)}else h(e,i.currentSlide);i.currentSlide=e,k=m,d()}}function h(b,c){angular.extend(b,{direction:"",active:!0,leaving:!1,entering:!1}),angular.extend(c||{},{direction:"",active:!1,leaving:!1,entering:!1}),a.$currentTransition=null}var m=j.indexOf(e);void 0===f&&(f=m>k?"next":"prev"),e&&e!==i.currentSlide&&(a.$currentTransition?(a.$currentTransition.cancel(),b(g)):g())},a.$on("$destroy",function(){l=!0}),i.indexOfSlide=function(a){return j.indexOf(a)},a.next=function(){var b=(k+1)%j.length;return a.$currentTransition?void 0:i.select(j[b],"next")},a.prev=function(){var b=0>k-1?j.length-1:k-1;return a.$currentTransition?void 0:i.select(j[b],"prev")},a.isActive=function(a){return i.currentSlide===a},a.$watch("interval",d),a.$on("$destroy",e),a.play=function(){h||(h=!0,d())},a.pause=function(){a.noPause||(h=!1,e())},i.addSlide=function(b,c){b.$element=c,j.push(b),1===j.length||b.active?(i.select(j[j.length-1]),1==j.length&&a.play()):b.active=!1},i.removeSlide=function(a){var b=j.indexOf(a);j.splice(b,1),j.length>0&&a.active?i.select(b>=j.length?j[b-1]:j[b]):k>b&&k--}}]).directive("carousel",[function(){return{restrict:"EA",transclude:!0,replace:!0,controller:"CarouselController",require:"carousel",templateUrl:"template/carousel/carousel.html",scope:{interval:"=",noTransition:"=",noPause:"="}}}]).directive("slide",function(){return{require:"^carousel",restrict:"EA",transclude:!0,replace:!0,templateUrl:"template/carousel/slide.html",scope:{active:"=?"},link:function(a,b,c,d){d.addSlide(a,b),a.$on("$destroy",function(){d.removeSlide(a)}),a.$watch("active",function(b){b&&d.select(a)})}}}),angular.module("ui.bootstrap.dateparser",[]).service("dateParser",["$locale","orderByFilter",function(a,b){function c(a,b,c){return 1===b&&c>28?29===c&&(a%4===0&&a%100!==0||a%400===0):3===b||5===b||8===b||10===b?31>c:!0}this.parsers={};var d={yyyy:{regex:"\\d{4}",apply:function(a){this.year=+a}},yy:{regex:"\\d{2}",apply:function(a){this.year=+a+2e3}},y:{regex:"\\d{1,4}",apply:function(a){this.year=+a}},MMMM:{regex:a.DATETIME_FORMATS.MONTH.join("|"),apply:function(b){this.month=a.DATETIME_FORMATS.MONTH.indexOf(b)}},MMM:{regex:a.DATETIME_FORMATS.SHORTMONTH.join("|"),apply:function(b){this.month=a.DATETIME_FORMATS.SHORTMONTH.indexOf(b)}},MM:{regex:"0[1-9]|1[0-2]",apply:function(a){this.month=a-1}},M:{regex:"[1-9]|1[0-2]",apply:function(a){this.month=a-1}},dd:{regex:"[0-2][0-9]{1}|3[0-1]{1}",apply:function(a){this.date=+a}},d:{regex:"[1-2]?[0-9]{1}|3[0-1]{1}",apply:function(a){this.date=+a}},EEEE:{regex:a.DATETIME_FORMATS.DAY.join("|")},EEE:{regex:a.DATETIME_FORMATS.SHORTDAY.join("|")}};this.createParser=function(a){var c=[],e=a.split("");return angular.forEach(d,function(b,d){var f=a.indexOf(d);if(f>-1){a=a.split(""),e[f]="("+b.regex+")",a[f]="$";for(var g=f+1,h=f+d.length;h>g;g++)e[g]="",a[g]="$";a=a.join(""),c.push({index:f,apply:b.apply})}}),{regex:new RegExp("^"+e.join("")+"$"),map:b(c,"index")}},this.parse=function(b,d){if(!angular.isString(b))return b;d=a.DATETIME_FORMATS[d]||d,this.parsers[d]||(this.parsers[d]=this.createParser(d));var e=this.parsers[d],f=e.regex,g=e.map,h=b.match(f);if(h&&h.length){for(var i,j={year:1900,month:0,date:1,hours:0},k=1,l=h.length;l>k;k++){var m=g[k-1];m.apply&&m.apply.call(j,h[k])}return c(j.year,j.month,j.date)&&(i=new Date(j.year,j.month,j.date,j.hours)),i}}}]),angular.module("ui.bootstrap.position",[]).factory("$position",["$document","$window",function(a,b){function c(a,c){return a.currentStyle?a.currentStyle[c]:b.getComputedStyle?b.getComputedStyle(a)[c]:a.style[c]}function d(a){return"static"===(c(a,"position")||"static")}var e=function(b){for(var c=a[0],e=b.offsetParent||c;e&&e!==c&&d(e);)e=e.offsetParent;return e||c};return{position:function(b){var c=this.offset(b),d={top:0,left:0},f=e(b[0]);f!=a[0]&&(d=this.offset(angular.element(f)),d.top+=f.clientTop-f.scrollTop,d.left+=f.clientLeft-f.scrollLeft);var g=b[0].getBoundingClientRect();return{width:g.width||b.prop("offsetWidth"),height:g.height||b.prop("offsetHeight"),top:c.top-d.top,left:c.left-d.left}},offset:function(c){var d=c[0].getBoundingClientRect();return{width:d.width||c.prop("offsetWidth"),height:d.height||c.prop("offsetHeight"),top:d.top+(b.pageYOffset||a[0].documentElement.scrollTop),left:d.left+(b.pageXOffset||a[0].documentElement.scrollLeft)}},positionElements:function(a,b,c,d){var e,f,g,h,i=c.split("-"),j=i[0],k=i[1]||"center";e=d?this.offset(a):this.position(a),f=b.prop("offsetWidth"),g=b.prop("offsetHeight");var l={center:function(){return e.left+e.width/2-f/2},left:function(){return e.left},right:function(){return e.left+e.width}},m={center:function(){return e.top+e.height/2-g/2},top:function(){return e.top},bottom:function(){return e.top+e.height}};switch(j){case"right":h={top:m[k](),left:l[j]()};break;case"left":h={top:m[k](),left:e.left-f};break;case"bottom":h={top:m[j](),left:l[k]()};break;default:h={top:e.top-g,left:l[k]()}}return h}}}]),angular.module("ui.bootstrap.datepicker",["ui.bootstrap.dateparser","ui.bootstrap.position"]).constant("datepickerConfig",{formatDay:"dd",formatMonth:"MMMM",formatYear:"yyyy",formatDayHeader:"EEE",formatDayTitle:"MMMM yyyy",formatMonthTitle:"yyyy",datepickerMode:"day",minMode:"day",maxMode:"year",showWeeks:!0,startingDay:0,yearRange:20,minDate:null,maxDate:null}).controller("DatepickerController",["$scope","$attrs","$parse","$interpolate","$timeout","$log","dateFilter","datepickerConfig",function(a,b,c,d,e,f,g,h){var i=this,j={$setViewValue:angular.noop};this.modes=["day","month","year"],angular.forEach(["formatDay","formatMonth","formatYear","formatDayHeader","formatDayTitle","formatMonthTitle","minMode","maxMode","showWeeks","startingDay","yearRange"],function(c,e){i[c]=angular.isDefined(b[c])?8>e?d(b[c])(a.$parent):a.$parent.$eval(b[c]):h[c]}),angular.forEach(["minDate","maxDate"],function(d){b[d]?a.$parent.$watch(c(b[d]),function(a){i[d]=a?new Date(a):null,i.refreshView()}):i[d]=h[d]?new Date(h[d]):null}),a.datepickerMode=a.datepickerMode||h.datepickerMode,a.uniqueId="datepicker-"+a.$id+"-"+Math.floor(1e4*Math.random()),this.activeDate=angular.isDefined(b.initDate)?a.$parent.$eval(b.initDate):new Date,a.isActive=function(b){return 0===i.compare(b.date,i.activeDate)?(a.activeDateId=b.uid,!0):!1},this.init=function(a){j=a,j.$render=function(){i.render()}},this.render=function(){if(j.$modelValue){var a=new Date(j.$modelValue),b=!isNaN(a);b?this.activeDate=a:f.error('Datepicker directive: "ng-model" value must be a Date object, a number of milliseconds since 01.01.1970 or a string representing an RFC2822 or ISO 8601 date.'),j.$setValidity("date",b)}this.refreshView()},this.refreshView=function(){if(this.element){this._refreshView();var a=j.$modelValue?new Date(j.$modelValue):null;j.$setValidity("date-disabled",!a||this.element&&!this.isDisabled(a))}},this.createDateObject=function(a,b){var c=j.$modelValue?new Date(j.$modelValue):null;return{date:a,label:g(a,b),selected:c&&0===this.compare(a,c),disabled:this.isDisabled(a),current:0===this.compare(a,new Date)}},this.isDisabled=function(c){return this.minDate&&this.compare(c,this.minDate)<0||this.maxDate&&this.compare(c,this.maxDate)>0||b.dateDisabled&&a.dateDisabled({date:c,mode:a.datepickerMode})},this.split=function(a,b){for(var c=[];a.length>0;)c.push(a.splice(0,b));return c},a.select=function(b){if(a.datepickerMode===i.minMode){var c=j.$modelValue?new Date(j.$modelValue):new Date(0,0,0,0,0,0,0);c.setFullYear(b.getFullYear(),b.getMonth(),b.getDate()),j.$setViewValue(c),j.$render()}else i.activeDate=b,a.datepickerMode=i.modes[i.modes.indexOf(a.datepickerMode)-1]},a.move=function(a){var b=i.activeDate.getFullYear()+a*(i.step.years||0),c=i.activeDate.getMonth()+a*(i.step.months||0);i.activeDate.setFullYear(b,c,1),i.refreshView()},a.toggleMode=function(b){b=b||1,a.datepickerMode===i.maxMode&&1===b||a.datepickerMode===i.minMode&&-1===b||(a.datepickerMode=i.modes[i.modes.indexOf(a.datepickerMode)+b])},a.keys={13:"enter",32:"space",33:"pageup",34:"pagedown",35:"end",36:"home",37:"left",38:"up",39:"right",40:"down"};var k=function(){e(function(){i.element[0].focus()},0,!1)};a.$on("datepicker.focus",k),a.keydown=function(b){var c=a.keys[b.which];if(c&&!b.shiftKey&&!b.altKey)if(b.preventDefault(),b.stopPropagation(),"enter"===c||"space"===c){if(i.isDisabled(i.activeDate))return;a.select(i.activeDate),k()}else!b.ctrlKey||"up"!==c&&"down"!==c?(i.handleKeyDown(c,b),i.refreshView()):(a.toggleMode("up"===c?1:-1),k())}}]).directive("datepicker",function(){return{restrict:"EA",replace:!0,templateUrl:"template/datepicker/datepicker.html",scope:{datepickerMode:"=?",dateDisabled:"&"},require:["datepicker","?^ngModel"],controller:"DatepickerController",link:function(a,b,c,d){var e=d[0],f=d[1];f&&e.init(f)}}}).directive("daypicker",["dateFilter",function(a){return{restrict:"EA",replace:!0,templateUrl:"template/datepicker/day.html",require:"^datepicker",link:function(b,c,d,e){function f(a,b){return 1!==b||a%4!==0||a%100===0&&a%400!==0?i[b]:29}function g(a,b){var c=new Array(b),d=new Date(a),e=0;for(d.setHours(12);b>e;)c[e++]=new Date(d),d.setDate(d.getDate()+1);return c}function h(a){var b=new Date(a);b.setDate(b.getDate()+4-(b.getDay()||7));var c=b.getTime();return b.setMonth(0),b.setDate(1),Math.floor(Math.round((c-b)/864e5)/7)+1}b.showWeeks=e.showWeeks,e.step={months:1},e.element=c;var i=[31,28,31,30,31,30,31,31,30,31,30,31];e._refreshView=function(){var c=e.activeDate.getFullYear(),d=e.activeDate.getMonth(),f=new Date(c,d,1),i=e.startingDay-f.getDay(),j=i>0?7-i:-i,k=new Date(f);j>0&&k.setDate(-j+1);for(var l=g(k,42),m=0;42>m;m++)l[m]=angular.extend(e.createDateObject(l[m],e.formatDay),{secondary:l[m].getMonth()!==d,uid:b.uniqueId+"-"+m});b.labels=new Array(7);for(var n=0;7>n;n++)b.labels[n]={abbr:a(l[n].date,e.formatDayHeader),full:a(l[n].date,"EEEE")};if(b.title=a(e.activeDate,e.formatDayTitle),b.rows=e.split(l,7),b.showWeeks){b.weekNumbers=[];for(var o=h(b.rows[0][0].date),p=b.rows.length;b.weekNumbers.push(o++)<p;);}},e.compare=function(a,b){return new Date(a.getFullYear(),a.getMonth(),a.getDate())-new Date(b.getFullYear(),b.getMonth(),b.getDate())},e.handleKeyDown=function(a){var b=e.activeDate.getDate();if("left"===a)b-=1;else if("up"===a)b-=7;else if("right"===a)b+=1;else if("down"===a)b+=7;else if("pageup"===a||"pagedown"===a){var c=e.activeDate.getMonth()+("pageup"===a?-1:1);e.activeDate.setMonth(c,1),b=Math.min(f(e.activeDate.getFullYear(),e.activeDate.getMonth()),b)}else"home"===a?b=1:"end"===a&&(b=f(e.activeDate.getFullYear(),e.activeDate.getMonth()));e.activeDate.setDate(b)},e.refreshView()}}}]).directive("monthpicker",["dateFilter",function(a){return{restrict:"EA",replace:!0,templateUrl:"template/datepicker/month.html",require:"^datepicker",link:function(b,c,d,e){e.step={years:1},e.element=c,e._refreshView=function(){for(var c=new Array(12),d=e.activeDate.getFullYear(),f=0;12>f;f++)c[f]=angular.extend(e.createDateObject(new Date(d,f,1),e.formatMonth),{uid:b.uniqueId+"-"+f});b.title=a(e.activeDate,e.formatMonthTitle),b.rows=e.split(c,3)},e.compare=function(a,b){return new Date(a.getFullYear(),a.getMonth())-new Date(b.getFullYear(),b.getMonth())},e.handleKeyDown=function(a){var b=e.activeDate.getMonth();if("left"===a)b-=1;else if("up"===a)b-=3;else if("right"===a)b+=1;else if("down"===a)b+=3;else if("pageup"===a||"pagedown"===a){var c=e.activeDate.getFullYear()+("pageup"===a?-1:1);e.activeDate.setFullYear(c)}else"home"===a?b=0:"end"===a&&(b=11);e.activeDate.setMonth(b)},e.refreshView()}}}]).directive("yearpicker",["dateFilter",function(){return{restrict:"EA",replace:!0,templateUrl:"template/datepicker/year.html",require:"^datepicker",link:function(a,b,c,d){function e(a){return parseInt((a-1)/f,10)*f+1}var f=d.yearRange;d.step={years:f},d.element=b,d._refreshView=function(){for(var b=new Array(f),c=0,g=e(d.activeDate.getFullYear());f>c;c++)b[c]=angular.extend(d.createDateObject(new Date(g+c,0,1),d.formatYear),{uid:a.uniqueId+"-"+c});a.title=[b[0].label,b[f-1].label].join(" - "),a.rows=d.split(b,5)},d.compare=function(a,b){return a.getFullYear()-b.getFullYear()},d.handleKeyDown=function(a){var b=d.activeDate.getFullYear();"left"===a?b-=1:"up"===a?b-=5:"right"===a?b+=1:"down"===a?b+=5:"pageup"===a||"pagedown"===a?b+=("pageup"===a?-1:1)*d.step.years:"home"===a?b=e(d.activeDate.getFullYear()):"end"===a&&(b=e(d.activeDate.getFullYear())+f-1),d.activeDate.setFullYear(b)},d.refreshView()}}}]).constant("datepickerPopupConfig",{datepickerPopup:"yyyy-MM-dd",currentText:"Today",clearText:"Clear",closeText:"Done",closeOnDateSelection:!0,appendToBody:!1,showButtonBar:!0}).directive("datepickerPopup",["$compile","$parse","$document","$position","dateFilter","dateParser","datepickerPopupConfig",function(a,b,c,d,e,f,g){return{restrict:"EA",require:"ngModel",scope:{isOpen:"=?",currentText:"@",clearText:"@",closeText:"@",dateDisabled:"&"},link:function(h,i,j,k){function l(a){return a.replace(/([A-Z])/g,function(a){return"-"+a.toLowerCase()})}function m(a){if(a){if(angular.isDate(a)&&!isNaN(a))return k.$setValidity("date",!0),a;if(angular.isString(a)){var b=f.parse(a,n)||new Date(a);return isNaN(b)?void k.$setValidity("date",!1):(k.$setValidity("date",!0),b)}return void k.$setValidity("date",!1)}return k.$setValidity("date",!0),null}var n,o=angular.isDefined(j.closeOnDateSelection)?h.$parent.$eval(j.closeOnDateSelection):g.closeOnDateSelection,p=angular.isDefined(j.datepickerAppendToBody)?h.$parent.$eval(j.datepickerAppendToBody):g.appendToBody;h.showButtonBar=angular.isDefined(j.showButtonBar)?h.$parent.$eval(j.showButtonBar):g.showButtonBar,h.getText=function(a){return h[a+"Text"]||g[a+"Text"]},j.$observe("datepickerPopup",function(a){n=a||g.datepickerPopup,k.$render()});var q=angular.element("<div datepicker-popup-wrap><div datepicker></div></div>");q.attr({"ng-model":"date","ng-change":"dateSelection()"});var r=angular.element(q.children()[0]);j.datepickerOptions&&angular.forEach(h.$parent.$eval(j.datepickerOptions),function(a,b){r.attr(l(b),a)}),angular.forEach(["minDate","maxDate"],function(a){j[a]&&(h.$parent.$watch(b(j[a]),function(b){h[a]=b}),r.attr(l(a),a))}),j.dateDisabled&&r.attr("date-disabled","dateDisabled({ date: date, mode: mode })"),k.$parsers.unshift(m),h.dateSelection=function(a){angular.isDefined(a)&&(h.date=a),k.$setViewValue(h.date),k.$render(),o&&(h.isOpen=!1,i[0].focus())},i.bind("input change keyup",function(){h.$apply(function(){h.date=k.$modelValue})}),k.$render=function(){var a=k.$viewValue?e(k.$viewValue,n):"";i.val(a),h.date=m(k.$modelValue)};var s=function(a){h.isOpen&&a.target!==i[0]&&h.$apply(function(){h.isOpen=!1})},t=function(a){h.keydown(a)};i.bind("keydown",t),h.keydown=function(a){27===a.which?(a.preventDefault(),a.stopPropagation(),h.close()):40!==a.which||h.isOpen||(h.isOpen=!0)},h.$watch("isOpen",function(a){a?(h.$broadcast("datepicker.focus"),h.position=p?d.offset(i):d.position(i),h.position.top=h.position.top+i.prop("offsetHeight"),c.bind("click",s)):c.unbind("click",s)}),h.select=function(a){if("today"===a){var b=new Date;angular.isDate(k.$modelValue)?(a=new Date(k.$modelValue),a.setFullYear(b.getFullYear(),b.getMonth(),b.getDate())):a=new Date(b.setHours(0,0,0,0))}h.dateSelection(a)},h.close=function(){h.isOpen=!1,i[0].focus()};var u=a(q)(h);p?c.find("body").append(u):i.after(u),h.$on("$destroy",function(){u.remove(),i.unbind("keydown",t),c.unbind("click",s)})}}}]).directive("datepickerPopupWrap",function(){return{restrict:"EA",replace:!0,transclude:!0,templateUrl:"template/datepicker/popup.html",link:function(a,b){b.bind("click",function(a){a.preventDefault(),a.stopPropagation()})}}}),angular.module("ui.bootstrap.dropdown",[]).constant("dropdownConfig",{openClass:"open"}).service("dropdownService",["$document",function(a){var b=null;this.open=function(e){b||(a.bind("click",c),a.bind("keydown",d)),b&&b!==e&&(b.isOpen=!1),b=e},this.close=function(e){b===e&&(b=null,a.unbind("click",c),a.unbind("keydown",d))};var c=function(a){a&&a.isDefaultPrevented()||b.$apply(function(){b.isOpen=!1})},d=function(a){27===a.which&&(b.focusToggleElement(),c())}}]).controller("DropdownController",["$scope","$attrs","$parse","dropdownConfig","dropdownService","$animate",function(a,b,c,d,e,f){var g,h=this,i=a.$new(),j=d.openClass,k=angular.noop,l=b.onToggle?c(b.onToggle):angular.noop;this.init=function(d){h.$element=d,b.isOpen&&(g=c(b.isOpen),k=g.assign,a.$watch(g,function(a){i.isOpen=!!a}))},this.toggle=function(a){return i.isOpen=arguments.length?!!a:!i.isOpen},this.isOpen=function(){return i.isOpen},i.focusToggleElement=function(){h.toggleElement&&h.toggleElement[0].focus()},i.$watch("isOpen",function(b,c){f[b?"addClass":"removeClass"](h.$element,j),b?(i.focusToggleElement(),e.open(i)):e.close(i),k(a,b),angular.isDefined(b)&&b!==c&&l(a,{open:!!b})}),a.$on("$locationChangeSuccess",function(){i.isOpen=!1}),a.$on("$destroy",function(){i.$destroy()})}]).directive("dropdown",function(){return{restrict:"CA",controller:"DropdownController",link:function(a,b,c,d){d.init(b)}}}).directive("dropdownToggle",function(){return{restrict:"CA",require:"?^dropdown",link:function(a,b,c,d){if(d){d.toggleElement=b;var e=function(e){e.preventDefault(),b.hasClass("disabled")||c.disabled||a.$apply(function(){d.toggle()})};b.bind("click",e),b.attr({"aria-haspopup":!0,"aria-expanded":!1}),a.$watch(d.isOpen,function(a){b.attr("aria-expanded",!!a)}),a.$on("$destroy",function(){b.unbind("click",e)})}}}}),angular.module("ui.bootstrap.modal",["ui.bootstrap.transition"]).factory("$$stackedMap",function(){return{createNew:function(){var a=[];return{add:function(b,c){a.push({key:b,value:c})},get:function(b){for(var c=0;c<a.length;c++)if(b==a[c].key)return a[c]},keys:function(){for(var b=[],c=0;c<a.length;c++)b.push(a[c].key);return b},top:function(){return a[a.length-1]},remove:function(b){for(var c=-1,d=0;d<a.length;d++)if(b==a[d].key){c=d;break}return a.splice(c,1)[0]},removeTop:function(){return a.splice(a.length-1,1)[0]},length:function(){return a.length}}}}}).directive("modalBackdrop",["$timeout",function(a){return{restrict:"EA",replace:!0,templateUrl:"template/modal/backdrop.html",link:function(b){b.animate=!1,a(function(){b.animate=!0})}}}]).directive("modalWindow",["$modalStack","$timeout",function(a,b){return{restrict:"EA",scope:{index:"@",animate:"="},replace:!0,transclude:!0,templateUrl:function(a,b){return b.templateUrl||"template/modal/window.html"},link:function(c,d,e){d.addClass(e.windowClass||""),c.size=e.size,b(function(){c.animate=!0,d[0].focus()}),c.close=function(b){var c=a.getTop();c&&c.value.backdrop&&"static"!=c.value.backdrop&&b.target===b.currentTarget&&(b.preventDefault(),b.stopPropagation(),a.dismiss(c.key,"backdrop click"))}}}}]).factory("$modalStack",["$transition","$timeout","$document","$compile","$rootScope","$$stackedMap",function(a,b,c,d,e,f){function g(){for(var a=-1,b=n.keys(),c=0;c<b.length;c++)n.get(b[c]).value.backdrop&&(a=c);return a}function h(a){var b=c.find("body").eq(0),d=n.get(a).value;n.remove(a),j(d.modalDomEl,d.modalScope,300,function(){d.modalScope.$destroy(),b.toggleClass(m,n.length()>0),i()})}function i(){if(k&&-1==g()){var a=l;j(k,l,150,function(){a.$destroy(),a=null}),k=void 0,l=void 0}}function j(c,d,e,f){function g(){g.done||(g.done=!0,c.remove(),f&&f())}d.animate=!1;var h=a.transitionEndEventName;if(h){var i=b(g,e);c.bind(h,function(){b.cancel(i),g(),d.$apply()})}else b(g,0)}var k,l,m="modal-open",n=f.createNew(),o={};return e.$watch(g,function(a){l&&(l.index=a)}),c.bind("keydown",function(a){var b;27===a.which&&(b=n.top(),b&&b.value.keyboard&&(a.preventDefault(),e.$apply(function(){o.dismiss(b.key,"escape key press")})))}),o.open=function(a,b){n.add(a,{deferred:b.deferred,modalScope:b.scope,backdrop:b.backdrop,keyboard:b.keyboard});var f=c.find("body").eq(0),h=g();h>=0&&!k&&(l=e.$new(!0),l.index=h,k=d("<div modal-backdrop></div>")(l),f.append(k));var i=angular.element("<div modal-window></div>");i.attr({"template-url":b.windowTemplateUrl,"window-class":b.windowClass,size:b.size,index:n.length()-1,animate:"animate"}).html(b.content);var j=d(i)(b.scope);n.top().value.modalDomEl=j,f.append(j),f.addClass(m)},o.close=function(a,b){var c=n.get(a).value;c&&(c.deferred.resolve(b),h(a))},o.dismiss=function(a,b){var c=n.get(a).value;c&&(c.deferred.reject(b),h(a))},o.dismissAll=function(a){for(var b=this.getTop();b;)this.dismiss(b.key,a),b=this.getTop()},o.getTop=function(){return n.top()},o}]).provider("$modal",function(){var a={options:{backdrop:!0,keyboard:!0},$get:["$injector","$rootScope","$q","$http","$templateCache","$controller","$modalStack",function(b,c,d,e,f,g,h){function i(a){return a.template?d.when(a.template):e.get(a.templateUrl,{cache:f}).then(function(a){return a.data})}function j(a){var c=[];return angular.forEach(a,function(a){(angular.isFunction(a)||angular.isArray(a))&&c.push(d.when(b.invoke(a)))}),c}var k={};return k.open=function(b){var e=d.defer(),f=d.defer(),k={result:e.promise,opened:f.promise,close:function(a){h.close(k,a)},dismiss:function(a){h.dismiss(k,a)}};if(b=angular.extend({},a.options,b),b.resolve=b.resolve||{},!b.template&&!b.templateUrl)throw new Error("One of template or templateUrl options is required.");var l=d.all([i(b)].concat(j(b.resolve)));return l.then(function(a){var d=(b.scope||c).$new();d.$close=k.close,d.$dismiss=k.dismiss;var f,i={},j=1;b.controller&&(i.$scope=d,i.$modalInstance=k,angular.forEach(b.resolve,function(b,c){i[c]=a[j++]}),f=g(b.controller,i)),h.open(k,{scope:d,deferred:e,content:a[0],backdrop:b.backdrop,keyboard:b.keyboard,windowClass:b.windowClass,windowTemplateUrl:b.windowTemplateUrl,size:b.size})},function(a){e.reject(a)}),l.then(function(){f.resolve(!0)},function(){f.reject(!1)}),k},k}]};return a}),angular.module("ui.bootstrap.pagination",[]).controller("PaginationController",["$scope","$attrs","$parse",function(a,b,c){var d=this,e={$setViewValue:angular.noop},f=b.numPages?c(b.numPages).assign:angular.noop;this.init=function(f,g){e=f,this.config=g,e.$render=function(){d.render()},b.itemsPerPage?a.$parent.$watch(c(b.itemsPerPage),function(b){d.itemsPerPage=parseInt(b,10),a.totalPages=d.calculateTotalPages()}):this.itemsPerPage=g.itemsPerPage},this.calculateTotalPages=function(){var b=this.itemsPerPage<1?1:Math.ceil(a.totalItems/this.itemsPerPage);return Math.max(b||0,1)},this.render=function(){a.page=parseInt(e.$viewValue,10)||1},a.selectPage=function(b){a.page!==b&&b>0&&b<=a.totalPages&&(e.$setViewValue(b),e.$render())},a.getText=function(b){return a[b+"Text"]||d.config[b+"Text"]},a.noPrevious=function(){return 1===a.page},a.noNext=function(){return a.page===a.totalPages},a.$watch("totalItems",function(){a.totalPages=d.calculateTotalPages()}),a.$watch("totalPages",function(b){f(a.$parent,b),a.page>b?a.selectPage(b):e.$render()})}]).constant("paginationConfig",{itemsPerPage:10,boundaryLinks:!1,directionLinks:!0,firstText:"First",previousText:"Previous",nextText:"Next",lastText:"Last",rotate:!0}).directive("pagination",["$parse","paginationConfig",function(a,b){return{restrict:"EA",scope:{totalItems:"=",firstText:"@",previousText:"@",nextText:"@",lastText:"@"},require:["pagination","?ngModel"],controller:"PaginationController",templateUrl:"template/pagination/pagination.html",replace:!0,link:function(c,d,e,f){function g(a,b,c){return{number:a,text:b,active:c}}function h(a,b){var c=[],d=1,e=b,f=angular.isDefined(k)&&b>k;f&&(l?(d=Math.max(a-Math.floor(k/2),1),e=d+k-1,e>b&&(e=b,d=e-k+1)):(d=(Math.ceil(a/k)-1)*k+1,e=Math.min(d+k-1,b)));for(var h=d;e>=h;h++){var i=g(h,h,h===a);c.push(i)}if(f&&!l){if(d>1){var j=g(d-1,"...",!1);c.unshift(j)}if(b>e){var m=g(e+1,"...",!1);c.push(m)}}return c}var i=f[0],j=f[1];if(j){var k=angular.isDefined(e.maxSize)?c.$parent.$eval(e.maxSize):b.maxSize,l=angular.isDefined(e.rotate)?c.$parent.$eval(e.rotate):b.rotate;c.boundaryLinks=angular.isDefined(e.boundaryLinks)?c.$parent.$eval(e.boundaryLinks):b.boundaryLinks,c.directionLinks=angular.isDefined(e.directionLinks)?c.$parent.$eval(e.directionLinks):b.directionLinks,i.init(j,b),e.maxSize&&c.$parent.$watch(a(e.maxSize),function(a){k=parseInt(a,10),i.render()});var m=i.render;i.render=function(){m(),c.page>0&&c.page<=c.totalPages&&(c.pages=h(c.page,c.totalPages))}}}}}]).constant("pagerConfig",{itemsPerPage:10,previousText:"« Previous",nextText:"Next »",align:!0}).directive("pager",["pagerConfig",function(a){return{restrict:"EA",scope:{totalItems:"=",previousText:"@",nextText:"@"},require:["pager","?ngModel"],controller:"PaginationController",templateUrl:"template/pagination/pager.html",replace:!0,link:function(b,c,d,e){var f=e[0],g=e[1];g&&(b.align=angular.isDefined(d.align)?b.$parent.$eval(d.align):a.align,f.init(g,a))}}}]),angular.module("ui.bootstrap.tooltip",["ui.bootstrap.position","ui.bootstrap.bindHtml"]).provider("$tooltip",function(){function a(a){var b=/[A-Z]/g,c="-"; -return a.replace(b,function(a,b){return(b?c:"")+a.toLowerCase()})}var b={placement:"top",animation:!0,popupDelay:0},c={mouseenter:"mouseleave",click:"click",focus:"blur"},d={};this.options=function(a){angular.extend(d,a)},this.setTriggers=function(a){angular.extend(c,a)},this.$get=["$window","$compile","$timeout","$parse","$document","$position","$interpolate",function(e,f,g,h,i,j,k){return function(e,l,m){function n(a){var b=a||o.trigger||m,d=c[b]||b;return{show:b,hide:d}}var o=angular.extend({},b,d),p=a(e),q=k.startSymbol(),r=k.endSymbol(),s="<div "+p+'-popup title="'+q+"tt_title"+r+'" content="'+q+"tt_content"+r+'" placement="'+q+"tt_placement"+r+'" animation="tt_animation" is-open="tt_isOpen"></div>';return{restrict:"EA",scope:!0,compile:function(){var a=f(s);return function(b,c,d){function f(){b.tt_isOpen?m():k()}function k(){(!y||b.$eval(d[l+"Enable"]))&&(b.tt_popupDelay?v||(v=g(p,b.tt_popupDelay,!1),v.then(function(a){a()})):p()())}function m(){b.$apply(function(){q()})}function p(){return v=null,u&&(g.cancel(u),u=null),b.tt_content?(r(),t.css({top:0,left:0,display:"block"}),w?i.find("body").append(t):c.after(t),z(),b.tt_isOpen=!0,b.$digest(),z):angular.noop}function q(){b.tt_isOpen=!1,g.cancel(v),v=null,b.tt_animation?u||(u=g(s,500)):s()}function r(){t&&s(),t=a(b,function(){}),b.$digest()}function s(){u=null,t&&(t.remove(),t=null)}var t,u,v,w=angular.isDefined(o.appendToBody)?o.appendToBody:!1,x=n(void 0),y=angular.isDefined(d[l+"Enable"]),z=function(){var a=j.positionElements(c,t,b.tt_placement,w);a.top+="px",a.left+="px",t.css(a)};b.tt_isOpen=!1,d.$observe(e,function(a){b.tt_content=a,!a&&b.tt_isOpen&&q()}),d.$observe(l+"Title",function(a){b.tt_title=a}),d.$observe(l+"Placement",function(a){b.tt_placement=angular.isDefined(a)?a:o.placement}),d.$observe(l+"PopupDelay",function(a){var c=parseInt(a,10);b.tt_popupDelay=isNaN(c)?o.popupDelay:c});var A=function(){c.unbind(x.show,k),c.unbind(x.hide,m)};d.$observe(l+"Trigger",function(a){A(),x=n(a),x.show===x.hide?c.bind(x.show,f):(c.bind(x.show,k),c.bind(x.hide,m))});var B=b.$eval(d[l+"Animation"]);b.tt_animation=angular.isDefined(B)?!!B:o.animation,d.$observe(l+"AppendToBody",function(a){w=angular.isDefined(a)?h(a)(b):w}),w&&b.$on("$locationChangeSuccess",function(){b.tt_isOpen&&q()}),b.$on("$destroy",function(){g.cancel(u),g.cancel(v),A(),s()})}}}}}]}).directive("tooltipPopup",function(){return{restrict:"EA",replace:!0,scope:{content:"@",placement:"@",animation:"&",isOpen:"&"},templateUrl:"template/tooltip/tooltip-popup.html"}}).directive("tooltip",["$tooltip",function(a){return a("tooltip","tooltip","mouseenter")}]).directive("tooltipHtmlUnsafePopup",function(){return{restrict:"EA",replace:!0,scope:{content:"@",placement:"@",animation:"&",isOpen:"&"},templateUrl:"template/tooltip/tooltip-html-unsafe-popup.html"}}).directive("tooltipHtmlUnsafe",["$tooltip",function(a){return a("tooltipHtmlUnsafe","tooltip","mouseenter")}]),angular.module("ui.bootstrap.popover",["ui.bootstrap.tooltip"]).directive("popoverPopup",function(){return{restrict:"EA",replace:!0,scope:{title:"@",content:"@",placement:"@",animation:"&",isOpen:"&"},templateUrl:"template/popover/popover.html"}}).directive("popover",["$tooltip",function(a){return a("popover","popover","click")}]),angular.module("ui.bootstrap.progressbar",[]).constant("progressConfig",{animate:!0,max:100}).controller("ProgressController",["$scope","$attrs","progressConfig",function(a,b,c){var d=this,e=angular.isDefined(b.animate)?a.$parent.$eval(b.animate):c.animate;this.bars=[],a.max=angular.isDefined(b.max)?a.$parent.$eval(b.max):c.max,this.addBar=function(b,c){e||c.css({transition:"none"}),this.bars.push(b),b.$watch("value",function(c){b.percent=+(100*c/a.max).toFixed(2)}),b.$on("$destroy",function(){c=null,d.removeBar(b)})},this.removeBar=function(a){this.bars.splice(this.bars.indexOf(a),1)}}]).directive("progress",function(){return{restrict:"EA",replace:!0,transclude:!0,controller:"ProgressController",require:"progress",scope:{},templateUrl:"template/progressbar/progress.html"}}).directive("bar",function(){return{restrict:"EA",replace:!0,transclude:!0,require:"^progress",scope:{value:"=",type:"@"},templateUrl:"template/progressbar/bar.html",link:function(a,b,c,d){d.addBar(a,b)}}}).directive("progressbar",function(){return{restrict:"EA",replace:!0,transclude:!0,controller:"ProgressController",scope:{value:"=",type:"@"},templateUrl:"template/progressbar/progressbar.html",link:function(a,b,c,d){d.addBar(a,angular.element(b.children()[0]))}}}),angular.module("ui.bootstrap.rating",[]).constant("ratingConfig",{max:5,stateOn:null,stateOff:null}).controller("RatingController",["$scope","$attrs","ratingConfig",function(a,b,c){var d={$setViewValue:angular.noop};this.init=function(e){d=e,d.$render=this.render,this.stateOn=angular.isDefined(b.stateOn)?a.$parent.$eval(b.stateOn):c.stateOn,this.stateOff=angular.isDefined(b.stateOff)?a.$parent.$eval(b.stateOff):c.stateOff;var f=angular.isDefined(b.ratingStates)?a.$parent.$eval(b.ratingStates):new Array(angular.isDefined(b.max)?a.$parent.$eval(b.max):c.max);a.range=this.buildTemplateObjects(f)},this.buildTemplateObjects=function(a){for(var b=0,c=a.length;c>b;b++)a[b]=angular.extend({index:b},{stateOn:this.stateOn,stateOff:this.stateOff},a[b]);return a},a.rate=function(b){!a.readonly&&b>=0&&b<=a.range.length&&(d.$setViewValue(b),d.$render())},a.enter=function(b){a.readonly||(a.value=b),a.onHover({value:b})},a.reset=function(){a.value=d.$viewValue,a.onLeave()},a.onKeydown=function(b){/(37|38|39|40)/.test(b.which)&&(b.preventDefault(),b.stopPropagation(),a.rate(a.value+(38===b.which||39===b.which?1:-1)))},this.render=function(){a.value=d.$viewValue}}]).directive("rating",function(){return{restrict:"EA",require:["rating","ngModel"],scope:{readonly:"=?",onHover:"&",onLeave:"&"},controller:"RatingController",templateUrl:"template/rating/rating.html",replace:!0,link:function(a,b,c,d){var e=d[0],f=d[1];f&&e.init(f)}}}),angular.module("ui.bootstrap.tabs",[]).controller("TabsetController",["$scope",function(a){var b=this,c=b.tabs=a.tabs=[];b.select=function(a){angular.forEach(c,function(b){b.active&&b!==a&&(b.active=!1,b.onDeselect())}),a.active=!0,a.onSelect()},b.addTab=function(a){c.push(a),1===c.length?a.active=!0:a.active&&b.select(a)},b.removeTab=function(a){var d=c.indexOf(a);if(a.active&&c.length>1){var e=d==c.length-1?d-1:d+1;b.select(c[e])}c.splice(d,1)}}]).directive("tabset",function(){return{restrict:"EA",transclude:!0,replace:!0,scope:{type:"@"},controller:"TabsetController",templateUrl:"template/tabs/tabset.html",link:function(a,b,c){a.vertical=angular.isDefined(c.vertical)?a.$parent.$eval(c.vertical):!1,a.justified=angular.isDefined(c.justified)?a.$parent.$eval(c.justified):!1}}}).directive("tab",["$parse",function(a){return{require:"^tabset",restrict:"EA",replace:!0,templateUrl:"template/tabs/tab.html",transclude:!0,scope:{active:"=?",heading:"@",onSelect:"&select",onDeselect:"&deselect"},controller:function(){},compile:function(b,c,d){return function(b,c,e,f){b.$watch("active",function(a){a&&f.select(b)}),b.disabled=!1,e.disabled&&b.$parent.$watch(a(e.disabled),function(a){b.disabled=!!a}),b.select=function(){b.disabled||(b.active=!0)},f.addTab(b),b.$on("$destroy",function(){f.removeTab(b)}),b.$transcludeFn=d}}}}]).directive("tabHeadingTransclude",[function(){return{restrict:"A",require:"^tab",link:function(a,b){a.$watch("headingElement",function(a){a&&(b.html(""),b.append(a))})}}}]).directive("tabContentTransclude",function(){function a(a){return a.tagName&&(a.hasAttribute("tab-heading")||a.hasAttribute("data-tab-heading")||"tab-heading"===a.tagName.toLowerCase()||"data-tab-heading"===a.tagName.toLowerCase())}return{restrict:"A",require:"^tabset",link:function(b,c,d){var e=b.$eval(d.tabContentTransclude);e.$transcludeFn(e.$parent,function(b){angular.forEach(b,function(b){a(b)?e.headingElement=b:c.append(b)})})}}}),angular.module("ui.bootstrap.timepicker",[]).constant("timepickerConfig",{hourStep:1,minuteStep:1,showMeridian:!0,meridians:null,readonlyInput:!1,mousewheel:!0}).controller("TimepickerController",["$scope","$attrs","$parse","$log","$locale","timepickerConfig",function(a,b,c,d,e,f){function g(){var b=parseInt(a.hours,10),c=a.showMeridian?b>0&&13>b:b>=0&&24>b;return c?(a.showMeridian&&(12===b&&(b=0),a.meridian===p[1]&&(b+=12)),b):void 0}function h(){var b=parseInt(a.minutes,10);return b>=0&&60>b?b:void 0}function i(a){return angular.isDefined(a)&&a.toString().length<2?"0"+a:a}function j(a){k(),o.$setViewValue(new Date(n)),l(a)}function k(){o.$setValidity("time",!0),a.invalidHours=!1,a.invalidMinutes=!1}function l(b){var c=n.getHours(),d=n.getMinutes();a.showMeridian&&(c=0===c||12===c?12:c%12),a.hours="h"===b?c:i(c),a.minutes="m"===b?d:i(d),a.meridian=n.getHours()<12?p[0]:p[1]}function m(a){var b=new Date(n.getTime()+6e4*a);n.setHours(b.getHours(),b.getMinutes()),j()}var n=new Date,o={$setViewValue:angular.noop},p=angular.isDefined(b.meridians)?a.$parent.$eval(b.meridians):f.meridians||e.DATETIME_FORMATS.AMPMS;this.init=function(c,d){o=c,o.$render=this.render;var e=d.eq(0),g=d.eq(1),h=angular.isDefined(b.mousewheel)?a.$parent.$eval(b.mousewheel):f.mousewheel;h&&this.setupMousewheelEvents(e,g),a.readonlyInput=angular.isDefined(b.readonlyInput)?a.$parent.$eval(b.readonlyInput):f.readonlyInput,this.setupInputEvents(e,g)};var q=f.hourStep;b.hourStep&&a.$parent.$watch(c(b.hourStep),function(a){q=parseInt(a,10)});var r=f.minuteStep;b.minuteStep&&a.$parent.$watch(c(b.minuteStep),function(a){r=parseInt(a,10)}),a.showMeridian=f.showMeridian,b.showMeridian&&a.$parent.$watch(c(b.showMeridian),function(b){if(a.showMeridian=!!b,o.$error.time){var c=g(),d=h();angular.isDefined(c)&&angular.isDefined(d)&&(n.setHours(c),j())}else l()}),this.setupMousewheelEvents=function(b,c){var d=function(a){a.originalEvent&&(a=a.originalEvent);var b=a.wheelDelta?a.wheelDelta:-a.deltaY;return a.detail||b>0};b.bind("mousewheel wheel",function(b){a.$apply(d(b)?a.incrementHours():a.decrementHours()),b.preventDefault()}),c.bind("mousewheel wheel",function(b){a.$apply(d(b)?a.incrementMinutes():a.decrementMinutes()),b.preventDefault()})},this.setupInputEvents=function(b,c){if(a.readonlyInput)return a.updateHours=angular.noop,void(a.updateMinutes=angular.noop);var d=function(b,c){o.$setViewValue(null),o.$setValidity("time",!1),angular.isDefined(b)&&(a.invalidHours=b),angular.isDefined(c)&&(a.invalidMinutes=c)};a.updateHours=function(){var a=g();angular.isDefined(a)?(n.setHours(a),j("h")):d(!0)},b.bind("blur",function(){!a.invalidHours&&a.hours<10&&a.$apply(function(){a.hours=i(a.hours)})}),a.updateMinutes=function(){var a=h();angular.isDefined(a)?(n.setMinutes(a),j("m")):d(void 0,!0)},c.bind("blur",function(){!a.invalidMinutes&&a.minutes<10&&a.$apply(function(){a.minutes=i(a.minutes)})})},this.render=function(){var a=o.$modelValue?new Date(o.$modelValue):null;isNaN(a)?(o.$setValidity("time",!1),d.error('Timepicker directive: "ng-model" value must be a Date object, a number of milliseconds since 01.01.1970 or a string representing an RFC2822 or ISO 8601 date.')):(a&&(n=a),k(),l())},a.incrementHours=function(){m(60*q)},a.decrementHours=function(){m(60*-q)},a.incrementMinutes=function(){m(r)},a.decrementMinutes=function(){m(-r)},a.toggleMeridian=function(){m(720*(n.getHours()<12?1:-1))}}]).directive("timepicker",function(){return{restrict:"EA",require:["timepicker","?^ngModel"],controller:"TimepickerController",replace:!0,scope:{},templateUrl:"template/timepicker/timepicker.html",link:function(a,b,c,d){var e=d[0],f=d[1];f&&e.init(f,b.find("input"))}}}),angular.module("ui.bootstrap.typeahead",["ui.bootstrap.position","ui.bootstrap.bindHtml"]).factory("typeaheadParser",["$parse",function(a){var b=/^\s*(.*?)(?:\s+as\s+(.*?))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\s+(.*)$/;return{parse:function(c){var d=c.match(b);if(!d)throw new Error('Expected typeahead specification in form of "_modelValue_ (as _label_)? for _item_ in _collection_" but got "'+c+'".');return{itemName:d[3],source:a(d[4]),viewMapper:a(d[2]||d[1]),modelMapper:a(d[1])}}}}]).directive("typeahead",["$compile","$parse","$q","$timeout","$document","$position","typeaheadParser",function(a,b,c,d,e,f,g){var h=[9,13,27,38,40];return{require:"ngModel",link:function(i,j,k,l){var m,n=i.$eval(k.typeaheadMinLength)||1,o=i.$eval(k.typeaheadWaitMs)||0,p=i.$eval(k.typeaheadEditable)!==!1,q=b(k.typeaheadLoading).assign||angular.noop,r=b(k.typeaheadOnSelect),s=k.typeaheadInputFormatter?b(k.typeaheadInputFormatter):void 0,t=k.typeaheadAppendToBody?i.$eval(k.typeaheadAppendToBody):!1,u=b(k.ngModel).assign,v=g.parse(k.typeahead),w=i.$new();i.$on("$destroy",function(){w.$destroy()});var x="typeahead-"+w.$id+"-"+Math.floor(1e4*Math.random());j.attr({"aria-autocomplete":"list","aria-expanded":!1,"aria-owns":x});var y=angular.element("<div typeahead-popup></div>");y.attr({id:x,matches:"matches",active:"activeIdx",select:"select(activeIdx)",query:"query",position:"position"}),angular.isDefined(k.typeaheadTemplateUrl)&&y.attr("template-url",k.typeaheadTemplateUrl);var z=function(){w.matches=[],w.activeIdx=-1,j.attr("aria-expanded",!1)},A=function(a){return x+"-option-"+a};w.$watch("activeIdx",function(a){0>a?j.removeAttr("aria-activedescendant"):j.attr("aria-activedescendant",A(a))});var B=function(a){var b={$viewValue:a};q(i,!0),c.when(v.source(i,b)).then(function(c){var d=a===l.$viewValue;if(d&&m)if(c.length>0){w.activeIdx=0,w.matches.length=0;for(var e=0;e<c.length;e++)b[v.itemName]=c[e],w.matches.push({id:A(e),label:v.viewMapper(w,b),model:c[e]});w.query=a,w.position=t?f.offset(j):f.position(j),w.position.top=w.position.top+j.prop("offsetHeight"),j.attr("aria-expanded",!0)}else z();d&&q(i,!1)},function(){z(),q(i,!1)})};z(),w.query=void 0;var C;l.$parsers.unshift(function(a){return m=!0,a&&a.length>=n?o>0?(C&&d.cancel(C),C=d(function(){B(a)},o)):B(a):(q(i,!1),z()),p?a:a?void l.$setValidity("editable",!1):(l.$setValidity("editable",!0),a)}),l.$formatters.push(function(a){var b,c,d={};return s?(d.$model=a,s(i,d)):(d[v.itemName]=a,b=v.viewMapper(i,d),d[v.itemName]=void 0,c=v.viewMapper(i,d),b!==c?b:a)}),w.select=function(a){var b,c,e={};e[v.itemName]=c=w.matches[a].model,b=v.modelMapper(i,e),u(i,b),l.$setValidity("editable",!0),r(i,{$item:c,$model:b,$label:v.viewMapper(i,e)}),z(),d(function(){j[0].focus()},0,!1)},j.bind("keydown",function(a){0!==w.matches.length&&-1!==h.indexOf(a.which)&&(a.preventDefault(),40===a.which?(w.activeIdx=(w.activeIdx+1)%w.matches.length,w.$digest()):38===a.which?(w.activeIdx=(w.activeIdx?w.activeIdx:w.matches.length)-1,w.$digest()):13===a.which||9===a.which?w.$apply(function(){w.select(w.activeIdx)}):27===a.which&&(a.stopPropagation(),z(),w.$digest()))}),j.bind("blur",function(){m=!1});var D=function(a){j[0]!==a.target&&(z(),w.$digest())};e.bind("click",D),i.$on("$destroy",function(){e.unbind("click",D)});var E=a(y)(w);t?e.find("body").append(E):j.after(E)}}}]).directive("typeaheadPopup",function(){return{restrict:"EA",scope:{matches:"=",query:"=",active:"=",position:"=",select:"&"},replace:!0,templateUrl:"template/typeahead/typeahead-popup.html",link:function(a,b,c){a.templateUrl=c.templateUrl,a.isOpen=function(){return a.matches.length>0},a.isActive=function(b){return a.active==b},a.selectActive=function(b){a.active=b},a.selectMatch=function(b){a.select({activeIdx:b})}}}}).directive("typeaheadMatch",["$http","$templateCache","$compile","$parse",function(a,b,c,d){return{restrict:"EA",scope:{index:"=",match:"=",query:"="},link:function(e,f,g){var h=d(g.templateUrl)(e.$parent)||"template/typeahead/typeahead-match.html";a.get(h,{cache:b}).success(function(a){f.replaceWith(c(a.trim())(e))})}}}]).filter("typeaheadHighlight",function(){function a(a){return a.replace(/([.?*+^$[\]\\(){}|-])/g,"\\$1")}return function(b,c){return c?(""+b).replace(new RegExp(a(c),"gi"),"<strong>$&</strong>"):b}}),angular.module("template/accordion/accordion-group.html",[]).run(["$templateCache",function(a){a.put("template/accordion/accordion-group.html",'<div class="panel panel-default">\n <div class="panel-heading">\n <h4 class="panel-title">\n <a class="accordion-toggle" ng-click="toggleOpen()" accordion-transclude="heading"><span ng-class="{\'text-muted\': isDisabled}">{{heading}}</span></a>\n </h4>\n </div>\n <div class="panel-collapse" collapse="!isOpen">\n <div class="panel-body" ng-transclude></div>\n </div>\n</div>')}]),angular.module("template/accordion/accordion.html",[]).run(["$templateCache",function(a){a.put("template/accordion/accordion.html",'<div class="panel-group" ng-transclude></div>')}]),angular.module("template/alert/alert.html",[]).run(["$templateCache",function(a){a.put("template/alert/alert.html",'<div class="alert" ng-class="{\'alert-{{type || \'warning\'}}\': true, \'alert-dismissable\': closeable}" role="alert">\n <button ng-show="closeable" type="button" class="close" ng-click="close()">\n <span aria-hidden="true">×</span>\n <span class="sr-only">Close</span>\n </button>\n <div ng-transclude></div>\n</div>\n')}]),angular.module("template/carousel/carousel.html",[]).run(["$templateCache",function(a){a.put("template/carousel/carousel.html",'<div ng-mouseenter="pause()" ng-mouseleave="play()" class="carousel" ng-swipe-right="prev()" ng-swipe-left="next()">\n <ol class="carousel-indicators" ng-show="slides.length > 1">\n <li ng-repeat="slide in slides track by $index" ng-class="{active: isActive(slide)}" ng-click="select(slide)"></li>\n </ol>\n <div class="carousel-inner" ng-transclude></div>\n <a class="left carousel-control" ng-click="prev()" ng-show="slides.length > 1"><span class="glyphicon glyphicon-chevron-left"></span></a>\n <a class="right carousel-control" ng-click="next()" ng-show="slides.length > 1"><span class="glyphicon glyphicon-chevron-right"></span></a>\n</div>\n')}]),angular.module("template/carousel/slide.html",[]).run(["$templateCache",function(a){a.put("template/carousel/slide.html","<div ng-class=\"{\n 'active': leaving || (active && !entering),\n 'prev': (next || active) && direction=='prev',\n 'next': (next || active) && direction=='next',\n 'right': direction=='prev',\n 'left': direction=='next'\n }\" class=\"item text-center\" ng-transclude></div>\n")}]),angular.module("template/datepicker/datepicker.html",[]).run(["$templateCache",function(a){a.put("template/datepicker/datepicker.html",'<div ng-switch="datepickerMode" role="application" ng-keydown="keydown($event)">\n <daypicker ng-switch-when="day" tabindex="0"></daypicker>\n <monthpicker ng-switch-when="month" tabindex="0"></monthpicker>\n <yearpicker ng-switch-when="year" tabindex="0"></yearpicker>\n</div>')}]),angular.module("template/datepicker/day.html",[]).run(["$templateCache",function(a){a.put("template/datepicker/day.html",'<table role="grid" aria-labelledby="{{uniqueId}}-title" aria-activedescendant="{{activeDateId}}">\n <thead>\n <tr>\n <th><button type="button" class="btn btn-default btn-sm pull-left" ng-click="move(-1)" tabindex="-1"><i class="glyphicon glyphicon-chevron-left"></i></button></th>\n <th colspan="{{5 + showWeeks}}"><button id="{{uniqueId}}-title" role="heading" aria-live="assertive" aria-atomic="true" type="button" class="btn btn-default btn-sm" ng-click="toggleMode()" tabindex="-1" style="width:100%;"><strong>{{title}}</strong></button></th>\n <th><button type="button" class="btn btn-default btn-sm pull-right" ng-click="move(1)" tabindex="-1"><i class="glyphicon glyphicon-chevron-right"></i></button></th>\n </tr>\n <tr>\n <th ng-show="showWeeks" class="text-center"></th>\n <th ng-repeat="label in labels track by $index" class="text-center"><small aria-label="{{label.full}}">{{label.abbr}}</small></th>\n </tr>\n </thead>\n <tbody>\n <tr ng-repeat="row in rows track by $index">\n <td ng-show="showWeeks" class="text-center h6"><em>{{ weekNumbers[$index] }}</em></td>\n <td ng-repeat="dt in row track by dt.date" class="text-center" role="gridcell" id="{{dt.uid}}" aria-disabled="{{!!dt.disabled}}">\n <button type="button" style="width:100%;" class="btn btn-default btn-sm" ng-class="{\'btn-info\': dt.selected, active: isActive(dt)}" ng-click="select(dt.date)" ng-disabled="dt.disabled" tabindex="-1"><span ng-class="{\'text-muted\': dt.secondary, \'text-info\': dt.current}">{{dt.label}}</span></button>\n </td>\n </tr>\n </tbody>\n</table>\n')}]),angular.module("template/datepicker/month.html",[]).run(["$templateCache",function(a){a.put("template/datepicker/month.html",'<table role="grid" aria-labelledby="{{uniqueId}}-title" aria-activedescendant="{{activeDateId}}">\n <thead>\n <tr>\n <th><button type="button" class="btn btn-default btn-sm pull-left" ng-click="move(-1)" tabindex="-1"><i class="glyphicon glyphicon-chevron-left"></i></button></th>\n <th><button id="{{uniqueId}}-title" role="heading" aria-live="assertive" aria-atomic="true" type="button" class="btn btn-default btn-sm" ng-click="toggleMode()" tabindex="-1" style="width:100%;"><strong>{{title}}</strong></button></th>\n <th><button type="button" class="btn btn-default btn-sm pull-right" ng-click="move(1)" tabindex="-1"><i class="glyphicon glyphicon-chevron-right"></i></button></th>\n </tr>\n </thead>\n <tbody>\n <tr ng-repeat="row in rows track by $index">\n <td ng-repeat="dt in row track by dt.date" class="text-center" role="gridcell" id="{{dt.uid}}" aria-disabled="{{!!dt.disabled}}">\n <button type="button" style="width:100%;" class="btn btn-default" ng-class="{\'btn-info\': dt.selected, active: isActive(dt)}" ng-click="select(dt.date)" ng-disabled="dt.disabled" tabindex="-1"><span ng-class="{\'text-info\': dt.current}">{{dt.label}}</span></button>\n </td>\n </tr>\n </tbody>\n</table>\n')}]),angular.module("template/datepicker/popup.html",[]).run(["$templateCache",function(a){a.put("template/datepicker/popup.html",'<ul class="dropdown-menu" ng-style="{display: (isOpen && \'block\') || \'none\', top: position.top+\'px\', left: position.left+\'px\'}" ng-keydown="keydown($event)">\n <li ng-transclude></li>\n <li ng-if="showButtonBar" style="padding:10px 9px 2px">\n <span class="btn-group">\n <button type="button" class="btn btn-sm btn-info" ng-click="select(\'today\')">{{ getText(\'current\') }}</button>\n <button type="button" class="btn btn-sm btn-danger" ng-click="select(null)">{{ getText(\'clear\') }}</button>\n </span>\n <button type="button" class="btn btn-sm btn-success pull-right" ng-click="close()">{{ getText(\'close\') }}</button>\n </li>\n</ul>\n')}]),angular.module("template/datepicker/year.html",[]).run(["$templateCache",function(a){a.put("template/datepicker/year.html",'<table role="grid" aria-labelledby="{{uniqueId}}-title" aria-activedescendant="{{activeDateId}}">\n <thead>\n <tr>\n <th><button type="button" class="btn btn-default btn-sm pull-left" ng-click="move(-1)" tabindex="-1"><i class="glyphicon glyphicon-chevron-left"></i></button></th>\n <th colspan="3"><button id="{{uniqueId}}-title" role="heading" aria-live="assertive" aria-atomic="true" type="button" class="btn btn-default btn-sm" ng-click="toggleMode()" tabindex="-1" style="width:100%;"><strong>{{title}}</strong></button></th>\n <th><button type="button" class="btn btn-default btn-sm pull-right" ng-click="move(1)" tabindex="-1"><i class="glyphicon glyphicon-chevron-right"></i></button></th>\n </tr>\n </thead>\n <tbody>\n <tr ng-repeat="row in rows track by $index">\n <td ng-repeat="dt in row track by dt.date" class="text-center" role="gridcell" id="{{dt.uid}}" aria-disabled="{{!!dt.disabled}}">\n <button type="button" style="width:100%;" class="btn btn-default" ng-class="{\'btn-info\': dt.selected, active: isActive(dt)}" ng-click="select(dt.date)" ng-disabled="dt.disabled" tabindex="-1"><span ng-class="{\'text-info\': dt.current}">{{dt.label}}</span></button>\n </td>\n </tr>\n </tbody>\n</table>\n')}]),angular.module("template/modal/backdrop.html",[]).run(["$templateCache",function(a){a.put("template/modal/backdrop.html",'<div class="modal-backdrop fade"\n ng-class="{in: animate}"\n ng-style="{\'z-index\': 1040 + (index && 1 || 0) + index*10}"\n></div>\n')}]),angular.module("template/modal/window.html",[]).run(["$templateCache",function(a){a.put("template/modal/window.html",'<div tabindex="-1" role="dialog" class="modal fade" ng-class="{in: animate}" ng-style="{\'z-index\': 1050 + index*10, display: \'block\'}" ng-click="close($event)">\n <div class="modal-dialog" ng-class="{\'modal-sm\': size == \'sm\', \'modal-lg\': size == \'lg\'}"><div class="modal-content" ng-transclude></div></div>\n</div>')}]),angular.module("template/pagination/pager.html",[]).run(["$templateCache",function(a){a.put("template/pagination/pager.html",'<ul class="pager">\n <li ng-class="{disabled: noPrevious(), previous: align}"><a href ng-click="selectPage(page - 1)">{{getText(\'previous\')}}</a></li>\n <li ng-class="{disabled: noNext(), next: align}"><a href ng-click="selectPage(page + 1)">{{getText(\'next\')}}</a></li>\n</ul>')}]),angular.module("template/pagination/pagination.html",[]).run(["$templateCache",function(a){a.put("template/pagination/pagination.html",'<ul class="pagination">\n <li ng-if="boundaryLinks" ng-class="{disabled: noPrevious()}"><a href ng-click="selectPage(1)">{{getText(\'first\')}}</a></li>\n <li ng-if="directionLinks" ng-class="{disabled: noPrevious()}"><a href ng-click="selectPage(page - 1)">{{getText(\'previous\')}}</a></li>\n <li ng-repeat="page in pages track by $index" ng-class="{active: page.active}"><a href ng-click="selectPage(page.number)">{{page.text}}</a></li>\n <li ng-if="directionLinks" ng-class="{disabled: noNext()}"><a href ng-click="selectPage(page + 1)">{{getText(\'next\')}}</a></li>\n <li ng-if="boundaryLinks" ng-class="{disabled: noNext()}"><a href ng-click="selectPage(totalPages)">{{getText(\'last\')}}</a></li>\n</ul>')}]),angular.module("template/tooltip/tooltip-html-unsafe-popup.html",[]).run(["$templateCache",function(a){a.put("template/tooltip/tooltip-html-unsafe-popup.html",'<div class="tooltip {{placement}}" ng-class="{ in: isOpen(), fade: animation() }">\n <div class="tooltip-arrow"></div>\n <div class="tooltip-inner" bind-html-unsafe="content"></div>\n</div>\n')}]),angular.module("template/tooltip/tooltip-popup.html",[]).run(["$templateCache",function(a){a.put("template/tooltip/tooltip-popup.html",'<div class="tooltip {{placement}}" ng-class="{ in: isOpen(), fade: animation() }">\n <div class="tooltip-arrow"></div>\n <div class="tooltip-inner" ng-bind="content"></div>\n</div>\n')}]),angular.module("template/popover/popover.html",[]).run(["$templateCache",function(a){a.put("template/popover/popover.html",'<div class="popover {{placement}}" ng-class="{ in: isOpen(), fade: animation() }">\n <div class="arrow"></div>\n\n <div class="popover-inner">\n <h3 class="popover-title" ng-bind="title" ng-show="title"></h3>\n <div class="popover-content" ng-bind="content"></div>\n </div>\n</div>\n')}]),angular.module("template/progressbar/bar.html",[]).run(["$templateCache",function(a){a.put("template/progressbar/bar.html",'<div class="progress-bar" ng-class="type && \'progress-bar-\' + type" role="progressbar" aria-valuenow="{{value}}" aria-valuemin="0" aria-valuemax="{{max}}" ng-style="{width: percent + \'%\'}" aria-valuetext="{{percent | number:0}}%" ng-transclude></div>')}]),angular.module("template/progressbar/progress.html",[]).run(["$templateCache",function(a){a.put("template/progressbar/progress.html",'<div class="progress" ng-transclude></div>')}]),angular.module("template/progressbar/progressbar.html",[]).run(["$templateCache",function(a){a.put("template/progressbar/progressbar.html",'<div class="progress">\n <div class="progress-bar" ng-class="type && \'progress-bar-\' + type" role="progressbar" aria-valuenow="{{value}}" aria-valuemin="0" aria-valuemax="{{max}}" ng-style="{width: percent + \'%\'}" aria-valuetext="{{percent | number:0}}%" ng-transclude></div>\n</div>')}]),angular.module("template/rating/rating.html",[]).run(["$templateCache",function(a){a.put("template/rating/rating.html",'<span ng-mouseleave="reset()" ng-keydown="onKeydown($event)" tabindex="0" role="slider" aria-valuemin="0" aria-valuemax="{{range.length}}" aria-valuenow="{{value}}">\n <i ng-repeat="r in range track by $index" ng-mouseenter="enter($index + 1)" ng-click="rate($index + 1)" class="glyphicon" ng-class="$index < value && (r.stateOn || \'glyphicon-star\') || (r.stateOff || \'glyphicon-star-empty\')">\n <span class="sr-only">({{ $index < value ? \'*\' : \' \' }})</span>\n </i>\n</span>')}]),angular.module("template/tabs/tab.html",[]).run(["$templateCache",function(a){a.put("template/tabs/tab.html",'<li ng-class="{active: active, disabled: disabled}">\n <a ng-click="select()" tab-heading-transclude>{{heading}}</a>\n</li>\n')}]),angular.module("template/tabs/tabset-titles.html",[]).run(["$templateCache",function(a){a.put("template/tabs/tabset-titles.html","<ul class=\"nav {{type && 'nav-' + type}}\" ng-class=\"{'nav-stacked': vertical}\">\n</ul>\n")}]),angular.module("template/tabs/tabset.html",[]).run(["$templateCache",function(a){a.put("template/tabs/tabset.html",'\n<div>\n <ul class="nav nav-{{type || \'tabs\'}}" ng-class="{\'nav-stacked\': vertical, \'nav-justified\': justified}" ng-transclude></ul>\n <div class="tab-content">\n <div class="tab-pane" \n ng-repeat="tab in tabs" \n ng-class="{active: tab.active}"\n tab-content-transclude="tab">\n </div>\n </div>\n</div>\n')}]),angular.module("template/timepicker/timepicker.html",[]).run(["$templateCache",function(a){a.put("template/timepicker/timepicker.html",'<table>\n <tbody>\n <tr class="text-center">\n <td><a ng-click="incrementHours()" class="btn btn-link"><span class="glyphicon glyphicon-chevron-up"></span></a></td>\n <td> </td>\n <td><a ng-click="incrementMinutes()" class="btn btn-link"><span class="glyphicon glyphicon-chevron-up"></span></a></td>\n <td ng-show="showMeridian"></td>\n </tr>\n <tr>\n <td style="width:50px;" class="form-group" ng-class="{\'has-error\': invalidHours}">\n <input type="text" ng-model="hours" ng-change="updateHours()" class="form-control text-center" ng-mousewheel="incrementHours()" ng-readonly="readonlyInput" maxlength="2">\n </td>\n <td>:</td>\n <td style="width:50px;" class="form-group" ng-class="{\'has-error\': invalidMinutes}">\n <input type="text" ng-model="minutes" ng-change="updateMinutes()" class="form-control text-center" ng-readonly="readonlyInput" maxlength="2">\n </td>\n <td ng-show="showMeridian"><button type="button" class="btn btn-default text-center" ng-click="toggleMeridian()">{{meridian}}</button></td>\n </tr>\n <tr class="text-center">\n <td><a ng-click="decrementHours()" class="btn btn-link"><span class="glyphicon glyphicon-chevron-down"></span></a></td>\n <td> </td>\n <td><a ng-click="decrementMinutes()" class="btn btn-link"><span class="glyphicon glyphicon-chevron-down"></span></a></td>\n <td ng-show="showMeridian"></td>\n </tr>\n </tbody>\n</table>\n')}]),angular.module("template/typeahead/typeahead-match.html",[]).run(["$templateCache",function(a){a.put("template/typeahead/typeahead-match.html",'<a tabindex="-1" bind-html-unsafe="match.label | typeaheadHighlight:query"></a>')}]),angular.module("template/typeahead/typeahead-popup.html",[]).run(["$templateCache",function(a){a.put("template/typeahead/typeahead-popup.html",'<ul class="dropdown-menu" ng-if="isOpen()" ng-style="{top: position.top+\'px\', left: position.left+\'px\'}" style="display: block;" role="listbox" aria-hidden="{{!isOpen()}}">\n <li ng-repeat="match in matches track by $index" ng-class="{active: isActive($index) }" ng-mouseenter="selectActive($index)" ng-click="selectMatch($index)" role="option" id="{{match.id}}">\n <div typeahead-match index="$index" match="match" query="query" template-url="templateUrl"></div>\n </li>\n</ul>') + return a.replace(b,function(a,b){return(b?c:"")+a.toLowerCase()})}var b={placement:"top",animation:!0,popupDelay:0},c={mouseenter:"mouseleave",click:"click",focus:"blur"},d={};this.options=function(a){angular.extend(d,a)},this.setTriggers=function(a){angular.extend(c,a)},this.$get=["$window","$compile","$timeout","$parse","$document","$position","$interpolate",function(e,f,g,h,i,j,k){return function(e,l,m){function n(a){var b=a||o.trigger||m,d=c[b]||b;return{show:b,hide:d}}var o=angular.extend({},b,d),p=a(e),q=k.startSymbol(),r=k.endSymbol(),s="<div "+p+'-popup title="'+q+"tt_title"+r+'" content="'+q+"tt_content"+r+'" placement="'+q+"tt_placement"+r+'" animation="tt_animation" is-open="tt_isOpen"></div>';return{restrict:"EA",scope:!0,compile:function(){var a=f(s);return function(b,c,d){function f(){b.tt_isOpen?m():k()}function k(){(!y||b.$eval(d[l+"Enable"]))&&(b.tt_popupDelay?v||(v=g(p,b.tt_popupDelay,!1),v.then(function(a){a()})):p()())}function m(){b.$apply(function(){q()})}function p(){return v=null,u&&(g.cancel(u),u=null),b.tt_content?(r(),t.css({top:0,left:0,display:"block"}),w?i.find("body").append(t):c.after(t),z(),b.tt_isOpen=!0,b.$digest(),z):angular.noop}function q(){b.tt_isOpen=!1,g.cancel(v),v=null,b.tt_animation?u||(u=g(s,500)):s()}function r(){t&&s(),t=a(b,function(){}),b.$digest()}function s(){u=null,t&&(t.remove(),t=null)}var t,u,v,w=angular.isDefined(o.appendToBody)?o.appendToBody:!1,x=n(void 0),y=angular.isDefined(d[l+"Enable"]),z=function(){var a=j.positionElements(c,t,b.tt_placement,w);a.top+="px",a.left+="px",t.css(a)};b.tt_isOpen=!1,d.$observe(e,function(a){b.tt_content=a,!a&&b.tt_isOpen&&q()}),d.$observe(l+"Title",function(a){b.tt_title=a}),d.$observe(l+"Placement",function(a){b.tt_placement=angular.isDefined(a)?a:o.placement}),d.$observe(l+"PopupDelay",function(a){var c=parseInt(a,10);b.tt_popupDelay=isNaN(c)?o.popupDelay:c});var A=function(){c.unbind(x.show,k),c.unbind(x.hide,m)};d.$observe(l+"Trigger",function(a){A(),x=n(a),x.show===x.hide?c.bind(x.show,f):(c.bind(x.show,k),c.bind(x.hide,m))});var B=b.$eval(d[l+"Animation"]);b.tt_animation=angular.isDefined(B)?!!B:o.animation,d.$observe(l+"AppendToBody",function(a){w=angular.isDefined(a)?h(a)(b):w}),w&&b.$on("$locationChangeSuccess",function(){b.tt_isOpen&&q()}),b.$on("$destroy",function(){g.cancel(u),g.cancel(v),A(),s()})}}}}}]}).directive("tooltipPopup",function(){return{restrict:"EA",replace:!0,scope:{content:"@",placement:"@",animation:"&",isOpen:"&"},templateUrl:"template/tooltip/tooltip-popup.html"}}).directive("tooltip",["$tooltip",function(a){return a("tooltip","tooltip","mouseenter")}]).directive("tooltipHtmlUnsafePopup",function(){return{restrict:"EA",replace:!0,scope:{content:"@",placement:"@",animation:"&",isOpen:"&"},templateUrl:"template/tooltip/tooltip-html-unsafe-popup.html"}}).directive("tooltipHtmlUnsafe",["$tooltip",function(a){return a("tooltipHtmlUnsafe","tooltip","mouseenter")}]),angular.module("ui.bootstrap.popover",["ui.bootstrap.tooltip"]).directive("popoverPopup",function(){return{restrict:"EA",replace:!0,scope:{title:"@",content:"@",placement:"@",animation:"&",isOpen:"&"},templateUrl:"template/popover/popover.html"}}).directive("popover",["$tooltip",function(a){return a("popover","popover","click")}]),angular.module("ui.bootstrap.progressbar",[]).constant("progressConfig",{animate:!0,max:100}).controller("ProgressController",["$scope","$attrs","progressConfig",function(a,b,c){var d=this,e=angular.isDefined(b.animate)?a.$parent.$eval(b.animate):c.animate;this.bars=[],a.max=angular.isDefined(b.max)?a.$parent.$eval(b.max):c.max,this.addBar=function(b,c){e||c.css({transition:"none"}),this.bars.push(b),b.$watch("value",function(c){b.percent=+(100*c/a.max).toFixed(2)}),b.$on("$destroy",function(){c=null,d.removeBar(b)})},this.removeBar=function(a){this.bars.splice(this.bars.indexOf(a),1)}}]).directive("progress",function(){return{restrict:"EA",replace:!0,transclude:!0,controller:"ProgressController",require:"progress",scope:{},templateUrl:"template/progressbar/progress.html"}}).directive("bar",function(){return{restrict:"EA",replace:!0,transclude:!0,require:"^progress",scope:{value:"=",type:"@"},templateUrl:"template/progressbar/bar.html",link:function(a,b,c,d){d.addBar(a,b)}}}).directive("progressbar",function(){return{restrict:"EA",replace:!0,transclude:!0,controller:"ProgressController",scope:{value:"=",type:"@"},templateUrl:"template/progressbar/progressbar.html",link:function(a,b,c,d){d.addBar(a,angular.element(b.children()[0]))}}}),angular.module("ui.bootstrap.rating",[]).constant("ratingConfig",{max:5,stateOn:null,stateOff:null}).controller("RatingController",["$scope","$attrs","ratingConfig",function(a,b,c){var d={$setViewValue:angular.noop};this.init=function(e){d=e,d.$render=this.render,this.stateOn=angular.isDefined(b.stateOn)?a.$parent.$eval(b.stateOn):c.stateOn,this.stateOff=angular.isDefined(b.stateOff)?a.$parent.$eval(b.stateOff):c.stateOff;var f=angular.isDefined(b.ratingStates)?a.$parent.$eval(b.ratingStates):new Array(angular.isDefined(b.max)?a.$parent.$eval(b.max):c.max);a.range=this.buildTemplateObjects(f)},this.buildTemplateObjects=function(a){for(var b=0,c=a.length;c>b;b++)a[b]=angular.extend({index:b},{stateOn:this.stateOn,stateOff:this.stateOff},a[b]);return a},a.rate=function(b){!a.readonly&&b>=0&&b<=a.range.length&&(d.$setViewValue(b),d.$render())},a.enter=function(b){a.readonly||(a.value=b),a.onHover({value:b})},a.reset=function(){a.value=d.$viewValue,a.onLeave()},a.onKeydown=function(b){/(37|38|39|40)/.test(b.which)&&(b.preventDefault(),b.stopPropagation(),a.rate(a.value+(38===b.which||39===b.which?1:-1)))},this.render=function(){a.value=d.$viewValue}}]).directive("rating",function(){return{restrict:"EA",require:["rating","ngModel"],scope:{readonly:"=?",onHover:"&",onLeave:"&"},controller:"RatingController",templateUrl:"template/rating/rating.html",replace:!0,link:function(a,b,c,d){var e=d[0],f=d[1];f&&e.init(f)}}}),angular.module("ui.bootstrap.tabs",[]).controller("TabsetController",["$scope",function(a){var b=this,c=b.tabs=a.tabs=[];b.select=function(a){angular.forEach(c,function(b){b.active&&b!==a&&(b.active=!1,b.onDeselect())}),a.active=!0,a.onSelect()},b.addTab=function(a){c.push(a),1===c.length?a.active=!0:a.active&&b.select(a)},b.removeTab=function(a){var d=c.indexOf(a);if(a.active&&c.length>1){var e=d==c.length-1?d-1:d+1;b.select(c[e])}c.splice(d,1)}}]).directive("tabset",function(){return{restrict:"EA",transclude:!0,replace:!0,scope:{type:"@"},controller:"TabsetController",templateUrl:"template/tabs/tabset.html",link:function(a,b,c){a.vertical=angular.isDefined(c.vertical)?a.$parent.$eval(c.vertical):!1,a.justified=angular.isDefined(c.justified)?a.$parent.$eval(c.justified):!1}}}).directive("tab",["$parse",function(a){return{require:"^tabset",restrict:"EA",replace:!0,templateUrl:"template/tabs/tab.html",transclude:!0,scope:{active:"=?",heading:"@",onSelect:"&select",onDeselect:"&deselect"},controller:function(){},compile:function(b,c,d){return function(b,c,e,f){b.$watch("active",function(a){a&&f.select(b)}),b.disabled=!1,e.disabled&&b.$parent.$watch(a(e.disabled),function(a){b.disabled=!!a}),b.select=function(){b.disabled||(b.active=!0)},f.addTab(b),b.$on("$destroy",function(){f.removeTab(b)}),b.$transcludeFn=d}}}}]).directive("tabHeadingTransclude",[function(){return{restrict:"A",require:"^tab",link:function(a,b){a.$watch("headingElement",function(a){a&&(b.html(""),b.append(a))})}}}]).directive("tabContentTransclude",function(){function a(a){return a.tagName&&(a.hasAttribute("tab-heading")||a.hasAttribute("data-tab-heading")||"tab-heading"===a.tagName.toLowerCase()||"data-tab-heading"===a.tagName.toLowerCase())}return{restrict:"A",require:"^tabset",link:function(b,c,d){var e=b.$eval(d.tabContentTransclude);e.$transcludeFn(e.$parent,function(b){angular.forEach(b,function(b){a(b)?e.headingElement=b:c.append(b)})})}}}),angular.module("ui.bootstrap.timepicker",[]).constant("timepickerConfig",{hourStep:1,minuteStep:1,showMeridian:!0,meridians:null,readonlyInput:!1,mousewheel:!0}).controller("TimepickerController",["$scope","$attrs","$parse","$log","$locale","timepickerConfig",function(a,b,c,d,e,f){function g(){var b=parseInt(a.hours,10),c=a.showMeridian?b>0&&13>b:b>=0&&24>b;return c?(a.showMeridian&&(12===b&&(b=0),a.meridian===p[1]&&(b+=12)),b):void 0}function h(){var b=parseInt(a.minutes,10);return b>=0&&60>b?b:void 0}function i(a){return angular.isDefined(a)&&a.toString().length<2?"0"+a:a}function j(a){k(),o.$setViewValue(new Date(n)),l(a)}function k(){o.$setValidity("time",!0),a.invalidHours=!1,a.invalidMinutes=!1}function l(b){var c=n.getHours(),d=n.getMinutes();a.showMeridian&&(c=0===c||12===c?12:c%12),a.hours="h"===b?c:i(c),a.minutes="m"===b?d:i(d),a.meridian=n.getHours()<12?p[0]:p[1]}function m(a){var b=new Date(n.getTime()+6e4*a);n.setHours(b.getHours(),b.getMinutes()),j()}var n=new Date,o={$setViewValue:angular.noop},p=angular.isDefined(b.meridians)?a.$parent.$eval(b.meridians):f.meridians||e.DATETIME_FORMATS.AMPMS;this.init=function(c,d){o=c,o.$render=this.render;var e=d.eq(0),g=d.eq(1),h=angular.isDefined(b.mousewheel)?a.$parent.$eval(b.mousewheel):f.mousewheel;h&&this.setupMousewheelEvents(e,g),a.readonlyInput=angular.isDefined(b.readonlyInput)?a.$parent.$eval(b.readonlyInput):f.readonlyInput,this.setupInputEvents(e,g)};var q=f.hourStep;b.hourStep&&a.$parent.$watch(c(b.hourStep),function(a){q=parseInt(a,10)});var r=f.minuteStep;b.minuteStep&&a.$parent.$watch(c(b.minuteStep),function(a){r=parseInt(a,10)}),a.showMeridian=f.showMeridian,b.showMeridian&&a.$parent.$watch(c(b.showMeridian),function(b){if(a.showMeridian=!!b,o.$error.time){var c=g(),d=h();angular.isDefined(c)&&angular.isDefined(d)&&(n.setHours(c),j())}else l()}),this.setupMousewheelEvents=function(b,c){var d=function(a){a.originalEvent&&(a=a.originalEvent);var b=a.wheelDelta?a.wheelDelta:-a.deltaY;return a.detail||b>0};b.bind("mousewheel wheel",function(b){a.$apply(d(b)?a.incrementHours():a.decrementHours()),b.preventDefault()}),c.bind("mousewheel wheel",function(b){a.$apply(d(b)?a.incrementMinutes():a.decrementMinutes()),b.preventDefault()})},this.setupInputEvents=function(b,c){if(a.readonlyInput)return a.updateHours=angular.noop,void(a.updateMinutes=angular.noop);var d=function(b,c){o.$setViewValue(null),o.$setValidity("time",!1),angular.isDefined(b)&&(a.invalidHours=b),angular.isDefined(c)&&(a.invalidMinutes=c)};a.updateHours=function(){var a=g();angular.isDefined(a)?(n.setHours(a),j("h")):d(!0)},b.bind("blur",function(){!a.invalidHours&&a.hours<10&&a.$apply(function(){a.hours=i(a.hours)})}),a.updateMinutes=function(){var a=h();angular.isDefined(a)?(n.setMinutes(a),j("m")):d(void 0,!0)},c.bind("blur",function(){!a.invalidMinutes&&a.minutes<10&&a.$apply(function(){a.minutes=i(a.minutes)})})},this.render=function(){var a=o.$modelValue?new Date(o.$modelValue):null;isNaN(a)?(o.$setValidity("time",!1),d.error('Timepicker directive: "ng-model" value must be a Date object, a number of milliseconds since 01.01.1970 or a string representing an RFC2822 or ISO 8601 date.')):(a&&(n=a),k(),l())},a.incrementHours=function(){m(60*q)},a.decrementHours=function(){m(60*-q)},a.incrementMinutes=function(){m(r)},a.decrementMinutes=function(){m(-r)},a.toggleMeridian=function(){m(720*(n.getHours()<12?1:-1))}}]).directive("timepicker",function(){return{restrict:"EA",require:["timepicker","?^ngModel"],controller:"TimepickerController",replace:!0,scope:{},templateUrl:"template/timepicker/timepicker.html",link:function(a,b,c,d){var e=d[0],f=d[1];f&&e.init(f,b.find("input"))}}}),angular.module("ui.bootstrap.typeahead",["ui.bootstrap.position","ui.bootstrap.bindHtml"]).factory("typeaheadParser",["$parse",function(a){var b=/^\s*(.*?)(?:\s+as\s+(.*?))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\s+(.*)$/;return{parse:function(c){var d=c.match(b);if(!d)throw new Error('Expected typeahead specification in form of "_modelValue_ (as _label_)? for _item_ in _collection_" but got "'+c+'".');return{itemName:d[3],source:a(d[4]),viewMapper:a(d[2]||d[1]),modelMapper:a(d[1])}}}}]).directive("typeahead",["$compile","$parse","$q","$timeout","$document","$position","typeaheadParser",function(a,b,c,d,e,f,g){var h=[9,13,27,38,40];return{require:"ngModel",link:function(i,j,k,l){var m,n=i.$eval(k.typeaheadMinLength)||1,o=i.$eval(k.typeaheadWaitMs)||0,p=i.$eval(k.typeaheadEditable)!==!1,q=b(k.typeaheadLoading).assign||angular.noop,r=b(k.typeaheadOnSelect),s=k.typeaheadInputFormatter?b(k.typeaheadInputFormatter):void 0,t=k.typeaheadAppendToBody?i.$eval(k.typeaheadAppendToBody):!1,u=b(k.ngModel).assign,v=g.parse(k.typeahead),w=i.$new();i.$on("$destroy",function(){w.$destroy()});var x="typeahead-"+w.$id+"-"+Math.floor(1e4*Math.random());j.attr({"aria-autocomplete":"list","aria-expanded":!1,"aria-owns":x});var y=angular.element("<div typeahead-popup></div>");y.attr({id:x,matches:"matches",active:"activeIdx",select:"select(activeIdx)",query:"query",position:"position"}),angular.isDefined(k.typeaheadTemplateUrl)&&y.attr("template-url",k.typeaheadTemplateUrl);var z=function(){w.matches=[],w.activeIdx=-1,j.attr("aria-expanded",!1)},A=function(a){return x+"-option-"+a};w.$watch("activeIdx",function(a){0>a?j.removeAttr("aria-activedescendant"):j.attr("aria-activedescendant",A(a))});var B=function(a){var b={$viewValue:a};q(i,!0),c.when(v.source(i,b)).then(function(c){var d=a===l.$viewValue;if(d&&m)if(c.length>0){w.activeIdx=0,w.matches.length=0;for(var e=0;e<c.length;e++)b[v.itemName]=c[e],w.matches.push({id:A(e),label:v.viewMapper(w,b),model:c[e]});w.query=a,w.position=t?f.offset(j):f.position(j),w.position.top=w.position.top+j.prop("offsetHeight"),j.attr("aria-expanded",!0)}else z();d&&q(i,!1)},function(){z(),q(i,!1)})};z(),w.query=void 0;var C;l.$parsers.unshift(function(a){return m=!0,a&&a.length>=n?o>0?(C&&d.cancel(C),C=d(function(){B(a)},o)):B(a):(q(i,!1),z()),p?a:a?void l.$setValidity("editable",!1):(l.$setValidity("editable",!0),a)}),l.$formatters.push(function(a){var b,c,d={};return s?(d.$model=a,s(i,d)):(d[v.itemName]=a,b=v.viewMapper(i,d),d[v.itemName]=void 0,c=v.viewMapper(i,d),b!==c?b:a)}),w.select=function(a){var b,c,e={};e[v.itemName]=c=w.matches[a].model,b=v.modelMapper(i,e),u(i,b),l.$setValidity("editable",!0),r(i,{$item:c,$model:b,$label:v.viewMapper(i,e)}),z(),d(function(){j[0].focus()},0,!1)},j.bind("keydown",function(a){0!==w.matches.length&&-1!==h.indexOf(a.which)&&(a.preventDefault(),40===a.which?(w.activeIdx=(w.activeIdx+1)%w.matches.length,w.$digest()):38===a.which?(w.activeIdx=(w.activeIdx?w.activeIdx:w.matches.length)-1,w.$digest()):13===a.which||9===a.which?w.$apply(function(){w.select(w.activeIdx)}):27===a.which&&(a.stopPropagation(),z(),w.$digest()))}),j.bind("blur",function(){m=!1});var D=function(a){j[0]!==a.target&&(z(),w.$digest())};e.bind("click",D),i.$on("$destroy",function(){e.unbind("click",D)});var E=a(y)(w);t?e.find("body").append(E):j.after(E)}}}]).directive("typeaheadPopup",function(){return{restrict:"EA",scope:{matches:"=",query:"=",active:"=",position:"=",select:"&"},replace:!0,templateUrl:"template/typeahead/typeahead-popup.html",link:function(a,b,c){a.templateUrl=c.templateUrl,a.isOpen=function(){return a.matches.length>0},a.isActive=function(b){return a.active==b},a.selectActive=function(b){a.active=b},a.selectMatch=function(b){a.select({activeIdx:b})}}}}).directive("typeaheadMatch",["$http","$templateCache","$compile","$parse",function(a,b,c,d){return{restrict:"EA",scope:{index:"=",match:"=",query:"="},link:function(e,f,g){var h=d(g.templateUrl)(e.$parent)||"template/typeahead/typeahead-match.html";a.get(h,{cache:b}).success(function(a){f.replaceWith(c(a.trim())(e))})}}}]).filter("typeaheadHighlight",function(){function a(a){return a.replace(/([.?*+^$[\]\\(){}|-])/g,"\\$1")}return function(b,c){return c?(""+b).replace(new RegExp(a(c),"gi"),"<strong>$&</strong>"):b}}),angular.module("template/accordion/accordion-group.html",[]).run(["$templateCache",function(a){a.put("template/accordion/accordion-group.html",'<div class="panel panel-default">\n <div class="panel-heading">\n <h4 class="panel-title">\n <a class="accordion-toggle" ng-click="toggleOpen()" accordion-transclude="heading"><span ng-class="{\'text-muted\': isDisabled}">{{heading}}</span></a>\n </h4>\n </div>\n <div class="panel-collapse" collapse="!isOpen">\n <div class="panel-body" ng-transclude></div>\n </div>\n</div>')}]),angular.module("template/accordion/accordion.html",[]).run(["$templateCache",function(a){a.put("template/accordion/accordion.html",'<div class="panel-group" ng-transclude></div>')}]),angular.module("template/alert/alert.html",[]).run(["$templateCache",function(a){a.put("template/alert/alert.html",'<div class="alert" ng-class="{\'alert-{{type || \'warning\'}}\': true, \'alert-dismissable\': closeable}" role="alert">\n <button ng-show="closeable" type="button" class="close" ng-click="close()">\n <span aria-hidden="true">×</span>\n <span class="sr-only">Close</span>\n </button>\n <div ng-transclude></div>\n</div>\n')}]),angular.module("template/carousel/carousel.html",[]).run(["$templateCache",function(a){a.put("template/carousel/carousel.html",'<div ng-mouseenter="pause()" ng-mouseleave="play()" class="carousel" ng-swipe-right="prev()" ng-swipe-left="next()">\n <ol class="carousel-indicators" ng-show="slides.length > 1">\n <li ng-repeat="slide in slides track by $index" ng-class="{active: isActive(slide)}" ng-click="select(slide)"></li>\n </ol>\n <div class="carousel-inner" ng-transclude></div>\n <a class="left carousel-control" ng-click="prev()" ng-show="slides.length > 1"><span class="glyphicon glyphicon-chevron-left"></span></a>\n <a class="right carousel-control" ng-click="next()" ng-show="slides.length > 1"><span class="glyphicon glyphicon-chevron-right"></span></a>\n</div>\n')}]),angular.module("template/carousel/slide.html",[]).run(["$templateCache",function(a){a.put("template/carousel/slide.html","<div ng-class=\"{\n 'active': leaving || (active && !entering),\n 'prev': (next || active) && direction=='prev',\n 'next': (next || active) && direction=='next',\n 'right': direction=='prev',\n 'left': direction=='next'\n }\" class=\"item text-center\" ng-transclude></div>\n")}]),angular.module("template/datepicker/datepicker.html",[]).run(["$templateCache",function(a){a.put("template/datepicker/datepicker.html",'<div ng-switch="datepickerMode" role="application" ng-keydown="keydown($event)">\n <daypicker ng-switch-when="day" tabindex="0"></daypicker>\n <monthpicker ng-switch-when="month" tabindex="0"></monthpicker>\n <yearpicker ng-switch-when="year" tabindex="0"></yearpicker>\n</div>')}]),angular.module("template/datepicker/day.html",[]).run(["$templateCache",function(a){a.put("template/datepicker/day.html",'<table role="grid" aria-labelledby="{{uniqueId}}-title" aria-activedescendant="{{activeDateId}}">\n <thead>\n <tr>\n <th><button type="button" class="btn btn-default btn-sm pull-left" ng-click="move(-1)" tabindex="-1"><i class="glyphicon glyphicon-chevron-left"></i></button></th>\n <th colspan="{{5 + showWeeks}}"><button id="{{uniqueId}}-title" role="heading" aria-live="assertive" aria-atomic="true" type="button" class="btn btn-default btn-sm" ng-click="toggleMode()" tabindex="-1" style="width:100%;"><strong>{{title}}</strong></button></th>\n <th><button type="button" class="btn btn-default btn-sm pull-right" ng-click="move(1)" tabindex="-1"><i class="glyphicon glyphicon-chevron-right"></i></button></th>\n </tr>\n <tr>\n <th ng-show="showWeeks" class="text-center"></th>\n <th ng-repeat="label in labels track by $index" class="text-center"><small aria-label="{{label.full}}">{{label.abbr}}</small></th>\n </tr>\n </thead>\n <tbody>\n <tr ng-repeat="row in rows track by $index">\n <td ng-show="showWeeks" class="text-center h6"><em>{{ weekNumbers[$index] }}</em></td>\n <td ng-repeat="dt in row track by dt.date" class="text-center" role="gridcell" id="{{dt.uid}}" aria-disabled="{{!!dt.disabled}}">\n <button type="button" style="width:100%;" class="btn btn-default btn-sm" ng-class="{\'btn-info\': dt.selected, active: isActive(dt)}" ng-click="select(dt.date)" ng-disabled="dt.disabled" tabindex="-1"><span ng-class="{\'text-muted\': dt.secondary, \'text-info\': dt.current}">{{dt.label}}</span></button>\n </td>\n </tr>\n </tbody>\n</table>\n')}]),angular.module("template/datepicker/month.html",[]).run(["$templateCache",function(a){a.put("template/datepicker/month.html",'<table role="grid" aria-labelledby="{{uniqueId}}-title" aria-activedescendant="{{activeDateId}}">\n <thead>\n <tr>\n <th><button type="button" class="btn btn-default btn-sm pull-left" ng-click="move(-1)" tabindex="-1"><i class="glyphicon glyphicon-chevron-left"></i></button></th>\n <th><button id="{{uniqueId}}-title" role="heading" aria-live="assertive" aria-atomic="true" type="button" class="btn btn-default btn-sm" ng-click="toggleMode()" tabindex="-1" style="width:100%;"><strong>{{title}}</strong></button></th>\n <th><button type="button" class="btn btn-default btn-sm pull-right" ng-click="move(1)" tabindex="-1"><i class="glyphicon glyphicon-chevron-right"></i></button></th>\n </tr>\n </thead>\n <tbody>\n <tr ng-repeat="row in rows track by $index">\n <td ng-repeat="dt in row track by dt.date" class="text-center" role="gridcell" id="{{dt.uid}}" aria-disabled="{{!!dt.disabled}}">\n <button type="button" style="width:100%;" class="btn btn-default" ng-class="{\'btn-info\': dt.selected, active: isActive(dt)}" ng-click="select(dt.date)" ng-disabled="dt.disabled" tabindex="-1"><span ng-class="{\'text-info\': dt.current}">{{dt.label}}</span></button>\n </td>\n </tr>\n </tbody>\n</table>\n')}]),angular.module("template/datepicker/popup.html",[]).run(["$templateCache",function(a){a.put("template/datepicker/popup.html",'<ul class="dropdown-menu" ng-style="{display: (isOpen && \'block\') || \'none\', top: position.top+\'px\', left: position.left+\'px\'}" ng-keydown="keydown($event)">\n <li ng-transclude></li>\n <li ng-if="showButtonBar" style="padding:10px 9px 2px">\n <span class="btn-group">\n <button type="button" class="btn btn-sm btn-info" ng-click="select(\'today\')">{{ getText(\'current\') }}</button>\n <button type="button" class="btn btn-sm btn-danger" ng-click="select(null)">{{ getText(\'clear\') }}</button>\n </span>\n <button type="button" class="btn btn-sm btn-success pull-right" ng-click="close()">{{ getText(\'close\') }}</button>\n </li>\n</ul>\n')}]),angular.module("template/datepicker/year.html",[]).run(["$templateCache",function(a){a.put("template/datepicker/year.html",'<table role="grid" aria-labelledby="{{uniqueId}}-title" aria-activedescendant="{{activeDateId}}">\n <thead>\n <tr>\n <th><button type="button" class="btn btn-default btn-sm pull-left" ng-click="move(-1)" tabindex="-1"><i class="glyphicon glyphicon-chevron-left"></i></button></th>\n <th colspan="3"><button id="{{uniqueId}}-title" role="heading" aria-live="assertive" aria-atomic="true" type="button" class="btn btn-default btn-sm" ng-click="toggleMode()" tabindex="-1" style="width:100%;"><strong>{{title}}</strong></button></th>\n <th><button type="button" class="btn btn-default btn-sm pull-right" ng-click="move(1)" tabindex="-1"><i class="glyphicon glyphicon-chevron-right"></i></button></th>\n </tr>\n </thead>\n <tbody>\n <tr ng-repeat="row in rows track by $index">\n <td ng-repeat="dt in row track by dt.date" class="text-center" role="gridcell" id="{{dt.uid}}" aria-disabled="{{!!dt.disabled}}">\n <button type="button" style="width:100%;" class="btn btn-default" ng-class="{\'btn-info\': dt.selected, active: isActive(dt)}" ng-click="select(dt.date)" ng-disabled="dt.disabled" tabindex="-1"><span ng-class="{\'text-info\': dt.current}">{{dt.label}}</span></button>\n </td>\n </tr>\n </tbody>\n</table>\n')}]),angular.module("template/modal/backdrop.html",[]).run(["$templateCache",function(a){a.put("template/modal/backdrop.html",'<div class="modal-backdrop fade"\n ng-class="{in: animate}"\n ng-style="{\'z-index\': 1040 + (index && 1 || 0) + index*10}"\n></div>\n')}]),angular.module("template/modal/window.html",[]).run(["$templateCache",function(a){a.put("template/modal/window.html",'<div tabindex="-1" role="dialog" class="modal fade" ng-class="{in: animate}" ng-style="{\'z-index\': 1050 + index*10, display: \'block\'}" ng-click="close($event)">\n <div class="modal-dialog" ng-class="{\'modal-sm\': size == \'sm\', \'modal-lg\': size == \'lg\'}"><div class="modal-content" ng-transclude></div></div>\n</div>')}]),angular.module("template/pagination/pager.html",[]).run(["$templateCache",function(a){a.put("template/pagination/pager.html",'<ul class="pager">\n <li ng-class="{disabled: noPrevious(), previous: align}"><a href ng-click="selectPage(page - 1)">{{getText(\'previous\')}}</a></li>\n <li ng-class="{disabled: noNext(), next: align}"><a href ng-click="selectPage(page + 1)">{{getText(\'next\')}}</a></li>\n</ul>')}]),angular.module("template/pagination/pagination.html",[]).run(["$templateCache",function(a){a.put("template/pagination/pagination.html",'<ul class="pagination">\n <li ng-if="boundaryLinks" ng-class="{disabled: noPrevious()}"><a href ng-click="selectPage(1)">{{getText(\'first\')}}</a></li>\n <li ng-if="directionLinks" ng-class="{disabled: noPrevious()}"><a href ng-click="selectPage(page - 1)">{{getText(\'previous\')}}</a></li>\n <li ng-repeat="page in pages track by $index" ng-class="{active: page.active}"><a href ng-click="selectPage(page.number)">{{page.text}}</a></li>\n <li ng-if="directionLinks" ng-class="{disabled: noNext()}"><a href ng-click="selectPage(page + 1)">{{getText(\'next\')}}</a></li>\n <li ng-if="boundaryLinks" ng-class="{disabled: noNext()}"><a href ng-click="selectPage(totalPages)">{{getText(\'last\')}}</a></li>\n</ul>')}]),angular.module("template/tooltip/tooltip-html-unsafe-popup.html",[]).run(["$templateCache",function(a){a.put("template/tooltip/tooltip-html-unsafe-popup.html",'<div class="tooltip {{placement}}" ng-class="{ in: isOpen(), fade: animation() }">\n <div class="tooltip-arrow"></div>\n <div class="tooltip-inner" bind-html-unsafe="content"></div>\n</div>\n')}]),angular.module("template/tooltip/tooltip-popup.html",[]).run(["$templateCache",function(a){a.put("template/tooltip/tooltip-popup.html",'<div class="tooltip {{placement}}" ng-class="{ in: isOpen(), fade: animation() }">\n <div class="tooltip-arrow"></div>\n <div class="tooltip-inner" ng-bind="content"></div>\n</div>\n')}]),angular.module("template/popover/popover.html",[]).run(["$templateCache",function(a){a.put("template/popover/popover.html",'<div class="popover {{placement}}" ng-class="{ in: isOpen(), fade: animation() }">\n <div class="arrow"></div>\n\n <div class="popover-inner">\n <h3 class="popover-title" ng-bind="title" ng-show="title"></h3>\n <div class="popover-content" ng-bind="content"></div>\n </div>\n</div>\n')}]),angular.module("template/progressbar/bar.html",[]).run(["$templateCache",function(a){a.put("template/progressbar/bar.html",'<div class="progress-bar" ng-class="type && \'progress-bar-\' + type" role="progressbar" aria-valuenow="{{value}}" aria-valuemin="0" aria-valuemax="{{max}}" ng-style="{width: percent + \'%\'}" aria-valuetext="{{percent | number:0}}%" ng-transclude></div>')}]),angular.module("template/progressbar/progress.html",[]).run(["$templateCache",function(a){a.put("template/progressbar/progress.html",'<div class="progress" ng-transclude></div>')}]),angular.module("template/progressbar/progressbar.html",[]).run(["$templateCache",function(a){a.put("template/progressbar/progressbar.html",'<div class="progress">\n <div class="progress-bar" ng-class="type && \'progress-bar-\' + type" role="progressbar" aria-valuenow="{{value}}" aria-valuemin="0" aria-valuemax="{{max}}" ng-style="{width: percent + \'%\'}" aria-valuetext="{{percent | number:0}}%" ng-transclude></div>\n</div>')}]),angular.module("template/rating/rating.html",[]).run(["$templateCache",function(a){a.put("template/rating/rating.html",'<span ng-mouseleave="reset()" ng-keydown="onKeydown($event)" tabindex="0" role="slider" aria-valuemin="0" aria-valuemax="{{range.length}}" aria-valuenow="{{value}}">\n <i ng-repeat="r in range track by $index" ng-mouseenter="enter($index + 1)" ng-click="rate($index + 1)" class="glyphicon" ng-class="$index < value && (r.stateOn || \'glyphicon-star\') || (r.stateOff || \'glyphicon-star-empty\')">\n <span class="sr-only">({{ $index < value ? \'*\' : \' \' }})</span>\n </i>\n</span>')}]),angular.module("template/tabs/tab.html",[]).run(["$templateCache",function(a){a.put("template/tabs/tab.html",'<li ng-class="{active: active, disabled: disabled}">\n <a ng-click="select()" tab-heading-transclude>{{heading}}</a>\n</li>\n')}]),angular.module("template/tabs/tabset-titles.html",[]).run(["$templateCache",function(a){a.put("template/tabs/tabset-titles.html","<ul class=\"nav {{type && 'nav-' + type}}\" ng-class=\"{'nav-stacked': vertical}\">\n</ul>\n")}]),angular.module("template/tabs/tabset.html",[]).run(["$templateCache",function(a){a.put("template/tabs/tabset.html",'\n<div>\n <ul class="nav nav-{{type || \'tabs\'}}" ng-class="{\'nav-stacked\': vertical, \'nav-justified\': justified}" ng-transclude></ul>\n <div class="tab-content">\n <div class="tab-pane" \n ng-repeat="tab in tabs" \n ng-class="{active: tab.active}"\n tab-content-transclude="tab">\n </div>\n </div>\n</div>\n')}]),angular.module("template/timepicker/timepicker.html",[]).run(["$templateCache",function(a){a.put("template/timepicker/timepicker.html",'<table>\n <tbody>\n <tr class="text-center">\n <td><a ng-click="incrementHours()" class="btn btn-link"><span class="glyphicon glyphicon-chevron-up"></span></a></td>\n <td> </td>\n <td><a ng-click="incrementMinutes()" class="btn btn-link"><span class="glyphicon glyphicon-chevron-up"></span></a></td>\n <td ng-show="showMeridian"></td>\n </tr>\n <tr>\n <td style="width:50px;" class="form-group" ng-class="{\'has-error\': invalidHours}">\n <input type="text" ng-model="hours" ng-change="updateHours()" class="form-control text-center" ng-mousewheel="incrementHours()" ng-readonly="readonlyInput" maxlength="2">\n </td>\n <td>:</td>\n <td style="width:50px;" class="form-group" ng-class="{\'has-error\': invalidMinutes}">\n <input type="text" ng-model="minutes" ng-change="updateMinutes()" class="form-control text-center" ng-readonly="readonlyInput" maxlength="2">\n </td>\n <td ng-show="showMeridian"><button type="button" class="btn btn-default text-center" ng-click="toggleMeridian()">{{meridian}}</button></td>\n </tr>\n <tr class="text-center">\n <td><a ng-click="decrementHours()" class="btn btn-link"><span class="glyphicon glyphicon-chevron-down"></span></a></td>\n <td> </td>\n <td><a ng-click="decrementMinutes()" class="btn btn-link"><span class="glyphicon glyphicon-chevron-down"></span></a></td>\n <td ng-show="showMeridian"></td>\n </tr>\n </tbody>\n</table>\n')}]),angular.module("template/typeahead/typeahead-match.html",[]).run(["$templateCache",function(a){a.put("template/typeahead/typeahead-match.html",'<a tabindex="-1" bind-html-unsafe="match.label | typeaheadHighlight:query"></a>')}]),angular.module("template/typeahead/typeahead-popup.html",[]).run(["$templateCache",function(a){a.put("template/typeahead/typeahead-popup.html",'<ul class="dropdown-menu" ng-if="isOpen()" ng-style="{top: position.top+\'px\', left: position.left+\'px\'}" style="display: block;" role="listbox" aria-hidden="{{!isOpen()}}">\n <li ng-repeat="match in matches track by $index" ng-class="{active: isActive($index) }" ng-mouseenter="selectActive($index)" ng-click="selectMatch($index)" role="option" id="{{match.id}}">\n <div typeahead-match index="$index" match="match" query="query" template-url="templateUrl"></div>\n </li>\n</ul>') }]); \ No newline at end of file diff --git a/setup/pub/angular-ui-router/angular-ui-router.min.js b/setup/pub/angular-ui-router/angular-ui-router.min.js index f065ecc960684..66568f91192ec 100644 --- a/setup/pub/angular-ui-router/angular-ui-router.min.js +++ b/setup/pub/angular-ui-router/angular-ui-router.min.js @@ -1,7 +1,7 @@ /** * State-based routing for AngularJS - * @version v0.2.10 + * @version v0.4.3 * @link http://angular-ui.github.com/ * @license MIT License, http://www.opensource.org/licenses/MIT */ -"undefined"!=typeof module&&"undefined"!=typeof exports&&module.exports===exports&&(module.exports="ui.router"),function(a,b,c){"use strict";function d(a,b){return I(new(I(function(){},{prototype:a})),b)}function e(a){return H(arguments,function(b){b!==a&&H(b,function(b,c){a.hasOwnProperty(c)||(a[c]=b)})}),a}function f(a,b){var c=[];for(var d in a.path){if(a.path[d]!==b.path[d])break;c.push(a.path[d])}return c}function g(a,b){if(Array.prototype.indexOf)return a.indexOf(b,Number(arguments[2])||0);var c=a.length>>>0,d=Number(arguments[2])||0;for(d=0>d?Math.ceil(d):Math.floor(d),0>d&&(d+=c);c>d;d++)if(d in a&&a[d]===b)return d;return-1}function h(a,b,c,d){var e,h=f(c,d),i={},j=[];for(var k in h)if(h[k].params&&h[k].params.length){e=h[k].params;for(var l in e)g(j,e[l])>=0||(j.push(e[l]),i[e[l]]=a[e[l]])}return I({},i,b)}function i(a,b){var c={};return H(a,function(a){var d=b[a];c[a]=null!=d?String(d):null}),c}function j(a,b,c){if(!c){c=[];for(var d in a)c.push(d)}for(var e=0;e<c.length;e++){var f=c[e];if(a[f]!=b[f])return!1}return!0}function k(a,b){var c={};return H(a,function(a){c[a]=b[a]}),c}function l(a,b){var d=1,f=2,g={},h=[],i=g,j=I(a.when(g),{$$promises:g,$$values:g});this.study=function(g){function k(a,c){if(o[c]!==f){if(n.push(c),o[c]===d)throw n.splice(0,n.indexOf(c)),new Error("Cyclic dependency: "+n.join(" -> "));if(o[c]=d,E(a))m.push(c,[function(){return b.get(a)}],h);else{var e=b.annotate(a);H(e,function(a){a!==c&&g.hasOwnProperty(a)&&k(g[a],a)}),m.push(c,a,e)}n.pop(),o[c]=f}}function l(a){return F(a)&&a.then&&a.$$promises}if(!F(g))throw new Error("'invocables' must be an object");var m=[],n=[],o={};return H(g,k),g=n=o=null,function(d,f,g){function h(){--s||(t||e(r,f.$$values),p.$$values=r,p.$$promises=!0,o.resolve(r))}function k(a){p.$$failure=a,o.reject(a)}function n(c,e,f){function i(a){l.reject(a),k(a)}function j(){if(!C(p.$$failure))try{l.resolve(b.invoke(e,g,r)),l.promise.then(function(a){r[c]=a,h()},i)}catch(a){i(a)}}var l=a.defer(),m=0;H(f,function(a){q.hasOwnProperty(a)&&!d.hasOwnProperty(a)&&(m++,q[a].then(function(b){r[a]=b,--m||j()},i))}),m||j(),q[c]=l.promise}if(l(d)&&g===c&&(g=f,f=d,d=null),d){if(!F(d))throw new Error("'locals' must be an object")}else d=i;if(f){if(!l(f))throw new Error("'parent' must be a promise returned by $resolve.resolve()")}else f=j;var o=a.defer(),p=o.promise,q=p.$$promises={},r=I({},d),s=1+m.length/3,t=!1;if(C(f.$$failure))return k(f.$$failure),p;f.$$values?(t=e(r,f.$$values),h()):(I(q,f.$$promises),f.then(h,k));for(var u=0,v=m.length;v>u;u+=3)d.hasOwnProperty(m[u])?h():n(m[u],m[u+1],m[u+2]);return p}},this.resolve=function(a,b,c,d){return this.study(a)(b,c,d)}}function m(a,b,c){this.fromConfig=function(a,b,c){return C(a.template)?this.fromString(a.template,b):C(a.templateUrl)?this.fromUrl(a.templateUrl,b):C(a.templateProvider)?this.fromProvider(a.templateProvider,b,c):null},this.fromString=function(a,b){return D(a)?a(b):a},this.fromUrl=function(c,d){return D(c)&&(c=c(d)),null==c?null:a.get(c,{cache:b}).then(function(a){return a.data})},this.fromProvider=function(a,b,d){return c.invoke(a,null,d||{params:b})}}function n(a){function b(b){if(!/^\w+(-+\w+)*$/.test(b))throw new Error("Invalid parameter name '"+b+"' in pattern '"+a+"'");if(f[b])throw new Error("Duplicate parameter name '"+b+"' in pattern '"+a+"'");f[b]=!0,j.push(b)}function c(a){return a.replace(/[\\\[\]\^$*+?.()|{}]/g,"\\$&")}var d,e=/([:*])(\w+)|\{(\w+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g,f={},g="^",h=0,i=this.segments=[],j=this.params=[];this.source=a;for(var k,l,m;(d=e.exec(a))&&(k=d[2]||d[3],l=d[4]||("*"==d[1]?".*":"[^/]*"),m=a.substring(h,d.index),!(m.indexOf("?")>=0));)g+=c(m)+"("+l+")",b(k),i.push(m),h=e.lastIndex;m=a.substring(h);var n=m.indexOf("?");if(n>=0){var o=this.sourceSearch=m.substring(n);m=m.substring(0,n),this.sourcePath=a.substring(0,h+n),H(o.substring(1).split(/[&?]/),b)}else this.sourcePath=a,this.sourceSearch="";g+=c(m)+"$",i.push(m),this.regexp=new RegExp(g),this.prefix=i[0]}function o(){this.compile=function(a){return new n(a)},this.isMatcher=function(a){return F(a)&&D(a.exec)&&D(a.format)&&D(a.concat)},this.$get=function(){return this}}function p(a){function b(a){var b=/^\^((?:\\[^a-zA-Z0-9]|[^\\\[\]\^$*+?.()|{}]+)*)/.exec(a.source);return null!=b?b[1].replace(/\\(.)/g,"$1"):""}function c(a,b){return a.replace(/\$(\$|\d{1,2})/,function(a,c){return b["$"===c?0:Number(c)]})}function d(a,b,c){if(!c)return!1;var d=a.invoke(b,b,{$match:c});return C(d)?d:!0}var e=[],f=null;this.rule=function(a){if(!D(a))throw new Error("'rule' must be a function");return e.push(a),this},this.otherwise=function(a){if(E(a)){var b=a;a=function(){return b}}else if(!D(a))throw new Error("'rule' must be a function");return f=a,this},this.when=function(e,f){var g,h=E(f);if(E(e)&&(e=a.compile(e)),!h&&!D(f)&&!G(f))throw new Error("invalid 'handler' in when()");var i={matcher:function(b,c){return h&&(g=a.compile(c),c=["$match",function(a){return g.format(a)}]),I(function(a,e){return d(a,c,b.exec(e.path(),e.search()))},{prefix:E(b.prefix)?b.prefix:""})},regex:function(a,e){if(a.global||a.sticky)throw new Error("when() RegExp must not be global or sticky");return h&&(g=e,e=["$match",function(a){return c(g,a)}]),I(function(b,c){return d(b,e,a.exec(c.path()))},{prefix:b(a)})}},j={matcher:a.isMatcher(e),regex:e instanceof RegExp};for(var k in j)if(j[k])return this.rule(i[k](e,f));throw new Error("invalid 'what' in when()")},this.$get=["$location","$rootScope","$injector",function(a,b,c){function d(b){function d(b){var d=b(c,a);return d?(E(d)&&a.replace().url(d),!0):!1}if(!b||!b.defaultPrevented){var g,h=e.length;for(g=0;h>g;g++)if(d(e[g]))return;f&&d(f)}}return b.$on("$locationChangeSuccess",d),{sync:function(){d()}}}]}function q(a,e,f){function g(a){return 0===a.indexOf(".")||0===a.indexOf("^")}function l(a,b){var d=E(a),e=d?a:a.name,f=g(e);if(f){if(!b)throw new Error("No reference point given for path '"+e+"'");for(var h=e.split("."),i=0,j=h.length,k=b;j>i;i++)if(""!==h[i]||0!==i){if("^"!==h[i])break;if(!k.parent)throw new Error("Path '"+e+"' not valid for state '"+b.name+"'");k=k.parent}else k=b;h=h.slice(i).join("."),e=k.name+(k.name&&h?".":"")+h}var l=w[e];return!l||!d&&(d||l!==a&&l.self!==a)?c:l}function m(a,b){x[a]||(x[a]=[]),x[a].push(b)}function n(b){b=d(b,{self:b,resolve:b.resolve||{},toString:function(){return this.name}});var c=b.name;if(!E(c)||c.indexOf("@")>=0)throw new Error("State must have a valid name");if(w.hasOwnProperty(c))throw new Error("State '"+c+"'' is already defined");var e=-1!==c.indexOf(".")?c.substring(0,c.lastIndexOf(".")):E(b.parent)?b.parent:"";if(e&&!w[e])return m(e,b.self);for(var f in z)D(z[f])&&(b[f]=z[f](b,z.$delegates[f]));if(w[c]=b,!b[y]&&b.url&&a.when(b.url,["$match","$stateParams",function(a,c){v.$current.navigable==b&&j(a,c)||v.transitionTo(b,a,{location:!1})}]),x[c])for(var g=0;g<x[c].length;g++)n(x[c][g]);return b}function o(a){return a.indexOf("*")>-1}function p(a){var b=a.split("."),c=v.$current.name.split(".");if("**"===b[0]&&(c=c.slice(c.indexOf(b[1])),c.unshift("**")),"**"===b[b.length-1]&&(c.splice(c.indexOf(b[b.length-2])+1,Number.MAX_VALUE),c.push("**")),b.length!=c.length)return!1;for(var d=0,e=b.length;e>d;d++)"*"===b[d]&&(c[d]="*");return c.join("")===b.join("")}function q(a,b){return E(a)&&!C(b)?z[a]:D(b)&&E(a)?(z[a]&&!z.$delegates[a]&&(z.$delegates[a]=z[a]),z[a]=b,this):this}function r(a,b){return F(a)?b=a:b.name=a,n(b),this}function s(a,e,g,m,n,q,r,s,x){function z(){r.url()!==M&&(r.url(M),r.replace())}function A(a,c,d,f,h){var i=d?c:k(a.params,c),j={$stateParams:i};h.resolve=n.resolve(a.resolve,j,h.resolve,a);var l=[h.resolve.then(function(a){h.globals=a})];return f&&l.push(f),H(a.views,function(c,d){var e=c.resolve&&c.resolve!==a.resolve?c.resolve:{};e.$template=[function(){return g.load(d,{view:c,locals:j,params:i,notify:!1})||""}],l.push(n.resolve(e,j,h.resolve,a).then(function(f){if(D(c.controllerProvider)||G(c.controllerProvider)){var g=b.extend({},e,j);f.$$controller=m.invoke(c.controllerProvider,null,g)}else f.$$controller=c.controller;f.$$state=a,f.$$controllerAs=c.controllerAs,h[d]=f}))}),e.all(l).then(function(){return h})}var B=e.reject(new Error("transition superseded")),F=e.reject(new Error("transition prevented")),K=e.reject(new Error("transition aborted")),L=e.reject(new Error("transition failed")),M=r.url(),N=x.baseHref();return u.locals={resolve:null,globals:{$stateParams:{}}},v={params:{},current:u.self,$current:u,transition:null},v.reload=function(){v.transitionTo(v.current,q,{reload:!0,inherit:!1,notify:!1})},v.go=function(a,b,c){return this.transitionTo(a,b,I({inherit:!0,relative:v.$current},c))},v.transitionTo=function(b,c,f){c=c||{},f=I({location:!0,inherit:!1,relative:null,notify:!0,reload:!1,$retry:!1},f||{});var g,k=v.$current,n=v.params,o=k.path,p=l(b,f.relative);if(!C(p)){var s={to:b,toParams:c,options:f};if(g=a.$broadcast("$stateNotFound",s,k.self,n),g.defaultPrevented)return z(),K;if(g.retry){if(f.$retry)return z(),L;var w=v.transition=e.when(g.retry);return w.then(function(){return w!==v.transition?B:(s.options.$retry=!0,v.transitionTo(s.to,s.toParams,s.options))},function(){return K}),z(),w}if(b=s.to,c=s.toParams,f=s.options,p=l(b,f.relative),!C(p)){if(f.relative)throw new Error("Could not resolve '"+b+"' from state '"+f.relative+"'");throw new Error("No such state '"+b+"'")}}if(p[y])throw new Error("Cannot transition to abstract state '"+b+"'");f.inherit&&(c=h(q,c||{},v.$current,p)),b=p;var x,D,E=b.path,G=u.locals,H=[];for(x=0,D=E[x];D&&D===o[x]&&j(c,n,D.ownParams)&&!f.reload;x++,D=E[x])G=H[x]=D.locals;if(t(b,k,G,f))return b.self.reloadOnSearch!==!1&&z(),v.transition=null,e.when(v.current);if(c=i(b.params,c||{}),f.notify&&(g=a.$broadcast("$stateChangeStart",b.self,c,k.self,n),g.defaultPrevented))return z(),F;for(var N=e.when(G),O=x;O<E.length;O++,D=E[O])G=H[O]=d(G),N=A(D,c,D===b,N,G);var P=v.transition=N.then(function(){var d,e,g;if(v.transition!==P)return B;for(d=o.length-1;d>=x;d--)g=o[d],g.self.onExit&&m.invoke(g.self.onExit,g.self,g.locals.globals),g.locals=null;for(d=x;d<E.length;d++)e=E[d],e.locals=H[d],e.self.onEnter&&m.invoke(e.self.onEnter,e.self,e.locals.globals);if(v.transition!==P)return B;v.$current=b,v.current=b.self,v.params=c,J(v.params,q),v.transition=null;var h=b.navigable;return f.location&&h&&(r.url(h.url.format(h.locals.globals.$stateParams)),"replace"===f.location&&r.replace()),f.notify&&a.$broadcast("$stateChangeSuccess",b.self,c,k.self,n),M=r.url(),v.current},function(d){return v.transition!==P?B:(v.transition=null,a.$broadcast("$stateChangeError",b.self,c,k.self,n,d),z(),e.reject(d))});return P},v.is=function(a,d){var e=l(a);return C(e)?v.$current!==e?!1:C(d)&&null!==d?b.equals(q,d):!0:c},v.includes=function(a,d){if(E(a)&&o(a)){if(!p(a))return!1;a=v.$current.name}var e=l(a);if(!C(e))return c;if(!C(v.$current.includes[e.name]))return!1;var f=!0;return b.forEach(d,function(a,b){C(q[b])&&q[b]===a||(f=!1)}),f},v.href=function(a,b,c){c=I({lossy:!0,inherit:!1,absolute:!1,relative:v.$current},c||{});var d=l(a,c.relative);if(!C(d))return null;b=h(q,b||{},v.$current,d);var e=d&&c.lossy?d.navigable:d,g=e&&e.url?e.url.format(i(d.params,b||{})):null;return!f.html5Mode()&&g&&(g="#"+f.hashPrefix()+g),"/"!==N&&(f.html5Mode()?g=N.slice(0,-1)+g:c.absolute&&(g=N.slice(1)+g)),c.absolute&&g&&(g=r.protocol()+"://"+r.host()+(80==r.port()||443==r.port()?"":":"+r.port())+(!f.html5Mode()&&g?"/":"")+g),g},v.get=function(a,b){if(!C(a)){var c=[];return H(w,function(a){c.push(a.self)}),c}var d=l(a,b);return d&&d.self?d.self:null},v}function t(a,b,c,d){return a!==b||(c!==b.locals||d.reload)&&a.self.reloadOnSearch!==!1?void 0:!0}var u,v,w={},x={},y="abstract",z={parent:function(a){if(C(a.parent)&&a.parent)return l(a.parent);var b=/^(.+)\.[^.]+$/.exec(a.name);return b?l(b[1]):u},data:function(a){return a.parent&&a.parent.data&&(a.data=a.self.data=I({},a.parent.data,a.data)),a.data},url:function(a){var b=a.url;if(E(b))return"^"==b.charAt(0)?e.compile(b.substring(1)):(a.parent.navigable||u).url.concat(b);if(e.isMatcher(b)||null==b)return b;throw new Error("Invalid url '"+b+"' in state '"+a+"'")},navigable:function(a){return a.url?a:a.parent?a.parent.navigable:null},params:function(a){if(!a.params)return a.url?a.url.parameters():a.parent.params;if(!G(a.params))throw new Error("Invalid params in state '"+a+"'");if(a.url)throw new Error("Both params and url specicified in state '"+a+"'");return a.params},views:function(a){var b={};return H(C(a.views)?a.views:{"":a},function(c,d){d.indexOf("@")<0&&(d+="@"+a.parent.name),b[d]=c}),b},ownParams:function(a){if(!a.parent)return a.params;var b={};H(a.params,function(a){b[a]=!0}),H(a.parent.params,function(c){if(!b[c])throw new Error("Missing required parameter '"+c+"' in state '"+a.name+"'");b[c]=!1});var c=[];return H(b,function(a,b){a&&c.push(b)}),c},path:function(a){return a.parent?a.parent.path.concat(a):[]},includes:function(a){var b=a.parent?I({},a.parent.includes):{};return b[a.name]=!0,b},$delegates:{}};u=n({name:"",url:"^",views:null,"abstract":!0}),u.navigable=null,this.decorator=q,this.state=r,this.$get=s,s.$inject=["$rootScope","$q","$view","$injector","$resolve","$stateParams","$location","$urlRouter","$browser"]}function r(){function a(a,b){return{load:function(c,d){var e,f={template:null,controller:null,view:null,locals:null,notify:!0,async:!0,params:{}};return d=I(f,d),d.view&&(e=b.fromConfig(d.view,d.params,d.locals)),e&&d.notify&&a.$broadcast("$viewContentLoading",d),e}}}this.$get=a,a.$inject=["$rootScope","$templateFactory"]}function s(){var a=!1;this.useAnchorScroll=function(){a=!0},this.$get=["$anchorScroll","$timeout",function(b,c){return a?b:function(a){c(function(){a[0].scrollIntoView()},0,!1)}}]}function t(a,c,d){function e(){return c.has?function(a){return c.has(a)?c.get(a):null}:function(a){try{return c.get(a)}catch(b){return null}}}function f(a,b){var c=function(){return{enter:function(a,b,c){b.after(a),c()},leave:function(a,b){a.remove(),b()}}};if(i)return{enter:function(a,b,c){i.enter(a,null,b,c)},leave:function(a,b){i.leave(a,b)}};if(h){var d=h&&h(b,a);return{enter:function(a,b,c){d.enter(a,null,b),c()},leave:function(a,b){d.leave(a),b()}}}return c()}var g=e(),h=g("$animator"),i=g("$animate"),j={restrict:"ECA",terminal:!0,priority:400,transclude:"element",compile:function(c,e,g){return function(c,e,h){function i(){k&&(k.remove(),k=null),m&&(m.$destroy(),m=null),l&&(q.leave(l,function(){k=null}),k=l,l=null)}function j(f){var h=c.$new(),j=l&&l.data("$uiViewName"),k=j&&a.$current&&a.$current.locals[j];if(f||k!==n){var r=g(h,function(a){q.enter(a,e,function(){(b.isDefined(p)&&!p||c.$eval(p))&&d(a)}),i()});n=a.$current.locals[r.data("$uiViewName")],l=r,m=h,m.$emit("$viewContentLoaded"),m.$eval(o)}}var k,l,m,n,o=h.onload||"",p=h.autoscroll,q=f(h,c);c.$on("$stateChangeSuccess",function(){j(!1)}),c.$on("$viewContentLoading",function(){j(!1)}),j(!0)}}};return j}function u(a,b,c){return{restrict:"ECA",priority:-400,compile:function(d){var e=d.html();return function(d,f,g){var h=g.uiView||g.name||"",i=f.inheritedData("$uiView");h.indexOf("@")<0&&(h=h+"@"+(i?i.state.name:"")),f.data("$uiViewName",h);var j=c.$current,k=j&&j.locals[h];if(k){f.data("$uiView",{name:h,state:k.$$state}),f.html(k.$template?k.$template:e);var l=a(f.contents());if(k.$$controller){k.$scope=d;var m=b(k.$$controller,k);k.$$controllerAs&&(d[k.$$controllerAs]=m),f.data("$ngControllerController",m),f.children().data("$ngControllerController",m)}l(d)}}}}}function v(a){var b=a.replace(/\n/g," ").match(/^([^(]+?)\s*(\((.*)\))?$/);if(!b||4!==b.length)throw new Error("Invalid state ref '"+a+"'");return{state:b[1],paramExpr:b[3]||null}}function w(a){var b=a.parent().inheritedData("$uiView");return b&&b.state&&b.state.name?b.state:void 0}function x(a,c){var d=["location","inherit","reload"];return{restrict:"A",require:"?^uiSrefActive",link:function(e,f,g,h){var i=v(g.uiSref),j=null,k=w(f)||a.$current,l="FORM"===f[0].nodeName,m=l?"action":"href",n=!0,o={relative:k},p=e.$eval(g.uiSrefOpts)||{};b.forEach(d,function(a){a in p&&(o[a]=p[a])});var q=function(b){if(b&&(j=b),n){var c=a.href(i.state,j,o);return h&&h.$$setStateInfo(i.state,j),c?void(f[0][m]=c):(n=!1,!1)}};i.paramExpr&&(e.$watch(i.paramExpr,function(a){a!==j&&q(a)},!0),j=e.$eval(i.paramExpr)),q(),l||f.bind("click",function(b){var d=b.which||b.button;d>1||b.ctrlKey||b.metaKey||b.shiftKey||f.attr("target")||(c(function(){a.go(i.state,j,o)}),b.preventDefault())})}}}function y(a,b,c){return{restrict:"A",controller:["$scope","$element","$attrs",function(d,e,f){function g(){a.$current.self===i&&h()?e.addClass(l):e.removeClass(l)}function h(){return!k||j(k,b)}var i,k,l;l=c(f.uiSrefActive||"",!1)(d),this.$$setStateInfo=function(b,c){i=a.get(b,w(e)),k=c,g()},d.$on("$stateChangeSuccess",g)}]}}function z(a){return function(b){return a.is(b)}}function A(a){return function(b){return a.includes(b)}}function B(a,b){function e(a){this.locals=a.locals.globals,this.params=this.locals.$stateParams}function f(){this.locals=null,this.params=null}function g(c,g){if(null!=g.redirectTo){var h,j=g.redirectTo;if(E(j))h=j;else{if(!D(j))throw new Error("Invalid 'redirectTo' in when()");h=function(a,b){return j(a,b.path(),b.search())}}b.when(c,h)}else a.state(d(g,{parent:null,name:"route:"+encodeURIComponent(c),url:c,onEnter:e,onExit:f}));return i.push(g),this}function h(a,b,d){function e(a){return""!==a.name?a:c}var f={routes:i,params:d,current:c};return b.$on("$stateChangeStart",function(a,c,d,f){b.$broadcast("$routeChangeStart",e(c),e(f))}),b.$on("$stateChangeSuccess",function(a,c,d,g){f.current=e(c),b.$broadcast("$routeChangeSuccess",e(c),e(g)),J(d,f.params)}),b.$on("$stateChangeError",function(a,c,d,f,g,h){b.$broadcast("$routeChangeError",e(c),e(f),h)}),f}var i=[];e.$inject=["$$state"],this.when=g,this.$get=h,h.$inject=["$state","$rootScope","$routeParams"]}var C=b.isDefined,D=b.isFunction,E=b.isString,F=b.isObject,G=b.isArray,H=b.forEach,I=b.extend,J=b.copy;b.module("ui.router.util",["ng"]),b.module("ui.router.router",["ui.router.util"]),b.module("ui.router.state",["ui.router.router","ui.router.util"]),b.module("ui.router",["ui.router.state"]),b.module("ui.router.compat",["ui.router"]),l.$inject=["$q","$injector"],b.module("ui.router.util").service("$resolve",l),m.$inject=["$http","$templateCache","$injector"],b.module("ui.router.util").service("$templateFactory",m),n.prototype.concat=function(a){return new n(this.sourcePath+a+this.sourceSearch)},n.prototype.toString=function(){return this.source},n.prototype.exec=function(a,b){var c=this.regexp.exec(a);if(!c)return null;var d,e=this.params,f=e.length,g=this.segments.length-1,h={};if(g!==c.length-1)throw new Error("Unbalanced capture group in route '"+this.source+"'");for(d=0;g>d;d++)h[e[d]]=c[d+1];for(;f>d;d++)h[e[d]]=b[e[d]];return h},n.prototype.parameters=function(){return this.params},n.prototype.format=function(a){var b=this.segments,c=this.params;if(!a)return b.join("");var d,e,f,g=b.length-1,h=c.length,i=b[0];for(d=0;g>d;d++)f=a[c[d]],null!=f&&(i+=encodeURIComponent(f)),i+=b[d+1];for(;h>d;d++)f=a[c[d]],null!=f&&(i+=(e?"&":"?")+c[d]+"="+encodeURIComponent(f),e=!0);return i},b.module("ui.router.util").provider("$urlMatcherFactory",o),p.$inject=["$urlMatcherFactoryProvider"],b.module("ui.router.router").provider("$urlRouter",p),q.$inject=["$urlRouterProvider","$urlMatcherFactoryProvider","$locationProvider"],b.module("ui.router.state").value("$stateParams",{}).provider("$state",q),r.$inject=[],b.module("ui.router.state").provider("$view",r),b.module("ui.router.state").provider("$uiViewScroll",s),t.$inject=["$state","$injector","$uiViewScroll"],u.$inject=["$compile","$controller","$state"],b.module("ui.router.state").directive("uiView",t),b.module("ui.router.state").directive("uiView",u),x.$inject=["$state","$timeout"],y.$inject=["$state","$stateParams","$interpolate"],b.module("ui.router.state").directive("uiSref",x).directive("uiSrefActive",y),z.$inject=["$state"],A.$inject=["$state"],b.module("ui.router.state").filter("isState",z).filter("includedByState",A),B.$inject=["$stateProvider","$urlRouterProvider"],b.module("ui.router.compat").provider("$route",B).directive("ngView",t)}(window,window.angular); \ No newline at end of file +"undefined"!=typeof module&&"undefined"!=typeof exports&&module.exports===exports&&(module.exports="ui.router"),function(a,b,c){"use strict";function d(a,b){return T(new(T(function(){},{prototype:a})),b)}function e(a){return S(arguments,function(b){b!==a&&S(b,function(b,c){a.hasOwnProperty(c)||(a[c]=b)})}),a}function f(a,b){var c=[];for(var d in a.path){if(a.path[d]!==b.path[d])break;c.push(a.path[d])}return c}function g(a){if(Object.keys)return Object.keys(a);var b=[];return S(a,function(a,c){b.push(c)}),b}function h(a,b){if(Array.prototype.indexOf)return a.indexOf(b,Number(arguments[2])||0);var c=a.length>>>0,d=Number(arguments[2])||0;for(d=d<0?Math.ceil(d):Math.floor(d),d<0&&(d+=c);d<c;d++)if(d in a&&a[d]===b)return d;return-1}function i(a,b,c,d){var e,i=f(c,d),j={},k=[];for(var l in i)if(i[l]&&i[l].params&&(e=g(i[l].params),e.length))for(var m in e)h(k,e[m])>=0||(k.push(e[m]),j[e[m]]=a[e[m]]);return T({},j,b)}function j(a,b,c){if(!c){c=[];for(var d in a)c.push(d)}for(var e=0;e<c.length;e++){var f=c[e];if(a[f]!=b[f])return!1}return!0}function k(a,b){var c={};return S(a,function(a){c[a]=b[a]}),c}function l(a){var b={},c=Array.prototype.concat.apply(Array.prototype,Array.prototype.slice.call(arguments,1));return S(c,function(c){c in a&&(b[c]=a[c])}),b}function m(a){var b={},c=Array.prototype.concat.apply(Array.prototype,Array.prototype.slice.call(arguments,1));for(var d in a)-1==h(c,d)&&(b[d]=a[d]);return b}function n(a,b){var c=R(a),d=c?[]:{};return S(a,function(a,e){b(a,e)&&(d[c?d.length:e]=a)}),d}function o(a,b){var c=R(a)?[]:{};return S(a,function(a,d){c[d]=b(a,d)}),c}function p(a){return a.then(c,function(){})&&a}function q(a,b){var d=1,f=2,i={},j=[],k=i,l=T(a.when(i),{$$promises:i,$$values:i});this.study=function(i){function n(a,c){if(t[c]!==f){if(s.push(c),t[c]===d)throw s.splice(0,h(s,c)),new Error("Cyclic dependency: "+s.join(" -> "));if(t[c]=d,P(a))r.push(c,[function(){return b.get(a)}],j);else{var e=b.annotate(a);S(e,function(a){a!==c&&i.hasOwnProperty(a)&&n(i[a],a)}),r.push(c,a,e)}s.pop(),t[c]=f}}function o(a){return Q(a)&&a.then&&a.$$promises}if(!Q(i))throw new Error("'invocables' must be an object");var q=g(i||{}),r=[],s=[],t={};return S(i,n),i=s=t=null,function(d,f,g){function h(){--v||(w||e(u,f.$$values),s.$$values=u,s.$$promises=s.$$promises||!0,delete s.$$inheritedValues,n.resolve(u))}function i(a){s.$$failure=a,n.reject(a)}function j(c,e,f){function j(a){l.reject(a),i(a)}function k(){if(!N(s.$$failure))try{l.resolve(b.invoke(e,g,u)),l.promise.then(function(a){u[c]=a,h()},j)}catch(a){j(a)}}var l=a.defer(),m=0;S(f,function(a){t.hasOwnProperty(a)&&!d.hasOwnProperty(a)&&(m++,t[a].then(function(b){u[a]=b,--m||k()},j))}),m||k(),t[c]=p(l.promise)}if(o(d)&&g===c&&(g=f,f=d,d=null),d){if(!Q(d))throw new Error("'locals' must be an object")}else d=k;if(f){if(!o(f))throw new Error("'parent' must be a promise returned by $resolve.resolve()")}else f=l;var n=a.defer(),s=p(n.promise),t=s.$$promises={},u=T({},d),v=1+r.length/3,w=!1;if(p(s),N(f.$$failure))return i(f.$$failure),s;f.$$inheritedValues&&e(u,m(f.$$inheritedValues,q)),T(t,f.$$promises),f.$$values?(w=e(u,m(f.$$values,q)),s.$$inheritedValues=m(f.$$values,q),h()):(f.$$inheritedValues&&(s.$$inheritedValues=m(f.$$inheritedValues,q)),f.then(h,i));for(var x=0,y=r.length;x<y;x+=3)d.hasOwnProperty(r[x])?h():j(r[x],r[x+1],r[x+2]);return s}},this.resolve=function(a,b,c,d){return this.study(a)(b,c,d)}}function r(){var a=b.version.minor<3;this.shouldUnsafelyUseHttp=function(b){a=!!b},this.$get=["$http","$templateCache","$injector",function(b,c,d){return new s(b,c,d,a)}]}function s(a,b,c,d){this.fromConfig=function(a,b,c){return N(a.template)?this.fromString(a.template,b):N(a.templateUrl)?this.fromUrl(a.templateUrl,b):N(a.templateProvider)?this.fromProvider(a.templateProvider,b,c):null},this.fromString=function(a,b){return O(a)?a(b):a},this.fromUrl=function(e,f){return O(e)&&(e=e(f)),null==e?null:d?a.get(e,{cache:b,headers:{Accept:"text/html"}}).then(function(a){return a.data}):c.get("$templateRequest")(e)},this.fromProvider=function(a,b,d){return c.invoke(a,null,d||{params:b})}}function t(a,b,e){function f(b,c,d,e){if(q.push(b),o[b])return o[b];if(!/^\w+([-.]+\w+)*(?:\[\])?$/.test(b))throw new Error("Invalid parameter name '"+b+"' in pattern '"+a+"'");if(p[b])throw new Error("Duplicate parameter name '"+b+"' in pattern '"+a+"'");return p[b]=new W.Param(b,c,d,e),p[b]}function g(a,b,c,d){var e=["",""],f=a.replace(/[\\\[\]\^$*+?.()|{}]/g,"\\$&");if(!b)return f;switch(c){case!1:e=["(",")"+(d?"?":"")];break;case!0:f=f.replace(/\/$/,""),e=["(?:/(",")|/)?"];break;default:e=["("+c+"|",")?"]}return f+e[0]+b+e[1]}function h(e,f){var g,h,i,j,k;return g=e[2]||e[3],k=b.params[g],i=a.substring(m,e.index),h=f?e[4]:e[4]||("*"==e[1]?".*":null),h&&(j=W.type(h)||d(W.type("string"),{pattern:new RegExp(h,b.caseInsensitive?"i":c)})),{id:g,regexp:h,segment:i,type:j,cfg:k}}b=T({params:{}},Q(b)?b:{});var i,j=/([:*])([\w\[\]]+)|\{([\w\[\]]+)(?:\:\s*((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g,k=/([:]?)([\w\[\].-]+)|\{([\w\[\].-]+)(?:\:\s*((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g,l="^",m=0,n=this.segments=[],o=e?e.params:{},p=this.params=e?e.params.$$new():new W.ParamSet,q=[];this.source=a;for(var r,s,t;(i=j.exec(a))&&(r=h(i,!1),!(r.segment.indexOf("?")>=0));)s=f(r.id,r.type,r.cfg,"path"),l+=g(r.segment,s.type.pattern.source,s.squash,s.isOptional),n.push(r.segment),m=j.lastIndex;t=a.substring(m);var u=t.indexOf("?");if(u>=0){var v=this.sourceSearch=t.substring(u);if(t=t.substring(0,u),this.sourcePath=a.substring(0,m+u),v.length>0)for(m=0;i=k.exec(v);)r=h(i,!0),s=f(r.id,r.type,r.cfg,"search"),m=j.lastIndex}else this.sourcePath=a,this.sourceSearch="";l+=g(t)+(!1===b.strict?"/?":"")+"$",n.push(t),this.regexp=new RegExp(l,b.caseInsensitive?"i":c),this.prefix=n[0],this.$$paramNames=q}function u(a){T(this,a)}function v(){function a(a){return null!=a?a.toString().replace(/(~|\/)/g,function(a){return{"~":"~~","/":"~2F"}[a]}):a}function e(a){return null!=a?a.toString().replace(/(~~|~2F)/g,function(a){return{"~~":"~","~2F":"/"}[a]}):a}function f(){return{strict:p,caseInsensitive:m}}function i(a){return O(a)||R(a)&&O(a[a.length-1])}function j(){for(;w.length;){var a=w.shift();if(a.pattern)throw new Error("You cannot override a type's .pattern at runtime.");b.extend(r[a.name],l.invoke(a.def))}}function k(a){T(this,a||{})}W=this;var l,m=!1,p=!0,q=!1,r={},s=!0,w=[],x={string:{encode:a,decode:e,is:function(a){return null==a||!N(a)||"string"==typeof a},pattern:/[^\/]*/},int:{encode:a,decode:function(a){return parseInt(a,10)},is:function(a){return a!==c&&null!==a&&this.decode(a.toString())===a},pattern:/-?\d+/},bool:{encode:function(a){return a?1:0},decode:function(a){return 0!==parseInt(a,10)},is:function(a){return!0===a||!1===a},pattern:/0|1/},date:{encode:function(a){return this.is(a)?[a.getFullYear(),("0"+(a.getMonth()+1)).slice(-2),("0"+a.getDate()).slice(-2)].join("-"):c},decode:function(a){if(this.is(a))return a;var b=this.capture.exec(a);return b?new Date(b[1],b[2]-1,b[3]):c},is:function(a){return a instanceof Date&&!isNaN(a.valueOf())},equals:function(a,b){return this.is(a)&&this.is(b)&&a.toISOString()===b.toISOString()},pattern:/[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[1-2][0-9]|3[0-1])/,capture:/([0-9]{4})-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])/},json:{encode:b.toJson,decode:b.fromJson,is:b.isObject,equals:b.equals,pattern:/[^\/]*/},any:{encode:b.identity,decode:b.identity,equals:b.equals,pattern:/.*/}};v.$$getDefaultValue=function(a){if(!i(a.value))return a.value;if(!l)throw new Error("Injectable functions cannot be called at configuration time");return l.invoke(a.value)},this.caseInsensitive=function(a){return N(a)&&(m=a),m},this.strictMode=function(a){return N(a)&&(p=a),p},this.defaultSquashPolicy=function(a){if(!N(a))return q;if(!0!==a&&!1!==a&&!P(a))throw new Error("Invalid squash policy: "+a+". Valid policies: false, true, arbitrary-string");return q=a,a},this.compile=function(a,b){return new t(a,T(f(),b))},this.isMatcher=function(a){if(!Q(a))return!1;var b=!0;return S(t.prototype,function(c,d){O(c)&&(b=b&&N(a[d])&&O(a[d]))}),b},this.type=function(a,b,c){if(!N(b))return r[a];if(r.hasOwnProperty(a))throw new Error("A type named '"+a+"' has already been defined.");return r[a]=new u(T({name:a},b)),c&&(w.push({name:a,def:c}),s||j()),this},S(x,function(a,b){r[b]=new u(T({name:b},a))}),r=d(r,{}),this.$get=["$injector",function(a){return l=a,s=!1,j(),S(x,function(a,b){r[b]||(r[b]=new u(a))}),this}],this.Param=function(a,d,e,f){function j(a){var b=Q(a)?g(a):[];return-1===h(b,"value")&&-1===h(b,"type")&&-1===h(b,"squash")&&-1===h(b,"array")&&(a={value:a}),a.$$fn=i(a.value)?a.value:function(){return a.value},a}function k(c,d,e){if(c.type&&d)throw new Error("Param '"+a+"' has two type configurations.");return d||(c.type?b.isString(c.type)?r[c.type]:c.type instanceof u?c.type:new u(c.type):"config"===e?r.any:r.string)}function m(){var b={array:"search"===f&&"auto"},c=a.match(/\[\]$/)?{array:!0}:{};return T(b,c,e).array}function p(a,b){var c=a.squash;if(!b||!1===c)return!1;if(!N(c)||null==c)return q;if(!0===c||P(c))return c;throw new Error("Invalid squash policy: '"+c+"'. Valid policies: false, true, or arbitrary string")}function s(a,b,d,e){var f,g,i=[{from:"",to:d||b?c:""},{from:null,to:d||b?c:""}];return f=R(a.replace)?a.replace:[],P(e)&&f.push({from:e,to:c}),g=o(f,function(a){return a.from}),n(i,function(a){return-1===h(g,a.from)}).concat(f)}function t(){if(!l)throw new Error("Injectable functions cannot be called at configuration time");var a=l.invoke(e.$$fn);if(null!==a&&a!==c&&!x.type.is(a))throw new Error("Default value ("+a+") for parameter '"+x.id+"' is not an instance of Type ("+x.type.name+")");return a}function v(a){function b(a){return function(b){return b.from===a}}function c(a){var c=o(n(x.replace,b(a)),function(a){return a.to});return c.length?c[0]:a}return a=c(a),N(a)?x.type.$normalize(a):t()}function w(){return"{Param:"+a+" "+d+" squash: '"+A+"' optional: "+z+"}"}var x=this;e=j(e),d=k(e,d,f);var y=m();d=y?d.$asArray(y,"search"===f):d,"string"!==d.name||y||"path"!==f||e.value!==c||(e.value="");var z=e.value!==c,A=p(e,z),B=s(e,y,z,A);T(this,{id:a,type:d,location:f,array:y,squash:A,replace:B,isOptional:z,value:v,dynamic:c,config:e,toString:w})},k.prototype={$$new:function(){return d(this,T(new k,{$$parent:this}))},$$keys:function(){for(var a=[],b=[],c=this,d=g(k.prototype);c;)b.push(c),c=c.$$parent;return b.reverse(),S(b,function(b){S(g(b),function(b){-1===h(a,b)&&-1===h(d,b)&&a.push(b)})}),a},$$values:function(a){var b={},c=this;return S(c.$$keys(),function(d){b[d]=c[d].value(a&&a[d])}),b},$$equals:function(a,b){var c=!0,d=this;return S(d.$$keys(),function(e){var f=a&&a[e],g=b&&b[e];d[e].type.equals(f,g)||(c=!1)}),c},$$validates:function(a){var d,e,f,g,h,i=this.$$keys();for(d=0;d<i.length&&(e=this[i[d]],(f=a[i[d]])!==c&&null!==f||!e.isOptional);d++){if(g=e.type.$normalize(f),!e.type.is(g))return!1;if(h=e.type.encode(g),b.isString(h)&&!e.type.pattern.exec(h))return!1}return!0},$$parent:c},this.ParamSet=k}function w(a,d){function e(a){var b=/^\^((?:\\[^a-zA-Z0-9]|[^\\\[\]\^$*+?.()|{}]+)*)/.exec(a.source);return null!=b?b[1].replace(/\\(.)/g,"$1"):""}function f(a,b){return a.replace(/\$(\$|\d{1,2})/,function(a,c){return b["$"===c?0:Number(c)]})}function g(a,b,c){if(!c)return!1;var d=a.invoke(b,b,{$match:c});return!N(d)||d}function h(d,e,f,g,h){function m(a,b,c){return"/"===q?a:b?q.slice(0,-1)+a:c?q.slice(1)+a:a}function n(a){function b(a){var b=a(f,d);return!!b&&(P(b)&&d.replace().url(b),!0)}if(!a||!a.defaultPrevented){p&&d.url();p=c;var e,g=j.length;for(e=0;e<g;e++)if(b(j[e]))return;k&&b(k)}}function o(){return i=i||e.$on("$locationChangeSuccess",n)}var p,q=g.baseHref(),r=d.url();return l||o(),{sync:function(){n()},listen:function(){return o()},update:function(a){if(a)return void(r=d.url());d.url()!==r&&(d.url(r),d.replace())},push:function(a,b,e){var f=a.format(b||{});null!==f&&b&&b["#"]&&(f+="#"+b["#"]),d.url(f),p=e&&e.$$avoidResync?d.url():c,e&&e.replace&&d.replace()},href:function(c,e,f){if(!c.validates(e))return null;var g=a.html5Mode();b.isObject(g)&&(g=g.enabled),g=g&&h.history;var i=c.format(e);if(f=f||{},g||null===i||(i="#"+a.hashPrefix()+i),null!==i&&e&&e["#"]&&(i+="#"+e["#"]),i=m(i,g,f.absolute),!f.absolute||!i)return i;var j=!g&&i?"/":"",k=d.port();return k=80===k||443===k?"":":"+k,[d.protocol(),"://",d.host(),k,j,i].join("")}}}var i,j=[],k=null,l=!1;this.rule=function(a){if(!O(a))throw new Error("'rule' must be a function");return j.push(a),this},this.otherwise=function(a){if(P(a)){var b=a;a=function(){return b}}else if(!O(a))throw new Error("'rule' must be a function");return k=a,this},this.when=function(a,b){var c,h=P(b);if(P(a)&&(a=d.compile(a)),!h&&!O(b)&&!R(b))throw new Error("invalid 'handler' in when()");var i={matcher:function(a,b){return h&&(c=d.compile(b),b=["$match",function(a){return c.format(a)}]),T(function(c,d){return g(c,b,a.exec(d.path(),d.search()))},{prefix:P(a.prefix)?a.prefix:""})},regex:function(a,b){if(a.global||a.sticky)throw new Error("when() RegExp must not be global or sticky");return h&&(c=b,b=["$match",function(a){return f(c,a)}]),T(function(c,d){return g(c,b,a.exec(d.path()))},{prefix:e(a)})}},j={matcher:d.isMatcher(a),regex:a instanceof RegExp};for(var k in j)if(j[k])return this.rule(i[k](a,b));throw new Error("invalid 'what' in when()")},this.deferIntercept=function(a){a===c&&(a=!0),l=a},this.$get=h,h.$inject=["$location","$rootScope","$injector","$browser","$sniffer"]}function x(a,e){function f(a){return 0===a.indexOf(".")||0===a.indexOf("^")}function m(a,b){if(!a)return c;var d=P(a),e=d?a:a.name;if(f(e)){if(!b)throw new Error("No reference point given for path '"+e+"'");b=m(b);for(var g=e.split("."),h=0,i=g.length,j=b;h<i;h++)if(""!==g[h]||0!==h){if("^"!==g[h])break;if(!j.parent)throw new Error("Path '"+e+"' not valid for state '"+b.name+"'");j=j.parent}else j=b;g=g.slice(h).join("."),e=j.name+(j.name&&g?".":"")+g}var k=A[e];return!k||!d&&(d||k!==a&&k.self!==a)?c:k}function n(a,b){B[a]||(B[a]=[]),B[a].push(b)}function q(a){for(var b=B[a]||[];b.length;)r(b.shift())}function r(b){b=d(b,{self:b,resolve:b.resolve||{},toString:function(){return this.name}});var c=b.name;if(!P(c)||c.indexOf("@")>=0)throw new Error("State must have a valid name");if(A.hasOwnProperty(c))throw new Error("State '"+c+"' is already defined");var e=-1!==c.indexOf(".")?c.substring(0,c.lastIndexOf(".")):P(b.parent)?b.parent:Q(b.parent)&&P(b.parent.name)?b.parent.name:"";if(e&&!A[e])return n(e,b.self);for(var f in D)O(D[f])&&(b[f]=D[f](b,D.$delegates[f]));return A[c]=b,!b[C]&&b.url&&a.when(b.url,["$match","$stateParams",function(a,c){z.$current.navigable==b&&j(a,c)||z.transitionTo(b,a,{inherit:!0,location:!1})}]),q(c),b}function s(a){return a.indexOf("*")>-1}function t(a){for(var b=a.split("."),c=z.$current.name.split("."),d=0,e=b.length;d<e;d++)"*"===b[d]&&(c[d]="*");return"**"===b[0]&&(c=c.slice(h(c,b[1])),c.unshift("**")),"**"===b[b.length-1]&&(c.splice(h(c,b[b.length-2])+1,Number.MAX_VALUE),c.push("**")),b.length==c.length&&c.join("")===b.join("")}function u(a,b){return P(a)&&!N(b)?D[a]:O(b)&&P(a)?(D[a]&&!D.$delegates[a]&&(D.$delegates[a]=D[a]),D[a]=b,this):this}function v(a,b){return Q(a)?b=a:b.name=a,r(b),this}function w(a,e,f,h,j,l,n,q,r){function u(b,c,d,f){var g=a.$broadcast("$stateNotFound",b,c,d);if(g.defaultPrevented)return n.update(),E;if(!g.retry)return null;if(f.$retry)return n.update(),F;var h=z.transition=e.when(g.retry);return h.then(function(){return h!==z.transition?(a.$broadcast("$stateChangeCancel",b.to,b.toParams,c,d),B):(b.options.$retry=!0,z.transitionTo(b.to,b.toParams,b.options))},function(){return E}),n.update(),h}function v(a,c,d,g,i,l){function m(){var c=[];return S(a.views,function(d,e){var g=d.resolve&&d.resolve!==a.resolve?d.resolve:{};g.$template=[function(){return f.load(e,{view:d,locals:i.globals,params:n,notify:l.notify})||""}],c.push(j.resolve(g,i.globals,i.resolve,a).then(function(c){if(O(d.controllerProvider)||R(d.controllerProvider)){var f=b.extend({},g,i.globals);c.$$controller=h.invoke(d.controllerProvider,null,f)}else c.$$controller=d.controller;c.$$state=a,c.$$controllerAs=d.controllerAs,c.$$resolveAs=d.resolveAs,i[e]=c}))}),e.all(c).then(function(){return i.globals})}var n=d?c:k(a.params.$$keys(),c),o={$stateParams:n};i.resolve=j.resolve(a.resolve,o,i.resolve,a);var p=[i.resolve.then(function(a){i.globals=a})];return g&&p.push(g),e.all(p).then(m).then(function(a){return i})}var w=new Error("transition superseded"),B=p(e.reject(w)),D=p(e.reject(new Error("transition prevented"))),E=p(e.reject(new Error("transition aborted"))),F=p(e.reject(new Error("transition failed")));return y.locals={resolve:null,globals:{$stateParams:{}}},z={params:{},current:y.self,$current:y,transition:null},z.reload=function(a){return z.transitionTo(z.current,l,{reload:a||!0,inherit:!1,notify:!0})},z.go=function(a,b,c){return z.transitionTo(a,b,T({inherit:!0,relative:z.$current},c))},z.transitionTo=function(b,c,f){c=c||{},f=T({location:!0,inherit:!1,relative:null,notify:!0,reload:!1,$retry:!1},f||{});var g,j=z.$current,o=z.params,q=j.path,r=m(b,f.relative),s=c["#"];if(!N(r)){var t={to:b,toParams:c,options:f},A=u(t,j.self,o,f);if(A)return A;if(b=t.to,c=t.toParams,f=t.options,r=m(b,f.relative),!N(r)){if(!f.relative)throw new Error("No such state '"+b+"'");throw new Error("Could not resolve '"+b+"' from state '"+f.relative+"'")}}if(r[C])throw new Error("Cannot transition to abstract state '"+b+"'");if(f.inherit&&(c=i(l,c||{},z.$current,r)),!r.params.$$validates(c))return F;c=r.params.$$values(c),b=r;var E=b.path,G=0,H=E[G],I=y.locals,J=[];if(f.reload){if(P(f.reload)||Q(f.reload)){if(Q(f.reload)&&!f.reload.name)throw new Error("Invalid reload state object");var K=!0===f.reload?q[0]:m(f.reload);if(f.reload&&!K)throw new Error("No such reload state '"+(P(f.reload)?f.reload:f.reload.name)+"'");for(;H&&H===q[G]&&H!==K;)I=J[G]=H.locals,G++,H=E[G]}}else for(;H&&H===q[G]&&H.ownParams.$$equals(c,o);)I=J[G]=H.locals,G++,H=E[G];if(x(b,c,j,o,I,f))return s&&(c["#"]=s),z.params=c,U(z.params,l),U(k(b.params.$$keys(),l),b.locals.globals.$stateParams),f.location&&b.navigable&&b.navigable.url&&(n.push(b.navigable.url,c,{$$avoidResync:!0,replace:"replace"===f.location}),n.update(!0)),z.transition=null,e.when(z.current);if(c=k(b.params.$$keys(),c||{}),s&&(c["#"]=s),f.notify&&a.$broadcast("$stateChangeStart",b.self,c,j.self,o,f).defaultPrevented)return a.$broadcast("$stateChangeCancel",b.self,c,j.self,o),null==z.transition&&n.update(),D;for(var L=e.when(I),M=G;M<E.length;M++,H=E[M])I=J[M]=d(I),L=v(H,c,H===b,L,I,f);var O=z.transition=L.then(function(){var d,e,g;if(z.transition!==O)return a.$broadcast("$stateChangeCancel",b.self,c,j.self,o),B;for(d=q.length-1;d>=G;d--)g=q[d],g.self.onExit&&h.invoke(g.self.onExit,g.self,g.locals.globals),g.locals=null;for(d=G;d<E.length;d++)e=E[d],e.locals=J[d],e.self.onEnter&&h.invoke(e.self.onEnter,e.self,e.locals.globals);return z.transition!==O?(a.$broadcast("$stateChangeCancel",b.self,c,j.self,o),B):(z.$current=b,z.current=b.self,z.params=c,U(z.params,l),z.transition=null,f.location&&b.navigable&&n.push(b.navigable.url,b.navigable.locals.globals.$stateParams,{$$avoidResync:!0,replace:"replace"===f.location}),f.notify&&a.$broadcast("$stateChangeSuccess",b.self,c,j.self,o),n.update(!0),z.current)}).then(null,function(d){return d===w?B:z.transition!==O?(a.$broadcast("$stateChangeCancel",b.self,c,j.self,o),B):(z.transition=null,g=a.$broadcast("$stateChangeError",b.self,c,j.self,o,d),g.defaultPrevented||n.update(),e.reject(d))});return p(O),O},z.is=function(a,b,d){d=T({relative:z.$current},d||{});var e=m(a,d.relative);return N(e)?z.$current===e&&(!b||g(b).reduce(function(a,c){var d=e.params[c];return a&&(!d||d.type.equals(l[c],b[c]))},!0)):c},z.includes=function(a,b,d){if(d=T({relative:z.$current},d||{}),P(a)&&s(a)){if(!t(a))return!1;a=z.$current.name}var e=m(a,d.relative);if(!N(e))return c;if(!N(z.$current.includes[e.name]))return!1;if(!b)return!0;for(var f=g(b),h=0;h<f.length;h++){var i=f[h],j=e.params[i];if(j&&!j.type.equals(l[i],b[i]))return!1}return g(b).reduce(function(a,c){var d=e.params[c];return a&&!d||d.type.equals(l[c],b[c])},!0)},z.href=function(a,b,d){d=T({lossy:!0,inherit:!0,absolute:!1,relative:z.$current},d||{});var e=m(a,d.relative);if(!N(e))return null;d.inherit&&(b=i(l,b||{},z.$current,e));var f=e&&d.lossy?e.navigable:e;return f&&f.url!==c&&null!==f.url?n.href(f.url,k(e.params.$$keys().concat("#"),b||{}),{absolute:d.absolute}):null},z.get=function(a,b){if(0===arguments.length)return o(g(A),function(a){return A[a].self});var c=m(a,b||z.$current);return c&&c.self?c.self:null},z}function x(a,b,c,d,e,f){function g(a,b,c){function d(b){return"search"!=a.params[b].location}var e=a.params.$$keys().filter(d),f=l.apply({},[a.params].concat(e));return new W.ParamSet(f).$$equals(b,c)}if(!f.reload&&a===c&&(e===c.locals||!1===a.self.reloadOnSearch&&g(c,d,b)))return!0}var y,z,A={},B={},C="abstract",D={parent:function(a){if(N(a.parent)&&a.parent)return m(a.parent);var b=/^(.+)\.[^.]+$/.exec(a.name);return b?m(b[1]):y},data:function(a){return a.parent&&a.parent.data&&(a.data=a.self.data=d(a.parent.data,a.data)),a.data},url:function(a){var b=a.url,c={params:a.params||{}};if(P(b))return"^"==b.charAt(0)?e.compile(b.substring(1),c):(a.parent.navigable||y).url.concat(b,c);if(!b||e.isMatcher(b))return b;throw new Error("Invalid url '"+b+"' in state '"+a+"'")},navigable:function(a){return a.url?a:a.parent?a.parent.navigable:null},ownParams:function(a){var b=a.url&&a.url.params||new W.ParamSet;return S(a.params||{},function(a,c){b[c]||(b[c]=new W.Param(c,null,a,"config"))}),b},params:function(a){var b=l(a.ownParams,a.ownParams.$$keys());return a.parent&&a.parent.params?T(a.parent.params.$$new(),b):new W.ParamSet},views:function(a){var b={};return S(N(a.views)?a.views:{"":a},function(c,d){d.indexOf("@")<0&&(d+="@"+a.parent.name),c.resolveAs=c.resolveAs||a.resolveAs||"$resolve",b[d]=c}),b},path:function(a){return a.parent?a.parent.path.concat(a):[]},includes:function(a){var b=a.parent?T({},a.parent.includes):{};return b[a.name]=!0,b},$delegates:{}};y=r({name:"",url:"^",views:null,abstract:!0}),y.navigable=null,this.decorator=u,this.state=v,this.$get=w,w.$inject=["$rootScope","$q","$view","$injector","$resolve","$stateParams","$urlRouter","$location","$urlMatcherFactory"]}function y(){function a(a,b){return{load:function(a,c){var d;return c=T({template:null,controller:null,view:null,locals:null,notify:!0,async:!0,params:{}},c),c.view&&(d=b.fromConfig(c.view,c.params,c.locals)),d}}}this.$get=a,a.$inject=["$rootScope","$templateFactory"]}function z(){var a=!1;this.useAnchorScroll=function(){a=!0},this.$get=["$anchorScroll","$timeout",function(b,c){return a?b:function(a){return c(function(){a[0].scrollIntoView()},0,!1)}}]}function A(a,c,d,e,f){function g(){return c.has?function(a){return c.has(a)?c.get(a):null}:function(a){try{return c.get(a)}catch(a){return null}}}function h(a,c){var d=function(){return{enter:function(a,b,c){b.after(a),c()},leave:function(a,b){a.remove(),b()}}};if(k)return{enter:function(a,c,d){b.version.minor>2?k.enter(a,null,c).then(d):k.enter(a,null,c,d)},leave:function(a,c){b.version.minor>2?k.leave(a).then(c):k.leave(a,c)}};if(j){var e=j&&j(c,a);return{enter:function(a,b,c){e.enter(a,null,b),c()},leave:function(a,b){e.leave(a),b()}}}return d()}var i=g(),j=i("$animator"),k=i("$animate");return{restrict:"ECA",terminal:!0,priority:400,transclude:"element",compile:function(c,g,i){return function(c,g,j){function k(){if(m&&(m.remove(),m=null),o&&(o.$destroy(),o=null),n){var a=n.data("$uiViewAnim");s.leave(n,function(){a.$$animLeave.resolve(),m=null}),m=n,n=null}}function l(h){var l,m=C(c,j,g,e),t=m&&a.$current&&a.$current.locals[m];if(h||t!==p){l=c.$new(),p=a.$current.locals[m],l.$emit("$viewContentLoading",m);var u=i(l,function(a){var e=f.defer(),h=f.defer(),i={$animEnter:e.promise,$animLeave:h.promise,$$animLeave:h};a.data("$uiViewAnim",i),s.enter(a,g,function(){e.resolve(),o&&o.$emit("$viewContentAnimationEnded"),(b.isDefined(r)&&!r||c.$eval(r))&&d(a)}),k()});n=u,o=l,o.$emit("$viewContentLoaded",m),o.$eval(q)}}var m,n,o,p,q=j.onload||"",r=j.autoscroll,s=h(j,c);g.inheritedData("$uiView");c.$on("$stateChangeSuccess",function(){l(!1)}),l(!0)}}}}function B(a,c,d,e){return{restrict:"ECA",priority:-400,compile:function(f){var g=f.html();return f.empty?f.empty():f[0].innerHTML=null,function(f,h,i){var j=d.$current,k=C(f,i,h,e),l=j&&j.locals[k];if(!l)return h.html(g),void a(h.contents())(f);h.data("$uiView",{name:k,state:l.$$state}),h.html(l.$template?l.$template:g);var m=b.extend({},l);f[l.$$resolveAs]=m;var n=a(h.contents());if(l.$$controller){l.$scope=f,l.$element=h;var o=c(l.$$controller,l);l.$$controllerAs&&(f[l.$$controllerAs]=o,f[l.$$controllerAs][l.$$resolveAs]=m),O(o.$onInit)&&o.$onInit(),h.data("$ngControllerController",o),h.children().data("$ngControllerController",o)}n(f)}}}}function C(a,b,c,d){var e=d(b.uiView||b.name||"")(a),f=c.inheritedData("$uiView");return e.indexOf("@")>=0?e:e+"@"+(f?f.state.name:"")}function D(a,b){var c,d=a.match(/^\s*({[^}]*})\s*$/);if(d&&(a=b+"("+d[1]+")"),!(c=a.replace(/\n/g," ").match(/^([^(]+?)\s*(\((.*)\))?$/))||4!==c.length)throw new Error("Invalid state ref '"+a+"'");return{state:c[1],paramExpr:c[3]||null}}function E(a){var b=a.parent().inheritedData("$uiView");if(b&&b.state&&b.state.name)return b.state}function F(a){var b="[object SVGAnimatedString]"===Object.prototype.toString.call(a.prop("href")),c="FORM"===a[0].nodeName;return{attr:c?"action":b?"xlink:href":"href",isAnchor:"A"===a.prop("tagName").toUpperCase(),clickable:!c}}function G(a,b,c,d,e){return function(f){var g=f.which||f.button,h=e();if(!(g>1||f.ctrlKey||f.metaKey||f.shiftKey||a.attr("target"))){var i=c(function(){b.go(h.state,h.params,h.options)});f.preventDefault();var j=d.isAnchor&&!h.href?1:0;f.preventDefault=function(){j--<=0&&c.cancel(i)}}}}function H(a,b){return{relative:E(a)||b.$current,inherit:!0}}function I(a,c){return{restrict:"A",require:["?^uiSrefActive","?^uiSrefActiveEq"],link:function(d,e,f,g){var h,i=D(f.uiSref,a.current.name),j={state:i.state,href:null,params:null},k=F(e),l=g[1]||g[0],m=null;j.options=T(H(e,a),f.uiSrefOpts?d.$eval(f.uiSrefOpts):{});var n=function(c){c&&(j.params=b.copy(c)),j.href=a.href(i.state,j.params,j.options),m&&m(),l&&(m=l.$$addStateInfo(i.state,j.params)),null!==j.href&&f.$set(k.attr,j.href)};i.paramExpr&&(d.$watch(i.paramExpr,function(a){a!==j.params&&n(a)},!0),j.params=b.copy(d.$eval(i.paramExpr))),n(),k.clickable&&(h=G(e,a,c,k,function(){return j}),e[e.on?"on":"bind"]("click",h),d.$on("$destroy",function(){e[e.off?"off":"unbind"]("click",h)}))}}}function J(a,b){return{restrict:"A",require:["?^uiSrefActive","?^uiSrefActiveEq"],link:function(c,d,e,f){function g(b){m.state=b[0],m.params=b[1],m.options=b[2],m.href=a.href(m.state,m.params,m.options),n&&n(),j&&(n=j.$$addStateInfo(m.state,m.params)),m.href&&e.$set(i.attr,m.href)}var h,i=F(d),j=f[1]||f[0],k=[e.uiState,e.uiStateParams||null,e.uiStateOpts||null],l="["+k.map(function(a){return a||"null"}).join(", ")+"]",m={state:null,params:null,options:null,href:null},n=null;c.$watch(l,g,!0),g(c.$eval(l)),i.clickable&&(h=G(d,a,b,i,function(){return m}),d[d.on?"on":"bind"]("click",h),c.$on("$destroy",function(){d[d.off?"off":"unbind"]("click",h)}))}}}function K(a,b,c){return{restrict:"A",controller:["$scope","$element","$attrs","$timeout",function(b,d,e,f){function g(b,c,e){var f=a.get(b,E(d)),g=h(b,c),i={state:f||{name:b},params:c,hash:g};return p.push(i),q[g]=e,function(){var a=p.indexOf(i);-1!==a&&p.splice(a,1)}}function h(a,c){if(!P(a))throw new Error("state should be a string");return Q(c)?a+V(c):(c=b.$eval(c),Q(c)?a+V(c):a)}function i(){for(var a=0;a<p.length;a++)l(p[a].state,p[a].params)?j(d,q[p[a].hash]):k(d,q[p[a].hash]),m(p[a].state,p[a].params)?j(d,n):k(d,n)}function j(a,b){f(function(){a.addClass(b)})}function k(a,b){a.removeClass(b)}function l(b,c){return a.includes(b.name,c)}function m(b,c){return a.is(b.name,c)}var n,o,p=[],q={};n=c(e.uiSrefActiveEq||"",!1)(b);try{o=b.$eval(e.uiSrefActive)}catch(a){}o=o||c(e.uiSrefActive||"",!1)(b),Q(o)&&S(o,function(c,d){if(P(c)){var e=D(c,a.current.name);g(e.state,b.$eval(e.paramExpr),d)}}),this.$$addStateInfo=function(a,b){if(!(Q(o)&&p.length>0)){var c=g(a,b,o);return i(),c}},b.$on("$stateChangeSuccess",i),i()}]}}function L(a){var b=function(b,c){return a.is(b,c)};return b.$stateful=!0,b}function M(a){var b=function(b,c,d){return a.includes(b,c,d)};return b.$stateful=!0,b}var N=b.isDefined,O=b.isFunction,P=b.isString,Q=b.isObject,R=b.isArray,S=b.forEach,T=b.extend,U=b.copy,V=b.toJson;b.module("ui.router.util",["ng"]),b.module("ui.router.router",["ui.router.util"]),b.module("ui.router.state",["ui.router.router","ui.router.util"]),b.module("ui.router",["ui.router.state"]),b.module("ui.router.compat",["ui.router"]),q.$inject=["$q","$injector"],b.module("ui.router.util").service("$resolve",q),b.module("ui.router.util").provider("$templateFactory",r);var W;t.prototype.concat=function(a,b){var c={caseInsensitive:W.caseInsensitive(),strict:W.strictMode(),squash:W.defaultSquashPolicy()};return new t(this.sourcePath+a+this.sourceSearch,T(c,b),this)},t.prototype.toString=function(){return this.source},t.prototype.exec=function(a,b){function c(a){function b(a){return a.split("").reverse().join("")}function c(a){return a.replace(/\\-/g,"-")}return o(o(b(a).split(/-(?!\\)/),b),c).reverse()}var d=this.regexp.exec(a);if(!d)return null;b=b||{};var e,f,g,h=this.parameters(),i=h.length,j=this.segments.length-1,k={};if(j!==d.length-1)throw new Error("Unbalanced capture group in route '"+this.source+"'");var l,m;for(e=0;e<j;e++){for(g=h[e],l=this.params[g],m=d[e+1],f=0;f<l.replace.length;f++)l.replace[f].from===m&&(m=l.replace[f].to);m&&!0===l.array&&(m=c(m)),N(m)&&(m=l.type.decode(m)),k[g]=l.value(m)}for(;e<i;e++){for(g=h[e],k[g]=this.params[g].value(b[g]),l=this.params[g],m=b[g],f=0;f<l.replace.length;f++)l.replace[f].from===m&&(m=l.replace[f].to);N(m)&&(m=l.type.decode(m)),k[g]=l.value(m)}return k},t.prototype.parameters=function(a){return N(a)?this.params[a]||null:this.$$paramNames},t.prototype.validates=function(a){return this.params.$$validates(a)},t.prototype.format=function(a){function b(a){return encodeURIComponent(a).replace(/-/g,function(a){return"%5C%"+a.charCodeAt(0).toString(16).toUpperCase()})}a=a||{};var c=this.segments,d=this.parameters(),e=this.params;if(!this.validates(a))return null;var f,g=!1,h=c.length-1,i=d.length,j=c[0];for(f=0;f<i;f++){var k=f<h,l=d[f],m=e[l],n=m.value(a[l]),p=m.isOptional&&m.type.equals(m.value(),n),q=!!p&&m.squash,r=m.type.encode(n);if(k){var s=c[f+1],t=f+1===h;if(!1===q)null!=r&&(R(r)?j+=o(r,b).join("-"):j+=encodeURIComponent(r)),j+=s;else if(!0===q){var u=j.match(/\/$/)?/\/?(.*)/:/(.*)/;j+=s.match(u)[1]}else P(q)&&(j+=q+s);t&&!0===m.squash&&"/"===j.slice(-1)&&(j=j.slice(0,-1))}else{if(null==r||p&&!1!==q)continue;if(R(r)||(r=[r]),0===r.length)continue;r=o(r,encodeURIComponent).join("&"+l+"="),j+=(g?"&":"?")+l+"="+r,g=!0}}return j},u.prototype.is=function(a,b){return!0},u.prototype.encode=function(a,b){return a},u.prototype.decode=function(a,b){return a},u.prototype.equals=function(a,b){return a==b},u.prototype.$subPattern=function(){var a=this.pattern.toString();return a.substr(1,a.length-2)},u.prototype.pattern=/.*/,u.prototype.toString=function(){return"{Type:"+this.name+"}"},u.prototype.$normalize=function(a){return this.is(a)?a:this.decode(a)},u.prototype.$asArray=function(a,b){function d(a,b){function d(a,b){return function(){return a[b].apply(a,arguments)}}function e(a){return R(a)?a:N(a)?[a]:[]}function f(a){switch(a.length){case 0:return c;case 1:return"auto"===b?a[0]:a;default:return a}}function g(a){return!a}function h(a,b){return function(c){if(R(c)&&0===c.length)return c;c=e(c);var d=o(c,a);return!0===b?0===n(d,g).length:f(d)}}function i(a){return function(b,c){var d=e(b),f=e(c);if(d.length!==f.length)return!1;for(var g=0;g<d.length;g++)if(!a(d[g],f[g]))return!1;return!0}}this.encode=h(d(a,"encode")),this.decode=h(d(a,"decode")),this.is=h(d(a,"is"),!0),this.equals=i(d(a,"equals")),this.pattern=a.pattern,this.$normalize=h(d(a,"$normalize")),this.name=a.name,this.$arrayMode=b}if(!a)return this;if("auto"===a&&!b)throw new Error("'auto' array mode is for query parameters only");return new d(this,a)},b.module("ui.router.util").provider("$urlMatcherFactory",v),b.module("ui.router.util").run(["$urlMatcherFactory",function(a){}]),w.$inject=["$locationProvider","$urlMatcherFactoryProvider"],b.module("ui.router.router").provider("$urlRouter",w),x.$inject=["$urlRouterProvider","$urlMatcherFactoryProvider"],b.module("ui.router.state").factory("$stateParams",function(){return{}}).constant("$state.runtime",{autoinject:!0}).provider("$state",x).run(["$injector",function(a){a.get("$state.runtime").autoinject&&a.get("$state")}]),y.$inject=[],b.module("ui.router.state").provider("$view",y),b.module("ui.router.state").provider("$uiViewScroll",z),A.$inject=["$state","$injector","$uiViewScroll","$interpolate","$q"],B.$inject=["$compile","$controller","$state","$interpolate"],b.module("ui.router.state").directive("uiView",A),b.module("ui.router.state").directive("uiView",B),I.$inject=["$state","$timeout"],J.$inject=["$state","$timeout"],K.$inject=["$state","$stateParams","$interpolate"],b.module("ui.router.state").directive("uiSref",I).directive("uiSrefActive",K).directive("uiSrefActiveEq",K).directive("uiState",J),L.$inject=["$state"],M.$inject=["$state"],b.module("ui.router.state").filter("isState",L).filter("includedByState",M)}(window,window.angular); \ No newline at end of file diff --git a/setup/pub/angular/angular.js b/setup/pub/angular/angular.js index e3a85e3679ac6..189ff16495b25 100644 --- a/setup/pub/angular/angular.js +++ b/setup/pub/angular/angular.js @@ -1,15 +1,65 @@ /** - * @license AngularJS v1.2.16 - * (c) 2010-2014 Google, Inc. http://angularjs.org + * @license AngularJS v1.6.9 + * (c) 2010-2018 Google, Inc. http://angularjs.org * License: MIT */ -(function(window, document, undefined) {'use strict'; +(function(window) {'use strict'; + + /* exported + minErrConfig, + errorHandlingConfig, + isValidObjectMaxDepth +*/ + + var minErrConfig = { + objectMaxDepth: 5 + }; + + /** + * @ngdoc function + * @name angular.errorHandlingConfig + * @module ng + * @kind function + * + * @description + * Configure several aspects of error handling in AngularJS if used as a setter or return the + * current configuration if used as a getter. The following options are supported: + * + * - **objectMaxDepth**: The maximum depth to which objects are traversed when stringified for error messages. + * + * Omitted or undefined options will leave the corresponding configuration values unchanged. + * + * @param {Object=} config - The configuration object. May only contain the options that need to be + * updated. Supported keys: + * + * * `objectMaxDepth` **{Number}** - The max depth for stringifying objects. Setting to a + * non-positive or non-numeric value, removes the max depth limit. + * Default: 5 + */ + function errorHandlingConfig(config) { + if (isObject(config)) { + if (isDefined(config.objectMaxDepth)) { + minErrConfig.objectMaxDepth = isValidObjectMaxDepth(config.objectMaxDepth) ? config.objectMaxDepth : NaN; + } + } else { + return minErrConfig; + } + } + + /** + * @private + * @param {Number} maxDepth + * @return {boolean} + */ + function isValidObjectMaxDepth(maxDepth) { + return isNumber(maxDepth) && maxDepth > 0; + } /** * @description * * This object provides a utility for producing rich Error messages within - * Angular. It can be called as follows: + * AngularJS. It can be called as follows: * * var exampleMinErr = minErr('example'); * throw exampleMinErr('one', 'This {0} is {1}', foo, bar); @@ -30,140 +80,145 @@ * should all be static strings, not variables or general expressions. * * @param {string} module The namespace to use for the new minErr instance. + * @param {function} ErrorConstructor Custom error constructor to be instantiated when returning + * error from returned function, for cases when a particular type of error is useful. * @returns {function(code:string, template:string, ...templateArgs): Error} minErr instance */ - function minErr(module) { - return function () { + function minErr(module, ErrorConstructor) { + ErrorConstructor = ErrorConstructor || Error; + return function() { var code = arguments[0], - prefix = '[' + (module ? module + ':' : '') + code + '] ', template = arguments[1], - templateArgs = arguments, - stringify = function (obj) { - if (typeof obj === 'function') { - return obj.toString().replace(/ \{[\s\S]*$/, ''); - } else if (typeof obj === 'undefined') { - return 'undefined'; - } else if (typeof obj !== 'string') { - return JSON.stringify(obj); - } - return obj; - }, - message, i; + message = '[' + (module ? module + ':' : '') + code + '] ', + templateArgs = sliceArgs(arguments, 2).map(function(arg) { + return toDebugString(arg, minErrConfig.objectMaxDepth); + }), + paramPrefix, i; - message = prefix + template.replace(/\{\d+\}/g, function (match) { - var index = +match.slice(1, -1), arg; + message += template.replace(/\{\d+\}/g, function(match) { + var index = +match.slice(1, -1); - if (index + 2 < templateArgs.length) { - arg = templateArgs[index + 2]; - if (typeof arg === 'function') { - return arg.toString().replace(/ ?\{[\s\S]*$/, ''); - } else if (typeof arg === 'undefined') { - return 'undefined'; - } else if (typeof arg !== 'string') { - return toJson(arg); - } - return arg; + if (index < templateArgs.length) { + return templateArgs[index]; } + return match; }); - message = message + '\nhttp://errors.angularjs.org/1.2.16/' + - (module ? module + '/' : '') + code; - for (i = 2; i < arguments.length; i++) { - message = message + (i == 2 ? '?' : '&') + 'p' + (i-2) + '=' + - encodeURIComponent(stringify(arguments[i])); + message += '\nhttp://errors.angularjs.org/1.6.9/' + + (module ? module + '/' : '') + code; + + for (i = 0, paramPrefix = '?'; i < templateArgs.length; i++, paramPrefix = '&') { + message += paramPrefix + 'p' + i + '=' + encodeURIComponent(templateArgs[i]); } - return new Error(message); + return new ErrorConstructor(message); }; } - /* We need to tell jshint what variables are being exported */ - /* global - -angular, - -msie, - -jqLite, - -jQuery, - -slice, - -push, - -toString, - -ngMinErr, - -_angular, - -angularModule, - -nodeName_, - -uid, - - -lowercase, - -uppercase, - -manualLowercase, - -manualUppercase, - -nodeName_, - -isArrayLike, - -forEach, - -sortedKeys, - -forEachSorted, - -reverseParams, - -nextUid, - -setHashKey, - -extend, - -int, - -inherit, - -noop, - -identity, - -valueFn, - -isUndefined, - -isDefined, - -isObject, - -isString, - -isNumber, - -isDate, - -isArray, - -isFunction, - -isRegExp, - -isWindow, - -isScope, - -isFile, - -isBlob, - -isBoolean, - -trim, - -isElement, - -makeMap, - -map, - -size, - -includes, - -indexOf, - -arrayRemove, - -isLeafNode, - -copy, - -shallowCopy, - -equals, - -csp, - -concat, - -sliceArgs, - -bind, - -toJsonReplacer, - -toJson, - -fromJson, - -toBoolean, - -startingTag, - -tryDecodeURIComponent, - -parseKeyValue, - -toKeyValue, - -encodeUriSegment, - -encodeUriQuery, - -angularInit, - -bootstrap, - -snake_case, - -bindJQuery, - -assertArg, - -assertArgFn, - -assertNotHasOwnProperty, - -getter, - -getBlockElements, - -hasOwnProperty, - - */ + /* We need to tell ESLint what variables are being exported */ + /* exported + angular, + msie, + jqLite, + jQuery, + slice, + splice, + push, + toString, + minErrConfig, + errorHandlingConfig, + isValidObjectMaxDepth, + ngMinErr, + angularModule, + uid, + REGEX_STRING_REGEXP, + VALIDITY_STATE_PROPERTY, + + lowercase, + uppercase, + manualLowercase, + manualUppercase, + nodeName_, + isArrayLike, + forEach, + forEachSorted, + reverseParams, + nextUid, + setHashKey, + extend, + toInt, + inherit, + merge, + noop, + identity, + valueFn, + isUndefined, + isDefined, + isObject, + isBlankObject, + isString, + isNumber, + isNumberNaN, + isDate, + isError, + isArray, + isFunction, + isRegExp, + isWindow, + isScope, + isFile, + isFormData, + isBlob, + isBoolean, + isPromiseLike, + trim, + escapeForRegexp, + isElement, + makeMap, + includes, + arrayRemove, + copy, + simpleCompare, + equals, + csp, + jq, + concat, + sliceArgs, + bind, + toJsonReplacer, + toJson, + fromJson, + convertTimezoneToLocal, + timezoneToOffset, + startingTag, + tryDecodeURIComponent, + parseKeyValue, + toKeyValue, + encodeUriSegment, + encodeUriQuery, + angularInit, + bootstrap, + getTestability, + snake_case, + bindJQuery, + assertArg, + assertArgFn, + assertNotHasOwnProperty, + getter, + getBlockNodes, + hasOwnProperty, + createMap, + stringify, + + NODE_TYPE_ELEMENT, + NODE_TYPE_ATTRIBUTE, + NODE_TYPE_TEXT, + NODE_TYPE_COMMENT, + NODE_TYPE_DOCUMENT, + NODE_TYPE_DOCUMENT_FRAGMENT +*/ //////////////////////////////////// @@ -171,91 +226,107 @@ * @ngdoc module * @name ng * @module ng + * @installation * @description * - * # ng (core module) * The ng module is loaded by default when an AngularJS application is started. The module itself * contains the essential components for an AngularJS application to function. The table below * lists a high level breakdown of each of the services/factories, filters, directives and testing * components available within this core module. * - * <div doc-module-components="ng"></div> */ + var REGEX_STRING_REGEXP = /^\/(.+)\/([a-z]*)$/; + +// The name of a form control's ValidityState property. +// This is used so that it's possible for internal tests to create mock ValidityStates. + var VALIDITY_STATE_PROPERTY = 'validity'; + + + var hasOwnProperty = Object.prototype.hasOwnProperty; + /** * @ngdoc function * @name angular.lowercase * @module ng - * @function + * @kind function + * + * @deprecated + * sinceVersion="1.5.0" + * removeVersion="1.7.0" + * Use [String.prototype.toLowerCase](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/toLowerCase) instead. * * @description Converts the specified string to lowercase. * @param {string} string String to be converted to lowercase. * @returns {string} Lowercased string. */ - var lowercase = function(string){return isString(string) ? string.toLowerCase() : string;}; - var hasOwnProperty = Object.prototype.hasOwnProperty; + var lowercase = function(string) {return isString(string) ? string.toLowerCase() : string;}; /** * @ngdoc function * @name angular.uppercase * @module ng - * @function + * @kind function + * + * @deprecated + * sinceVersion="1.5.0" + * removeVersion="1.7.0" + * Use [String.prototype.toUpperCase](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/toUpperCase) instead. * * @description Converts the specified string to uppercase. * @param {string} string String to be converted to uppercase. * @returns {string} Uppercased string. */ - var uppercase = function(string){return isString(string) ? string.toUpperCase() : string;}; + var uppercase = function(string) {return isString(string) ? string.toUpperCase() : string;}; var manualLowercase = function(s) { - /* jshint bitwise: false */ + /* eslint-disable no-bitwise */ return isString(s) ? s.replace(/[A-Z]/g, function(ch) {return String.fromCharCode(ch.charCodeAt(0) | 32);}) : s; + /* eslint-enable */ }; var manualUppercase = function(s) { - /* jshint bitwise: false */ + /* eslint-disable no-bitwise */ return isString(s) ? s.replace(/[a-z]/g, function(ch) {return String.fromCharCode(ch.charCodeAt(0) & ~32);}) : s; + /* eslint-enable */ }; // String#toLowerCase and String#toUpperCase don't produce correct results in browsers with Turkish // locale, for this reason we need to detect this case and redefine lowercase/uppercase methods -// with correct but slower alternatives. +// with correct but slower alternatives. See https://github.com/angular/angular.js/issues/11387 if ('i' !== 'I'.toLowerCase()) { lowercase = manualLowercase; uppercase = manualUppercase; } - var /** holds major version number for IE or NaN for real browsers */ - msie, + var + msie, // holds major version number for IE, or NaN if UA is not IE. jqLite, // delay binding since jQuery could be loaded after us. jQuery, // delay binding slice = [].slice, + splice = [].splice, push = [].push, toString = Object.prototype.toString, + getPrototypeOf = Object.getPrototypeOf, ngMinErr = minErr('ng'), - - _angular = window.angular, /** @name angular */ angular = window.angular || (window.angular = {}), angularModule, - nodeName_, - uid = ['0', '0', '0']; + uid = 0; +// Support: IE 9-11 only /** - * IE 11 changed the format of the UserAgent string. - * See http://msdn.microsoft.com/en-us/library/ms537503.aspx + * documentMode is an IE-only property + * http://msdn.microsoft.com/en-us/library/ie/cc196988(v=vs.85).aspx */ - msie = int((/msie (\d+)/.exec(lowercase(navigator.userAgent)) || [])[1]); - if (isNaN(msie)) { - msie = int((/trident\/.*; rv:(\d+)/.exec(lowercase(navigator.userAgent)) || [])[1]); - } + msie = window.document.documentMode; /** @@ -265,39 +336,51 @@ * String ...) */ function isArrayLike(obj) { - if (obj == null || isWindow(obj)) { - return false; - } - var length = obj.length; + // `null`, `undefined` and `window` are not array-like + if (obj == null || isWindow(obj)) return false; - if (obj.nodeType === 1 && length) { - return true; - } + // arrays, strings and jQuery/jqLite objects are array like + // * jqLite is either the jQuery or jqLite constructor function + // * we have to check the existence of jqLite first as this method is called + // via the forEach method when constructing the jqLite object in the first place + if (isArray(obj) || isString(obj) || (jqLite && obj instanceof jqLite)) return true; + + // Support: iOS 8.2 (not reproducible in simulator) + // "length" in obj used to prevent JIT error (gh-11508) + var length = 'length' in Object(obj) && obj.length; + + // NodeList objects (with `item` method) and + // other objects with suitable length characteristics are array-like + return isNumber(length) && + (length >= 0 && ((length - 1) in obj || obj instanceof Array) || typeof obj.item === 'function'); - return isString(obj) || isArray(obj) || length === 0 || - typeof length === 'number' && length > 0 && (length - 1) in obj; } /** * @ngdoc function * @name angular.forEach * @module ng - * @function + * @kind function * * @description * Invokes the `iterator` function once for each item in `obj` collection, which can be either an - * object or an array. The `iterator` function is invoked with `iterator(value, key)`, where `value` - * is the value of an object property or an array element and `key` is the object property key or - * array element index. Specifying a `context` for the function is optional. + * object or an array. The `iterator` function is invoked with `iterator(value, key, obj)`, where `value` + * is the value of an object property or an array element, `key` is the object property key or + * array element index and obj is the `obj` itself. Specifying a `context` for the function is optional. * * It is worth noting that `.forEach` does not iterate over inherited properties because it filters * using the `hasOwnProperty` method. * + * Unlike ES262's + * [Array.prototype.forEach](http://www.ecma-international.org/ecma-262/5.1/#sec-15.4.4.18), + * providing 'undefined' or 'null' values for `obj` will not throw a TypeError, but rather just + * return the value provided. + * ```js var values = {name: 'misko', gender: 'male'}; var log = []; - angular.forEach(values, function(value, key){ + angular.forEach(values, function(value, key) { this.push(key + ': ' + value); }, log); expect(log).toEqual(['name: misko', 'gender: male']); @@ -308,26 +391,42 @@ * @param {Object=} context Object to become context (`this`) for the iterator function. * @returns {Object|Array} Reference to `obj`. */ + function forEach(obj, iterator, context) { - var key; + var key, length; if (obj) { - if (isFunction(obj)){ + if (isFunction(obj)) { for (key in obj) { - // Need to check if hasOwnProperty exists, - // as on IE8 the result of querySelectorAll is an object without a hasOwnProperty function - if (key != 'prototype' && key != 'length' && key != 'name' && (!obj.hasOwnProperty || obj.hasOwnProperty(key))) { - iterator.call(context, obj[key], key); + if (key !== 'prototype' && key !== 'length' && key !== 'name' && obj.hasOwnProperty(key)) { + iterator.call(context, obj[key], key, obj); + } + } + } else if (isArray(obj) || isArrayLike(obj)) { + var isPrimitive = typeof obj !== 'object'; + for (key = 0, length = obj.length; key < length; key++) { + if (isPrimitive || key in obj) { + iterator.call(context, obj[key], key, obj); } } } else if (obj.forEach && obj.forEach !== forEach) { - obj.forEach(iterator, context); - } else if (isArrayLike(obj)) { - for (key = 0; key < obj.length; key++) - iterator.call(context, obj[key], key); - } else { + obj.forEach(iterator, context, obj); + } else if (isBlankObject(obj)) { + // createMap() fast path --- Safe to avoid hasOwnProperty check because prototype chain is empty + for (key in obj) { + iterator.call(context, obj[key], key, obj); + } + } else if (typeof obj.hasOwnProperty === 'function') { + // Slow path for objects inheriting Object.prototype, hasOwnProperty check needed for (key in obj) { if (obj.hasOwnProperty(key)) { - iterator.call(context, obj[key], key); + iterator.call(context, obj[key], key, obj); + } + } + } else { + // Slow path for objects which do not have a method `hasOwnProperty` + for (key in obj) { + if (hasOwnProperty.call(obj, key)) { + iterator.call(context, obj[key], key, obj); } } } @@ -335,19 +434,9 @@ return obj; } - function sortedKeys(obj) { - var keys = []; - for (var key in obj) { - if (obj.hasOwnProperty(key)) { - keys.push(key); - } - } - return keys.sort(); - } - function forEachSorted(obj, iterator, context) { - var keys = sortedKeys(obj); - for ( var i = 0; i < keys.length; i++) { + var keys = Object.keys(obj).sort(); + for (var i = 0; i < keys.length; i++) { iterator.call(context, obj[keys[i]], keys[i]); } return keys; @@ -360,37 +449,21 @@ * @returns {function(*, string)} */ function reverseParams(iteratorFn) { - return function(value, key) { iteratorFn(key, value); }; + return function(value, key) {iteratorFn(key, value);}; } /** - * A consistent way of creating unique IDs in angular. The ID is a sequence of alpha numeric - * characters such as '012ABC'. The reason why we are not using simply a number counter is that - * the number string gets longer over time, and it can also overflow, where as the nextId - * will grow much slower, it is a string, and it will never overflow. + * A consistent way of creating unique IDs in angular. * - * @returns {string} an unique alpha-numeric string + * Using simple numbers allows us to generate 28.6 million unique ids per second for 10 years before + * we hit number precision issues in JavaScript. + * + * Math.pow(2,53) / 60 / 60 / 24 / 365 / 10 = 28.6M + * + * @returns {number} an unique alpha-numeric string */ function nextUid() { - var index = uid.length; - var digit; - - while(index) { - index--; - digit = uid[index].charCodeAt(0); - if (digit == 57 /*'9'*/) { - uid[index] = 'A'; - return uid.join(''); - } - if (digit == 90 /*'Z'*/) { - uid[index] = '0'; - } else { - uid[index] = String.fromCharCode(digit + 1); - return uid.join(''); - } - } - uid.unshift('0'); - return uid.join(''); + return ++uid; } @@ -402,54 +475,126 @@ function setHashKey(obj, h) { if (h) { obj.$$hashKey = h; - } - else { + } else { delete obj.$$hashKey; } } + + function baseExtend(dst, objs, deep) { + var h = dst.$$hashKey; + + for (var i = 0, ii = objs.length; i < ii; ++i) { + var obj = objs[i]; + if (!isObject(obj) && !isFunction(obj)) continue; + var keys = Object.keys(obj); + for (var j = 0, jj = keys.length; j < jj; j++) { + var key = keys[j]; + var src = obj[key]; + + if (deep && isObject(src)) { + if (isDate(src)) { + dst[key] = new Date(src.valueOf()); + } else if (isRegExp(src)) { + dst[key] = new RegExp(src); + } else if (src.nodeName) { + dst[key] = src.cloneNode(true); + } else if (isElement(src)) { + dst[key] = src.clone(); + } else { + if (!isObject(dst[key])) dst[key] = isArray(src) ? [] : {}; + baseExtend(dst[key], [src], true); + } + } else { + dst[key] = src; + } + } + } + + setHashKey(dst, h); + return dst; + } + /** * @ngdoc function * @name angular.extend * @module ng - * @function + * @kind function * * @description - * Extends the destination object `dst` by copying all of the properties from the `src` object(s) - * to `dst`. You can specify multiple `src` objects. + * Extends the destination object `dst` by copying own enumerable properties from the `src` object(s) + * to `dst`. You can specify multiple `src` objects. If you want to preserve original objects, you can do so + * by passing an empty object as the target: `var object = angular.extend({}, object1, object2)`. + * + * **Note:** Keep in mind that `angular.extend` does not support recursive merge (deep copy). Use + * {@link angular.merge} for this. * * @param {Object} dst Destination object. * @param {...Object} src Source object(s). * @returns {Object} Reference to `dst`. */ function extend(dst) { - var h = dst.$$hashKey; - forEach(arguments, function(obj){ - if (obj !== dst) { - forEach(obj, function(value, key){ - dst[key] = value; - }); - } - }); + return baseExtend(dst, slice.call(arguments, 1), false); + } - setHashKey(dst,h); - return dst; + + /** + * @ngdoc function + * @name angular.merge + * @module ng + * @kind function + * + * @description + * Deeply extends the destination object `dst` by copying own enumerable properties from the `src` object(s) + * to `dst`. You can specify multiple `src` objects. If you want to preserve original objects, you can do so + * by passing an empty object as the target: `var object = angular.merge({}, object1, object2)`. + * + * Unlike {@link angular.extend extend()}, `merge()` recursively descends into object properties of source + * objects, performing a deep copy. + * + * @deprecated + * sinceVersion="1.6.5" + * This function is deprecated, but will not be removed in the 1.x lifecycle. + * There are edge cases (see {@link angular.merge#known-issues known issues}) that are not + * supported by this function. We suggest + * using [lodash's merge()](https://lodash.com/docs/4.17.4#merge) instead. + * + * @knownIssue + * This is a list of (known) object types that are not handled correctly by this function: + * - [`Blob`](https://developer.mozilla.org/docs/Web/API/Blob) + * - [`MediaStream`](https://developer.mozilla.org/docs/Web/API/MediaStream) + * - [`CanvasGradient`](https://developer.mozilla.org/docs/Web/API/CanvasGradient) + * - AngularJS {@link $rootScope.Scope scopes}; + * + * @param {Object} dst Destination object. + * @param {...Object} src Source object(s). + * @returns {Object} Reference to `dst`. + */ + function merge(dst) { + return baseExtend(dst, slice.call(arguments, 1), true); } - function int(str) { + + + function toInt(str) { return parseInt(str, 10); } + var isNumberNaN = Number.isNaN || function isNumberNaN(num) { + // eslint-disable-next-line no-self-compare + return num !== num; + }; + function inherit(parent, extra) { - return extend(new (extend(function() {}, {prototype:parent}))(), extra); + return extend(Object.create(parent), extra); } /** * @ngdoc function * @name angular.noop * @module ng - * @function + * @kind function * * @description * A function that performs no operations. This function can be useful when writing code in the @@ -469,7 +614,7 @@ * @ngdoc function * @name angular.identity * @module ng - * @function + * @kind function * * @description * A function that returns its first argument. This function is useful when writing code in the @@ -477,21 +622,38 @@ * ```js function transformer(transformationFn, value) { - return (transformationFn || angular.identity)(value); - }; + return (transformationFn || angular.identity)(value); + }; + + // E.g. + function getResult(fn, input) { + return (fn || angular.identity)(input); + }; + + getResult(function(n) { return n * 2; }, 21); // returns 42 + getResult(null, 21); // returns 21 + getResult(undefined, 21); // returns 21 ``` + * + * @param {*} value to be returned. + * @returns {*} the value passed in. */ function identity($) {return $;} identity.$inject = []; - function valueFn(value) {return function() {return value;};} + function valueFn(value) {return function valueRef() {return value;};} + + function hasCustomToString(obj) { + return isFunction(obj.toString) && obj.toString !== toString; + } + /** * @ngdoc function * @name angular.isUndefined * @module ng - * @function + * @kind function * * @description * Determines if a reference is undefined. @@ -499,14 +661,14 @@ * @param {*} value Reference to check. * @returns {boolean} True if `value` is undefined. */ - function isUndefined(value){return typeof value === 'undefined';} + function isUndefined(value) {return typeof value === 'undefined';} /** * @ngdoc function * @name angular.isDefined * @module ng - * @function + * @kind function * * @description * Determines if a reference is defined. @@ -514,14 +676,14 @@ * @param {*} value Reference to check. * @returns {boolean} True if `value` is defined. */ - function isDefined(value){return typeof value !== 'undefined';} + function isDefined(value) {return typeof value !== 'undefined';} /** * @ngdoc function * @name angular.isObject * @module ng - * @function + * @kind function * * @description * Determines if a reference is an `Object`. Unlike `typeof` in JavaScript, `null`s are not @@ -530,14 +692,27 @@ * @param {*} value Reference to check. * @returns {boolean} True if `value` is an `Object` but not `null`. */ - function isObject(value){return value != null && typeof value === 'object';} + function isObject(value) { + // http://jsperf.com/isobject4 + return value !== null && typeof value === 'object'; + } + + + /** + * Determine if a value is an object with a null prototype + * + * @returns {boolean} True if `value` is an `Object` with a null prototype + */ + function isBlankObject(value) { + return value !== null && typeof value === 'object' && !getPrototypeOf(value); + } /** * @ngdoc function * @name angular.isString * @module ng - * @function + * @kind function * * @description * Determines if a reference is a `String`. @@ -545,29 +720,35 @@ * @param {*} value Reference to check. * @returns {boolean} True if `value` is a `String`. */ - function isString(value){return typeof value === 'string';} + function isString(value) {return typeof value === 'string';} /** * @ngdoc function * @name angular.isNumber * @module ng - * @function + * @kind function * * @description * Determines if a reference is a `Number`. * + * This includes the "special" numbers `NaN`, `+Infinity` and `-Infinity`. + * + * If you wish to exclude these then you can use the native + * [`isFinite'](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/isFinite) + * method. + * * @param {*} value Reference to check. * @returns {boolean} True if `value` is a `Number`. */ - function isNumber(value){return typeof value === 'number';} + function isNumber(value) {return typeof value === 'number';} /** * @ngdoc function * @name angular.isDate * @module ng - * @function + * @kind function * * @description * Determines if a value is a date. @@ -575,7 +756,7 @@ * @param {*} value Reference to check. * @returns {boolean} True if `value` is a `Date`. */ - function isDate(value){ + function isDate(value) { return toString.call(value) === '[object Date]'; } @@ -584,24 +765,39 @@ * @ngdoc function * @name angular.isArray * @module ng - * @function + * @kind function * * @description - * Determines if a reference is an `Array`. + * Determines if a reference is an `Array`. Alias of Array.isArray. * * @param {*} value Reference to check. * @returns {boolean} True if `value` is an `Array`. */ - function isArray(value) { - return toString.call(value) === '[object Array]'; - } + var isArray = Array.isArray; + /** + * @description + * Determines if a reference is an `Error`. + * Loosely based on https://www.npmjs.com/package/iserror + * + * @param {*} value Reference to check. + * @returns {boolean} True if `value` is an `Error`. + */ + function isError(value) { + var tag = toString.call(value); + switch (tag) { + case '[object Error]': return true; + case '[object Exception]': return true; + case '[object DOMException]': return true; + default: return value instanceof Error; + } + } /** * @ngdoc function * @name angular.isFunction * @module ng - * @function + * @kind function * * @description * Determines if a reference is a `Function`. @@ -609,7 +805,7 @@ * @param {*} value Reference to check. * @returns {boolean} True if `value` is a `Function`. */ - function isFunction(value){return typeof value === 'function';} + function isFunction(value) {return typeof value === 'function';} /** @@ -632,7 +828,7 @@ * @returns {boolean} True if `obj` is a window obj. */ function isWindow(obj) { - return obj && obj.document && obj.location && obj.alert && obj.setInterval; + return obj && obj.window === obj; } @@ -646,6 +842,11 @@ } + function isFormData(obj) { + return toString.call(obj) === '[object FormData]'; + } + + function isBlob(obj) { return toString.call(obj) === '[object Blob]'; } @@ -656,26 +857,41 @@ } - var trim = (function() { - // native trim is way faster: http://jsperf.com/angular-trim-test - // but IE doesn't have it... :-( - // TODO: we should move this into IE/ES5 polyfill - if (!String.prototype.trim) { - return function(value) { - return isString(value) ? value.replace(/^\s\s*/, '').replace(/\s\s*$/, '') : value; - }; - } - return function(value) { - return isString(value) ? value.trim() : value; - }; - })(); + function isPromiseLike(obj) { + return obj && isFunction(obj.then); + } + + + var TYPED_ARRAY_REGEXP = /^\[object (?:Uint8|Uint8Clamped|Uint16|Uint32|Int8|Int16|Int32|Float32|Float64)Array]$/; + function isTypedArray(value) { + return value && isNumber(value.length) && TYPED_ARRAY_REGEXP.test(toString.call(value)); + } + + function isArrayBuffer(obj) { + return toString.call(obj) === '[object ArrayBuffer]'; + } + + + var trim = function(value) { + return isString(value) ? value.trim() : value; + }; + +// Copied from: +// http://docs.closure-library.googlecode.com/git/local_closure_goog_string_string.js.source.html#line1021 +// Prereq: s is a string. + var escapeForRegexp = function(s) { + return s + .replace(/([-()[\]{}+?*.$^|,:#<!\\])/g, '\\$1') + // eslint-disable-next-line no-control-regex + .replace(/\x08/g, '\\x08'); + }; /** * @ngdoc function * @name angular.isElement * @module ng - * @function + * @kind function * * @description * Determines if a reference is a DOM element (or wrapped jQuery element). @@ -685,117 +901,59 @@ */ function isElement(node) { return !!(node && - (node.nodeName // we are a direct element - || (node.prop && node.attr && node.find))); // we have an on and find method part of jQuery API + (node.nodeName // We are a direct element. + || (node.prop && node.attr && node.find))); // We have an on and find method part of jQuery API. } /** * @param str 'key1,key2,...' * @returns {object} in the form of {key1:true, key2:true, ...} */ - function makeMap(str){ - var obj = {}, items = str.split(","), i; - for ( i = 0; i < items.length; i++ ) - obj[ items[i] ] = true; + function makeMap(str) { + var obj = {}, items = str.split(','), i; + for (i = 0; i < items.length; i++) { + obj[items[i]] = true; + } return obj; } - if (msie < 9) { - nodeName_ = function(element) { - element = element.nodeName ? element : element[0]; - return (element.scopeName && element.scopeName != 'HTML') - ? uppercase(element.scopeName + ':' + element.nodeName) : element.nodeName; - }; - } else { - nodeName_ = function(element) { - return element.nodeName ? element.nodeName : element[0].nodeName; - }; - } - - - function map(obj, iterator, context) { - var results = []; - forEach(obj, function(value, index, list) { - results.push(iterator.call(context, value, index, list)); - }); - return results; - } - - - /** - * @description - * Determines the number of elements in an array, the number of properties an object has, or - * the length of a string. - * - * Note: This function is used to augment the Object type in Angular expressions. See - * {@link angular.Object} for more information about Angular arrays. - * - * @param {Object|Array|string} obj Object, array, or string to inspect. - * @param {boolean} [ownPropsOnly=false] Count only "own" properties in an object - * @returns {number} The size of `obj` or `0` if `obj` is neither an object nor an array. - */ - function size(obj, ownPropsOnly) { - var count = 0, key; - - if (isArray(obj) || isString(obj)) { - return obj.length; - } else if (isObject(obj)){ - for (key in obj) - if (!ownPropsOnly || obj.hasOwnProperty(key)) - count++; - } - - return count; + function nodeName_(element) { + return lowercase(element.nodeName || (element[0] && element[0].nodeName)); } - function includes(array, obj) { - return indexOf(array, obj) != -1; - } - - function indexOf(array, obj) { - if (array.indexOf) return array.indexOf(obj); - - for (var i = 0; i < array.length; i++) { - if (obj === array[i]) return i; - } - return -1; + return Array.prototype.indexOf.call(array, obj) !== -1; } function arrayRemove(array, value) { - var index = indexOf(array, value); - if (index >=0) + var index = array.indexOf(value); + if (index >= 0) { array.splice(index, 1); - return value; - } - - function isLeafNode (node) { - if (node) { - switch (node.nodeName) { - case "OPTION": - case "PRE": - case "TITLE": - return true; - } } - return false; + return index; } /** * @ngdoc function * @name angular.copy * @module ng - * @function + * @kind function * * @description * Creates a deep copy of `source`, which should be an object or an array. * * * If no destination is supplied, a copy of the object or array is created. - * * If a destination is provided, all of its elements (for array) or properties (for objects) + * * If a destination is provided, all of its elements (for arrays) or properties (for objects) * are deleted and then all elements/properties from the source are copied to it. * * If `source` is not an object or array (inc. `null` and `undefined`), `source` is returned. - * * If `source` is identical to 'destination' an exception will be thrown. + * * If `source` is identical to `destination` an exception will be thrown. + * + * <br /> + * <div class="alert alert-warning"> + * Only enumerable properties are taken into account. Non-enumerable properties (both on `source` + * and on `destination`) will be ignored. + * </div> * * @param {*} source The source that will be used to make a copy. * Can be any type, including primitives, `null`, and `undefined`. @@ -804,105 +962,198 @@ * @returns {*} The copy or updated `destination`, if `destination` was specified. * * @example - <example> + <example module="copyExample" name="angular-copy"> <file name="index.html"> - <div ng-controller="Controller"> + <div ng-controller="ExampleController"> <form novalidate class="simple-form"> - Name: <input type="text" ng-model="user.name" /><br /> - Email: <input type="email" ng-model="user.email" /><br /> - Gender: <input type="radio" ng-model="user.gender" value="male" />male - <input type="radio" ng-model="user.gender" value="female" />female<br /> + <label>Name: <input type="text" ng-model="user.name" /></label><br /> + <label>Age: <input type="number" ng-model="user.age" /></label><br /> + Gender: <label><input type="radio" ng-model="user.gender" value="male" />male</label> + <label><input type="radio" ng-model="user.gender" value="female" />female</label><br /> <button ng-click="reset()">RESET</button> <button ng-click="update(user)">SAVE</button> </form> <pre>form = {{user | json}}</pre> - <pre>master = {{master | json}}</pre> + <pre>leader = {{leader | json}}</pre> </div> + </file> + <file name="script.js"> + // Module: copyExample + angular. + module('copyExample', []). + controller('ExampleController', ['$scope', function($scope) { + $scope.leader = {}; + + $scope.reset = function() { + // Example with 1 argument + $scope.user = angular.copy($scope.leader); + }; - <script> - function Controller($scope) { - $scope.master= {}; - - $scope.update = function(user) { - // Example with 1 argument - $scope.master= angular.copy(user); - }; - - $scope.reset = function() { - // Example with 2 arguments - angular.copy($scope.master, $scope.user); - }; + $scope.update = function(user) { + // Example with 2 arguments + angular.copy(user, $scope.leader); + }; - $scope.reset(); - } - </script> + $scope.reset(); + }]); </file> </example> */ - function copy(source, destination){ - if (isWindow(source) || isScope(source)) { - throw ngMinErr('cpws', - "Can't copy! Making copies of Window or Scope instances is not supported."); + function copy(source, destination, maxDepth) { + var stackSource = []; + var stackDest = []; + maxDepth = isValidObjectMaxDepth(maxDepth) ? maxDepth : NaN; + + if (destination) { + if (isTypedArray(destination) || isArrayBuffer(destination)) { + throw ngMinErr('cpta', 'Can\'t copy! TypedArray destination cannot be mutated.'); + } + if (source === destination) { + throw ngMinErr('cpi', 'Can\'t copy! Source and destination are identical.'); + } + + // Empty the destination object + if (isArray(destination)) { + destination.length = 0; + } else { + forEach(destination, function(value, key) { + if (key !== '$$hashKey') { + delete destination[key]; + } + }); + } + + stackSource.push(source); + stackDest.push(destination); + return copyRecurse(source, destination, maxDepth); } - if (!destination) { - destination = source; - if (source) { - if (isArray(source)) { - destination = copy(source, []); - } else if (isDate(source)) { - destination = new Date(source.getTime()); - } else if (isRegExp(source)) { - destination = new RegExp(source.source); - } else if (isObject(source)) { - destination = copy(source, {}); - } + return copyElement(source, maxDepth); + + function copyRecurse(source, destination, maxDepth) { + maxDepth--; + if (maxDepth < 0) { + return '...'; } - } else { - if (source === destination) throw ngMinErr('cpi', - "Can't copy! Source and destination are identical."); + var h = destination.$$hashKey; + var key; if (isArray(source)) { - destination.length = 0; - for ( var i = 0; i < source.length; i++) { - destination.push(copy(source[i])); + for (var i = 0, ii = source.length; i < ii; i++) { + destination.push(copyElement(source[i], maxDepth)); + } + } else if (isBlankObject(source)) { + // createMap() fast path --- Safe to avoid hasOwnProperty check because prototype chain is empty + for (key in source) { + destination[key] = copyElement(source[key], maxDepth); + } + } else if (source && typeof source.hasOwnProperty === 'function') { + // Slow path, which must rely on hasOwnProperty + for (key in source) { + if (source.hasOwnProperty(key)) { + destination[key] = copyElement(source[key], maxDepth); + } } } else { - var h = destination.$$hashKey; - forEach(destination, function(value, key){ - delete destination[key]; - }); - for ( var key in source) { - destination[key] = copy(source[key]); + // Slowest path --- hasOwnProperty can't be called as a method + for (key in source) { + if (hasOwnProperty.call(source, key)) { + destination[key] = copyElement(source[key], maxDepth); + } } - setHashKey(destination,h); } + setHashKey(destination, h); + return destination; } - return destination; - } - /** - * Create a shallow copy of an object - */ - function shallowCopy(src, dst) { - dst = dst || {}; + function copyElement(source, maxDepth) { + // Simple values + if (!isObject(source)) { + return source; + } + + // Already copied values + var index = stackSource.indexOf(source); + if (index !== -1) { + return stackDest[index]; + } - for(var key in src) { - // shallowCopy is only ever called by $compile nodeLinkFn, which has control over src - // so we don't need to worry about using our custom hasOwnProperty here - if (src.hasOwnProperty(key) && !(key.charAt(0) === '$' && key.charAt(1) === '$')) { - dst[key] = src[key]; + if (isWindow(source) || isScope(source)) { + throw ngMinErr('cpws', + 'Can\'t copy! Making copies of Window or Scope instances is not supported.'); } + + var needsRecurse = false; + var destination = copyType(source); + + if (destination === undefined) { + destination = isArray(source) ? [] : Object.create(getPrototypeOf(source)); + needsRecurse = true; + } + + stackSource.push(source); + stackDest.push(destination); + + return needsRecurse + ? copyRecurse(source, destination, maxDepth) + : destination; } - return dst; + function copyType(source) { + switch (toString.call(source)) { + case '[object Int8Array]': + case '[object Int16Array]': + case '[object Int32Array]': + case '[object Float32Array]': + case '[object Float64Array]': + case '[object Uint8Array]': + case '[object Uint8ClampedArray]': + case '[object Uint16Array]': + case '[object Uint32Array]': + return new source.constructor(copyElement(source.buffer), source.byteOffset, source.length); + + case '[object ArrayBuffer]': + // Support: IE10 + if (!source.slice) { + // If we're in this case we know the environment supports ArrayBuffer + /* eslint-disable no-undef */ + var copied = new ArrayBuffer(source.byteLength); + new Uint8Array(copied).set(new Uint8Array(source)); + /* eslint-enable */ + return copied; + } + return source.slice(0); + + case '[object Boolean]': + case '[object Number]': + case '[object String]': + case '[object Date]': + return new source.constructor(source.valueOf()); + + case '[object RegExp]': + var re = new RegExp(source.source, source.toString().match(/[^/]*$/)[0]); + re.lastIndex = source.lastIndex; + return re; + + case '[object Blob]': + return new source.constructor([source], {type: source.type}); + } + + if (isFunction(source.cloneNode)) { + return source.cloneNode(true); + } + } } +// eslint-disable-next-line no-self-compare + function simpleCompare(a, b) { return a === b || (a !== a && b !== b); } + + /** * @ngdoc function * @name angular.equals * @module ng - * @function + * @kind function * * @description * Determines if two objects or two values are equivalent. Supports value types, regular @@ -914,7 +1165,7 @@ * * Both objects or values are of the same type and all of their properties are equal by * comparing them with `angular.equals`. * * Both values are NaN. (In JavaScript, NaN == NaN => false. But we consider two NaN as equal) - * * Both values represent the same regular expression (In JavasScript, + * * Both values represent the same regular expression (In JavaScript, * /abc/ == /abc/ => false. But we consider two regular expressions as equal when their textual * representation matches). * @@ -926,54 +1177,171 @@ * @param {*} o1 Object or value to compare. * @param {*} o2 Object or value to compare. * @returns {boolean} True if arguments are equal. - */ - function equals(o1, o2) { - if (o1 === o2) return true; - if (o1 === null || o2 === null) return false; - if (o1 !== o1 && o2 !== o2) return true; // NaN === NaN - var t1 = typeof o1, t2 = typeof o2, length, key, keySet; - if (t1 == t2) { - if (t1 == 'object') { - if (isArray(o1)) { - if (!isArray(o2)) return false; - if ((length = o1.length) == o2.length) { - for(key=0; key<length; key++) { - if (!equals(o1[key], o2[key])) return false; - } - return true; - } - } else if (isDate(o1)) { - return isDate(o2) && o1.getTime() == o2.getTime(); - } else if (isRegExp(o1) && isRegExp(o2)) { - return o1.toString() == o2.toString(); - } else { - if (isScope(o1) || isScope(o2) || isWindow(o1) || isWindow(o2) || isArray(o2)) return false; - keySet = {}; - for(key in o1) { - if (key.charAt(0) === '$' || isFunction(o1[key])) continue; + * + * @example + <example module="equalsExample" name="equalsExample"> + <file name="index.html"> + <div ng-controller="ExampleController"> + <form novalidate> + <h3>User 1</h3> + Name: <input type="text" ng-model="user1.name"> + Age: <input type="number" ng-model="user1.age"> + + <h3>User 2</h3> + Name: <input type="text" ng-model="user2.name"> + Age: <input type="number" ng-model="user2.age"> + + <div> + <br/> + <input type="button" value="Compare" ng-click="compare()"> + </div> + User 1: <pre>{{user1 | json}}</pre> + User 2: <pre>{{user2 | json}}</pre> + Equal: <pre>{{result}}</pre> + </form> + </div> + </file> + <file name="script.js"> + angular.module('equalsExample', []).controller('ExampleController', ['$scope', function($scope) { + $scope.user1 = {}; + $scope.user2 = {}; + $scope.compare = function() { + $scope.result = angular.equals($scope.user1, $scope.user2); + }; + }]); + </file> + </example> + */ + function equals(o1, o2) { + if (o1 === o2) return true; + if (o1 === null || o2 === null) return false; + // eslint-disable-next-line no-self-compare + if (o1 !== o1 && o2 !== o2) return true; // NaN === NaN + var t1 = typeof o1, t2 = typeof o2, length, key, keySet; + if (t1 === t2 && t1 === 'object') { + if (isArray(o1)) { + if (!isArray(o2)) return false; + if ((length = o1.length) === o2.length) { + for (key = 0; key < length; key++) { if (!equals(o1[key], o2[key])) return false; - keySet[key] = true; - } - for(key in o2) { - if (!keySet.hasOwnProperty(key) && - key.charAt(0) !== '$' && - o2[key] !== undefined && - !isFunction(o2[key])) return false; } return true; } + } else if (isDate(o1)) { + if (!isDate(o2)) return false; + return simpleCompare(o1.getTime(), o2.getTime()); + } else if (isRegExp(o1)) { + if (!isRegExp(o2)) return false; + return o1.toString() === o2.toString(); + } else { + if (isScope(o1) || isScope(o2) || isWindow(o1) || isWindow(o2) || + isArray(o2) || isDate(o2) || isRegExp(o2)) return false; + keySet = createMap(); + for (key in o1) { + if (key.charAt(0) === '$' || isFunction(o1[key])) continue; + if (!equals(o1[key], o2[key])) return false; + keySet[key] = true; + } + for (key in o2) { + if (!(key in keySet) && + key.charAt(0) !== '$' && + isDefined(o2[key]) && + !isFunction(o2[key])) return false; + } + return true; } } return false; } + var csp = function() { + if (!isDefined(csp.rules)) { - function csp() { - return (document.securityPolicy && document.securityPolicy.isActive) || - (document.querySelector && - !!(document.querySelector('[ng-csp]') || document.querySelector('[data-ng-csp]'))); - } + var ngCspElement = (window.document.querySelector('[ng-csp]') || + window.document.querySelector('[data-ng-csp]')); + + if (ngCspElement) { + var ngCspAttribute = ngCspElement.getAttribute('ng-csp') || + ngCspElement.getAttribute('data-ng-csp'); + csp.rules = { + noUnsafeEval: !ngCspAttribute || (ngCspAttribute.indexOf('no-unsafe-eval') !== -1), + noInlineStyle: !ngCspAttribute || (ngCspAttribute.indexOf('no-inline-style') !== -1) + }; + } else { + csp.rules = { + noUnsafeEval: noUnsafeEval(), + noInlineStyle: false + }; + } + } + + return csp.rules; + + function noUnsafeEval() { + try { + // eslint-disable-next-line no-new, no-new-func + new Function(''); + return false; + } catch (e) { + return true; + } + } + }; + + /** + * @ngdoc directive + * @module ng + * @name ngJq + * + * @element ANY + * @param {string=} ngJq the name of the library available under `window` + * to be used for angular.element + * @description + * Use this directive to force the angular.element library. This should be + * used to force either jqLite by leaving ng-jq blank or setting the name of + * the jquery variable under window (eg. jQuery). + * + * Since AngularJS looks for this directive when it is loaded (doesn't wait for the + * DOMContentLoaded event), it must be placed on an element that comes before the script + * which loads angular. Also, only the first instance of `ng-jq` will be used and all + * others ignored. + * + * @example + * This example shows how to force jqLite using the `ngJq` directive to the `html` tag. + ```html + <!doctype html> + <html ng-app ng-jq> + ... + ... + </html> + ``` + * @example + * This example shows how to use a jQuery based library of a different name. + * The library name must be available at the top most 'window'. + ```html + <!doctype html> + <html ng-app ng-jq="jQueryLib"> + ... + ... + </html> + ``` + */ + var jq = function() { + if (isDefined(jq.name_)) return jq.name_; + var el; + var i, ii = ngAttrPrefixes.length, prefix, name; + for (i = 0; i < ii; ++i) { + prefix = ngAttrPrefixes[i]; + el = window.document.querySelector('[' + prefix.replace(':', '\\:') + 'jq]'); + if (el) { + name = el.getAttribute(prefix + 'jq'); + break; + } + } + + return (jq.name_ = name); + }; function concat(array1, array2, index) { return array1.concat(slice.call(array2, index)); @@ -984,12 +1352,11 @@ } - /* jshint -W101 */ /** * @ngdoc function * @name angular.bind * @module ng - * @function + * @kind function * * @description * Returns a function which calls function `fn` bound to `self` (`self` becomes the `this` for @@ -1002,23 +1369,22 @@ * @param {...*} args Optional arguments to be prebound to the `fn` function call. * @returns {function()} Function that wraps the `fn` with all the specified bindings. */ - /* jshint +W101 */ function bind(self, fn) { var curryArgs = arguments.length > 2 ? sliceArgs(arguments, 2) : []; if (isFunction(fn) && !(fn instanceof RegExp)) { return curryArgs.length ? function() { - return arguments.length - ? fn.apply(self, curryArgs.concat(slice.call(arguments, 0))) - : fn.apply(self, curryArgs); - } + return arguments.length + ? fn.apply(self, concat(curryArgs, arguments, 0)) + : fn.apply(self, curryArgs); + } : function() { - return arguments.length - ? fn.apply(self, arguments) - : fn.call(self); - }; + return arguments.length + ? fn.apply(self, arguments) + : fn.call(self); + }; } else { - // in IE, native methods are not functions so they cannot be bound (note: they don't need to be) + // In IE, native methods are not functions so they cannot be bound (note: they don't need to be). return fn; } } @@ -1027,11 +1393,11 @@ function toJsonReplacer(key, value) { var val = value; - if (typeof key === 'string' && key.charAt(0) === '$') { + if (typeof key === 'string' && key.charAt(0) === '$' && key.charAt(1) === '$') { val = undefined; } else if (isWindow(value)) { val = '$WINDOW'; - } else if (value && document === value) { + } else if (value && window.document === value) { val = '$DOCUMENT'; } else if (isScope(value)) { val = '$SCOPE'; @@ -1045,19 +1411,44 @@ * @ngdoc function * @name angular.toJson * @module ng - * @function + * @kind function * * @description - * Serializes input into a JSON-formatted string. Properties with leading $ characters will be - * stripped since angular uses this notation internally. + * Serializes input into a JSON-formatted string. Properties with leading $$ characters will be + * stripped since AngularJS uses this notation internally. * - * @param {Object|Array|Date|string|number} obj Input to be serialized into JSON. - * @param {boolean=} pretty If set to true, the JSON output will contain newlines and whitespace. + * @param {Object|Array|Date|string|number|boolean} obj Input to be serialized into JSON. + * @param {boolean|number} [pretty=2] If set to true, the JSON output will contain newlines and whitespace. + * If set to an integer, the JSON output will contain that many spaces per indentation. * @returns {string|undefined} JSON-ified string representing `obj`. + * @knownIssue + * + * The Safari browser throws a `RangeError` instead of returning `null` when it tries to stringify a `Date` + * object with an invalid date value. The only reliable way to prevent this is to monkeypatch the + * `Date.prototype.toJSON` method as follows: + * + * ``` + * var _DatetoJSON = Date.prototype.toJSON; + * Date.prototype.toJSON = function() { + * try { + * return _DatetoJSON.call(this); + * } catch(e) { + * if (e instanceof RangeError) { + * return null; + * } + * throw e; + * } + * }; + * ``` + * + * See https://github.com/angular/angular.js/pull/14221 for more information. */ function toJson(obj, pretty) { - if (typeof obj === 'undefined') return undefined; - return JSON.stringify(obj, toJsonReplacer, pretty ? ' ' : null); + if (isUndefined(obj)) return undefined; + if (!isNumber(pretty)) { + pretty = pretty ? 2 : null; + } + return JSON.stringify(obj, toJsonReplacer, pretty); } @@ -1065,13 +1456,13 @@ * @ngdoc function * @name angular.fromJson * @module ng - * @function + * @kind function * * @description * Deserializes a JSON string. * * @param {string} json JSON string to deserialize. - * @returns {Object|Array|string|number} Deserialized thingy. + * @returns {Object|Array|string|number} Deserialized JSON string. */ function fromJson(json) { return isString(json) @@ -1080,37 +1471,43 @@ } - function toBoolean(value) { - if (typeof value === 'function') { - value = true; - } else if (value && value.length !== 0) { - var v = lowercase("" + value); - value = !(v == 'f' || v == '0' || v == 'false' || v == 'no' || v == 'n' || v == '[]'); - } else { - value = false; - } - return value; + var ALL_COLONS = /:/g; + function timezoneToOffset(timezone, fallback) { + // Support: IE 9-11 only, Edge 13-15+ + // IE/Edge do not "understand" colon (`:`) in timezone + timezone = timezone.replace(ALL_COLONS, ''); + var requestedTimezoneOffset = Date.parse('Jan 01, 1970 00:00:00 ' + timezone) / 60000; + return isNumberNaN(requestedTimezoneOffset) ? fallback : requestedTimezoneOffset; + } + + + function addDateMinutes(date, minutes) { + date = new Date(date.getTime()); + date.setMinutes(date.getMinutes() + minutes); + return date; } + + function convertTimezoneToLocal(date, timezone, reverse) { + reverse = reverse ? -1 : 1; + var dateTimezoneOffset = date.getTimezoneOffset(); + var timezoneOffset = timezoneToOffset(timezone, dateTimezoneOffset); + return addDateMinutes(date, reverse * (timezoneOffset - dateTimezoneOffset)); + } + + /** * @returns {string} Returns the string representation of the element. */ function startingTag(element) { - element = jqLite(element).clone(); - try { - // turns out IE does not let you set .html() on elements which - // are not allowed to have children. So we just ignore it. - element.empty(); - } catch(e) {} - // As Per DOM Standards - var TEXT_NODE = 3; + element = jqLite(element).clone().empty(); var elemHtml = jqLite('<div>').append(element).html(); try { - return element[0].nodeType === TEXT_NODE ? lowercase(elemHtml) : + return element[0].nodeType === NODE_TYPE_TEXT ? lowercase(elemHtml) : elemHtml. - match(/^(<[^>]+>)/)[1]. - replace(/^<([\w\-]+)/, function(match, nodeName) { return '<' + lowercase(nodeName); }); - } catch(e) { + match(/^(<[^>]+>)/)[1]. + replace(/^<([\w-]+)/, function(match, nodeName) {return '<' + lowercase(nodeName);}); + } catch (e) { return lowercase(elemHtml); } @@ -1130,8 +1527,8 @@ function tryDecodeURIComponent(value) { try { return decodeURIComponent(value); - } catch(e) { - // Ignore any invalid uri component + } catch (e) { + // Ignore any invalid uri component. } } @@ -1141,16 +1538,22 @@ * @returns {Object.<string,boolean|Array>} */ function parseKeyValue(/**string*/keyValue) { - var obj = {}, key_value, key; - forEach((keyValue || "").split('&'), function(keyValue){ - if ( keyValue ) { - key_value = keyValue.split('='); - key = tryDecodeURIComponent(key_value[0]); - if ( isDefined(key) ) { - var val = isDefined(key_value[1]) ? tryDecodeURIComponent(key_value[1]) : true; - if (!obj[key]) { + var obj = {}; + forEach((keyValue || '').split('&'), function(keyValue) { + var splitPoint, key, val; + if (keyValue) { + key = keyValue = keyValue.replace(/\+/g,'%20'); + splitPoint = keyValue.indexOf('='); + if (splitPoint !== -1) { + key = keyValue.substring(0, splitPoint); + val = keyValue.substring(splitPoint + 1); + } + key = tryDecodeURIComponent(key); + if (isDefined(key)) { + val = isDefined(val) ? tryDecodeURIComponent(val) : true; + if (!hasOwnProperty.call(obj, key)) { obj[key] = val; - } else if(isArray(obj[key])) { + } else if (isArray(obj[key])) { obj[key].push(val); } else { obj[key] = [obj[key],val]; @@ -1167,11 +1570,11 @@ if (isArray(value)) { forEach(value, function(arrayValue) { parts.push(encodeUriQuery(key, true) + - (arrayValue === true ? '' : '=' + encodeUriQuery(arrayValue, true))); + (arrayValue === true ? '' : '=' + encodeUriQuery(arrayValue, true))); }); } else { parts.push(encodeUriQuery(key, true) + - (value === true ? '' : '=' + encodeUriQuery(value, true))); + (value === true ? '' : '=' + encodeUriQuery(value, true))); } }); return parts.length ? parts.join('&') : ''; @@ -1191,9 +1594,9 @@ */ function encodeUriSegment(val) { return encodeUriQuery(val, true). - replace(/%26/gi, '&'). - replace(/%3D/gi, '='). - replace(/%2B/gi, '+'); + replace(/%26/gi, '&'). + replace(/%3D/gi, '='). + replace(/%2B/gi, '+'); } @@ -1201,7 +1604,7 @@ * This method is intended for encoding *key* or *value* parts of query component. We need a custom * method because encodeURIComponent is too aggressive and encodes stuff that doesn't have to be * encoded per http://tools.ietf.org/html/rfc3986: - * query = *( pchar / "/" / "?" ) + * query = *( pchar / "/" / "?" ) * pchar = unreserved / pct-encoded / sub-delims / ":" / "@" * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" * pct-encoded = "%" HEXDIG HEXDIG @@ -1210,13 +1613,78 @@ */ function encodeUriQuery(val, pctEncodeSpaces) { return encodeURIComponent(val). - replace(/%40/gi, '@'). - replace(/%3A/gi, ':'). - replace(/%24/g, '$'). - replace(/%2C/gi, ','). - replace(/%20/g, (pctEncodeSpaces ? '%20' : '+')); + replace(/%40/gi, '@'). + replace(/%3A/gi, ':'). + replace(/%24/g, '$'). + replace(/%2C/gi, ','). + replace(/%3B/gi, ';'). + replace(/%20/g, (pctEncodeSpaces ? '%20' : '+')); + } + + var ngAttrPrefixes = ['ng-', 'data-ng-', 'ng:', 'x-ng-']; + + function getNgAttribute(element, ngAttr) { + var attr, i, ii = ngAttrPrefixes.length; + for (i = 0; i < ii; ++i) { + attr = ngAttrPrefixes[i] + ngAttr; + if (isString(attr = element.getAttribute(attr))) { + return attr; + } + } + return null; + } + + function allowAutoBootstrap(document) { + var script = document.currentScript; + + if (!script) { + // Support: IE 9-11 only + // IE does not have `document.currentScript` + return true; + } + + // If the `currentScript` property has been clobbered just return false, since this indicates a probable attack + if (!(script instanceof window.HTMLScriptElement || script instanceof window.SVGScriptElement)) { + return false; + } + + var attributes = script.attributes; + var srcs = [attributes.getNamedItem('src'), attributes.getNamedItem('href'), attributes.getNamedItem('xlink:href')]; + + return srcs.every(function(src) { + if (!src) { + return true; + } + if (!src.value) { + return false; + } + + var link = document.createElement('a'); + link.href = src.value; + + if (document.location.origin === link.origin) { + // Same-origin resources are always allowed, even for non-whitelisted schemes. + return true; + } + // Disabled bootstrapping unless angular.js was loaded from a known scheme used on the web. + // This is to prevent angular.js bundled with browser extensions from being used to bypass the + // content security policy in web pages and other browser extensions. + switch (link.protocol) { + case 'http:': + case 'https:': + case 'ftp:': + case 'blob:': + case 'file:': + case 'data:': + return true; + default: + return false; + } + }); } +// Cached as it has to run during loading so that document.currentScript is available. + var isAutoBootstrapAllowed = allowAutoBootstrap(window.document); /** * @ngdoc directive @@ -1226,6 +1694,11 @@ * @element ANY * @param {angular.Module} ngApp an optional application * {@link angular.module module} name to load. + * @param {boolean=} ngStrictDi if this attribute is present on the app element, the injector will be + * created in "strict-di" mode. This means that the application will fail to invoke functions which + * do not use explicit function annotation (and are thus unsuitable for minification), as described + * in {@link guide/di the Dependency Injection guide}, and useful debugging info will assist in + * tracking down the root of these bugs. * * @description * @@ -1233,13 +1706,20 @@ * designates the **root element** of the application and is typically placed near the root element * of the page - e.g. on the `<body>` or `<html>` tags. * - * Only one AngularJS application can be auto-bootstrapped per HTML document. The first `ngApp` - * found in the document will be used to define the root element to auto-bootstrap as an - * application. To run multiple applications in an HTML document you must manually bootstrap them using - * {@link angular.bootstrap} instead. AngularJS applications cannot be nested within each other. + * There are a few things to keep in mind when using `ngApp`: + * - only one AngularJS application can be auto-bootstrapped per HTML document. The first `ngApp` + * found in the document will be used to define the root element to auto-bootstrap as an + * application. To run multiple applications in an HTML document you must manually bootstrap them using + * {@link angular.bootstrap} instead. + * - AngularJS applications cannot be nested within each other. + * - Do not use a directive that uses {@link ng.$compile#transclusion transclusion} on the same element as `ngApp`. + * This includes directives such as {@link ng.ngIf `ngIf`}, {@link ng.ngInclude `ngInclude`} and + * {@link ngRoute.ngView `ngView`}. + * Doing this misplaces the app {@link ng.$rootElement `$rootElement`} and the app's {@link auto.$injector injector}, + * causing animations to stop working and making the injector inaccessible from outside the app. * * You can specify an **AngularJS module** to be used as the root module for the application. This - * module will be loaded into the {@link auto.$injector} when the application is bootstrapped and + * module will be loaded into the {@link auto.$injector} when the application is bootstrapped. It * should contain the application code needed or have dependencies on other modules that will * contain the code. See {@link angular.module} for more information. * @@ -1247,9 +1727,13 @@ * document would not be compiled, the `AppController` would not be instantiated and the `{{ a+b }}` * would not be resolved to `3`. * - * `ngApp` is the easiest, and most common, way to bootstrap an application. + * @example + * + * ### Simple Usage + * + * `ngApp` is the easiest, and most common way to bootstrap an application. * - <example module="ngAppDemo"> + <example module="ngAppDemo" name="ng-app"> <file name="index.html"> <div ng-controller="ngAppDemoController"> I can add: {{a}} + {{b}} = {{ a+b }} @@ -1263,48 +1747,118 @@ </file> </example> * + * @example + * + * ### With `ngStrictDi` + * + * Using `ngStrictDi`, you would see something like this: + * + <example ng-app-included="true" name="strict-di"> + <file name="index.html"> + <div ng-app="ngAppStrictDemo" ng-strict-di> + <div ng-controller="GoodController1"> + I can add: {{a}} + {{b}} = {{ a+b }} + + <p>This renders because the controller does not fail to + instantiate, by using explicit annotation style (see + script.js for details) + </p> + </div> + + <div ng-controller="GoodController2"> + Name: <input ng-model="name"><br /> + Hello, {{name}}! + + <p>This renders because the controller does not fail to + instantiate, by using explicit annotation style + (see script.js for details) + </p> + </div> + + <div ng-controller="BadController"> + I can add: {{a}} + {{b}} = {{ a+b }} + + <p>The controller could not be instantiated, due to relying + on automatic function annotations (which are disabled in + strict mode). As such, the content of this section is not + interpolated, and there should be an error in your web console. + </p> + </div> + </div> + </file> + <file name="script.js"> + angular.module('ngAppStrictDemo', []) + // BadController will fail to instantiate, due to relying on automatic function annotation, + // rather than an explicit annotation + .controller('BadController', function($scope) { + $scope.a = 1; + $scope.b = 2; + }) + // Unlike BadController, GoodController1 and GoodController2 will not fail to be instantiated, + // due to using explicit annotations using the array style and $inject property, respectively. + .controller('GoodController1', ['$scope', function($scope) { + $scope.a = 1; + $scope.b = 2; + }]) + .controller('GoodController2', GoodController2); + function GoodController2($scope) { + $scope.name = 'World'; + } + GoodController2.$inject = ['$scope']; + </file> + <file name="style.css"> + div[ng-controller] { + margin-bottom: 1em; + -webkit-border-radius: 4px; + border-radius: 4px; + border: 1px solid; + padding: .5em; + } + div[ng-controller^=Good] { + border-color: #d6e9c6; + background-color: #dff0d8; + color: #3c763d; + } + div[ng-controller^=Bad] { + border-color: #ebccd1; + background-color: #f2dede; + color: #a94442; + margin-bottom: 0; + } + </file> + </example> */ function angularInit(element, bootstrap) { - var elements = [element], - appElement, + var appElement, module, - names = ['ng:app', 'ng-app', 'x-ng-app', 'data-ng-app'], - NG_APP_CLASS_REGEXP = /\sng[:\-]app(:\s*([\w\d_]+);?)?\s/; + config = {}; - function append(element) { - element && elements.push(element); - } + // The element `element` has priority over any other element. + forEach(ngAttrPrefixes, function(prefix) { + var name = prefix + 'app'; - forEach(names, function(name) { - names[name] = true; - append(document.getElementById(name)); - name = name.replace(':', '\\:'); - if (element.querySelectorAll) { - forEach(element.querySelectorAll('.' + name), append); - forEach(element.querySelectorAll('.' + name + '\\:'), append); - forEach(element.querySelectorAll('[' + name + ']'), append); + if (!appElement && element.hasAttribute && element.hasAttribute(name)) { + appElement = element; + module = element.getAttribute(name); } }); + forEach(ngAttrPrefixes, function(prefix) { + var name = prefix + 'app'; + var candidate; - forEach(elements, function(element) { - if (!appElement) { - var className = ' ' + element.className + ' '; - var match = NG_APP_CLASS_REGEXP.exec(className); - if (match) { - appElement = element; - module = (match[2] || '').replace(/\s+/g, ','); - } else { - forEach(element.attributes, function(attr) { - if (!appElement && names[attr.name]) { - appElement = element; - module = attr.value; - } - }); - } + if (!appElement && (candidate = element.querySelector('[' + name.replace(':', '\\:') + ']'))) { + appElement = candidate; + module = candidate.getAttribute(name); } }); if (appElement) { - bootstrap(appElement, module ? [module] : []); + if (!isAutoBootstrapAllowed) { + window.console.error('AngularJS: disabling automatic bootstrap. <script> protocol indicates ' + + 'an extension, document.location.href does not match.'); + return; + } + config.strictDi = getNgAttribute(appElement, 'strict-di') !== null; + bootstrap(appElement, module ? [module] : [], config); } } @@ -1313,83 +1867,111 @@ * @name angular.bootstrap * @module ng * @description - * Use this function to manually start up angular application. + * Use this function to manually start up AngularJS application. + * + * For more information, see the {@link guide/bootstrap Bootstrap guide}. * - * See: {@link guide/bootstrap Bootstrap} + * AngularJS will detect if it has been loaded into the browser more than once and only allow the + * first loaded script to be bootstrapped and will report a warning to the browser console for + * each of the subsequent scripts. This prevents strange results in applications, where otherwise + * multiple instances of AngularJS try to work on the DOM. * - * Note that ngScenario-based end-to-end tests cannot use this function to bootstrap manually. + * <div class="alert alert-warning"> + * **Note:** Protractor based end-to-end tests cannot use this function to bootstrap manually. * They must use {@link ng.directive:ngApp ngApp}. + * </div> * - * Angular will detect if it has been loaded into the browser more than once and only allow the - * first loaded script to be bootstrapped and will report a warning to the browser console for - * each of the subsequent scripts. This prevents strange results in applications, where otherwise - * multiple instances of Angular try to work on the DOM. + * <div class="alert alert-warning"> + * **Note:** Do not bootstrap the app on an element with a directive that uses {@link ng.$compile#transclusion transclusion}, + * such as {@link ng.ngIf `ngIf`}, {@link ng.ngInclude `ngInclude`} and {@link ngRoute.ngView `ngView`}. + * Doing this misplaces the app {@link ng.$rootElement `$rootElement`} and the app's {@link auto.$injector injector}, + * causing animations to stop working and making the injector inaccessible from outside the app. + * </div> * - * <example name="multi-bootstrap" module="multi-bootstrap"> - * <file name="index.html"> - * <script src="../../../angular.js"></script> - * <div ng-controller="BrokenTable"> - * <table> - * <tr> - * <th ng-repeat="heading in headings">{{heading}}</th> - * </tr> - * <tr ng-repeat="filling in fillings"> - * <td ng-repeat="fill in filling">{{fill}}</td> - * </tr> - * </table> + * ```html + * <!doctype html> + * <html> + * <body> + * <div ng-controller="WelcomeController"> + * {{greeting}} * </div> - * </file> - * <file name="controller.js"> - * var app = angular.module('multi-bootstrap', []) * - * .controller('BrokenTable', function($scope) { - * $scope.headings = ['One', 'Two', 'Three']; - * $scope.fillings = [[1, 2, 3], ['A', 'B', 'C'], [7, 8, 9]]; - * }); - * </file> - * <file name="protractor.js" type="protractor"> - * it('should only insert one table cell for each item in $scope.fillings', function() { - * expect(element.all(by.css('td')).count()) - * .toBe(9); - * }); - * </file> - * </example> + * <script src="angular.js"></script> + * <script> + * var app = angular.module('demo', []) + * .controller('WelcomeController', function($scope) { + * $scope.greeting = 'Welcome!'; + * }); + * angular.bootstrap(document, ['demo']); + * </script> + * </body> + * </html> + * ``` * - * @param {DOMElement} element DOM element which is the root of angular application. + * @param {DOMElement} element DOM element which is the root of AngularJS application. * @param {Array<String|Function|Array>=} modules an array of modules to load into the application. * Each item in the array should be the name of a predefined module or a (DI annotated) - * function that will be invoked by the injector as a run block. + * function that will be invoked by the injector as a `config` block. * See: {@link angular.module modules} + * @param {Object=} config an object for defining configuration options for the application. The + * following keys are supported: + * + * * `strictDi` - disable automatic function annotation for the application. This is meant to + * assist in finding bugs which break minified code. Defaults to `false`. + * * @returns {auto.$injector} Returns the newly created injector for this app. */ - function bootstrap(element, modules) { + function bootstrap(element, modules, config) { + if (!isObject(config)) config = {}; + var defaultConfig = { + strictDi: false + }; + config = extend(defaultConfig, config); var doBootstrap = function() { element = jqLite(element); if (element.injector()) { - var tag = (element[0] === document) ? 'document' : startingTag(element); - throw ngMinErr('btstrpd', "App Already Bootstrapped with this Element '{0}'", tag); + var tag = (element[0] === window.document) ? 'document' : startingTag(element); + // Encode angle brackets to prevent input from being sanitized to empty string #8683. + throw ngMinErr( + 'btstrpd', + 'App already bootstrapped with this element \'{0}\'', + tag.replace(/</,'<').replace(/>/,'>')); } modules = modules || []; modules.unshift(['$provide', function($provide) { $provide.value('$rootElement', element); }]); + + if (config.debugInfoEnabled) { + // Pushing so that this overrides `debugInfoEnabled` setting defined in user's `modules`. + modules.push(['$compileProvider', function($compileProvider) { + $compileProvider.debugInfoEnabled(true); + }]); + } + modules.unshift('ng'); - var injector = createInjector(modules); - injector.invoke(['$rootScope', '$rootElement', '$compile', '$injector', '$animate', - function(scope, element, compile, injector, animate) { - scope.$apply(function() { - element.data('$injector', injector); - compile(element)(scope); - }); - }] + var injector = createInjector(modules, config.strictDi); + injector.invoke(['$rootScope', '$rootElement', '$compile', '$injector', + function bootstrapApply(scope, element, compile, injector) { + scope.$apply(function() { + element.data('$injector', injector); + compile(element)(scope); + }); + }] ); return injector; }; + var NG_ENABLE_DEBUG_INFO = /^NG_ENABLE_DEBUG_INFO!/; var NG_DEFER_BOOTSTRAP = /^NG_DEFER_BOOTSTRAP!/; + if (window && NG_ENABLE_DEBUG_INFO.test(window.name)) { + config.debugInfoEnabled = true; + window.name = window.name.replace(NG_ENABLE_DEBUG_INFO, ''); + } + if (window && !NG_DEFER_BOOTSTRAP.test(window.name)) { return doBootstrap(); } @@ -1399,40 +1981,104 @@ forEach(extraModules, function(module) { modules.push(module); }); - doBootstrap(); + return doBootstrap(); }; + + if (isFunction(angular.resumeDeferredBootstrap)) { + angular.resumeDeferredBootstrap(); + } + } + + /** + * @ngdoc function + * @name angular.reloadWithDebugInfo + * @module ng + * @description + * Use this function to reload the current application with debug information turned on. + * This takes precedence over a call to `$compileProvider.debugInfoEnabled(false)`. + * + * See {@link ng.$compileProvider#debugInfoEnabled} for more. + */ + function reloadWithDebugInfo() { + window.name = 'NG_ENABLE_DEBUG_INFO!' + window.name; + window.location.reload(); + } + + /** + * @name angular.getTestability + * @module ng + * @description + * Get the testability service for the instance of AngularJS on the given + * element. + * @param {DOMElement} element DOM element which is the root of AngularJS application. + */ + function getTestability(rootElement) { + var injector = angular.element(rootElement).injector(); + if (!injector) { + throw ngMinErr('test', + 'no injector found for element argument to getTestability'); + } + return injector.get('$$testability'); } var SNAKE_CASE_REGEXP = /[A-Z]/g; - function snake_case(name, separator){ + function snake_case(name, separator) { separator = separator || '_'; return name.replace(SNAKE_CASE_REGEXP, function(letter, pos) { return (pos ? separator : '') + letter.toLowerCase(); }); } + var bindJQueryFired = false; function bindJQuery() { + var originalCleanData; + + if (bindJQueryFired) { + return; + } + // bind to jQuery if present; - jQuery = window.jQuery; - // reset to jQuery or default to us. - if (jQuery) { + var jqName = jq(); + jQuery = isUndefined(jqName) ? window.jQuery : // use jQuery (if present) + !jqName ? undefined : // use jqLite + window[jqName]; // use jQuery specified by `ngJq` + + // Use jQuery if it exists with proper functionality, otherwise default to us. + // AngularJS 1.2+ requires jQuery 1.7+ for on()/off() support. + // AngularJS 1.3+ technically requires at least jQuery 2.1+ but it may work with older + // versions. It will not work for sure with jQuery <1.7, though. + if (jQuery && jQuery.fn.on) { jqLite = jQuery; extend(jQuery.fn, { scope: JQLitePrototype.scope, isolateScope: JQLitePrototype.isolateScope, - controller: JQLitePrototype.controller, + controller: /** @type {?} */ (JQLitePrototype).controller, injector: JQLitePrototype.injector, inheritedData: JQLitePrototype.inheritedData }); - // Method signature: - // jqLitePatchJQueryRemove(name, dispatchThis, filterElems, getterIfNoArguments) - jqLitePatchJQueryRemove('remove', true, true, false); - jqLitePatchJQueryRemove('empty', false, false, false); - jqLitePatchJQueryRemove('html', false, false, true); + + // All nodes removed from the DOM via various jQuery APIs like .remove() + // are passed through jQuery.cleanData. Monkey-patch this method to fire + // the $destroy event on all removed nodes. + originalCleanData = jQuery.cleanData; + jQuery.cleanData = function(elems) { + var events; + for (var i = 0, elem; (elem = elems[i]) != null; i++) { + events = jQuery._data(elem, 'events'); + if (events && events.$destroy) { + jQuery(elem).triggerHandler('$destroy'); + } + } + originalCleanData(elems); + }; } else { jqLite = JQLite; } + angular.element = jqLite; + + // Prevent double-proxying. + bindJQueryFired = true; } /** @@ -1440,7 +2086,7 @@ */ function assertArg(arg, name, reason) { if (!arg) { - throw ngMinErr('areq', "Argument '{0}' is {1}", (name || '?'), (reason || "required")); + throw ngMinErr('areq', 'Argument \'{0}\' is {1}', (name || '?'), (reason || 'required')); } return arg; } @@ -1451,7 +2097,7 @@ } assertArg(isFunction(arg), name, 'not a function, got ' + - (arg && typeof arg == 'object' ? arg.constructor.name || 'Object' : typeof arg)); + (arg && typeof arg === 'object' ? arg.constructor.name || 'Object' : typeof arg)); return arg; } @@ -1462,7 +2108,7 @@ */ function assertNotHasOwnProperty(name, context) { if (name === 'hasOwnProperty') { - throw ngMinErr('badname', "hasOwnProperty is not a valid {0} name", context); + throw ngMinErr('badname', 'hasOwnProperty is not a valid {0} name', context); } } @@ -1496,34 +2142,77 @@ /** * Return the DOM siblings between the first and last node in the given array. * @param {Array} array like object - * @returns {DOMElement} object containing the elements + * @returns {Array} the inputted object or a jqLite collection containing the nodes */ - function getBlockElements(nodes) { - var startNode = nodes[0], - endNode = nodes[nodes.length - 1]; - if (startNode === endNode) { - return jqLite(startNode); + function getBlockNodes(nodes) { + // TODO(perf): update `nodes` instead of creating a new object? + var node = nodes[0]; + var endNode = nodes[nodes.length - 1]; + var blockNodes; + + for (var i = 1; node !== endNode && (node = node.nextSibling); i++) { + if (blockNodes || nodes[i] !== node) { + if (!blockNodes) { + blockNodes = jqLite(slice.call(nodes, 0, i)); + } + blockNodes.push(node); + } } - var element = startNode; - var elements = [element]; + return blockNodes || nodes; + } + + + /** + * Creates a new object without a prototype. This object is useful for lookup without having to + * guard against prototypically inherited properties via hasOwnProperty. + * + * Related micro-benchmarks: + * - http://jsperf.com/object-create2 + * - http://jsperf.com/proto-map-lookup/2 + * - http://jsperf.com/for-in-vs-object-keys2 + * + * @returns {Object} + */ + function createMap() { + return Object.create(null); + } - do { - element = element.nextSibling; - if (!element) break; - elements.push(element); - } while (element !== endNode); + function stringify(value) { + if (value == null) { // null || undefined + return ''; + } + switch (typeof value) { + case 'string': + break; + case 'number': + value = '' + value; + break; + default: + if (hasCustomToString(value) && !isArray(value) && !isDate(value)) { + value = value.toString(); + } else { + value = toJson(value); + } + } - return jqLite(elements); + return value; } + var NODE_TYPE_ELEMENT = 1; + var NODE_TYPE_ATTRIBUTE = 2; + var NODE_TYPE_TEXT = 3; + var NODE_TYPE_COMMENT = 8; + var NODE_TYPE_DOCUMENT = 9; + var NODE_TYPE_DOCUMENT_FRAGMENT = 11; + /** * @ngdoc type * @name angular.Module * @module ng * @description * - * Interface for configuring angular {@link angular.module modules}. + * Interface for configuring AngularJS {@link angular.module modules}. */ function setupModuleLoader(window) { @@ -1550,18 +2239,18 @@ * @module ng * @description * - * The `angular.module` is a global place for creating, registering and retrieving Angular + * The `angular.module` is a global place for creating, registering and retrieving AngularJS * modules. - * All modules (angular core or 3rd party) that should be available to an application must be + * All modules (AngularJS core or 3rd party) that should be available to an application must be * registered using this mechanism. * - * When passed two or more arguments, a new module is created. If passed only one argument, an - * existing module (the name passed as the first argument to `module`) is retrieved. + * Passing one argument retrieves an existing {@link angular.Module}, + * whereas passing more than one argument creates a new {@link angular.Module} * * * # Module * - * A module is a collection of services, directives, filters, and configuration information. + * A module is a collection of services, directives, controllers, filters, and configuration information. * `angular.module` is used to configure the {@link auto.$injector $injector}. * * ```js @@ -1573,9 +2262,9 @@ * * // configure existing services inside initialization blocks. * myModule.config(['$locationProvider', function($locationProvider) { - * // Configure existing providers - * $locationProvider.hashPrefix('!'); - * }]); + * // Configure existing providers + * $locationProvider.hashPrefix('!'); + * }]); * ``` * * Then you can create an injector and load your modules like this: @@ -1589,13 +2278,16 @@ * {@link angular.bootstrap} to simplify this process for you. * * @param {!string} name The name of the module to create or retrieve. - <<<<<* @param {!Array.<string>=} requires If specified then new module is being created. If - >>>>>* unspecified then the module is being retrieved for further configuration. - * @param {Function} configFn Optional configuration function for the module. Same as + * @param {!Array.<string>=} requires If specified then new module is being created. If + * unspecified then the module is being retrieved for further configuration. + * @param {Function=} configFn Optional configuration function for the module. Same as * {@link angular.Module#config Module#config()}. - * @returns {module} new module with the {@link angular.Module} api. + * @returns {angular.Module} new module with the {@link angular.Module} api. */ return function module(name, requires, configFn) { + + var info = {}; + var assertNotHasOwnProperty = function(name, context) { if (name === 'hasOwnProperty') { throw ngMinErr('badname', 'hasOwnProperty is not a valid {0} name', context); @@ -1608,42 +2300,86 @@ } return ensure(modules, name, function() { if (!requires) { - throw $injectorMinErr('nomod', "Module '{0}' is not available! You either misspelled " + - "the module name or forgot to load it. If registering a module ensure that you " + - "specify the dependencies as the second argument.", name); + throw $injectorMinErr('nomod', 'Module \'{0}\' is not available! You either misspelled ' + + 'the module name or forgot to load it. If registering a module ensure that you ' + + 'specify the dependencies as the second argument.', name); } /** @type {!Array.<Array.<*>>} */ var invokeQueue = []; + /** @type {!Array.<Function>} */ + var configBlocks = []; + /** @type {!Array.<Function>} */ var runBlocks = []; - var config = invokeLater('$injector', 'invoke'); + var config = invokeLater('$injector', 'invoke', 'push', configBlocks); /** @type {angular.Module} */ var moduleInstance = { // Private state _invokeQueue: invokeQueue, + _configBlocks: configBlocks, _runBlocks: runBlocks, /** - * @ngdoc property - * @name angular.Module#requires + * @ngdoc method + * @name angular.Module#info * @module ng - * @returns {Array.<string>} List of module names which must be loaded before this module. + * + * @param {Object=} info Information about the module + * @returns {Object|Module} The current info object for this module if called as a getter, + * or `this` if called as a setter. + * * @description - * Holds the list of modules which the injector will load before the current module is - * loaded. + * Read and write custom information about this module. + * For example you could put the version of the module in here. + * + * ```js + * angular.module('myModule', []).info({ version: '1.0.0' }); + * ``` + * + * The version could then be read back out by accessing the module elsewhere: + * + * ``` + * var version = angular.module('myModule').info().version; + * ``` + * + * You can also retrieve this information during runtime via the + * {@link $injector#modules `$injector.modules`} property: + * + * ```js + * var version = $injector.modules['myModule'].info().version; + * ``` */ - requires: requires, - + info: function(value) { + if (isDefined(value)) { + if (!isObject(value)) throw ngMinErr('aobj', 'Argument \'{0}\' must be an object', 'value'); + info = value; + return this; + } + return info; + }, + + /** + * @ngdoc property + * @name angular.Module#requires + * @module ng + * + * @description + * Holds the list of modules which the injector will load before the current module is + * loaded. + */ + requires: requires, + /** * @ngdoc property * @name angular.Module#name * @module ng - * @returns {string} Name of the module. + * * @description + * Name of the module. */ name: name, @@ -1658,7 +2394,7 @@ * @description * See {@link auto.$provide#provider $provide.provider()}. */ - provider: invokeLater('$provide', 'provider'), + provider: invokeLaterAndSetModuleName('$provide', 'provider'), /** * @ngdoc method @@ -1669,7 +2405,7 @@ * @description * See {@link auto.$provide#factory $provide.factory()}. */ - factory: invokeLater('$provide', 'factory'), + factory: invokeLaterAndSetModuleName('$provide', 'factory'), /** * @ngdoc method @@ -1680,7 +2416,7 @@ * @description * See {@link auto.$provide#service $provide.service()}. */ - service: invokeLater('$provide', 'service'), + service: invokeLaterAndSetModuleName('$provide', 'service'), /** * @ngdoc method @@ -1700,11 +2436,23 @@ * @param {string} name constant name * @param {*} object Constant value. * @description - * Because the constant are fixed, they get applied before other provide methods. + * Because the constants are fixed, they get applied before other provide methods. * See {@link auto.$provide#constant $provide.constant()}. */ constant: invokeLater('$provide', 'constant', 'unshift'), + /** + * @ngdoc method + * @name angular.Module#decorator + * @module ng + * @param {string} name The name of the service to decorate. + * @param {Function} decorFn This function will be invoked when the service needs to be + * instantiated and should return the decorated service instance. + * @description + * See {@link auto.$provide#decorator $provide.decorator()}. + */ + decorator: invokeLaterAndSetModuleName('$provide', 'decorator', configBlocks), + /** * @ngdoc method * @name angular.Module#animation @@ -1718,37 +2466,44 @@ * * * Defines an animation hook that can be later used with - * {@link ngAnimate.$animate $animate} service and directives that use this service. + * {@link $animate $animate} service and directives that use this service. * * ```js * module.animation('.animation-name', function($inject1, $inject2) { - * return { - * eventName : function(element, done) { - * //code to run the animation - * //once complete, then run done() - * return function cancellationFunction(element) { - * //code to cancel the animation - * } - * } - * } - * }) + * return { + * eventName : function(element, done) { + * //code to run the animation + * //once complete, then run done() + * return function cancellationFunction(element) { + * //code to cancel the animation + * } + * } + * } + * }) * ``` * - * See {@link ngAnimate.$animateProvider#register $animateProvider.register()} and + * See {@link ng.$animateProvider#register $animateProvider.register()} and * {@link ngAnimate ngAnimate module} for more information. */ - animation: invokeLater('$animateProvider', 'register'), + animation: invokeLaterAndSetModuleName('$animateProvider', 'register'), /** * @ngdoc method * @name angular.Module#filter * @module ng - * @param {string} name Filter name. + * @param {string} name Filter name - this must be a valid AngularJS expression identifier * @param {Function} filterFactory Factory function for creating new instance of filter. * @description * See {@link ng.$filterProvider#register $filterProvider.register()}. + * + * <div class="alert alert-warning"> + * **Note:** Filter names must be valid AngularJS {@link expression} identifiers, such as `uppercase` or `orderBy`. + * Names with special characters, such as hyphens and dots, are not allowed. If you wish to namespace + * your filters, then you can use capitalization (`myappSubsectionFilterx`) or underscores + * (`myapp_subsection_filterx`). + * </div> */ - filter: invokeLater('$filterProvider', 'register'), + filter: invokeLaterAndSetModuleName('$filterProvider', 'register'), /** * @ngdoc method @@ -1760,7 +2515,7 @@ * @description * See {@link ng.$controllerProvider#register $controllerProvider.register()}. */ - controller: invokeLater('$controllerProvider', 'register'), + controller: invokeLaterAndSetModuleName('$controllerProvider', 'register'), /** * @ngdoc method @@ -1773,7 +2528,20 @@ * @description * See {@link ng.$compileProvider#directive $compileProvider.directive()}. */ - directive: invokeLater('$compileProvider', 'directive'), + directive: invokeLaterAndSetModuleName('$compileProvider', 'directive'), + + /** + * @ngdoc method + * @name angular.Module#component + * @module ng + * @param {string} name Name of the component in camel-case (i.e. myComp which will match as my-comp) + * @param {Object} options Component definition object (a simplified + * {@link ng.$compile#directive-definition-object directive definition object}) + * + * @description + * See {@link ng.$compileProvider#component $compileProvider.component()}. + */ + component: invokeLaterAndSetModuleName('$compileProvider', 'component'), /** * @ngdoc method @@ -1782,7 +2550,15 @@ * @param {Function} configFn Execute this function on module load. Useful for service * configuration. * @description - * Use this method to register work which needs to be performed on module loading. + * Use this method to configure services by injecting their + * {@link angular.Module#provider `providers`}, e.g. for adding routes to the + * {@link ngRoute.$routeProvider $routeProvider}. + * + * Note that you can only inject {@link angular.Module#provider `providers`} and + * {@link angular.Module#constant `constants`} into this function. + * + * For more about how to configure services, see + * {@link providers#provider-recipe Provider Recipe}. */ config: config, @@ -1806,7 +2582,7 @@ config(configFn); } - return moduleInstance; + return moduleInstance; /** * @param {string} provider @@ -1814,9 +2590,24 @@ * @param {String=} insertMethod * @returns {angular.Module} */ - function invokeLater(provider, method, insertMethod) { + function invokeLater(provider, method, insertMethod, queue) { + if (!queue) queue = invokeQueue; return function() { - invokeQueue[insertMethod || 'push']([provider, method, arguments]); + queue[insertMethod || 'push']([provider, method, arguments]); + return moduleInstance; + }; + } + + /** + * @param {string} provider + * @param {string} method + * @returns {angular.Module} + */ + function invokeLaterAndSetModuleName(provider, method, queue) { + if (!queue) queue = invokeQueue; + return function(recipeName, factoryFunction) { + if (factoryFunction && isFunction(factoryFunction)) factoryFunction.$$moduleName = name; + queue.push([provider, method, arguments]); return moduleInstance; }; } @@ -1826,82 +2617,165 @@ } - /* global - angularModule: true, - version: true, - - $LocaleProvider, - $CompileProvider, - - htmlAnchorDirective, - inputDirective, - inputDirective, - formDirective, - scriptDirective, - selectDirective, - styleDirective, - optionDirective, - ngBindDirective, - ngBindHtmlDirective, - ngBindTemplateDirective, - ngClassDirective, - ngClassEvenDirective, - ngClassOddDirective, - ngCspDirective, - ngCloakDirective, - ngControllerDirective, - ngFormDirective, - ngHideDirective, - ngIfDirective, - ngIncludeDirective, - ngIncludeFillContentDirective, - ngInitDirective, - ngNonBindableDirective, - ngPluralizeDirective, - ngRepeatDirective, - ngShowDirective, - ngStyleDirective, - ngSwitchDirective, - ngSwitchWhenDirective, - ngSwitchDefaultDirective, - ngOptionsDirective, - ngTranscludeDirective, - ngModelDirective, - ngListDirective, - ngChangeDirective, - requiredDirective, - requiredDirective, - ngValueDirective, - ngAttributeAliasDirectives, - ngEventDirectives, - - $AnchorScrollProvider, - $AnimateProvider, - $BrowserProvider, - $CacheFactoryProvider, - $ControllerProvider, - $DocumentProvider, - $ExceptionHandlerProvider, - $FilterProvider, - $InterpolateProvider, - $IntervalProvider, - $HttpProvider, - $HttpBackendProvider, - $LocationProvider, - $LogProvider, - $ParseProvider, - $RootScopeProvider, - $QProvider, - $$SanitizeUriProvider, - $SceProvider, - $SceDelegateProvider, - $SnifferProvider, - $TemplateCacheProvider, - $TimeoutProvider, - $$RAFProvider, - $$AsyncCallbackProvider, - $WindowProvider + /* global shallowCopy: true */ + + /** + * Creates a shallow copy of an object, an array or a primitive. + * + * Assumes that there are no proto properties for objects. */ + function shallowCopy(src, dst) { + if (isArray(src)) { + dst = dst || []; + + for (var i = 0, ii = src.length; i < ii; i++) { + dst[i] = src[i]; + } + } else if (isObject(src)) { + dst = dst || {}; + + for (var key in src) { + if (!(key.charAt(0) === '$' && key.charAt(1) === '$')) { + dst[key] = src[key]; + } + } + } + + return dst || src; + } + + /* exported toDebugString */ + + function serializeObject(obj, maxDepth) { + var seen = []; + + // There is no direct way to stringify object until reaching a specific depth + // and a very deep object can cause a performance issue, so we copy the object + // based on this specific depth and then stringify it. + if (isValidObjectMaxDepth(maxDepth)) { + // This file is also included in `angular-loader`, so `copy()` might not always be available in + // the closure. Therefore, it is lazily retrieved as `angular.copy()` when needed. + obj = angular.copy(obj, null, maxDepth); + } + return JSON.stringify(obj, function(key, val) { + val = toJsonReplacer(key, val); + if (isObject(val)) { + + if (seen.indexOf(val) >= 0) return '...'; + + seen.push(val); + } + return val; + }); + } + + function toDebugString(obj, maxDepth) { + if (typeof obj === 'function') { + return obj.toString().replace(/ \{[\s\S]*$/, ''); + } else if (isUndefined(obj)) { + return 'undefined'; + } else if (typeof obj !== 'string') { + return serializeObject(obj, maxDepth); + } + return obj; + } + + /* global angularModule: true, + version: true, + + $CompileProvider, + + htmlAnchorDirective, + inputDirective, + inputDirective, + formDirective, + scriptDirective, + selectDirective, + optionDirective, + ngBindDirective, + ngBindHtmlDirective, + ngBindTemplateDirective, + ngClassDirective, + ngClassEvenDirective, + ngClassOddDirective, + ngCloakDirective, + ngControllerDirective, + ngFormDirective, + ngHideDirective, + ngIfDirective, + ngIncludeDirective, + ngIncludeFillContentDirective, + ngInitDirective, + ngNonBindableDirective, + ngPluralizeDirective, + ngRepeatDirective, + ngShowDirective, + ngStyleDirective, + ngSwitchDirective, + ngSwitchWhenDirective, + ngSwitchDefaultDirective, + ngOptionsDirective, + ngTranscludeDirective, + ngModelDirective, + ngListDirective, + ngChangeDirective, + patternDirective, + patternDirective, + requiredDirective, + requiredDirective, + minlengthDirective, + minlengthDirective, + maxlengthDirective, + maxlengthDirective, + ngValueDirective, + ngModelOptionsDirective, + ngAttributeAliasDirectives, + ngEventDirectives, + + $AnchorScrollProvider, + $AnimateProvider, + $CoreAnimateCssProvider, + $$CoreAnimateJsProvider, + $$CoreAnimateQueueProvider, + $$AnimateRunnerFactoryProvider, + $$AnimateAsyncRunFactoryProvider, + $BrowserProvider, + $CacheFactoryProvider, + $ControllerProvider, + $DateProvider, + $DocumentProvider, + $$IsDocumentHiddenProvider, + $ExceptionHandlerProvider, + $FilterProvider, + $$ForceReflowProvider, + $InterpolateProvider, + $IntervalProvider, + $HttpProvider, + $HttpParamSerializerProvider, + $HttpParamSerializerJQLikeProvider, + $HttpBackendProvider, + $xhrFactoryProvider, + $jsonpCallbacksProvider, + $LocationProvider, + $LogProvider, + $$MapProvider, + $ParseProvider, + $RootScopeProvider, + $QProvider, + $$QProvider, + $$SanitizeUriProvider, + $SceProvider, + $SceDelegateProvider, + $SnifferProvider, + $TemplateCacheProvider, + $TemplateRequestProvider, + $$TestabilityProvider, + $TimeoutProvider, + $$RAFProvider, + $WindowProvider, + $$jqLiteProvider, + $$CookieReaderProvider +*/ /** @@ -1909,8 +2783,9 @@ * @name angular.version * @module ng * @description - * An object that contains information about the current AngularJS version. This object has the - * following properties: + * An object that contains information about the current AngularJS version. + * + * This object has the following properties: * * - `full` – `{string}` – Full version string, such as "0.9.18". * - `major` – `{number}` – Major version number, such as "0". @@ -1919,28 +2794,32 @@ * - `codeName` – `{string}` – Code name of the release, such as "jiggling-armfat". */ var version = { - full: '1.2.16', // all of these placeholder strings will be replaced by grunt's - major: 1, // package task - minor: 2, - dot: 16, - codeName: 'badger-enumeration' + // These placeholder strings will be replaced by grunt's `build` task. + // They need to be double- or single-quoted. + full: '1.6.9', + major: 1, + minor: 6, + dot: 9, + codeName: 'fiery-basilisk' }; - function publishExternalAPI(angular){ + function publishExternalAPI(angular) { extend(angular, { + 'errorHandlingConfig': errorHandlingConfig, 'bootstrap': bootstrap, 'copy': copy, 'extend': extend, + 'merge': merge, 'equals': equals, 'element': jqLite, 'forEach': forEach, 'injector': createInjector, - 'noop':noop, - 'bind':bind, + 'noop': noop, + 'bind': bind, 'toJson': toJson, 'fromJson': fromJson, - 'identity':identity, + 'identity': identity, 'isUndefined': isUndefined, 'isDefined': isDefined, 'isString': isString, @@ -1953,17 +2832,17 @@ 'isDate': isDate, 'lowercase': lowercase, 'uppercase': uppercase, - 'callbacks': {counter: 0}, + 'callbacks': {$$counter: 0}, + 'getTestability': getTestability, + 'reloadWithDebugInfo': reloadWithDebugInfo, '$$minErr': minErr, - '$$csp': csp + '$$csp': csp, + '$$encodeUriSegment': encodeUriSegment, + '$$encodeUriQuery': encodeUriQuery, + '$$stringify': stringify }); angularModule = setupModuleLoader(window); - try { - angularModule('ngLocale'); - } catch (e) { - angularModule('ngLocale', []).provider('$locale', $LocaleProvider); - } angularModule('ng', ['ngLocale'], ['$provide', function ngModule($provide) { @@ -1972,88 +2851,120 @@ $$sanitizeUri: $$SanitizeUriProvider }); $provide.provider('$compile', $CompileProvider). - directive({ - a: htmlAnchorDirective, - input: inputDirective, - textarea: inputDirective, - form: formDirective, - script: scriptDirective, - select: selectDirective, - style: styleDirective, - option: optionDirective, - ngBind: ngBindDirective, - ngBindHtml: ngBindHtmlDirective, - ngBindTemplate: ngBindTemplateDirective, - ngClass: ngClassDirective, - ngClassEven: ngClassEvenDirective, - ngClassOdd: ngClassOddDirective, - ngCloak: ngCloakDirective, - ngController: ngControllerDirective, - ngForm: ngFormDirective, - ngHide: ngHideDirective, - ngIf: ngIfDirective, - ngInclude: ngIncludeDirective, - ngInit: ngInitDirective, - ngNonBindable: ngNonBindableDirective, - ngPluralize: ngPluralizeDirective, - ngRepeat: ngRepeatDirective, - ngShow: ngShowDirective, - ngStyle: ngStyleDirective, - ngSwitch: ngSwitchDirective, - ngSwitchWhen: ngSwitchWhenDirective, - ngSwitchDefault: ngSwitchDefaultDirective, - ngOptions: ngOptionsDirective, - ngTransclude: ngTranscludeDirective, - ngModel: ngModelDirective, - ngList: ngListDirective, - ngChange: ngChangeDirective, - required: requiredDirective, - ngRequired: requiredDirective, - ngValue: ngValueDirective - }). - directive({ - ngInclude: ngIncludeFillContentDirective - }). - directive(ngAttributeAliasDirectives). - directive(ngEventDirectives); + directive({ + a: htmlAnchorDirective, + input: inputDirective, + textarea: inputDirective, + form: formDirective, + script: scriptDirective, + select: selectDirective, + option: optionDirective, + ngBind: ngBindDirective, + ngBindHtml: ngBindHtmlDirective, + ngBindTemplate: ngBindTemplateDirective, + ngClass: ngClassDirective, + ngClassEven: ngClassEvenDirective, + ngClassOdd: ngClassOddDirective, + ngCloak: ngCloakDirective, + ngController: ngControllerDirective, + ngForm: ngFormDirective, + ngHide: ngHideDirective, + ngIf: ngIfDirective, + ngInclude: ngIncludeDirective, + ngInit: ngInitDirective, + ngNonBindable: ngNonBindableDirective, + ngPluralize: ngPluralizeDirective, + ngRepeat: ngRepeatDirective, + ngShow: ngShowDirective, + ngStyle: ngStyleDirective, + ngSwitch: ngSwitchDirective, + ngSwitchWhen: ngSwitchWhenDirective, + ngSwitchDefault: ngSwitchDefaultDirective, + ngOptions: ngOptionsDirective, + ngTransclude: ngTranscludeDirective, + ngModel: ngModelDirective, + ngList: ngListDirective, + ngChange: ngChangeDirective, + pattern: patternDirective, + ngPattern: patternDirective, + required: requiredDirective, + ngRequired: requiredDirective, + minlength: minlengthDirective, + ngMinlength: minlengthDirective, + maxlength: maxlengthDirective, + ngMaxlength: maxlengthDirective, + ngValue: ngValueDirective, + ngModelOptions: ngModelOptionsDirective + }). + directive({ + ngInclude: ngIncludeFillContentDirective + }). + directive(ngAttributeAliasDirectives). + directive(ngEventDirectives); $provide.provider({ $anchorScroll: $AnchorScrollProvider, $animate: $AnimateProvider, + $animateCss: $CoreAnimateCssProvider, + $$animateJs: $$CoreAnimateJsProvider, + $$animateQueue: $$CoreAnimateQueueProvider, + $$AnimateRunner: $$AnimateRunnerFactoryProvider, + $$animateAsyncRun: $$AnimateAsyncRunFactoryProvider, $browser: $BrowserProvider, $cacheFactory: $CacheFactoryProvider, $controller: $ControllerProvider, $document: $DocumentProvider, + $$isDocumentHidden: $$IsDocumentHiddenProvider, $exceptionHandler: $ExceptionHandlerProvider, $filter: $FilterProvider, + $$forceReflow: $$ForceReflowProvider, $interpolate: $InterpolateProvider, $interval: $IntervalProvider, $http: $HttpProvider, + $httpParamSerializer: $HttpParamSerializerProvider, + $httpParamSerializerJQLike: $HttpParamSerializerJQLikeProvider, $httpBackend: $HttpBackendProvider, + $xhrFactory: $xhrFactoryProvider, + $jsonpCallbacks: $jsonpCallbacksProvider, $location: $LocationProvider, $log: $LogProvider, $parse: $ParseProvider, $rootScope: $RootScopeProvider, $q: $QProvider, + $$q: $$QProvider, $sce: $SceProvider, $sceDelegate: $SceDelegateProvider, $sniffer: $SnifferProvider, $templateCache: $TemplateCacheProvider, + $templateRequest: $TemplateRequestProvider, + $$testability: $$TestabilityProvider, $timeout: $TimeoutProvider, $window: $WindowProvider, $$rAF: $$RAFProvider, - $$asyncCallback : $$AsyncCallbackProvider + $$jqLite: $$jqLiteProvider, + $$Map: $$MapProvider, + $$cookieReader: $$CookieReaderProvider }); } - ]); + ]) + .info({ angularVersion: '1.6.9' }); } - /* global + /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * Any commits to this file should be reviewed with security in mind. * + * Changes to this file can potentially create security vulnerabilities. * + * An approval from 2 Core members with history of modifying * + * this file is required. * + * * + * Does the change somehow allow for arbitrary javascript to be executed? * + * Or allows for someone to change the prototype of built-in objects? * + * Or gives undesired access to variables likes document or window? * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ - -JQLitePrototype, - -addEventListenerFn, - -removeEventListenerFn, - -BOOLEAN_ATTR - */ + /* global + JQLitePrototype: true, + BOOLEAN_ATTR: true, + ALIASED_ATTR: true +*/ ////////////////////////////////// //JQLite @@ -2063,37 +2974,45 @@ * @ngdoc function * @name angular.element * @module ng - * @function + * @kind function * * @description * Wraps a raw DOM element or HTML string as a [jQuery](http://jquery.com) element. * * If jQuery is available, `angular.element` is an alias for the * [jQuery](http://api.jquery.com/jQuery/) function. If jQuery is not available, `angular.element` - * delegates to Angular's built-in subset of jQuery, called "jQuery lite" or "jqLite." + * delegates to AngularJS's built-in subset of jQuery, called "jQuery lite" or **jqLite**. + * + * jqLite is a tiny, API-compatible subset of jQuery that allows + * AngularJS to manipulate the DOM in a cross-browser compatible way. jqLite implements only the most + * commonly needed functionality with the goal of having a very small footprint. * - * <div class="alert alert-success">jqLite is a tiny, API-compatible subset of jQuery that allows - * Angular to manipulate the DOM in a cross-browser compatible way. **jqLite** implements only the most - * commonly needed functionality with the goal of having a very small footprint.</div> + * To use `jQuery`, simply ensure it is loaded before the `angular.js` file. You can also use the + * {@link ngJq `ngJq`} directive to specify that jqlite should be used over jQuery, or to use a + * specific version of jQuery if multiple versions exist on the page. * - * To use jQuery, simply load it before `DOMContentLoaded` event fired. + * <div class="alert alert-info">**Note:** All element references in AngularJS are always wrapped with jQuery or + * jqLite (such as the element argument in a directive's compile / link function). They are never raw DOM references.</div> * - * <div class="alert">**Note:** all element references in Angular are always wrapped with jQuery or - * jqLite; they are never raw DOM references.</div> + * <div class="alert alert-warning">**Note:** Keep in mind that this function will not find elements + * by tag name / CSS selector. For lookups by tag name, try instead `angular.element(document).find(...)` + * or `$document.find()`, or use the standard DOM APIs, e.g. `document.querySelectorAll()`.</div> * - * ## Angular's jqLite + * ## AngularJS's jqLite * jqLite provides only the following jQuery methods: * - * - [`addClass()`](http://api.jquery.com/addClass/) + * - [`addClass()`](http://api.jquery.com/addClass/) - Does not support a function as first argument * - [`after()`](http://api.jquery.com/after/) * - [`append()`](http://api.jquery.com/append/) - * - [`attr()`](http://api.jquery.com/attr/) - * - [`bind()`](http://api.jquery.com/bind/) - Does not support namespaces, selectors or eventData + * - [`attr()`](http://api.jquery.com/attr/) - Does not support functions as parameters + * - [`bind()`](http://api.jquery.com/bind/) (_deprecated_, use [`on()`](http://api.jquery.com/on/)) - Does not support namespaces, selectors or eventData * - [`children()`](http://api.jquery.com/children/) - Does not support selectors * - [`clone()`](http://api.jquery.com/clone/) * - [`contents()`](http://api.jquery.com/contents/) - * - [`css()`](http://api.jquery.com/css/) + * - [`css()`](http://api.jquery.com/css/) - Only retrieves inline-styles, does not call `getComputedStyle()`. + * As a setter, does not convert numbers to strings or append 'px', and also does not have automatic property prefixing. * - [`data()`](http://api.jquery.com/data/) + * - [`detach()`](http://api.jquery.com/detach/) * - [`empty()`](http://api.jquery.com/empty/) * - [`eq()`](http://api.jquery.com/eq/) * - [`find()`](http://api.jquery.com/find/) - Limited to lookups by tag name @@ -2101,26 +3020,26 @@ * - [`html()`](http://api.jquery.com/html/) * - [`next()`](http://api.jquery.com/next/) - Does not support selectors * - [`on()`](http://api.jquery.com/on/) - Does not support namespaces, selectors or eventData - * - [`off()`](http://api.jquery.com/off/) - Does not support namespaces or selectors + * - [`off()`](http://api.jquery.com/off/) - Does not support namespaces, selectors or event object as parameter * - [`one()`](http://api.jquery.com/one/) - Does not support namespaces or selectors * - [`parent()`](http://api.jquery.com/parent/) - Does not support selectors * - [`prepend()`](http://api.jquery.com/prepend/) * - [`prop()`](http://api.jquery.com/prop/) - * - [`ready()`](http://api.jquery.com/ready/) + * - [`ready()`](http://api.jquery.com/ready/) (_deprecated_, use `angular.element(callback)` instead of `angular.element(document).ready(callback)`) * - [`remove()`](http://api.jquery.com/remove/) - * - [`removeAttr()`](http://api.jquery.com/removeAttr/) - * - [`removeClass()`](http://api.jquery.com/removeClass/) + * - [`removeAttr()`](http://api.jquery.com/removeAttr/) - Does not support multiple attributes + * - [`removeClass()`](http://api.jquery.com/removeClass/) - Does not support a function as first argument * - [`removeData()`](http://api.jquery.com/removeData/) * - [`replaceWith()`](http://api.jquery.com/replaceWith/) * - [`text()`](http://api.jquery.com/text/) - * - [`toggleClass()`](http://api.jquery.com/toggleClass/) - * - [`triggerHandler()`](http://api.jquery.com/triggerHandler/) - Passes a dummy event object to handlers. - * - [`unbind()`](http://api.jquery.com/unbind/) - Does not support namespaces + * - [`toggleClass()`](http://api.jquery.com/toggleClass/) - Does not support a function as first argument + * - [`triggerHandler()`](http://api.jquery.com/triggerHandler/) - Passes a dummy event object to handlers + * - [`unbind()`](http://api.jquery.com/unbind/) (_deprecated_, use [`off()`](http://api.jquery.com/off/)) - Does not support namespaces or event object as parameter * - [`val()`](http://api.jquery.com/val/) * - [`wrap()`](http://api.jquery.com/wrap/) * * ## jQuery/jqLite Extras - * Angular also provides the following additional methods and events to both jQuery and jqLite: + * AngularJS also provides the following additional methods and events to both jQuery and jqLite: * * ### Events * - `$destroy` - AngularJS intercepts all jqLite/jQuery's DOM destruction apis and fires this event @@ -2134,31 +3053,31 @@ * `'ngModel'`). * - `injector()` - retrieves the injector of the current element or its parent. * - `scope()` - retrieves the {@link ng.$rootScope.Scope scope} of the current - * element or its parent. + * element or its parent. Requires {@link guide/production#disabling-debug-data Debug Data} to + * be enabled. * - `isolateScope()` - retrieves an isolate {@link ng.$rootScope.Scope scope} if one is attached directly to the * current element. This getter should be used only on elements that contain a directive which starts a new isolate * scope. Calling `scope()` on this element always returns the original non-isolate scope. + * Requires {@link guide/production#disabling-debug-data Debug Data} to be enabled. * - `inheritedData()` - same as `data()`, but walks up the DOM until a value is found or the top * parent element is reached. * + * @knownIssue You cannot spy on `angular.element` if you are using Jasmine version 1.x. See + * https://github.com/angular/angular.js/issues/14251 for more information. + * * @param {string|DOMElement} element HTML string or DOMElement to be wrapped into jQuery. * @returns {Object} jQuery object. */ + JQLite.expando = 'ng339'; + var jqCache = JQLite.cache = {}, - jqName = JQLite.expando = 'ng-' + new Date().getTime(), - jqId = 1, - addEventListenerFn = (window.document.addEventListener - ? function(element, type, fn) {element.addEventListener(type, fn, false);} - : function(element, type, fn) {element.attachEvent('on' + type, fn);}), - removeEventListenerFn = (window.document.removeEventListener - ? function(element, type, fn) {element.removeEventListener(type, fn, false); } - : function(element, type, fn) {element.detachEvent('on' + type, fn); }); + jqId = 1; /* - * !!! This is an undocumented "private" function !!! - */ - var jqData = JQLite._data = function(node) { + * !!! This is an undocumented "private" function !!! + */ + JQLite._data = function(node) { //jQuery always returns an object on cache miss return this.cache[node[this.expando]] || {}; }; @@ -2166,70 +3085,37 @@ function jqNextId() { return ++jqId; } - var SPECIAL_CHARS_REGEXP = /([\:\-\_]+(.))/g; - var MOZ_HACK_REGEXP = /^moz([A-Z])/; + var DASH_LOWERCASE_REGEXP = /-([a-z])/g; + var MS_HACK_REGEXP = /^-ms-/; + var MOUSE_EVENT_MAP = { mouseleave: 'mouseout', mouseenter: 'mouseover' }; var jqLiteMinErr = minErr('jqLite'); /** - * Converts snake_case to camelCase. - * Also there is special case for Moz prefix starting with upper case letter. + * Converts kebab-case to camelCase. + * There is also a special case for the ms prefix starting with a lowercase letter. * @param name Name to normalize */ - function camelCase(name) { - return name. - replace(SPECIAL_CHARS_REGEXP, function(_, separator, letter, offset) { - return offset ? letter.toUpperCase() : letter; - }). - replace(MOZ_HACK_REGEXP, 'Moz$1'); + function cssKebabToCamel(name) { + return kebabToCamel(name.replace(MS_HACK_REGEXP, 'ms-')); } -///////////////////////////////////////////// -// jQuery mutation patch -// -// In conjunction with bindJQuery intercepts all jQuery's DOM destruction apis and fires a -// $destroy event on all DOM nodes being removed. -// -///////////////////////////////////////////// + function fnCamelCaseReplace(all, letter) { + return letter.toUpperCase(); + } - function jqLitePatchJQueryRemove(name, dispatchThis, filterElems, getterIfNoArguments) { - var originalJqFn = jQuery.fn[name]; - originalJqFn = originalJqFn.$original || originalJqFn; - removePatch.$original = originalJqFn; - jQuery.fn[name] = removePatch; - - function removePatch(param) { - // jshint -W040 - var list = filterElems && param ? [this.filter(param)] : [this], - fireEvent = dispatchThis, - set, setIndex, setLength, - element, childIndex, childLength, children; - - if (!getterIfNoArguments || param != null) { - while(list.length) { - set = list.shift(); - for(setIndex = 0, setLength = set.length; setIndex < setLength; setIndex++) { - element = jqLite(set[setIndex]); - if (fireEvent) { - element.triggerHandler('$destroy'); - } else { - fireEvent = !fireEvent; - } - for(childIndex = 0, childLength = (children = element.children()).length; - childIndex < childLength; - childIndex++) { - list.push(jQuery(children[childIndex])); - } - } - } - } - return originalJqFn.apply(this, arguments); - } + /** + * Converts kebab-case to camelCase. + * @param name Name to normalize + */ + function kebabToCamel(name) { + return name + .replace(DASH_LOWERCASE_REGEXP, fnCamelCaseReplace); } - var SINGLE_TAG_REGEXP = /^<(\w+)\s*\/?>(?:<\/\1>|)$/; + var SINGLE_TAG_REGEXP = /^<([\w-]+)\s*\/?>(?:<\/\1>|)$/; var HTML_REGEXP = /<|&#?\w+;/; - var TAG_NAME_REGEXP = /<([\w:]+)/; - var XHTML_TAG_REGEXP = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi; + var TAG_NAME_REGEXP = /<([\w:-]+)/; + var XHTML_TAG_REGEXP = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:-]+)[^>]*)\/>/gi; var wrapMap = { 'option': [1, '<select multiple="multiple">', '</select>'], @@ -2238,33 +3124,46 @@ 'col': [2, '<table><colgroup>', '</colgroup></table>'], 'tr': [2, '<table><tbody>', '</tbody></table>'], 'td': [3, '<table><tbody><tr>', '</tr></tbody></table>'], - '_default': [0, "", ""] + '_default': [0, '', ''] }; wrapMap.optgroup = wrapMap.option; wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; wrapMap.th = wrapMap.td; + function jqLiteIsTextNode(html) { return !HTML_REGEXP.test(html); } + function jqLiteAcceptsData(node) { + // The window object can accept data but has no nodeType + // Otherwise we are only interested in elements (1) and documents (9) + var nodeType = node.nodeType; + return nodeType === NODE_TYPE_ELEMENT || !nodeType || nodeType === NODE_TYPE_DOCUMENT; + } + + function jqLiteHasData(node) { + for (var key in jqCache[node.ng339]) { + return true; + } + return false; + } + function jqLiteBuildFragment(html, context) { - var elem, tmp, tag, wrap, + var tmp, tag, wrap, fragment = context.createDocumentFragment(), - nodes = [], i, j, jj; + nodes = [], i; if (jqLiteIsTextNode(html)) { // Convert non-html into a text node nodes.push(context.createTextNode(html)); } else { - tmp = fragment.appendChild(context.createElement('div')); // Convert html into DOM nodes - tag = (TAG_NAME_REGEXP.exec(html) || ["", ""])[1].toLowerCase(); + tmp = fragment.appendChild(context.createElement('div')); + tag = (TAG_NAME_REGEXP.exec(html) || ['', ''])[1].toLowerCase(); wrap = wrapMap[tag] || wrapMap._default; - tmp.innerHTML = '<div> </div>' + - wrap[1] + html.replace(XHTML_TAG_REGEXP, "<$1></$2>") + wrap[2]; - tmp.removeChild(tmp.firstChild); + tmp.innerHTML = wrap[1] + html.replace(XHTML_TAG_REGEXP, '<$1></$2>') + wrap[2]; // Descend through wrappers to the right content i = wrap[0]; @@ -2272,48 +3171,77 @@ tmp = tmp.lastChild; } - for (j=0, jj=tmp.childNodes.length; j<jj; ++j) nodes.push(tmp.childNodes[j]); + nodes = concat(nodes, tmp.childNodes); tmp = fragment.firstChild; - tmp.textContent = ""; + tmp.textContent = ''; } // Remove wrapper from fragment - fragment.textContent = ""; - fragment.innerHTML = ""; // Clear inner HTML - return nodes; + fragment.textContent = ''; + fragment.innerHTML = ''; // Clear inner HTML + forEach(nodes, function(node) { + fragment.appendChild(node); + }); + + return fragment; } function jqLiteParseHTML(html, context) { - context = context || document; + context = context || window.document; var parsed; if ((parsed = SINGLE_TAG_REGEXP.exec(html))) { return [context.createElement(parsed[1])]; } - return jqLiteBuildFragment(html, context); + if ((parsed = jqLiteBuildFragment(html, context))) { + return parsed.childNodes; + } + + return []; + } + + function jqLiteWrapNode(node, wrapper) { + var parent = node.parentNode; + + if (parent) { + parent.replaceChild(wrapper, node); + } + + wrapper.appendChild(node); } + +// IE9-11 has no method "contains" in SVG element and in Node.prototype. Bug #10259. + var jqLiteContains = window.Node.prototype.contains || /** @this */ function(arg) { + // eslint-disable-next-line no-bitwise + return !!(this.compareDocumentPosition(arg) & 16); + }; + ///////////////////////////////////////////// function JQLite(element) { if (element instanceof JQLite) { return element; } + + var argIsString; + if (isString(element)) { element = trim(element); + argIsString = true; } if (!(this instanceof JQLite)) { - if (isString(element) && element.charAt(0) != '<') { + if (argIsString && element.charAt(0) !== '<') { throw jqLiteMinErr('nosel', 'Looking up elements via selectors is not supported by jqLite! See: http://docs.angularjs.org/api/angular.element'); } return new JQLite(element); } - if (isString(element)) { + if (argIsString) { jqLiteAddNodes(this, jqLiteParseHTML(element)); - var fragment = jqLite(document.createDocumentFragment()); - fragment.append(this); + } else if (isFunction(element)) { + jqLiteReady(element); } else { jqLiteAddNodes(this, element); } @@ -2323,206 +3251,265 @@ return element.cloneNode(true); } - function jqLiteDealoc(element){ - jqLiteRemoveData(element); - for ( var i = 0, children = element.childNodes || []; i < children.length; i++) { - jqLiteDealoc(children[i]); + function jqLiteDealoc(element, onlyDescendants) { + if (!onlyDescendants && jqLiteAcceptsData(element)) jqLite.cleanData([element]); + + if (element.querySelectorAll) { + jqLite.cleanData(element.querySelectorAll('*')); } } function jqLiteOff(element, type, fn, unsupported) { if (isDefined(unsupported)) throw jqLiteMinErr('offargs', 'jqLite#off() does not support the `selector` argument'); - var events = jqLiteExpandoStore(element, 'events'), - handle = jqLiteExpandoStore(element, 'handle'); + var expandoStore = jqLiteExpandoStore(element); + var events = expandoStore && expandoStore.events; + var handle = expandoStore && expandoStore.handle; if (!handle) return; //no listeners registered - if (isUndefined(type)) { - forEach(events, function(eventHandler, type) { - removeEventListenerFn(element, type, eventHandler); + if (!type) { + for (type in events) { + if (type !== '$destroy') { + element.removeEventListener(type, handle); + } delete events[type]; - }); + } } else { - forEach(type.split(' '), function(type) { - if (isUndefined(fn)) { - removeEventListenerFn(element, type, events[type]); + + var removeHandler = function(type) { + var listenerFns = events[type]; + if (isDefined(fn)) { + arrayRemove(listenerFns || [], fn); + } + if (!(isDefined(fn) && listenerFns && listenerFns.length > 0)) { + element.removeEventListener(type, handle); delete events[type]; - } else { - arrayRemove(events[type] || [], fn); + } + }; + + forEach(type.split(' '), function(type) { + removeHandler(type); + if (MOUSE_EVENT_MAP[type]) { + removeHandler(MOUSE_EVENT_MAP[type]); } }); } } function jqLiteRemoveData(element, name) { - var expandoId = element[jqName], - expandoStore = jqCache[expandoId]; + var expandoId = element.ng339; + var expandoStore = expandoId && jqCache[expandoId]; if (expandoStore) { if (name) { - delete jqCache[expandoId].data[name]; + delete expandoStore.data[name]; return; } if (expandoStore.handle) { - expandoStore.events.$destroy && expandoStore.handle({}, '$destroy'); + if (expandoStore.events.$destroy) { + expandoStore.handle({}, '$destroy'); + } jqLiteOff(element); } delete jqCache[expandoId]; - element[jqName] = undefined; // ie does not allow deletion of attributes on elements. + element.ng339 = undefined; // don't delete DOM expandos. IE and Chrome don't like it } } - function jqLiteExpandoStore(element, key, value) { - var expandoId = element[jqName], - expandoStore = jqCache[expandoId || -1]; - if (isDefined(value)) { - if (!expandoStore) { - element[jqName] = expandoId = jqNextId(); - expandoStore = jqCache[expandoId] = {}; - } - expandoStore[key] = value; - } else { - return expandoStore && expandoStore[key]; + function jqLiteExpandoStore(element, createIfNecessary) { + var expandoId = element.ng339, + expandoStore = expandoId && jqCache[expandoId]; + + if (createIfNecessary && !expandoStore) { + element.ng339 = expandoId = jqNextId(); + expandoStore = jqCache[expandoId] = {events: {}, data: {}, handle: undefined}; } + + return expandoStore; } + function jqLiteData(element, key, value) { - var data = jqLiteExpandoStore(element, 'data'), - isSetter = isDefined(value), - keyDefined = !isSetter && isDefined(key), - isSimpleGetter = keyDefined && !isObject(key); + if (jqLiteAcceptsData(element)) { + var prop; - if (!data && !isSimpleGetter) { - jqLiteExpandoStore(element, 'data', data = {}); - } + var isSimpleSetter = isDefined(value); + var isSimpleGetter = !isSimpleSetter && key && !isObject(key); + var massGetter = !key; + var expandoStore = jqLiteExpandoStore(element, !isSimpleGetter); + var data = expandoStore && expandoStore.data; - if (isSetter) { - data[key] = value; - } else { - if (keyDefined) { - if (isSimpleGetter) { - // don't create data in this case. - return data && data[key]; + if (isSimpleSetter) { // data('key', value) + data[kebabToCamel(key)] = value; + } else { + if (massGetter) { // data() + return data; } else { - extend(data, key); + if (isSimpleGetter) { // data('key') + // don't force creation of expandoStore if it doesn't exist yet + return data && data[kebabToCamel(key)]; + } else { // mass-setter: data({key1: val1, key2: val2}) + for (prop in key) { + data[kebabToCamel(prop)] = key[prop]; + } + } } - } else { - return data; } } } function jqLiteHasClass(element, selector) { if (!element.getAttribute) return false; - return ((" " + (element.getAttribute('class') || '') + " ").replace(/[\n\t]/g, " "). - indexOf( " " + selector + " " ) > -1); + return ((' ' + (element.getAttribute('class') || '') + ' ').replace(/[\n\t]/g, ' '). + indexOf(' ' + selector + ' ') > -1); } function jqLiteRemoveClass(element, cssClasses) { if (cssClasses && element.setAttribute) { + var existingClasses = (' ' + (element.getAttribute('class') || '') + ' ') + .replace(/[\n\t]/g, ' '); + var newClasses = existingClasses; + forEach(cssClasses.split(' '), function(cssClass) { - element.setAttribute('class', trim( - (" " + (element.getAttribute('class') || '') + " ") - .replace(/[\n\t]/g, " ") - .replace(" " + trim(cssClass) + " ", " ")) - ); + cssClass = trim(cssClass); + newClasses = newClasses.replace(' ' + cssClass + ' ', ' '); }); + + if (newClasses !== existingClasses) { + element.setAttribute('class', trim(newClasses)); + } } } function jqLiteAddClass(element, cssClasses) { if (cssClasses && element.setAttribute) { var existingClasses = (' ' + (element.getAttribute('class') || '') + ' ') - .replace(/[\n\t]/g, " "); + .replace(/[\n\t]/g, ' '); + var newClasses = existingClasses; forEach(cssClasses.split(' '), function(cssClass) { cssClass = trim(cssClass); - if (existingClasses.indexOf(' ' + cssClass + ' ') === -1) { - existingClasses += cssClass + ' '; + if (newClasses.indexOf(' ' + cssClass + ' ') === -1) { + newClasses += cssClass + ' '; } }); - element.setAttribute('class', trim(existingClasses)); + if (newClasses !== existingClasses) { + element.setAttribute('class', trim(newClasses)); + } } } + function jqLiteAddNodes(root, elements) { + // THIS CODE IS VERY HOT. Don't make changes without benchmarking. + if (elements) { - elements = (!elements.nodeName && isDefined(elements.length) && !isWindow(elements)) - ? elements - : [ elements ]; - for(var i=0; i < elements.length; i++) { - root.push(elements[i]); + + // if a Node (the most common case) + if (elements.nodeType) { + root[root.length++] = elements; + } else { + var length = elements.length; + + // if an Array or NodeList and not a Window + if (typeof length === 'number' && elements.window !== elements) { + if (length) { + for (var i = 0; i < length; i++) { + root[root.length++] = elements[i]; + } + } + } else { + root[root.length++] = elements; + } } } } + function jqLiteController(element, name) { - return jqLiteInheritedData(element, '$' + (name || 'ngController' ) + 'Controller'); + return jqLiteInheritedData(element, '$' + (name || 'ngController') + 'Controller'); } function jqLiteInheritedData(element, name, value) { - element = jqLite(element); - // if element is the document object work with the html element instead // this makes $(document).scope() possible - if(element[0].nodeType == 9) { - element = element.find('html'); + if (element.nodeType === NODE_TYPE_DOCUMENT) { + element = element.documentElement; } var names = isArray(name) ? name : [name]; - while (element.length) { - var node = element[0]; + while (element) { for (var i = 0, ii = names.length; i < ii; i++) { - if ((value = element.data(names[i])) !== undefined) return value; + if (isDefined(value = jqLite.data(element, names[i]))) return value; } // If dealing with a document fragment node with a host element, and no parent, use the host // element as the parent. This enables directives within a Shadow DOM or polyfilled Shadow DOM // to lookup parent controllers. - element = jqLite(node.parentNode || (node.nodeType === 11 && node.host)); + element = element.parentNode || (element.nodeType === NODE_TYPE_DOCUMENT_FRAGMENT && element.host); } } function jqLiteEmpty(element) { - for (var i = 0, childNodes = element.childNodes; i < childNodes.length; i++) { - jqLiteDealoc(childNodes[i]); - } + jqLiteDealoc(element, true); while (element.firstChild) { element.removeChild(element.firstChild); } } + function jqLiteRemove(element, keepData) { + if (!keepData) jqLiteDealoc(element); + var parent = element.parentNode; + if (parent) parent.removeChild(element); + } + + + function jqLiteDocumentLoaded(action, win) { + win = win || window; + if (win.document.readyState === 'complete') { + // Force the action to be run async for consistent behavior + // from the action's point of view + // i.e. it will definitely not be in a $apply + win.setTimeout(action); + } else { + // No need to unbind this handler as load is only ever called once + jqLite(win).on('load', action); + } + } + + function jqLiteReady(fn) { + function trigger() { + window.document.removeEventListener('DOMContentLoaded', trigger); + window.removeEventListener('load', trigger); + fn(); + } + + // check if document is already loaded + if (window.document.readyState === 'complete') { + window.setTimeout(fn); + } else { + // We can not use jqLite since we are not done loading and jQuery could be loaded later. + + // Works for modern browsers and IE9 + window.document.addEventListener('DOMContentLoaded', trigger); + + // Fallback to window.onload for others + window.addEventListener('load', trigger); + } + } + ////////////////////////////////////////// // Functions which are declared directly. ////////////////////////////////////////// var JQLitePrototype = JQLite.prototype = { - ready: function(fn) { - var fired = false; - - function trigger() { - if (fired) return; - fired = true; - fn(); - } - - // check if document already is loaded - if (document.readyState === 'complete'){ - setTimeout(trigger); - } else { - this.on('DOMContentLoaded', trigger); // works for modern browsers and IE9 - // we can not use jqLite since we are not done loading and jQuery could be loaded later. - // jshint -W064 - JQLite(window).on('load', trigger); // fallback to window.onload for others - // jshint +W064 - } - }, + ready: jqLiteReady, toString: function() { var value = []; - forEach(this, function(e){ value.push('' + e);}); + forEach(this, function(e) { value.push('' + e);}); return '[' + value.join(', ') + ']'; }, @@ -2547,29 +3534,54 @@ }); var BOOLEAN_ELEMENTS = {}; forEach('input,select,option,textarea,button,form,details'.split(','), function(value) { - BOOLEAN_ELEMENTS[uppercase(value)] = true; + BOOLEAN_ELEMENTS[value] = true; }); + var ALIASED_ATTR = { + 'ngMinlength': 'minlength', + 'ngMaxlength': 'maxlength', + 'ngMin': 'min', + 'ngMax': 'max', + 'ngPattern': 'pattern', + 'ngStep': 'step' + }; function getBooleanAttrName(element, name) { // check dom last since we will most likely fail on name var booleanAttr = BOOLEAN_ATTR[name.toLowerCase()]; // booleanAttr is here twice to minimize DOM access - return booleanAttr && BOOLEAN_ELEMENTS[element.nodeName] && booleanAttr; + return booleanAttr && BOOLEAN_ELEMENTS[nodeName_(element)] && booleanAttr; + } + + function getAliasedAttrName(name) { + return ALIASED_ATTR[name]; } + forEach({ + data: jqLiteData, + removeData: jqLiteRemoveData, + hasData: jqLiteHasData, + cleanData: function jqLiteCleanData(nodes) { + for (var i = 0, ii = nodes.length; i < ii; i++) { + jqLiteRemoveData(nodes[i]); + } + } + }, function(fn, name) { + JQLite[name] = fn; + }); + forEach({ data: jqLiteData, inheritedData: jqLiteInheritedData, scope: function(element) { // Can't use jqLiteData here directly so we stay compatible with jQuery! - return jqLite(element).data('$scope') || jqLiteInheritedData(element.parentNode || element, ['$isolateScope', '$scope']); + return jqLite.data(element, '$scope') || jqLiteInheritedData(element.parentNode || element, ['$isolateScope', '$scope']); }, isolateScope: function(element) { // Can't use jqLiteData here directly so we stay compatible with jQuery! - return jqLite(element).data('$isolateScope') || jqLite(element).data('$isolateScopeNoTemplate'); + return jqLite.data(element, '$isolateScope') || jqLite.data(element, '$isolateScopeNoTemplate'); }, controller: jqLiteController, @@ -2578,61 +3590,50 @@ return jqLiteInheritedData(element, '$injector'); }, - removeAttr: function(element,name) { + removeAttr: function(element, name) { element.removeAttribute(name); }, hasClass: jqLiteHasClass, css: function(element, name, value) { - name = camelCase(name); + name = cssKebabToCamel(name); if (isDefined(value)) { element.style[name] = value; } else { - var val; + return element.style[name]; + } + }, - if (msie <= 8) { - // this is some IE specific weirdness that jQuery 1.6.4 does not sure why - val = element.currentStyle && element.currentStyle[name]; - if (val === '') val = 'auto'; - } + attr: function(element, name, value) { + var ret; + var nodeType = element.nodeType; + if (nodeType === NODE_TYPE_TEXT || nodeType === NODE_TYPE_ATTRIBUTE || nodeType === NODE_TYPE_COMMENT || + !element.getAttribute) { + return; + } - val = val || element.style[name]; + var lowercasedName = lowercase(name); + var isBooleanAttr = BOOLEAN_ATTR[lowercasedName]; + + if (isDefined(value)) { + // setter - if (msie <= 8) { - // jquery weirdness :-/ - val = (val === '') ? undefined : val; + if (value === null || (value === false && isBooleanAttr)) { + element.removeAttribute(name); + } else { + element.setAttribute(name, isBooleanAttr ? lowercasedName : value); } + } else { + // getter - return val; - } - }, + ret = element.getAttribute(name); - attr: function(element, name, value){ - var lowercasedName = lowercase(name); - if (BOOLEAN_ATTR[lowercasedName]) { - if (isDefined(value)) { - if (!!value) { - element[name] = true; - element.setAttribute(name, lowercasedName); - } else { - element[name] = false; - element.removeAttribute(lowercasedName); - } - } else { - return (element[name] || - (element.attributes.getNamedItem(name)|| noop).specified) - ? lowercasedName - : undefined; - } - } else if (isDefined(value)) { - element.setAttribute(name, value); - } else if (element.getAttribute) { - // the extra argument "2" is to get the right thing for a.href in IE, see jQuery code - // some elements (e.g. Document) don't have get attribute, so return undefined - var ret = element.getAttribute(name, 2); - // normalize non-existing attributes to undefined (as jQuery) + if (isBooleanAttr && ret !== null) { + ret = lowercasedName; + } + // Normalize non-existing attributes to undefined (as jQuery). return ret === null ? undefined : ret; } }, @@ -2646,36 +3647,28 @@ }, text: (function() { - var NODE_TYPE_TEXT_PROPERTY = []; - if (msie < 9) { - NODE_TYPE_TEXT_PROPERTY[1] = 'innerText'; /** Element **/ - NODE_TYPE_TEXT_PROPERTY[3] = 'nodeValue'; /** Text **/ - } else { - NODE_TYPE_TEXT_PROPERTY[1] = /** Element **/ - NODE_TYPE_TEXT_PROPERTY[3] = 'textContent'; /** Text **/ - } getText.$dv = ''; return getText; function getText(element, value) { - var textProp = NODE_TYPE_TEXT_PROPERTY[element.nodeType]; if (isUndefined(value)) { - return textProp ? element[textProp] : ''; + var nodeType = element.nodeType; + return (nodeType === NODE_TYPE_ELEMENT || nodeType === NODE_TYPE_TEXT) ? element.textContent : ''; } - element[textProp] = value; + element.textContent = value; } })(), val: function(element, value) { if (isUndefined(value)) { - if (nodeName_(element) === 'SELECT' && element.multiple) { + if (element.multiple && nodeName_(element) === 'select') { var result = []; - forEach(element.options, function (option) { + forEach(element.options, function(option) { if (option.selected) { result.push(option.value || option.text); } }); - return result.length === 0 ? null : result; + return result; } return element.value; } @@ -2686,29 +3679,28 @@ if (isUndefined(value)) { return element.innerHTML; } - for (var i = 0, childNodes = element.childNodes; i < childNodes.length; i++) { - jqLiteDealoc(childNodes[i]); - } + jqLiteDealoc(element, true); element.innerHTML = value; }, empty: jqLiteEmpty - }, function(fn, name){ + }, function(fn, name) { /** * Properties: writes return selection, reads return first value */ JQLite.prototype[name] = function(arg1, arg2) { var i, key; + var nodeCount = this.length; // jqLiteHasClass has only two arguments, but is a getter-only fn, so we need to special-case it // in a way that survives minification. // jqLiteEmpty takes no arguments but is a setter. if (fn !== jqLiteEmpty && - (((fn.length == 2 && (fn !== jqLiteHasClass && fn !== jqLiteController)) ? arg1 : arg2) === undefined)) { + (isUndefined((fn.length === 2 && (fn !== jqLiteHasClass && fn !== jqLiteController)) ? arg1 : arg2))) { if (isObject(arg1)) { // we are a write, but the object properties are the key/values - for (i = 0; i < this.length; i++) { + for (i = 0; i < nodeCount; i++) { if (fn === jqLiteData) { // data() takes the whole object in jQuery fn(this[i], arg1); @@ -2722,9 +3714,10 @@ return this; } else { // we are a read, so read the first child. + // TODO: do we still need this? var value = fn.$dv; // Only if we have $dv do we iterate over all, otherwise it is just the first element. - var jj = (value === undefined) ? Math.min(this.length, 1) : this.length; + var jj = (isUndefined(value)) ? Math.min(nodeCount, 1) : nodeCount; for (var j = 0; j < jj; j++) { var nodeValue = fn(this[j], arg1, arg2); value = value ? value + nodeValue : nodeValue; @@ -2733,7 +3726,7 @@ } } else { // we are a write, so apply to all children - for (i = 0; i < this.length; i++) { + for (i = 0; i < nodeCount; i++) { fn(this[i], arg1, arg2); } // return self for chaining @@ -2743,61 +3736,73 @@ }); function createEventHandler(element, events) { - var eventHandler = function (event, type) { - if (!event.preventDefault) { - event.preventDefault = function() { - event.returnValue = false; //ie - }; - } + var eventHandler = function(event, type) { + // jQuery specific api + event.isDefaultPrevented = function() { + return event.defaultPrevented; + }; - if (!event.stopPropagation) { - event.stopPropagation = function() { - event.cancelBubble = true; //ie - }; - } + var eventFns = events[type || event.type]; + var eventFnsLength = eventFns ? eventFns.length : 0; - if (!event.target) { - event.target = event.srcElement || document; - } + if (!eventFnsLength) return; + + if (isUndefined(event.immediatePropagationStopped)) { + var originalStopImmediatePropagation = event.stopImmediatePropagation; + event.stopImmediatePropagation = function() { + event.immediatePropagationStopped = true; - if (isUndefined(event.defaultPrevented)) { - var prevent = event.preventDefault; - event.preventDefault = function() { - event.defaultPrevented = true; - prevent.call(event); + if (event.stopPropagation) { + event.stopPropagation(); + } + + if (originalStopImmediatePropagation) { + originalStopImmediatePropagation.call(event); + } }; - event.defaultPrevented = false; } - event.isDefaultPrevented = function() { - return event.defaultPrevented || event.returnValue === false; + event.isImmediatePropagationStopped = function() { + return event.immediatePropagationStopped === true; }; - // Copy event handlers in case event handlers array is modified during execution. - var eventHandlersCopy = shallowCopy(events[type || event.type] || []); + // Some events have special handlers that wrap the real handler + var handlerWrapper = eventFns.specialHandlerWrapper || defaultHandlerWrapper; - forEach(eventHandlersCopy, function(fn) { - fn.call(element, event); - }); + // Copy event handlers in case event handlers array is modified during execution. + if ((eventFnsLength > 1)) { + eventFns = shallowCopy(eventFns); + } - // Remove monkey-patched methods (IE), - // as they would cause memory leaks in IE8. - if (msie <= 8) { - // IE7/8 does not allow to delete property on native object - event.preventDefault = null; - event.stopPropagation = null; - event.isDefaultPrevented = null; - } else { - // It shouldn't affect normal browsers (native methods are defined on prototype). - delete event.preventDefault; - delete event.stopPropagation; - delete event.isDefaultPrevented; + for (var i = 0; i < eventFnsLength; i++) { + if (!event.isImmediatePropagationStopped()) { + handlerWrapper(element, event, eventFns[i]); + } } }; + + // TODO: this is a hack for angularMocks/clearDataCache that makes it possible to deregister all + // events on `element` eventHandler.elem = element; return eventHandler; } + function defaultHandlerWrapper(element, event, handler) { + handler.call(element, event); + } + + function specialMouseHandlerWrapper(target, event, handler) { + // Refer to jQuery's implementation of mouseenter & mouseleave + // Read about mouseenter and mouseleave: + // http://www.quirksmode.org/js/events_mouse.html#link8 + var related = event.relatedTarget; + // For mousenter/leave call the handler if related is outside the target. + // NB: No relatedTarget if the mouse left/entered the browser window + if (!related || (related !== target && !jqLiteContains.call(target, related))) { + handler.call(target, event); + } + } + ////////////////////////////////////////// // Functions iterating traversal. // These functions chain results into a single @@ -2806,68 +3811,49 @@ forEach({ removeData: jqLiteRemoveData, - dealoc: jqLiteDealoc, - - on: function onFn(element, type, fn, unsupported){ + on: function jqLiteOn(element, type, fn, unsupported) { if (isDefined(unsupported)) throw jqLiteMinErr('onargs', 'jqLite#on() does not support the `selector` or `eventData` parameters'); - var events = jqLiteExpandoStore(element, 'events'), - handle = jqLiteExpandoStore(element, 'handle'); + // Do not add event handlers to non-elements because they will not be cleaned up. + if (!jqLiteAcceptsData(element)) { + return; + } + + var expandoStore = jqLiteExpandoStore(element, true); + var events = expandoStore.events; + var handle = expandoStore.handle; - if (!events) jqLiteExpandoStore(element, 'events', events = {}); - if (!handle) jqLiteExpandoStore(element, 'handle', handle = createEventHandler(element, events)); + if (!handle) { + handle = expandoStore.handle = createEventHandler(element, events); + } + + // http://jsperf.com/string-indexof-vs-split + var types = type.indexOf(' ') >= 0 ? type.split(' ') : [type]; + var i = types.length; - forEach(type.split(' '), function(type){ + var addHandler = function(type, specialHandlerWrapper, noEventListener) { var eventFns = events[type]; if (!eventFns) { - if (type == 'mouseenter' || type == 'mouseleave') { - var contains = document.body.contains || document.body.compareDocumentPosition ? - function( a, b ) { - // jshint bitwise: false - var adown = a.nodeType === 9 ? a.documentElement : a, - bup = b && b.parentNode; - return a === bup || !!( bup && bup.nodeType === 1 && ( - adown.contains ? - adown.contains( bup ) : - a.compareDocumentPosition && a.compareDocumentPosition( bup ) & 16 - )); - } : - function( a, b ) { - if ( b ) { - while ( (b = b.parentNode) ) { - if ( b === a ) { - return true; - } - } - } - return false; - }; - - events[type] = []; + eventFns = events[type] = []; + eventFns.specialHandlerWrapper = specialHandlerWrapper; + if (type !== '$destroy' && !noEventListener) { + element.addEventListener(type, handle); + } + } - // Refer to jQuery's implementation of mouseenter & mouseleave - // Read about mouseenter and mouseleave: - // http://www.quirksmode.org/js/events_mouse.html#link8 - var eventmap = { mouseleave : "mouseout", mouseenter : "mouseover"}; + eventFns.push(fn); + }; - onFn(element, eventmap[type], function(event) { - var target = this, related = event.relatedTarget; - // For mousenter/leave call the handler if related is outside the target. - // NB: No relatedTarget if the mouse left/entered the browser window - if ( !related || (related !== target && !contains(target, related)) ){ - handle(event, type); - } - }); - - } else { - addEventListenerFn(element, type, handle); - events[type] = []; - } - eventFns = events[type]; + while (i--) { + type = types[i]; + if (MOUSE_EVENT_MAP[type]) { + addHandler(MOUSE_EVENT_MAP[type], specialMouseHandlerWrapper); + addHandler(type, undefined, true); + } else { + addHandler(type); } - eventFns.push(fn); - }); + } }, off: jqLiteOff, @@ -2888,7 +3874,7 @@ replaceWith: function(element, replaceNode) { var index, parent = element.parentNode; jqLiteDealoc(element); - forEach(new JQLite(replaceNode), function(node){ + forEach(new JQLite(replaceNode), function(node) { if (index) { parent.insertBefore(node, index.nextSibling); } else { @@ -2900,9 +3886,10 @@ children: function(element) { var children = []; - forEach(element.childNodes, function(element){ - if (element.nodeType === 1) + forEach(element.childNodes, function(element) { + if (element.nodeType === NODE_TYPE_ELEMENT) { children.push(element); + } }); return children; }, @@ -2912,43 +3899,48 @@ }, append: function(element, node) { - forEach(new JQLite(node), function(child){ - if (element.nodeType === 1 || element.nodeType === 11) { - element.appendChild(child); - } - }); + var nodeType = element.nodeType; + if (nodeType !== NODE_TYPE_ELEMENT && nodeType !== NODE_TYPE_DOCUMENT_FRAGMENT) return; + + node = new JQLite(node); + + for (var i = 0, ii = node.length; i < ii; i++) { + var child = node[i]; + element.appendChild(child); + } }, prepend: function(element, node) { - if (element.nodeType === 1) { + if (element.nodeType === NODE_TYPE_ELEMENT) { var index = element.firstChild; - forEach(new JQLite(node), function(child){ + forEach(new JQLite(node), function(child) { element.insertBefore(child, index); }); } }, wrap: function(element, wrapNode) { - wrapNode = jqLite(wrapNode)[0]; - var parent = element.parentNode; - if (parent) { - parent.replaceChild(wrapNode, element); - } - wrapNode.appendChild(element); + jqLiteWrapNode(element, jqLite(wrapNode).eq(0).clone()[0]); }, - remove: function(element) { - jqLiteDealoc(element); - var parent = element.parentNode; - if (parent) parent.removeChild(element); + remove: jqLiteRemove, + + detach: function(element) { + jqLiteRemove(element, true); }, after: function(element, newElement) { var index = element, parent = element.parentNode; - forEach(new JQLite(newElement), function(node){ - parent.insertBefore(node, index.nextSibling); - index = node; - }); + + if (parent) { + newElement = new JQLite(newElement); + + for (var i = 0, ii = newElement.length; i < ii; i++) { + var node = newElement[i]; + parent.insertBefore(node, index.nextSibling); + index = node; + } + } }, addClass: jqLiteAddClass, @@ -2956,7 +3948,7 @@ toggleClass: function(element, selector, condition) { if (selector) { - forEach(selector.split(' '), function(className){ + forEach(selector.split(' '), function(className) { var classCondition = condition; if (isUndefined(classCondition)) { classCondition = !jqLiteHasClass(element, className); @@ -2968,20 +3960,11 @@ parent: function(element) { var parent = element.parentNode; - return parent && parent.nodeType !== 11 ? parent : null; + return parent && parent.nodeType !== NODE_TYPE_DOCUMENT_FRAGMENT ? parent : null; }, next: function(element) { - if (element.nextElementSibling) { - return element.nextElementSibling; - } - - // IE8 doesn't have nextElementSibling - var elm = element.nextSibling; - while (elm != null && elm.nodeType !== 1) { - elm = elm.nextSibling; - } - return elm; + return element.nextElementSibling; }, find: function(element, selector) { @@ -2994,27 +3977,50 @@ clone: jqLiteClone, - triggerHandler: function(element, eventName, eventData) { - var eventFns = (jqLiteExpandoStore(element, 'events') || {})[eventName]; + triggerHandler: function(element, event, extraParameters) { + + var dummyEvent, eventFnsCopy, handlerArgs; + var eventName = event.type || event; + var expandoStore = jqLiteExpandoStore(element); + var events = expandoStore && expandoStore.events; + var eventFns = events && events[eventName]; + + if (eventFns) { + // Create a dummy event to pass to the handlers + dummyEvent = { + preventDefault: function() { this.defaultPrevented = true; }, + isDefaultPrevented: function() { return this.defaultPrevented === true; }, + stopImmediatePropagation: function() { this.immediatePropagationStopped = true; }, + isImmediatePropagationStopped: function() { return this.immediatePropagationStopped === true; }, + stopPropagation: noop, + type: eventName, + target: element + }; - eventData = eventData || []; + // If a custom event was provided then extend our dummy event with it + if (event.type) { + dummyEvent = extend(dummyEvent, event); + } - var event = [{ - preventDefault: noop, - stopPropagation: noop - }]; + // Copy event handlers in case event handlers array is modified during execution. + eventFnsCopy = shallowCopy(eventFns); + handlerArgs = extraParameters ? [dummyEvent].concat(extraParameters) : [dummyEvent]; - forEach(eventFns, function(fn) { - fn.apply(element, event.concat(eventData)); - }); + forEach(eventFnsCopy, function(fn) { + if (!dummyEvent.isImmediatePropagationStopped()) { + fn.apply(element, handlerArgs); + } + }); + } } - }, function(fn, name){ + }, function(fn, name) { /** * chaining functions */ JQLite.prototype[name] = function(arg1, arg2, arg3) { var value; - for(var i=0; i < this.length; i++) { + + for (var i = 0, ii = this.length; i < ii; i++) { if (isUndefined(value)) { value = fn(this[i], arg1, arg2, arg3); if (isDefined(value)) { @@ -3027,12 +4033,34 @@ } return isDefined(value) ? value : this; }; - - // bind legacy bind/unbind to on/off - JQLite.prototype.bind = JQLite.prototype.on; - JQLite.prototype.unbind = JQLite.prototype.off; }); +// bind legacy bind/unbind to on/off + JQLite.prototype.bind = JQLite.prototype.on; + JQLite.prototype.unbind = JQLite.prototype.off; + + +// Provider for private $$jqLite service + /** @this */ + function $$jqLiteProvider() { + this.$get = function $$jqLite() { + return extend(JQLite, { + hasClass: function(node, classes) { + if (node.attr) node = node[0]; + return jqLiteHasClass(node, classes); + }, + addClass: function(node, classes) { + if (node.attr) node = node[0]; + return jqLiteAddClass(node, classes); + }, + removeClass: function(node, classes) { + if (node.attr) node = node[0]; + return jqLiteRemoveClass(node, classes); + } + }); + }; + } + /** * Computes a hash of an 'obj'. * Hash of a: @@ -3045,73 +4073,108 @@ * @returns {string} hash string such that the same input will have the same hash string. * The resulting string key is in 'type:hashKey' format. */ - function hashKey(obj) { - var objType = typeof obj, - key; + function hashKey(obj, nextUidFn) { + var key = obj && obj.$$hashKey; - if (objType == 'object' && obj !== null) { - if (typeof (key = obj.$$hashKey) == 'function') { - // must invoke on object to keep the right this + if (key) { + if (typeof key === 'function') { key = obj.$$hashKey(); - } else if (key === undefined) { - key = obj.$$hashKey = nextUid(); } + return key; + } + + var objType = typeof obj; + if (objType === 'function' || (objType === 'object' && obj !== null)) { + key = obj.$$hashKey = objType + ':' + (nextUidFn || nextUid)(); } else { - key = obj; + key = objType + ':' + obj; } - return objType + ':' + key; + return key; } - /** - * HashMap which can use objects as keys - */ - function HashMap(array){ - forEach(array, this.put, this); +// A minimal ES2015 Map implementation. +// Should be bug/feature equivalent to the native implementations of supported browsers +// (for the features required in Angular). +// See https://kangax.github.io/compat-table/es6/#test-Map + var nanKey = Object.create(null); + function NgMapShim() { + this._keys = []; + this._values = []; + this._lastKey = NaN; + this._lastIndex = -1; } - HashMap.prototype = { - /** - * Store key value pair - * @param key key to store can be any type - * @param value value to store can be any type - */ - put: function(key, value) { - this[hashKey(key)] = value; + NgMapShim.prototype = { + _idx: function(key) { + if (key === this._lastKey) { + return this._lastIndex; + } + this._lastKey = key; + this._lastIndex = this._keys.indexOf(key); + return this._lastIndex; + }, + _transformKey: function(key) { + return isNumberNaN(key) ? nanKey : key; }, - - /** - * @param key - * @returns {Object} the value for the key - */ get: function(key) { - return this[hashKey(key)]; + key = this._transformKey(key); + var idx = this._idx(key); + if (idx !== -1) { + return this._values[idx]; + } }, + set: function(key, value) { + key = this._transformKey(key); + var idx = this._idx(key); + if (idx === -1) { + idx = this._lastIndex = this._keys.length; + } + this._keys[idx] = key; + this._values[idx] = value; - /** - * Remove the key/value pair - * @param key - */ - remove: function(key) { - var value = this[key = hashKey(key)]; - delete this[key]; - return value; + // Support: IE11 + // Do not `return this` to simulate the partial IE11 implementation + }, + delete: function(key) { + key = this._transformKey(key); + var idx = this._idx(key); + if (idx === -1) { + return false; + } + this._keys.splice(idx, 1); + this._values.splice(idx, 1); + this._lastKey = NaN; + this._lastIndex = -1; + return true; } }; +// For now, always use `NgMapShim`, even if `window.Map` is available. Some native implementations +// are still buggy (often in subtle ways) and can cause hard-to-debug failures. When native `Map` +// implementations get more stable, we can reconsider switching to `window.Map` (when available). + var NgMap = NgMapShim; + + var $$MapProvider = [/** @this */function() { + this.$get = [function() { + return NgMap; + }]; + }]; + /** * @ngdoc function * @module ng * @name angular.injector - * @function + * @kind function * * @description - * Creates an injector function that can be used for retrieving services as well as for + * Creates an injector object that can be used for retrieving services as well as for * dependency injection (see {@link guide/di dependency injection}). * - * @param {Array.<string|Function>} modules A list of module functions or their aliases. See - * {@link angular.module}. The `ng` module must be explicitly added. - * @returns {function()} Injector function. See {@link auto.$injector $injector}. + * {@link angular.module}. The `ng` module must be explicitly added. + * @param {boolean=} [strictDi=false] Whether the injector should be in strict mode, which + * disallows argument name annotation inference. + * @returns {injector} Injector object. See {@link auto.$injector $injector}. * * @example * Typical usage @@ -3121,15 +4184,15 @@ * * // use the injector to kick off your application * // use the type inference to auto inject arguments, or use implicit injection - * $injector.invoke(function($rootScope, $compile, $document){ - * $compile($document)($rootScope); - * $rootScope.$digest(); - * }); + * $injector.invoke(function($rootScope, $compile, $document) { + * $compile($document)($rootScope); + * $rootScope.$digest(); + * }); * ``` * - * Sometimes you want to get access to the injector of a currently running Angular app - * from outside Angular. Perhaps, you want to inject and compile some markup after the - * application has been bootstrapped. You can do this using extra `injector()` added + * Sometimes you want to get access to the injector of a currently running AngularJS app + * from outside AngularJS. Perhaps, you want to inject and compile some markup after the + * application has been bootstrapped. You can do this using the extra `injector()` added * to JQuery/jqLite elements. See {@link angular.element}. * * *This is fairly rare but could be the case if a third party library is injecting the @@ -3144,9 +4207,9 @@ * $(document.body).append($div); * * angular.element(document).injector().invoke(function($compile) { - * var scope = angular.element($div).scope(); - * $compile($div)(scope); - * }); + * var scope = angular.element($div).scope(); + * $compile($div)(scope); + * }); * ``` */ @@ -3154,30 +4217,58 @@ /** * @ngdoc module * @name auto + * @installation * @description * * Implicit module which gets automatically added to each {@link auto.$injector $injector}. */ - var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m; + var ARROW_ARG = /^([^(]+?)=>/; + var FN_ARGS = /^[^(]*\(\s*([^)]*)\)/m; var FN_ARG_SPLIT = /,/; var FN_ARG = /^\s*(_?)(\S+?)\1\s*$/; var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg; var $injectorMinErr = minErr('$injector'); - function annotate(fn) { + + function stringifyFn(fn) { + return Function.prototype.toString.call(fn); + } + + function extractArgs(fn) { + var fnText = stringifyFn(fn).replace(STRIP_COMMENTS, ''), + args = fnText.match(ARROW_ARG) || fnText.match(FN_ARGS); + return args; + } + + function anonFn(fn) { + // For anonymous functions, showing at the very least the function signature can help in + // debugging. + var args = extractArgs(fn); + if (args) { + return 'function(' + (args[1] || '').replace(/[\s\r\n]+/, ' ') + ')'; + } + return 'fn'; + } + + function annotate(fn, strictDi, name) { var $inject, - fnText, argDecl, last; - if (typeof fn == 'function') { + if (typeof fn === 'function') { if (!($inject = fn.$inject)) { $inject = []; if (fn.length) { - fnText = fn.toString().replace(STRIP_COMMENTS, ''); - argDecl = fnText.match(FN_ARGS); - forEach(argDecl[1].split(FN_ARG_SPLIT), function(arg){ - arg.replace(FN_ARG, function(all, underscore, name){ + if (strictDi) { + if (!isString(name) || !name) { + name = fn.name || anonFn(fn); + } + throw $injectorMinErr('strictdi', + '{0} is not using explicit annotation and cannot be invoked in strict mode', name); + } + argDecl = extractArgs(fn); + forEach(argDecl[1].split(FN_ARG_SPLIT), function(arg) { + arg.replace(FN_ARG, function(all, underscore, name) { $inject.push(name); }); }); @@ -3199,7 +4290,6 @@ /** * @ngdoc service * @name $injector - * @function * * @description * @@ -3212,12 +4302,12 @@ * ```js * var $injector = angular.injector(); * expect($injector.get('$injector')).toBe($injector); - * expect($injector.invoke(function($injector){ - * return $injector; - * }).toBe($injector); + * expect($injector.invoke(function($injector) { + * return $injector; + * })).toBe($injector); * ``` * - * # Injection Function Annotation + * ## Injection Function Annotation * * JavaScript does not have annotations, and annotations are needed for dependency injection. The * following are all valid ways of annotating function with injection arguments and are equivalent. @@ -3235,19 +4325,43 @@ * $injector.invoke(['serviceA', function(serviceA){}]); * ``` * - * ## Inference + * ### Inference * * In JavaScript calling `toString()` on a function returns the function definition. The definition - * can then be parsed and the function arguments can be extracted. *NOTE:* This does not work with - * minification, and obfuscation tools since these tools change the argument names. + * can then be parsed and the function arguments can be extracted. This method of discovering + * annotations is disallowed when the injector is in strict mode. + * *NOTE:* This does not work with minification, and obfuscation tools since these tools change the + * argument names. * - * ## `$inject` Annotation - * By adding a `$inject` property onto a function the injection parameters can be specified. + * ### `$inject` Annotation + * By adding an `$inject` property onto a function the injection parameters can be specified. * - * ## Inline + * ### Inline * As an array of injection names, where the last item in the array is the function to call. */ + /** + * @ngdoc property + * @name $injector#modules + * @type {Object} + * @description + * A hash containing all the modules that have been loaded into the + * $injector. + * + * You can use this property to find out information about a module via the + * {@link angular.Module#info `myModule.info(...)`} method. + * + * For example: + * + * ``` + * var info = $injector.modules['ngAnimate'].info(); + * ``` + * + * **Do not use this property to attempt to modify the modules after the application + * has been bootstrapped.** + */ + + /** * @ngdoc method * @name $injector#get @@ -3256,6 +4370,7 @@ * Return an instance of the service. * * @param {string} name The name of the instance to retrieve. + * @param {string=} caller An optional string to provide the origin of the function call for error messages. * @return {*} The instance. */ @@ -3266,8 +4381,8 @@ * @description * Invoke the method and supply the method arguments from the `$injector`. * - * @param {!Function} fn The function to invoke. Function parameters are injected according to the - * {@link guide/di $inject Annotation} rules. + * @param {Function|Array.<string|Function>} fn The injectable function to invoke. Function parameters are + * injected according to the {@link guide/di $inject Annotation} rules. * @param {Object=} self The `this` for the invoked method. * @param {Object=} locals Optional object. If preset then any argument names are read from this * object first, before the `$injector` is consulted. @@ -3279,18 +4394,18 @@ * @name $injector#has * * @description - * Allows the user to query if the particular service exist. + * Allows the user to query if the particular service exists. * - * @param {string} Name of the service to query. - * @returns {boolean} returns true if injector has given service. + * @param {string} name Name of the service to query. + * @returns {boolean} `true` if injector has given service. */ /** * @ngdoc method * @name $injector#instantiate * @description - * Create a new instance of JS type. The method takes a constructor function invokes the new - * operator and supplies all of the arguments to the constructor function as specified by the + * Create a new instance of JS type. The method takes a constructor function, invokes the new + * operator, and supplies all of the arguments to the constructor function as specified by the * constructor annotation. * * @param {Function} Type Annotated constructor function. @@ -3309,7 +4424,7 @@ * function is invoked. There are three ways in which the function can be annotated with the needed * dependencies. * - * # Argument names + * #### Argument names * * The simplest form is to extract the dependencies from the arguments of the function. This is done * by converting the function into a string using `toString()` method and extracting the argument @@ -3317,25 +4432,27 @@ * ```js * // Given * function MyController($scope, $route) { - * // ... - * } + * // ... + * } * * // Then * expect(injector.annotate(MyController)).toEqual(['$scope', '$route']); * ``` * + * You can disallow this method by using strict injection mode. + * * This method does not work with code minification / obfuscation. For this reason the following * annotation strategies are supported. * - * # The `$inject` property + * #### The `$inject` property * * If a function has an `$inject` property and its value is an array of strings, then the strings * represent names of services to be injected into the function. * ```js * // Given * var MyController = function(obfuscatedScope, obfuscatedRoute) { - * // ... - * } + * // ... + * } * // Define function dependencies * MyController['$inject'] = ['$scope', '$route']; * @@ -3343,7 +4460,7 @@ * expect(injector.annotate(MyController)).toEqual(['$scope', '$route']); * ``` * - * # The array notation + * #### The array notation * * It is often desirable to inline Injected functions and that's when setting the `$inject` property * is very inconvenient. In these situations using the array notation to specify the dependencies in @@ -3352,20 +4469,20 @@ * ```js * // We wish to write this (not minification / obfuscation safe) * injector.invoke(function($compile, $rootScope) { - * // ... - * }); + * // ... + * }); * * // We are forced to write break inlining * var tmpFn = function(obfuscatedCompile, obfuscatedRootScope) { - * // ... - * }; + * // ... + * }; * tmpFn.$inject = ['$compile', '$rootScope']; * injector.invoke(tmpFn); * * // To better support inline function the inline annotation is supported * injector.invoke(['$compile', '$rootScope', function(obfCompile, obfRootScope) { - * // ... - * }]); + * // ... + * }]); * * // Therefore * expect(injector.annotate( @@ -3376,14 +4493,53 @@ * @param {Function|Array.<string|Function>} fn Function for which dependent service names need to * be retrieved as described above. * + * @param {boolean=} [strictDi=false] Disallow argument name annotation inference. + * * @returns {Array.<string>} The names of the services which the function requires. */ - - + /** + * @ngdoc method + * @name $injector#loadNewModules + * + * @description + * + * **This is a dangerous API, which you use at your own risk!** + * + * Add the specified modules to the current injector. + * + * This method will add each of the injectables to the injector and execute all of the config and run + * blocks for each module passed to the method. + * + * If a module has already been loaded into the injector then it will not be loaded again. + * + * * The application developer is responsible for loading the code containing the modules; and for + * ensuring that lazy scripts are not downloaded and executed more often that desired. + * * Previously compiled HTML will not be affected by newly loaded directives, filters and components. + * * Modules cannot be unloaded. + * + * You can use {@link $injector#modules `$injector.modules`} to check whether a module has been loaded + * into the injector, which may indicate whether the script has been executed already. + * + * @example + * Here is an example of loading a bundle of modules, with a utility method called `getScript`: + * + * ```javascript + * app.factory('loadModule', function($injector) { + * return function loadModule(moduleName, bundleUrl) { + * return getScript(bundleUrl).then(function() { $injector.loadNewModules([moduleName]); }); + * }; + * }) + * ``` + * + * @param {Array<String|Function|Array>=} mods an array of modules to load into the application. + * Each item in the array should be the name of a predefined module or a (DI annotated) + * function that will be invoked by the injector as a `config` block. + * See: {@link angular.module modules} + */ /** - * @ngdoc object + * @ngdoc service * @name $provide * * @description @@ -3392,7 +4548,7 @@ * with the {@link auto.$injector $injector}. Many of these functions are also exposed on * {@link angular.Module}. * - * An Angular **service** is a singleton object created by a **service factory**. These **service + * An AngularJS **service** is a singleton object created by a **service factory**. These **service * factories** are functions which, in turn, are created by a **service provider**. * The **service providers** are constructor functions. When instantiated they must contain a * property called `$get`, which holds the **service factory** function. @@ -3406,18 +4562,20 @@ * these cases the {@link auto.$provide $provide} service has additional helper methods to register * services without specifying a provider. * - * * {@link auto.$provide#provider provider(provider)} - registers a **service provider** with the + * * {@link auto.$provide#provider provider(name, provider)} - registers a **service provider** with the * {@link auto.$injector $injector} - * * {@link auto.$provide#constant constant(obj)} - registers a value/object that can be accessed by + * * {@link auto.$provide#constant constant(name, obj)} - registers a value/object that can be accessed by * providers and services. - * * {@link auto.$provide#value value(obj)} - registers a value/object that can only be accessed by + * * {@link auto.$provide#value value(name, obj)} - registers a value/object that can only be accessed by * services, not providers. - * * {@link auto.$provide#factory factory(fn)} - registers a service **factory function**, `fn`, + * * {@link auto.$provide#factory factory(name, fn)} - registers a service **factory function** * that will be wrapped in a **service provider** object, whose `$get` property will contain the * given factory function. - * * {@link auto.$provide#service service(class)} - registers a **constructor function**, `class` + * * {@link auto.$provide#service service(name, Fn)} - registers a **constructor function** * that will be wrapped in a **service provider** object, whose `$get` property will instantiate * a new object using the given constructor function. + * * {@link auto.$provide#decorator decorator(name, decorFn)} - registers a **decorator function** that + * will be able to modify or replace the implementation of another service. * * See the individual methods for more information and examples. */ @@ -3442,6 +4600,9 @@ * which lets you specify whether the {@link ng.$log $log} service will log debug messages to the * console or not. * + * It is possible to inject other providers into the provider function, + * but the injected provider must have been defined before the one that requires it. + * * @param {string} name The name of the instance. NOTE: the provider will be available under `name + 'Provider'` key. * @param {(Object|function())} provider If the provider is: @@ -3461,60 +4622,60 @@ * ```js * // Define the eventTracker provider * function EventTrackerProvider() { - * var trackingUrl = '/track'; - * - * // A provider method for configuring where the tracked events should been saved - * this.setTrackingUrl = function(url) { - * trackingUrl = url; - * }; - * - * // The service factory function - * this.$get = ['$http', function($http) { - * var trackedEvents = {}; - * return { - * // Call this to track an event - * event: function(event) { - * var count = trackedEvents[event] || 0; - * count += 1; - * trackedEvents[event] = count; - * return count; - * }, - * // Call this to save the tracked events to the trackingUrl - * save: function() { - * $http.post(trackingUrl, trackedEvents); - * } - * }; - * }]; - * } + * var trackingUrl = '/track'; + * + * // A provider method for configuring where the tracked events should been saved + * this.setTrackingUrl = function(url) { + * trackingUrl = url; + * }; + * + * // The service factory function + * this.$get = ['$http', function($http) { + * var trackedEvents = {}; + * return { + * // Call this to track an event + * event: function(event) { + * var count = trackedEvents[event] || 0; + * count += 1; + * trackedEvents[event] = count; + * return count; + * }, + * // Call this to save the tracked events to the trackingUrl + * save: function() { + * $http.post(trackingUrl, trackedEvents); + * } + * }; + * }]; + * } * * describe('eventTracker', function() { - * var postSpy; - * - * beforeEach(module(function($provide) { - * // Register the eventTracker provider - * $provide.provider('eventTracker', EventTrackerProvider); - * })); - * - * beforeEach(module(function(eventTrackerProvider) { - * // Configure eventTracker provider - * eventTrackerProvider.setTrackingUrl('/custom-track'); - * })); - * - * it('tracks events', inject(function(eventTracker) { - * expect(eventTracker.event('login')).toEqual(1); - * expect(eventTracker.event('login')).toEqual(2); - * })); - * - * it('saves to the tracking url', inject(function(eventTracker, $http) { - * postSpy = spyOn($http, 'post'); - * eventTracker.event('login'); - * eventTracker.save(); - * expect(postSpy).toHaveBeenCalled(); - * expect(postSpy.mostRecentCall.args[0]).not.toEqual('/track'); - * expect(postSpy.mostRecentCall.args[0]).toEqual('/custom-track'); - * expect(postSpy.mostRecentCall.args[1]).toEqual({ 'login': 1 }); - * })); - * }); + * var postSpy; + * + * beforeEach(module(function($provide) { + * // Register the eventTracker provider + * $provide.provider('eventTracker', EventTrackerProvider); + * })); + * + * beforeEach(module(function(eventTrackerProvider) { + * // Configure eventTracker provider + * eventTrackerProvider.setTrackingUrl('/custom-track'); + * })); + * + * it('tracks events', inject(function(eventTracker) { + * expect(eventTracker.event('login')).toEqual(1); + * expect(eventTracker.event('login')).toEqual(2); + * })); + * + * it('saves to the tracking url', inject(function(eventTracker, $http) { + * postSpy = spyOn($http, 'post'); + * eventTracker.event('login'); + * eventTracker.save(); + * expect(postSpy).toHaveBeenCalled(); + * expect(postSpy.mostRecentCall.args[0]).not.toEqual('/track'); + * expect(postSpy.mostRecentCall.args[0]).toEqual('/custom-track'); + * expect(postSpy.mostRecentCall.args[1]).toEqual({ 'login': 1 }); + * })); + * }); * ``` */ @@ -3530,24 +4691,24 @@ * configure your service in a provider. * * @param {string} name The name of the instance. - * @param {function()} $getFn The $getFn for the instance creation. Internally this is a short hand - * for `$provide.provider(name, {$get: $getFn})`. + * @param {Function|Array.<string|Function>} $getFn The injectable $getFn for the instance creation. + * Internally this is a short hand for `$provide.provider(name, {$get: $getFn})`. * @returns {Object} registered provider instance * * @example * Here is an example of registering a service * ```js * $provide.factory('ping', ['$http', function($http) { - * return function ping() { - * return $http.send('/ping'); - * }; - * }]); + * return function ping() { + * return $http.send('/ping'); + * }; + * }]); * ``` * You would then inject and use this service like this: * ```js * someModule.controller('Ctrl', ['ping', function(ping) { - * ping(); - * }]); + * ping(); + * }]); * ``` */ @@ -3559,14 +4720,27 @@ * * Register a **service constructor**, which will be invoked with `new` to create the service * instance. - * This is short for registering a service where its provider's `$get` property is the service - * constructor function that will be used to instantiate the service instance. + * This is short for registering a service where its provider's `$get` property is a factory + * function that returns an instance instantiated by the injector from the service constructor + * function. + * + * Internally it looks a bit like this: + * + * ``` + * { + * $get: function() { + * return $injector.instantiate(constructor); + * } + * } + * ``` + * * * You should use {@link auto.$provide#service $provide.service(class)} if you define your service * as a type/class. * * @param {string} name The name of the instance. - * @param {Function} constructor A class (constructor function) that will be instantiated. + * @param {Function|Array.<string|Function>} constructor An injectable class (constructor function) + * that will be instantiated. * @returns {Object} registered provider instance * * @example @@ -3574,21 +4748,21 @@ * {@link auto.$provide#service $provide.service(class)}. * ```js * var Ping = function($http) { - * this.$http = $http; - * }; + * this.$http = $http; + * }; * * Ping.$inject = ['$http']; * * Ping.prototype.send = function() { - * return this.$http.get('/ping'); - * }; + * return this.$http.get('/ping'); + * }; * $provide.service('ping', Ping); * ``` * You would then inject and use this service like this: * ```js * someModule.controller('Ctrl', ['ping', function(ping) { - * ping.send(); - * }]); + * ping.send(); + * }]); * ``` */ @@ -3599,14 +4773,13 @@ * @description * * Register a **value service** with the {@link auto.$injector $injector}, such as a string, a - * number, an array, an object or a function. This is short for registering a service where its + * number, an array, an object or a function. This is short for registering a service where its * provider's `$get` property is a factory function that takes no arguments and returns the **value - * service**. + * service**. That also means it is not possible to inject other services into a value service. * * Value services are similar to constant services, except that they cannot be injected into a * module configuration function (see {@link angular.Module#config}) but they can be overridden by - * an Angular - * {@link auto.$provide#decorator decorator}. + * an AngularJS {@link auto.$provide#decorator decorator}. * * @param {string} name The name of the instance. * @param {*} value The value. @@ -3620,8 +4793,8 @@ * $provide.value('RoleLookup', { admin: 0, writer: 1, reader: 2 }); * * $provide.value('halfOf', function(value) { - * return value / 2; - * }); + * return value / 2; + * }); * ``` */ @@ -3631,10 +4804,13 @@ * @name $provide#constant * @description * - * Register a **constant service**, such as a string, a number, an array, an object or a function, - * with the {@link auto.$injector $injector}. Unlike {@link auto.$provide#value value} it can be + * Register a **constant service** with the {@link auto.$injector $injector}, such as a string, + * a number, an array, an object or a function. Like the {@link auto.$provide#value value}, it is not + * possible to inject other services into a constant. + * + * But unlike {@link auto.$provide#value value}, a constant can be * injected into a module configuration function (see {@link angular.Module#config}) and it cannot - * be overridden by an Angular {@link auto.$provide#decorator decorator}. + * be overridden by an AngularJS {@link auto.$provide#decorator decorator}. * * @param {string} name The name of the constant. * @param {*} value The constant value. @@ -3648,8 +4824,8 @@ * $provide.constant('MY_COLOURS', ['red', 'blue', 'grey']); * * $provide.constant('double', function(value) { - * return value * 2; - * }); + * return value * 2; + * }); * ``` */ @@ -3659,18 +4835,20 @@ * @name $provide#decorator * @description * - * Register a **service decorator** with the {@link auto.$injector $injector}. A service decorator - * intercepts the creation of a service, allowing it to override or modify the behaviour of the - * service. The object returned by the decorator may be the original service, or a new service - * object which replaces or wraps and delegates to the original service. + * Register a **decorator function** with the {@link auto.$injector $injector}. A decorator function + * intercepts the creation of a service, allowing it to override or modify the behavior of the + * service. The return value of the decorator function may be the original service, or a new service + * that replaces (or wraps and delegates to) the original service. + * + * You can find out more about using decorators in the {@link guide/decorators} guide. * * @param {string} name The name of the service to decorate. - * @param {function()} decorator This function will be invoked when the service needs to be - * instantiated and should return the decorated service instance. The function is called using + * @param {Function|Array.<string|Function>} decorator This function will be invoked when the service needs to be + * provided and should return the decorated service instance. The function is called using * the {@link auto.$injector#invoke injector.invoke} method and is therefore fully injectable. * Local injection arguments: * - * * `$delegate` - The original service instance, which can be monkey patched, configured, + * * `$delegate` - The original service instance, which can be replaced, monkey patched, configured, * decorated or delegated to. * * @example @@ -3678,18 +4856,19 @@ * calls to {@link ng.$log#error $log.warn()}. * ```js * $provide.decorator('$log', ['$delegate', function($delegate) { - * $delegate.warn = $delegate.error; - * return $delegate; - * }]); + * $delegate.warn = $delegate.error; + * return $delegate; + * }]); * ``` */ - function createInjector(modulesToLoad) { + function createInjector(modulesToLoad, strictDi) { + strictDi = (strictDi === true); var INSTANTIATING = {}, providerSuffix = 'Provider', path = [], - loadedModules = new HashMap(), + loadedModules = new NgMap(), providerCache = { $provide: { provider: supportObject(provider), @@ -3701,18 +4880,32 @@ } }, providerInjector = (providerCache.$injector = - createInternalInjector(providerCache, function() { - throw $injectorMinErr('unpr', "Unknown provider: {0}", path.join(' <- ')); + createInternalInjector(providerCache, function(serviceName, caller) { + if (angular.isString(caller)) { + path.push(caller); + } + throw $injectorMinErr('unpr', 'Unknown provider: {0}', path.join(' <- ')); })), instanceCache = {}, - instanceInjector = (instanceCache.$injector = - createInternalInjector(instanceCache, function(servicename) { - var provider = providerInjector.get(servicename + providerSuffix); - return instanceInjector.invoke(provider.$get, provider); - })); + protoInstanceInjector = + createInternalInjector(instanceCache, function(serviceName, caller) { + var provider = providerInjector.get(serviceName + providerSuffix, caller); + return instanceInjector.invoke( + provider.$get, provider, undefined, serviceName); + }), + instanceInjector = protoInstanceInjector; + + providerCache['$injector' + providerSuffix] = { $get: valueFn(protoInstanceInjector) }; + instanceInjector.modules = providerInjector.modules = createMap(); + var runBlocks = loadModules(modulesToLoad); + instanceInjector = protoInstanceInjector.get('$injector'); + instanceInjector.strictDi = strictDi; + forEach(runBlocks, function(fn) { if (fn) instanceInjector.invoke(fn); }); + instanceInjector.loadNewModules = function(mods) { + forEach(loadModules(mods), function(fn) { if (fn) instanceInjector.invoke(fn); }); + }; - forEach(loadModules(modulesToLoad), function(fn) { instanceInjector.invoke(fn || noop); }); return instanceInjector; @@ -3736,12 +4929,26 @@ provider_ = providerInjector.instantiate(provider_); } if (!provider_.$get) { - throw $injectorMinErr('pget', "Provider '{0}' must define $get factory method.", name); + throw $injectorMinErr('pget', 'Provider \'{0}\' must define $get factory method.', name); } - return providerCache[name + providerSuffix] = provider_; + return (providerCache[name + providerSuffix] = provider_); + } + + function enforceReturnValue(name, factory) { + return /** @this */ function enforcedReturnValue() { + var result = instanceInjector.invoke(factory, this); + if (isUndefined(result)) { + throw $injectorMinErr('undef', 'Provider \'{0}\' must return a value from $get factory method.', name); + } + return result; + }; } - function factory(name, factoryFn) { return provider(name, { $get: factoryFn }); } + function factory(name, factoryFn, enforce) { + return provider(name, { + $get: enforce !== false ? enforceReturnValue(name, factoryFn) : factoryFn + }); + } function service(name, constructor) { return factory(name, ['$injector', function($injector) { @@ -3749,7 +4956,7 @@ }]); } - function value(name, val) { return factory(name, valueFn(val)); } + function value(name, val) { return factory(name, valueFn(val), false); } function constant(name, value) { assertNotHasOwnProperty(name, 'constant'); @@ -3770,23 +4977,30 @@ //////////////////////////////////// // Module Loading //////////////////////////////////// - function loadModules(modulesToLoad){ - var runBlocks = [], moduleFn, invokeQueue, i, ii; + function loadModules(modulesToLoad) { + assertArg(isUndefined(modulesToLoad) || isArray(modulesToLoad), 'modulesToLoad', 'not an array'); + var runBlocks = [], moduleFn; forEach(modulesToLoad, function(module) { if (loadedModules.get(module)) return; - loadedModules.put(module, true); + loadedModules.set(module, true); + + function runInvokeQueue(queue) { + var i, ii; + for (i = 0, ii = queue.length; i < ii; i++) { + var invokeArgs = queue[i], + provider = providerInjector.get(invokeArgs[0]); + + provider[invokeArgs[1]].apply(provider, invokeArgs[2]); + } + } try { if (isString(module)) { moduleFn = angularModule(module); + instanceInjector.modules[module] = moduleFn; runBlocks = runBlocks.concat(loadModules(moduleFn.requires)).concat(moduleFn._runBlocks); - - for(invokeQueue = moduleFn._invokeQueue, i = 0, ii = invokeQueue.length; i < ii; i++) { - var invokeArgs = invokeQueue[i], - provider = providerInjector.get(invokeArgs[0]); - - provider[invokeArgs[1]].apply(provider, invokeArgs[2]); - } + runInvokeQueue(moduleFn._invokeQueue); + runInvokeQueue(moduleFn._configBlocks); } else if (isFunction(module)) { runBlocks.push(providerInjector.invoke(module)); } else if (isArray(module)) { @@ -3798,15 +5012,15 @@ if (isArray(module)) { module = module[module.length - 1]; } - if (e.message && e.stack && e.stack.indexOf(e.message) == -1) { + if (e.message && e.stack && e.stack.indexOf(e.message) === -1) { // Safari & FF's stack traces don't contain error.message content // unlike those of Chrome and IE // So if stack doesn't contain message, we create a new string that contains both. // Since error.stack is read-only in Safari, I'm overriding e and not e.stack here. - /* jshint -W022 */ + // eslint-disable-next-line no-ex-assign e = e.message + '\n' + e.stack; } - throw $injectorMinErr('modulerr', "Failed to instantiate module {0} due to:\n{1}", + throw $injectorMinErr('modulerr', 'Failed to instantiate module {0} due to:\n{1}', module, e.stack || e.message || e); } }); @@ -3819,17 +5033,19 @@ function createInternalInjector(cache, factory) { - function getService(serviceName) { + function getService(serviceName, caller) { if (cache.hasOwnProperty(serviceName)) { if (cache[serviceName] === INSTANTIATING) { - throw $injectorMinErr('cdep', 'Circular dependency found: {0}', path.join(' <- ')); + throw $injectorMinErr('cdep', 'Circular dependency found: {0}', + serviceName + ' <- ' + path.join(' <- ')); } return cache[serviceName]; } else { try { path.unshift(serviceName); cache[serviceName] = INSTANTIATING; - return cache[serviceName] = factory(serviceName); + cache[serviceName] = factory(serviceName, caller); + return cache[serviceName]; } catch (err) { if (cache[serviceName] === INSTANTIATING) { delete cache[serviceName]; @@ -3841,52 +5057,76 @@ } } - function invoke(fn, self, locals){ + + function injectionArgs(fn, locals, serviceName) { var args = [], - $inject = annotate(fn), - length, i, - key; + $inject = createInjector.$$annotate(fn, strictDi, serviceName); - for(i = 0, length = $inject.length; i < length; i++) { - key = $inject[i]; + for (var i = 0, length = $inject.length; i < length; i++) { + var key = $inject[i]; if (typeof key !== 'string') { throw $injectorMinErr('itkn', 'Incorrect injection token! Expected service name as string, got {0}', key); } - args.push( - locals && locals.hasOwnProperty(key) - ? locals[key] - : getService(key) - ); + args.push(locals && locals.hasOwnProperty(key) ? locals[key] : + getService(key, serviceName)); + } + return args; + } + + function isClass(func) { + // Support: IE 9-11 only + // IE 9-11 do not support classes and IE9 leaks with the code below. + if (msie || typeof func !== 'function') { + return false; + } + var result = func.$$ngIsClass; + if (!isBoolean(result)) { + // Support: Edge 12-13 only + // See: https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/6156135/ + result = func.$$ngIsClass = /^(?:class\b|constructor\()/.test(stringifyFn(func)); } - if (!fn.$inject) { - // this means that we must be an array. - fn = fn[length]; + return result; + } + + function invoke(fn, self, locals, serviceName) { + if (typeof locals === 'string') { + serviceName = locals; + locals = null; + } + + var args = injectionArgs(fn, locals, serviceName); + if (isArray(fn)) { + fn = fn[fn.length - 1]; } - // http://jsperf.com/angularjs-invoke-apply-vs-switch - // #5388 - return fn.apply(self, args); + if (!isClass(fn)) { + // http://jsperf.com/angularjs-invoke-apply-vs-switch + // #5388 + return fn.apply(self, args); + } else { + args.unshift(null); + return new (Function.prototype.bind.apply(fn, args))(); + } } - function instantiate(Type, locals) { - var Constructor = function() {}, - instance, returnedValue; + function instantiate(Type, locals, serviceName) { // Check if Type is annotated and use just the given function at n-1 as parameter // e.g. someModule.factory('greeter', ['$window', function(renamed$window) {}]); - Constructor.prototype = (isArray(Type) ? Type[Type.length - 1] : Type).prototype; - instance = new Constructor(); - returnedValue = invoke(Type, instance, locals); - - return isObject(returnedValue) || isFunction(returnedValue) ? returnedValue : instance; + var ctor = (isArray(Type) ? Type[Type.length - 1] : Type); + var args = injectionArgs(Type, locals, serviceName); + // Empty object at position 0 is ignored for invocation with `new`, but required. + args.unshift(null); + return new (Function.prototype.bind.apply(ctor, args))(); } + return { invoke: invoke, instantiate: instantiate, get: getService, - annotate: annotate, + annotate: createInjector.$$annotate, has: function(name) { return providerCache.hasOwnProperty(name + providerSuffix) || cache.hasOwnProperty(name); } @@ -3894,108 +5134,445 @@ } } + createInjector.$$annotate = annotate; + /** - * @ngdoc service - * @name $anchorScroll - * @kind function - * @requires $window - * @requires $location - * @requires $rootScope + * @ngdoc provider + * @name $anchorScrollProvider + * @this * * @description - * When called, it checks current value of `$location.hash()` and scroll to related element, - * according to rules specified in - * [Html5 spec](http://dev.w3.org/html5/spec/Overview.html#the-indicated-part-of-the-document). - * - * It also watches the `$location.hash()` and scrolls whenever it changes to match any anchor. - * This can be disabled by calling `$anchorScrollProvider.disableAutoScrolling()`. - * - * @example - <example> - <file name="index.html"> - <div id="scrollArea" ng-controller="ScrollCtrl"> - <a ng-click="gotoBottom()">Go to bottom</a> - <a id="bottom"></a> You're at the bottom! - </div> - </file> - <file name="script.js"> - function ScrollCtrl($scope, $location, $anchorScroll) { - $scope.gotoBottom = function (){ - // set the location.hash to the id of - // the element you wish to scroll to. - $location.hash('bottom'); - - // call $anchorScroll() - $anchorScroll(); - }; - } - </file> - <file name="style.css"> - #scrollArea { - height: 350px; - overflow: auto; - } - - #bottom { - display: block; - margin-top: 2000px; - } - </file> - </example> + * Use `$anchorScrollProvider` to disable automatic scrolling whenever + * {@link ng.$location#hash $location.hash()} changes. */ function $AnchorScrollProvider() { var autoScrollingEnabled = true; + /** + * @ngdoc method + * @name $anchorScrollProvider#disableAutoScrolling + * + * @description + * By default, {@link ng.$anchorScroll $anchorScroll()} will automatically detect changes to + * {@link ng.$location#hash $location.hash()} and scroll to the element matching the new hash.<br /> + * Use this method to disable automatic scrolling. + * + * If automatic scrolling is disabled, one must explicitly call + * {@link ng.$anchorScroll $anchorScroll()} in order to scroll to the element related to the + * current hash. + */ this.disableAutoScrolling = function() { autoScrollingEnabled = false; }; + /** + * @ngdoc service + * @name $anchorScroll + * @kind function + * @requires $window + * @requires $location + * @requires $rootScope + * + * @description + * When called, it scrolls to the element related to the specified `hash` or (if omitted) to the + * current value of {@link ng.$location#hash $location.hash()}, according to the rules specified + * in the + * [HTML5 spec](http://www.w3.org/html/wg/drafts/html/master/browsers.html#an-indicated-part-of-the-document). + * + * It also watches the {@link ng.$location#hash $location.hash()} and automatically scrolls to + * match any anchor whenever it changes. This can be disabled by calling + * {@link ng.$anchorScrollProvider#disableAutoScrolling $anchorScrollProvider.disableAutoScrolling()}. + * + * Additionally, you can use its {@link ng.$anchorScroll#yOffset yOffset} property to specify a + * vertical scroll-offset (either fixed or dynamic). + * + * @param {string=} hash The hash specifying the element to scroll to. If omitted, the value of + * {@link ng.$location#hash $location.hash()} will be used. + * + * @property {(number|function|jqLite)} yOffset + * If set, specifies a vertical scroll-offset. This is often useful when there are fixed + * positioned elements at the top of the page, such as navbars, headers etc. + * + * `yOffset` can be specified in various ways: + * - **number**: A fixed number of pixels to be used as offset.<br /><br /> + * - **function**: A getter function called everytime `$anchorScroll()` is executed. Must return + * a number representing the offset (in pixels).<br /><br /> + * - **jqLite**: A jqLite/jQuery element to be used for specifying the offset. The distance from + * the top of the page to the element's bottom will be used as offset.<br /> + * **Note**: The element will be taken into account only as long as its `position` is set to + * `fixed`. This option is useful, when dealing with responsive navbars/headers that adjust + * their height and/or positioning according to the viewport's size. + * + * <br /> + * <div class="alert alert-warning"> + * In order for `yOffset` to work properly, scrolling should take place on the document's root and + * not some child element. + * </div> + * + * @example + <example module="anchorScrollExample" name="anchor-scroll"> + <file name="index.html"> + <div id="scrollArea" ng-controller="ScrollController"> + <a ng-click="gotoBottom()">Go to bottom</a> + <a id="bottom"></a> You're at the bottom! + </div> + </file> + <file name="script.js"> + angular.module('anchorScrollExample', []) + .controller('ScrollController', ['$scope', '$location', '$anchorScroll', + function($scope, $location, $anchorScroll) { + $scope.gotoBottom = function() { + // set the location.hash to the id of + // the element you wish to scroll to. + $location.hash('bottom'); + + // call $anchorScroll() + $anchorScroll(); + }; + }]); + </file> + <file name="style.css"> + #scrollArea { + height: 280px; + overflow: auto; + } + + #bottom { + display: block; + margin-top: 2000px; + } + </file> + </example> + * + * <hr /> + * The example below illustrates the use of a vertical scroll-offset (specified as a fixed value). + * See {@link ng.$anchorScroll#yOffset $anchorScroll.yOffset} for more details. + * + * @example + <example module="anchorScrollOffsetExample" name="anchor-scroll-offset"> + <file name="index.html"> + <div class="fixed-header" ng-controller="headerCtrl"> + <a href="" ng-click="gotoAnchor(x)" ng-repeat="x in [1,2,3,4,5]"> + Go to anchor {{x}} + </a> + </div> + <div id="anchor{{x}}" class="anchor" ng-repeat="x in [1,2,3,4,5]"> + Anchor {{x}} of 5 + </div> + </file> + <file name="script.js"> + angular.module('anchorScrollOffsetExample', []) + .run(['$anchorScroll', function($anchorScroll) { + $anchorScroll.yOffset = 50; // always scroll by 50 extra pixels + }]) + .controller('headerCtrl', ['$anchorScroll', '$location', '$scope', + function($anchorScroll, $location, $scope) { + $scope.gotoAnchor = function(x) { + var newHash = 'anchor' + x; + if ($location.hash() !== newHash) { + // set the $location.hash to `newHash` and + // $anchorScroll will automatically scroll to it + $location.hash('anchor' + x); + } else { + // call $anchorScroll() explicitly, + // since $location.hash hasn't changed + $anchorScroll(); + } + }; + } + ]); + </file> + <file name="style.css"> + body { + padding-top: 50px; + } + + .anchor { + border: 2px dashed DarkOrchid; + padding: 10px 10px 200px 10px; + } + + .fixed-header { + background-color: rgba(0, 0, 0, 0.2); + height: 50px; + position: fixed; + top: 0; left: 0; right: 0; + } + + .fixed-header > a { + display: inline-block; + margin: 5px 15px; + } + </file> + </example> + */ this.$get = ['$window', '$location', '$rootScope', function($window, $location, $rootScope) { var document = $window.document; - // helper function to get first anchor from a NodeList - // can't use filter.filter, as it accepts only instances of Array - // and IE can't convert NodeList to an array using [].slice - // TODO(vojta): use filter if we change it to accept lists as well + // Helper function to get first anchor from a NodeList + // (using `Array#some()` instead of `angular#forEach()` since it's more performant + // and working in all supported browsers.) function getFirstAnchor(list) { var result = null; - forEach(list, function(element) { - if (!result && lowercase(element.nodeName) === 'a') result = element; + Array.prototype.some.call(list, function(element) { + if (nodeName_(element) === 'a') { + result = element; + return true; + } }); return result; } - function scroll() { - var hash = $location.hash(), elm; - - // empty hash, scroll to the top of the page - if (!hash) $window.scrollTo(0, 0); + function getYOffset() { - // element with given id - else if ((elm = document.getElementById(hash))) elm.scrollIntoView(); + var offset = scroll.yOffset; - // first anchor with given name :-D - else if ((elm = getFirstAnchor(document.getElementsByName(hash)))) elm.scrollIntoView(); + if (isFunction(offset)) { + offset = offset(); + } else if (isElement(offset)) { + var elem = offset[0]; + var style = $window.getComputedStyle(elem); + if (style.position !== 'fixed') { + offset = 0; + } else { + offset = elem.getBoundingClientRect().bottom; + } + } else if (!isNumber(offset)) { + offset = 0; + } - // no element and hash == 'top', scroll to the top of the page - else if (hash === 'top') $window.scrollTo(0, 0); + return offset; } - // does not scroll when user clicks on anchor link that is currently on - // (no url change, no $location.hash() change), browser native does scroll - if (autoScrollingEnabled) { - $rootScope.$watch(function autoScrollWatch() {return $location.hash();}, - function autoScrollWatchAction() { - $rootScope.$evalAsync(scroll); - }); - } + function scrollTo(elem) { + if (elem) { + elem.scrollIntoView(); - return scroll; + var offset = getYOffset(); + + if (offset) { + // `offset` is the number of pixels we should scroll UP in order to align `elem` properly. + // This is true ONLY if the call to `elem.scrollIntoView()` initially aligns `elem` at the + // top of the viewport. + // + // IF the number of pixels from the top of `elem` to the end of the page's content is less + // than the height of the viewport, then `elem.scrollIntoView()` will align the `elem` some + // way down the page. + // + // This is often the case for elements near the bottom of the page. + // + // In such cases we do not need to scroll the whole `offset` up, just the difference between + // the top of the element and the offset, which is enough to align the top of `elem` at the + // desired position. + var elemTop = elem.getBoundingClientRect().top; + $window.scrollBy(0, elemTop - offset); + } + } else { + $window.scrollTo(0, 0); + } + } + + function scroll(hash) { + // Allow numeric hashes + hash = isString(hash) ? hash : isNumber(hash) ? hash.toString() : $location.hash(); + var elm; + + // empty hash, scroll to the top of the page + if (!hash) scrollTo(null); + + // element with given id + else if ((elm = document.getElementById(hash))) scrollTo(elm); + + // first anchor with given name :-D + else if ((elm = getFirstAnchor(document.getElementsByName(hash)))) scrollTo(elm); + + // no element and hash === 'top', scroll to the top of the page + else if (hash === 'top') scrollTo(null); + } + + // does not scroll when user clicks on anchor link that is currently on + // (no url change, no $location.hash() change), browser native does scroll + if (autoScrollingEnabled) { + $rootScope.$watch(function autoScrollWatch() {return $location.hash();}, + function autoScrollWatchAction(newVal, oldVal) { + // skip the initial scroll if $location.hash is empty + if (newVal === oldVal && newVal === '') return; + + jqLiteDocumentLoaded(function() { + $rootScope.$evalAsync(scroll); + }); + }); + } + + return scroll; }]; } var $animateMinErr = minErr('$animate'); + var ELEMENT_NODE = 1; + var NG_ANIMATE_CLASSNAME = 'ng-animate'; + + function mergeClasses(a,b) { + if (!a && !b) return ''; + if (!a) return b; + if (!b) return a; + if (isArray(a)) a = a.join(' '); + if (isArray(b)) b = b.join(' '); + return a + ' ' + b; + } + + function extractElementNode(element) { + for (var i = 0; i < element.length; i++) { + var elm = element[i]; + if (elm.nodeType === ELEMENT_NODE) { + return elm; + } + } + } + + function splitClasses(classes) { + if (isString(classes)) { + classes = classes.split(' '); + } + + // Use createMap() to prevent class assumptions involving property names in + // Object.prototype + var obj = createMap(); + forEach(classes, function(klass) { + // sometimes the split leaves empty string values + // incase extra spaces were applied to the options + if (klass.length) { + obj[klass] = true; + } + }); + return obj; + } + +// if any other type of options value besides an Object value is +// passed into the $animate.method() animation then this helper code +// will be run which will ignore it. While this patch is not the +// greatest solution to this, a lot of existing plugins depend on +// $animate to either call the callback (< 1.2) or return a promise +// that can be changed. This helper function ensures that the options +// are wiped clean incase a callback function is provided. + function prepareAnimateOptions(options) { + return isObject(options) + ? options + : {}; + } + + var $$CoreAnimateJsProvider = /** @this */ function() { + this.$get = noop; + }; + +// this is prefixed with Core since it conflicts with +// the animateQueueProvider defined in ngAnimate/animateQueue.js + var $$CoreAnimateQueueProvider = /** @this */ function() { + var postDigestQueue = new NgMap(); + var postDigestElements = []; + + this.$get = ['$$AnimateRunner', '$rootScope', + function($$AnimateRunner, $rootScope) { + return { + enabled: noop, + on: noop, + off: noop, + pin: noop, + + push: function(element, event, options, domOperation) { + if (domOperation) { + domOperation(); + } + + options = options || {}; + if (options.from) { + element.css(options.from); + } + if (options.to) { + element.css(options.to); + } + + if (options.addClass || options.removeClass) { + addRemoveClassesPostDigest(element, options.addClass, options.removeClass); + } + + var runner = new $$AnimateRunner(); + + // since there are no animations to run the runner needs to be + // notified that the animation call is complete. + runner.complete(); + return runner; + } + }; + + + function updateData(data, classes, value) { + var changed = false; + if (classes) { + classes = isString(classes) ? classes.split(' ') : + isArray(classes) ? classes : []; + forEach(classes, function(className) { + if (className) { + changed = true; + data[className] = value; + } + }); + } + return changed; + } + + function handleCSSClassChanges() { + forEach(postDigestElements, function(element) { + var data = postDigestQueue.get(element); + if (data) { + var existing = splitClasses(element.attr('class')); + var toAdd = ''; + var toRemove = ''; + forEach(data, function(status, className) { + var hasClass = !!existing[className]; + if (status !== hasClass) { + if (status) { + toAdd += (toAdd.length ? ' ' : '') + className; + } else { + toRemove += (toRemove.length ? ' ' : '') + className; + } + } + }); + + forEach(element, function(elm) { + if (toAdd) { + jqLiteAddClass(elm, toAdd); + } + if (toRemove) { + jqLiteRemoveClass(elm, toRemove); + } + }); + postDigestQueue.delete(element); + } + }); + postDigestElements.length = 0; + } + + + function addRemoveClassesPostDigest(element, add, remove) { + var data = postDigestQueue.get(element) || {}; + + var classesAdded = updateData(data, add, true); + var classesRemoved = updateData(data, remove, false); + + if (classesAdded || classesRemoved) { + + postDigestQueue.set(element, data); + postDigestElements.push(element); + + if (postDigestElements.length === 1) { + $rootScope.$$postDigest(handleCSSClassChanges); + } + } + } + }]; + }; /** * @ngdoc provider @@ -4003,18 +5580,18 @@ * * @description * Default implementation of $animate that doesn't perform any animations, instead just - * synchronously performs DOM - * updates and calls done() callbacks. + * synchronously performs DOM updates and resolves the returned runner promise. * - * In order to enable animations the ngAnimate module has to be loaded. + * In order to enable animations the `ngAnimate` module has to be loaded. * - * To see the functional implementation check out src/ngAnimate/animate.js + * To see the functional implementation check out `src/ngAnimate/animate.js`. */ - var $AnimateProvider = ['$provide', function($provide) { - - - this.$$selectors = {}; + var $AnimateProvider = ['$provide', /** @this */ function($provide) { + var provider = this; + var classNameFilter = null; + var customFilter = null; + this.$$registeredAnimations = Object.create(null); /** * @ngdoc method @@ -4025,36 +5602,91 @@ * animation object which contains callback functions for each event that is expected to be * animated. * - * * `eventFn`: `function(Element, doneFunction)` The element to animate, the `doneFunction` - * must be called once the element animation is complete. If a function is returned then the - * animation service will use this function to cancel the animation whenever a cancel event is - * triggered. + * * `eventFn`: `function(element, ... , doneFunction, options)` + * The element to animate, the `doneFunction` and the options fed into the animation. Depending + * on the type of animation additional arguments will be injected into the animation function. The + * list below explains the function signatures for the different animation methods: * + * - setClass: function(element, addedClasses, removedClasses, doneFunction, options) + * - addClass: function(element, addedClasses, doneFunction, options) + * - removeClass: function(element, removedClasses, doneFunction, options) + * - enter, leave, move: function(element, doneFunction, options) + * - animate: function(element, fromStyles, toStyles, doneFunction, options) + * + * Make sure to trigger the `doneFunction` once the animation is fully complete. * * ```js * return { - * eventFn : function(element, done) { - * //code to run the animation - * //once complete, then run done() - * return function cancellationFunction() { - * //code to cancel the animation - * } - * } - * } + * //enter, leave, move signature + * eventFn : function(element, done, options) { + * //code to run the animation + * //once complete, then run done() + * return function endFunction(wasCancelled) { + * //code to cancel the animation + * } + * } + * } * ``` * - * @param {string} name The name of the animation. + * @param {string} name The name of the animation (this is what the class-based CSS value will be compared to). * @param {Function} factory The factory function that will be executed to return the animation * object. */ this.register = function(name, factory) { + if (name && name.charAt(0) !== '.') { + throw $animateMinErr('notcsel', 'Expecting class selector starting with \'.\' got \'{0}\'.', name); + } + var key = name + '-animation'; - if (name && name.charAt(0) != '.') throw $animateMinErr('notcsel', - "Expecting class selector starting with '.' got '{0}'.", name); - this.$$selectors[name.substr(1)] = key; + provider.$$registeredAnimations[name.substr(1)] = key; $provide.factory(key, factory); }; + /** + * @ngdoc method + * @name $animateProvider#customFilter + * + * @description + * Sets and/or returns the custom filter function that is used to "filter" animations, i.e. + * determine if an animation is allowed or not. When no filter is specified (the default), no + * animation will be blocked. Setting the `customFilter` value will only allow animations for + * which the filter function's return value is truthy. + * + * This allows to easily create arbitrarily complex rules for filtering animations, such as + * allowing specific events only, or enabling animations on specific subtrees of the DOM, etc. + * Filtering animations can also boost performance for low-powered devices, as well as + * applications containing a lot of structural operations. + * + * <div class="alert alert-success"> + * **Best Practice:** + * Keep the filtering function as lean as possible, because it will be called for each DOM + * action (e.g. insertion, removal, class change) performed by "animation-aware" directives. + * See {@link guide/animations#which-directives-support-animations- here} for a list of built-in + * directives that support animations. + * Performing computationally expensive or time-consuming operations on each call of the + * filtering function can make your animations sluggish. + * </div> + * + * **Note:** If present, `customFilter` will be checked before + * {@link $animateProvider#classNameFilter classNameFilter}. + * + * @param {Function=} filterFn - The filter function which will be used to filter all animations. + * If a falsy value is returned, no animation will be performed. The function will be called + * with the following arguments: + * - **node** `{DOMElement}` - The DOM element to be animated. + * - **event** `{String}` - The name of the animation event (e.g. `enter`, `leave`, `addClass` + * etc). + * - **options** `{Object}` - A collection of options/styles used for the animation. + * @return {Function} The current filter function or `null` if there is none set. + */ + this.customFilter = function(filterFn) { + if (arguments.length === 1) { + customFilter = isFunction(filterFn) ? filterFn : null; + } + + return customFilter; + }; + /** * @ngdoc method * @name $animateProvider#classNameFilter @@ -4062,220 +5694,716 @@ * @description * Sets and/or returns the CSS class regular expression that is checked when performing * an animation. Upon bootstrap the classNameFilter value is not set at all and will - * therefore enable $animate to attempt to perform an animation on any element. - * When setting the classNameFilter value, animations will only be performed on elements + * therefore enable $animate to attempt to perform an animation on any element that is triggered. + * When setting the `classNameFilter` value, animations will only be performed on elements * that successfully match the filter expression. This in turn can boost performance * for low-powered devices as well as applications containing a lot of structural operations. + * + * **Note:** If present, `classNameFilter` will be checked after + * {@link $animateProvider#customFilter customFilter}. If `customFilter` is present and returns + * false, `classNameFilter` will not be checked. + * * @param {RegExp=} expression The className expression which will be checked against all animations * @return {RegExp} The current CSS className expression value. If null then there is no expression value */ this.classNameFilter = function(expression) { - if(arguments.length === 1) { - this.$$classNameFilter = (expression instanceof RegExp) ? expression : null; + if (arguments.length === 1) { + classNameFilter = (expression instanceof RegExp) ? expression : null; + if (classNameFilter) { + var reservedRegex = new RegExp('[(\\s|\\/)]' + NG_ANIMATE_CLASSNAME + '[(\\s|\\/)]'); + if (reservedRegex.test(classNameFilter.toString())) { + classNameFilter = null; + throw $animateMinErr('nongcls', '$animateProvider.classNameFilter(regex) prohibits accepting a regex value which matches/contains the "{0}" CSS class.', NG_ANIMATE_CLASSNAME); + } + } } - return this.$$classNameFilter; + return classNameFilter; }; - this.$get = ['$timeout', '$$asyncCallback', function($timeout, $$asyncCallback) { - - function async(fn) { - fn && $$asyncCallback(fn); + this.$get = ['$$animateQueue', function($$animateQueue) { + function domInsert(element, parentElement, afterElement) { + // if for some reason the previous element was removed + // from the dom sometime before this code runs then let's + // just stick to using the parent element as the anchor + if (afterElement) { + var afterNode = extractElementNode(afterElement); + if (afterNode && !afterNode.parentNode && !afterNode.previousElementSibling) { + afterElement = null; + } + } + if (afterElement) { + afterElement.after(element); + } else { + parentElement.prepend(element); + } } /** - * * @ngdoc service * @name $animate - * @description The $animate service provides rudimentary DOM manipulation functions to - * insert, remove and move elements within the DOM, as well as adding and removing classes. - * This service is the core service used by the ngAnimate $animator service which provides - * high-level animation hooks for CSS and JavaScript. + * @description The $animate service exposes a series of DOM utility methods that provide support + * for animation hooks. The default behavior is the application of DOM operations, however, + * when an animation is detected (and animations are enabled), $animate will do the heavy lifting + * to ensure that animation runs with the triggered DOM operation. + * + * By default $animate doesn't trigger any animations. This is because the `ngAnimate` module isn't + * included and only when it is active then the animation hooks that `$animate` triggers will be + * functional. Once active then all structural `ng-` directives will trigger animations as they perform + * their DOM-related operations (enter, leave and move). Other directives such as `ngClass`, + * `ngShow`, `ngHide` and `ngMessages` also provide support for animations. * - * $animate is available in the AngularJS core, however, the ngAnimate module must be included - * to enable full out animation support. Otherwise, $animate will only perform simple DOM - * manipulation operations. + * It is recommended that the`$animate` service is always used when executing DOM-related procedures within directives. * - * To learn more about enabling animation support, click here to visit the {@link ngAnimate - * ngAnimate module page} as well as the {@link ngAnimate.$animate ngAnimate $animate service - * page}. + * To learn more about enabling animation support, click here to visit the + * {@link ngAnimate ngAnimate module page}. */ return { + // we don't call it directly since non-existant arguments may + // be interpreted as null within the sub enabled function /** * * @ngdoc method - * @name $animate#enter - * @function - * @description Inserts the element into the DOM either after the `after` element or within - * the `parent` element. Once complete, the done() callback will be fired (if provided). - * @param {DOMElement} element the element which will be inserted into the DOM - * @param {DOMElement} parent the parent element which will append the element as - * a child (if the after element is not present) - * @param {DOMElement} after the sibling element which will append the element - * after itself - * @param {Function=} done callback function that will be called after the element has been - * inserted into the DOM + * @name $animate#on + * @kind function + * @description Sets up an event listener to fire whenever the animation event (enter, leave, move, etc...) + * has fired on the given element or among any of its children. Once the listener is fired, the provided callback + * is fired with the following params: + * + * ```js + * $animate.on('enter', container, + * function callback(element, phase) { + * // cool we detected an enter animation within the container + * } + * ); + * ``` + * + * @param {string} event the animation event that will be captured (e.g. enter, leave, move, addClass, removeClass, etc...) + * @param {DOMElement} container the container element that will capture each of the animation events that are fired on itself + * as well as among its children + * @param {Function} callback the callback function that will be fired when the listener is triggered + * + * The arguments present in the callback function are: + * * `element` - The captured DOM element that the animation was fired on. + * * `phase` - The phase of the animation. The two possible phases are **start** (when the animation starts) and **close** (when it ends). */ - enter : function(element, parent, after, done) { - if (after) { - after.after(element); - } else { - if (!parent || !parent[0]) { - parent = after.parent(); - } - parent.append(element); + on: $$animateQueue.on, + + /** + * + * @ngdoc method + * @name $animate#off + * @kind function + * @description Deregisters an event listener based on the event which has been associated with the provided element. This method + * can be used in three different ways depending on the arguments: + * + * ```js + * // remove all the animation event listeners listening for `enter` + * $animate.off('enter'); + * + * // remove listeners for all animation events from the container element + * $animate.off(container); + * + * // remove all the animation event listeners listening for `enter` on the given element and its children + * $animate.off('enter', container); + * + * // remove the event listener function provided by `callback` that is set + * // to listen for `enter` on the given `container` as well as its children + * $animate.off('enter', container, callback); + * ``` + * + * @param {string|DOMElement} event|container the animation event (e.g. enter, leave, move, + * addClass, removeClass, etc...), or the container element. If it is the element, all other + * arguments are ignored. + * @param {DOMElement=} container the container element the event listener was placed on + * @param {Function=} callback the callback function that was registered as the listener + */ + off: $$animateQueue.off, + + /** + * @ngdoc method + * @name $animate#pin + * @kind function + * @description Associates the provided element with a host parent element to allow the element to be animated even if it exists + * outside of the DOM structure of the AngularJS application. By doing so, any animation triggered via `$animate` can be issued on the + * element despite being outside the realm of the application or within another application. Say for example if the application + * was bootstrapped on an element that is somewhere inside of the `<body>` tag, but we wanted to allow for an element to be situated + * as a direct child of `document.body`, then this can be achieved by pinning the element via `$animate.pin(element)`. Keep in mind + * that calling `$animate.pin(element, parentElement)` will not actually insert into the DOM anywhere; it will just create the association. + * + * Note that this feature is only active when the `ngAnimate` module is used. + * + * @param {DOMElement} element the external element that will be pinned + * @param {DOMElement} parentElement the host parent element that will be associated with the external element + */ + pin: $$animateQueue.pin, + + /** + * + * @ngdoc method + * @name $animate#enabled + * @kind function + * @description Used to get and set whether animations are enabled or not on the entire application or on an element and its children. This + * function can be called in four ways: + * + * ```js + * // returns true or false + * $animate.enabled(); + * + * // changes the enabled state for all animations + * $animate.enabled(false); + * $animate.enabled(true); + * + * // returns true or false if animations are enabled for an element + * $animate.enabled(element); + * + * // changes the enabled state for an element and its children + * $animate.enabled(element, true); + * $animate.enabled(element, false); + * ``` + * + * @param {DOMElement=} element the element that will be considered for checking/setting the enabled state + * @param {boolean=} enabled whether or not the animations will be enabled for the element + * + * @return {boolean} whether or not animations are enabled + */ + enabled: $$animateQueue.enabled, + + /** + * @ngdoc method + * @name $animate#cancel + * @kind function + * @description Cancels the provided animation. + * + * @param {Promise} animationPromise The animation promise that is returned when an animation is started. + */ + cancel: function(runner) { + if (runner.end) { + runner.end(); } - async(done); }, /** * * @ngdoc method - * @name $animate#leave - * @function - * @description Removes the element from the DOM. Once complete, the done() callback will be - * fired (if provided). - * @param {DOMElement} element the element which will be removed from the DOM - * @param {Function=} done callback function that will be called after the element has been - * removed from the DOM + * @name $animate#enter + * @kind function + * @description Inserts the element into the DOM either after the `after` element (if provided) or + * as the first child within the `parent` element and then triggers an animation. + * A promise is returned that will be resolved during the next digest once the animation + * has completed. + * + * @param {DOMElement} element the element which will be inserted into the DOM + * @param {DOMElement} parent the parent element which will append the element as + * a child (so long as the after element is not present) + * @param {DOMElement=} after the sibling element after which the element will be appended + * @param {object=} options an optional collection of options/styles that will be applied to the element. + * The object can have the following properties: + * + * - **addClass** - `{string}` - space-separated CSS classes to add to element + * - **from** - `{Object}` - CSS properties & values at the beginning of animation. Must have matching `to` + * - **removeClass** - `{string}` - space-separated CSS classes to remove from element + * - **to** - `{Object}` - CSS properties & values at end of animation. Must have matching `from` + * + * @return {Promise} the animation callback promise */ - leave : function(element, done) { - element.remove(); - async(done); + enter: function(element, parent, after, options) { + parent = parent && jqLite(parent); + after = after && jqLite(after); + parent = parent || after.parent(); + domInsert(element, parent, after); + return $$animateQueue.push(element, 'enter', prepareAnimateOptions(options)); }, /** * * @ngdoc method * @name $animate#move - * @function - * @description Moves the position of the provided element within the DOM to be placed - * either after the `after` element or inside of the `parent` element. Once complete, the - * done() callback will be fired (if provided). + * @kind function + * @description Inserts (moves) the element into its new position in the DOM either after + * the `after` element (if provided) or as the first child within the `parent` element + * and then triggers an animation. A promise is returned that will be resolved + * during the next digest once the animation has completed. + * + * @param {DOMElement} element the element which will be moved into the new DOM position + * @param {DOMElement} parent the parent element which will append the element as + * a child (so long as the after element is not present) + * @param {DOMElement=} after the sibling element after which the element will be appended + * @param {object=} options an optional collection of options/styles that will be applied to the element. + * The object can have the following properties: + * + * - **addClass** - `{string}` - space-separated CSS classes to add to element + * - **from** - `{Object}` - CSS properties & values at the beginning of animation. Must have matching `to` + * - **removeClass** - `{string}` - space-separated CSS classes to remove from element + * - **to** - `{Object}` - CSS properties & values at end of animation. Must have matching `from` * - * @param {DOMElement} element the element which will be moved around within the - * DOM - * @param {DOMElement} parent the parent element where the element will be - * inserted into (if the after element is not present) - * @param {DOMElement} after the sibling element where the element will be - * positioned next to - * @param {Function=} done the callback function (if provided) that will be fired after the - * element has been moved to its new position + * @return {Promise} the animation callback promise */ - move : function(element, parent, after, done) { - // Do not remove element before insert. Removing will cause data associated with the - // element to be dropped. Insert will implicitly do the remove. - this.enter(element, parent, after, done); + move: function(element, parent, after, options) { + parent = parent && jqLite(parent); + after = after && jqLite(after); + parent = parent || after.parent(); + domInsert(element, parent, after); + return $$animateQueue.push(element, 'move', prepareAnimateOptions(options)); }, /** - * * @ngdoc method - * @name $animate#addClass - * @function - * @description Adds the provided className CSS class value to the provided element. Once - * complete, the done() callback will be fired (if provided). - * @param {DOMElement} element the element which will have the className value - * added to it - * @param {string} className the CSS class which will be added to the element - * @param {Function=} done the callback function (if provided) that will be fired after the - * className value has been added to the element + * @name $animate#leave + * @kind function + * @description Triggers an animation and then removes the element from the DOM. + * When the function is called a promise is returned that will be resolved during the next + * digest once the animation has completed. + * + * @param {DOMElement} element the element which will be removed from the DOM + * @param {object=} options an optional collection of options/styles that will be applied to the element. + * The object can have the following properties: + * + * - **addClass** - `{string}` - space-separated CSS classes to add to element + * - **from** - `{Object}` - CSS properties & values at the beginning of animation. Must have matching `to` + * - **removeClass** - `{string}` - space-separated CSS classes to remove from element + * - **to** - `{Object}` - CSS properties & values at end of animation. Must have matching `from` + * + * @return {Promise} the animation callback promise */ - addClass : function(element, className, done) { - className = isString(className) ? - className : - isArray(className) ? className.join(' ') : ''; - forEach(element, function (element) { - jqLiteAddClass(element, className); + leave: function(element, options) { + return $$animateQueue.push(element, 'leave', prepareAnimateOptions(options), function() { + element.remove(); }); - async(done); }, /** + * @ngdoc method + * @name $animate#addClass + * @kind function + * + * @description Triggers an addClass animation surrounding the addition of the provided CSS class(es). Upon + * execution, the addClass operation will only be handled after the next digest and it will not trigger an + * animation if element already contains the CSS class or if the class is removed at a later step. + * Note that class-based animations are treated differently compared to structural animations + * (like enter, move and leave) since the CSS classes may be added/removed at different points + * depending if CSS or JavaScript animations are used. + * + * @param {DOMElement} element the element which the CSS classes will be applied to + * @param {string} className the CSS class(es) that will be added (multiple classes are separated via spaces) + * @param {object=} options an optional collection of options/styles that will be applied to the element. + * The object can have the following properties: + * + * - **addClass** - `{string}` - space-separated CSS classes to add to element + * - **from** - `{Object}` - CSS properties & values at the beginning of animation. Must have matching `to` + * - **removeClass** - `{string}` - space-separated CSS classes to remove from element + * - **to** - `{Object}` - CSS properties & values at end of animation. Must have matching `from` * + * @return {Promise} the animation callback promise + */ + addClass: function(element, className, options) { + options = prepareAnimateOptions(options); + options.addClass = mergeClasses(options.addclass, className); + return $$animateQueue.push(element, 'addClass', options); + }, + + /** * @ngdoc method * @name $animate#removeClass - * @function - * @description Removes the provided className CSS class value from the provided element. - * Once complete, the done() callback will be fired (if provided). - * @param {DOMElement} element the element which will have the className value - * removed from it - * @param {string} className the CSS class which will be removed from the element - * @param {Function=} done the callback function (if provided) that will be fired after the - * className value has been removed from the element + * @kind function + * + * @description Triggers a removeClass animation surrounding the removal of the provided CSS class(es). Upon + * execution, the removeClass operation will only be handled after the next digest and it will not trigger an + * animation if element does not contain the CSS class or if the class is added at a later step. + * Note that class-based animations are treated differently compared to structural animations + * (like enter, move and leave) since the CSS classes may be added/removed at different points + * depending if CSS or JavaScript animations are used. + * + * @param {DOMElement} element the element which the CSS classes will be applied to + * @param {string} className the CSS class(es) that will be removed (multiple classes are separated via spaces) + * @param {object=} options an optional collection of options/styles that will be applied to the element. + * The object can have the following properties: + * + * - **addClass** - `{string}` - space-separated CSS classes to add to element + * - **from** - `{Object}` - CSS properties & values at the beginning of animation. Must have matching `to` + * - **removeClass** - `{string}` - space-separated CSS classes to remove from element + * - **to** - `{Object}` - CSS properties & values at end of animation. Must have matching `from` + * + * @return {Promise} the animation callback promise */ - removeClass : function(element, className, done) { - className = isString(className) ? - className : - isArray(className) ? className.join(' ') : ''; - forEach(element, function (element) { - jqLiteRemoveClass(element, className); - }); - async(done); + removeClass: function(element, className, options) { + options = prepareAnimateOptions(options); + options.removeClass = mergeClasses(options.removeClass, className); + return $$animateQueue.push(element, 'removeClass', options); }, /** - * * @ngdoc method * @name $animate#setClass - * @function - * @description Adds and/or removes the given CSS classes to and from the element. - * Once complete, the done() callback will be fired (if provided). - * @param {DOMElement} element the element which will it's CSS classes changed - * removed from it - * @param {string} add the CSS classes which will be added to the element - * @param {string} remove the CSS class which will be removed from the element - * @param {Function=} done the callback function (if provided) that will be fired after the - * CSS classes have been set on the element + * @kind function + * + * @description Performs both the addition and removal of a CSS classes on an element and (during the process) + * triggers an animation surrounding the class addition/removal. Much like `$animate.addClass` and + * `$animate.removeClass`, `setClass` will only evaluate the classes being added/removed once a digest has + * passed. Note that class-based animations are treated differently compared to structural animations + * (like enter, move and leave) since the CSS classes may be added/removed at different points + * depending if CSS or JavaScript animations are used. + * + * @param {DOMElement} element the element which the CSS classes will be applied to + * @param {string} add the CSS class(es) that will be added (multiple classes are separated via spaces) + * @param {string} remove the CSS class(es) that will be removed (multiple classes are separated via spaces) + * @param {object=} options an optional collection of options/styles that will be applied to the element. + * The object can have the following properties: + * + * - **addClass** - `{string}` - space-separated CSS classes to add to element + * - **from** - `{Object}` - CSS properties & values at the beginning of animation. Must have matching `to` + * - **removeClass** - `{string}` - space-separated CSS classes to remove from element + * - **to** - `{Object}` - CSS properties & values at end of animation. Must have matching `from` + * + * @return {Promise} the animation callback promise */ - setClass : function(element, add, remove, done) { - forEach(element, function (element) { - jqLiteAddClass(element, add); - jqLiteRemoveClass(element, remove); - }); - async(done); + setClass: function(element, add, remove, options) { + options = prepareAnimateOptions(options); + options.addClass = mergeClasses(options.addClass, add); + options.removeClass = mergeClasses(options.removeClass, remove); + return $$animateQueue.push(element, 'setClass', options); }, - enabled : noop + /** + * @ngdoc method + * @name $animate#animate + * @kind function + * + * @description Performs an inline animation on the element which applies the provided to and from CSS styles to the element. + * If any detected CSS transition, keyframe or JavaScript matches the provided className value, then the animation will take + * on the provided styles. For example, if a transition animation is set for the given className, then the provided `from` and + * `to` styles will be applied alongside the given transition. If the CSS style provided in `from` does not have a corresponding + * style in `to`, the style in `from` is applied immediately, and no animation is run. + * If a JavaScript animation is detected then the provided styles will be given in as function parameters into the `animate` + * method (or as part of the `options` parameter): + * + * ```js + * ngModule.animation('.my-inline-animation', function() { + * return { + * animate : function(element, from, to, done, options) { + * //animation + * done(); + * } + * } + * }); + * ``` + * + * @param {DOMElement} element the element which the CSS styles will be applied to + * @param {object} from the from (starting) CSS styles that will be applied to the element and across the animation. + * @param {object} to the to (destination) CSS styles that will be applied to the element and across the animation. + * @param {string=} className an optional CSS class that will be applied to the element for the duration of the animation. If + * this value is left as empty then a CSS class of `ng-inline-animate` will be applied to the element. + * (Note that if no animation is detected then this value will not be applied to the element.) + * @param {object=} options an optional collection of options/styles that will be applied to the element. + * The object can have the following properties: + * + * - **addClass** - `{string}` - space-separated CSS classes to add to element + * - **from** - `{Object}` - CSS properties & values at the beginning of animation. Must have matching `to` + * - **removeClass** - `{string}` - space-separated CSS classes to remove from element + * - **to** - `{Object}` - CSS properties & values at end of animation. Must have matching `from` + * + * @return {Promise} the animation callback promise + */ + animate: function(element, from, to, className, options) { + options = prepareAnimateOptions(options); + options.from = options.from ? extend(options.from, from) : from; + options.to = options.to ? extend(options.to, to) : to; + + className = className || 'ng-inline-animate'; + options.tempClasses = mergeClasses(options.tempClasses, className); + return $$animateQueue.push(element, 'animate', options); + } }; }]; }]; - function $$AsyncCallbackProvider(){ - this.$get = ['$$rAF', '$timeout', function($$rAF, $timeout) { - return $$rAF.supported - ? function(fn) { return $$rAF(fn); } - : function(fn) { - return $timeout(fn, 0, false); + var $$AnimateAsyncRunFactoryProvider = /** @this */ function() { + this.$get = ['$$rAF', function($$rAF) { + var waitQueue = []; + + function waitForTick(fn) { + waitQueue.push(fn); + if (waitQueue.length > 1) return; + $$rAF(function() { + for (var i = 0; i < waitQueue.length; i++) { + waitQueue[i](); + } + waitQueue = []; + }); + } + + return function() { + var passed = false; + waitForTick(function() { + passed = true; + }); + return function(callback) { + if (passed) { + callback(); + } else { + waitForTick(callback); + } + }; }; }]; - } + }; - /** - * ! This is a private undocumented service ! - * - * @name $browser - * @requires $log - * @description - * This object has two goals: - * - * - hide all the global state in the browser caused by the window object - * - abstract away all the browser specific features and inconsistencies - * - * For tests we provide {@link ngMock.$browser mock implementation} of the `$browser` - * service, which can be used for convenient testing of the application without the interaction with - * the real browser apis. - */ - /** - * @param {object} window The global window object. - * @param {object} document jQuery wrapped document. - * @param {function()} XHR XMLHttpRequest constructor. - * @param {object} $log console.log or an object with the same interface. - * @param {object} $sniffer $sniffer service + var $$AnimateRunnerFactoryProvider = /** @this */ function() { + this.$get = ['$q', '$sniffer', '$$animateAsyncRun', '$$isDocumentHidden', '$timeout', + function($q, $sniffer, $$animateAsyncRun, $$isDocumentHidden, $timeout) { + + var INITIAL_STATE = 0; + var DONE_PENDING_STATE = 1; + var DONE_COMPLETE_STATE = 2; + + AnimateRunner.chain = function(chain, callback) { + var index = 0; + + next(); + function next() { + if (index === chain.length) { + callback(true); + return; + } + + chain[index](function(response) { + if (response === false) { + callback(false); + return; + } + index++; + next(); + }); + } + }; + + AnimateRunner.all = function(runners, callback) { + var count = 0; + var status = true; + forEach(runners, function(runner) { + runner.done(onProgress); + }); + + function onProgress(response) { + status = status && response; + if (++count === runners.length) { + callback(status); + } + } + }; + + function AnimateRunner(host) { + this.setHost(host); + + var rafTick = $$animateAsyncRun(); + var timeoutTick = function(fn) { + $timeout(fn, 0, false); + }; + + this._doneCallbacks = []; + this._tick = function(fn) { + if ($$isDocumentHidden()) { + timeoutTick(fn); + } else { + rafTick(fn); + } + }; + this._state = 0; + } + + AnimateRunner.prototype = { + setHost: function(host) { + this.host = host || {}; + }, + + done: function(fn) { + if (this._state === DONE_COMPLETE_STATE) { + fn(); + } else { + this._doneCallbacks.push(fn); + } + }, + + progress: noop, + + getPromise: function() { + if (!this.promise) { + var self = this; + this.promise = $q(function(resolve, reject) { + self.done(function(status) { + if (status === false) { + reject(); + } else { + resolve(); + } + }); + }); + } + return this.promise; + }, + + then: function(resolveHandler, rejectHandler) { + return this.getPromise().then(resolveHandler, rejectHandler); + }, + + 'catch': function(handler) { + return this.getPromise()['catch'](handler); + }, + + 'finally': function(handler) { + return this.getPromise()['finally'](handler); + }, + + pause: function() { + if (this.host.pause) { + this.host.pause(); + } + }, + + resume: function() { + if (this.host.resume) { + this.host.resume(); + } + }, + + end: function() { + if (this.host.end) { + this.host.end(); + } + this._resolve(true); + }, + + cancel: function() { + if (this.host.cancel) { + this.host.cancel(); + } + this._resolve(false); + }, + + complete: function(response) { + var self = this; + if (self._state === INITIAL_STATE) { + self._state = DONE_PENDING_STATE; + self._tick(function() { + self._resolve(response); + }); + } + }, + + _resolve: function(response) { + if (this._state !== DONE_COMPLETE_STATE) { + forEach(this._doneCallbacks, function(fn) { + fn(response); + }); + this._doneCallbacks.length = 0; + this._state = DONE_COMPLETE_STATE; + } + } + }; + + return AnimateRunner; + }]; + }; + + /* exported $CoreAnimateCssProvider */ + + /** + * @ngdoc service + * @name $animateCss + * @kind object + * @this + * + * @description + * This is the core version of `$animateCss`. By default, only when the `ngAnimate` is included, + * then the `$animateCss` service will actually perform animations. + * + * Click here {@link ngAnimate.$animateCss to read the documentation for $animateCss}. + */ + var $CoreAnimateCssProvider = function() { + this.$get = ['$$rAF', '$q', '$$AnimateRunner', function($$rAF, $q, $$AnimateRunner) { + + return function(element, initialOptions) { + // all of the animation functions should create + // a copy of the options data, however, if a + // parent service has already created a copy then + // we should stick to using that + var options = initialOptions || {}; + if (!options.$$prepared) { + options = copy(options); + } + + // there is no point in applying the styles since + // there is no animation that goes on at all in + // this version of $animateCss. + if (options.cleanupStyles) { + options.from = options.to = null; + } + + if (options.from) { + element.css(options.from); + options.from = null; + } + + var closed, runner = new $$AnimateRunner(); + return { + start: run, + end: run + }; + + function run() { + $$rAF(function() { + applyAnimationContents(); + if (!closed) { + runner.complete(); + } + closed = true; + }); + return runner; + } + + function applyAnimationContents() { + if (options.addClass) { + element.addClass(options.addClass); + options.addClass = null; + } + if (options.removeClass) { + element.removeClass(options.removeClass); + options.removeClass = null; + } + if (options.to) { + element.css(options.to); + options.to = null; + } + } + }; + }]; + }; + + /* global stripHash: true */ + + /** + * ! This is a private undocumented service ! + * + * @name $browser + * @requires $log + * @description + * This object has two goals: + * + * - hide all the global state in the browser caused by the window object + * - abstract away all the browser specific features and inconsistencies + * + * For tests we provide {@link ngMock.$browser mock implementation} of the `$browser` + * service, which can be used for convenient testing of the application without the interaction with + * the real browser apis. + */ + /** + * @param {object} window The global window object. + * @param {object} document jQuery wrapped document. + * @param {object} $log window.console or an object with the same interface. + * @param {object} $sniffer $sniffer service */ function Browser(window, document, $log, $sniffer) { var self = this, - rawDocument = document[0], location = window.location, history = window.history, setTimeout = window.setTimeout, @@ -4301,7 +6429,7 @@ } finally { outstandingRequestCount--; if (outstandingRequestCount === 0) { - while(outstandingRequestCallbacks.length) { + while (outstandingRequestCallbacks.length) { try { outstandingRequestCallbacks.pop()(); } catch (e) { @@ -4312,18 +6440,17 @@ } } + function getHash(url) { + var index = url.indexOf('#'); + return index === -1 ? '' : url.substr(index); + } + /** * @private - * Note: this method is used only by scenario runner * TODO(vojta): prefix this method with $$ ? * @param {function()} callback Function that will be called when no outstanding request */ self.notifyWhenNoOutstandingRequests = function(callback) { - // force browser to execute all pollFns - this is needed so that cookies and other pollers fire - // at some deterministic time in respect to the test runner's actions. Leaving things up to the - // regular poller would result in flaky tests. - forEach(pollFns, function(pollFn){ pollFn(); }); - if (outstandingRequestCount === 0) { callback(); } else { @@ -4331,51 +6458,23 @@ } }; - ////////////////////////////////////////////////////////////// - // Poll Watcher API - ////////////////////////////////////////////////////////////// - var pollFns = [], - pollTimeout; - - /** - * @name $browser#addPollFn - * - * @param {function()} fn Poll function to add - * - * @description - * Adds a function to the list of functions that poller periodically executes, - * and starts polling if not started yet. - * - * @returns {function()} the added function - */ - self.addPollFn = function(fn) { - if (isUndefined(pollTimeout)) startPoller(100, setTimeout); - pollFns.push(fn); - return fn; - }; - - /** - * @param {number} interval How often should browser call poll functions (ms) - * @param {function()} setTimeout Reference to a real or fake `setTimeout` function. - * - * @description - * Configures the poller to run in the specified intervals, using the specified - * setTimeout fn and kicks it off. - */ - function startPoller(interval, setTimeout) { - (function check() { - forEach(pollFns, function(pollFn){ pollFn(); }); - pollTimeout = setTimeout(check, interval); - })(); - } - ////////////////////////////////////////////////////////////// // URL API ////////////////////////////////////////////////////////////// - var lastBrowserUrl = location.href, + var cachedState, lastHistoryState, + lastBrowserUrl = location.href, baseElement = document.find('base'), - newLocation = null; + pendingLocation = null, + getCurrentState = !$sniffer.history ? noop : function getCurrentState() { + try { + return history.state; + } catch (e) { + // MSIE can reportedly throw when there is no state (UNCONFIRMED). + } + }; + + cacheState(); /** * @name $browser#url @@ -4394,52 +6493,120 @@ * {@link ng.$location $location service} to change url. * * @param {string} url New url (when used as setter) - * @param {boolean=} replace Should new url replace current history record ? + * @param {boolean=} replace Should new url replace current history record? + * @param {object=} state object to use with pushState/replaceState */ - self.url = function(url, replace) { + self.url = function(url, replace, state) { + // In modern browsers `history.state` is `null` by default; treating it separately + // from `undefined` would cause `$browser.url('/foo')` to change `history.state` + // to undefined via `pushState`. Instead, let's change `undefined` to `null` here. + if (isUndefined(state)) { + state = null; + } + // Android Browser BFCache causes location, history reference to become stale. if (location !== window.location) location = window.location; if (history !== window.history) history = window.history; // setter if (url) { - if (lastBrowserUrl == url) return; + var sameState = lastHistoryState === state; + + // Don't change anything if previous and current URLs and states match. This also prevents + // IE<10 from getting into redirect loop when in LocationHashbangInHtml5Url mode. + // See https://github.com/angular/angular.js/commit/ffb2701 + if (lastBrowserUrl === url && (!$sniffer.history || sameState)) { + return self; + } + var sameBase = lastBrowserUrl && stripHash(lastBrowserUrl) === stripHash(url); lastBrowserUrl = url; - if ($sniffer.history) { - if (replace) history.replaceState(null, '', url); - else { - history.pushState(null, '', url); - // Crazy Opera Bug: http://my.opera.com/community/forums/topic.dml?id=1185462 - baseElement.attr('href', baseElement.attr('href')); - } + lastHistoryState = state; + // Don't use history API if only the hash changed + // due to a bug in IE10/IE11 which leads + // to not firing a `hashchange` nor `popstate` event + // in some cases (see #9143). + if ($sniffer.history && (!sameBase || !sameState)) { + history[replace ? 'replaceState' : 'pushState'](state, '', url); + cacheState(); } else { - newLocation = url; + if (!sameBase) { + pendingLocation = url; + } if (replace) { location.replace(url); - } else { + } else if (!sameBase) { location.href = url; + } else { + location.hash = getHash(url); } + if (location.href !== url) { + pendingLocation = url; + } + } + if (pendingLocation) { + pendingLocation = url; } return self; // getter } else { - // - newLocation is a workaround for an IE7-9 issue with location.replace and location.href - // methods not updating location.href synchronously. + // - pendingLocation is needed as browsers don't allow to read out + // the new location.href if a reload happened or if there is a bug like in iOS 9 (see + // https://openradar.appspot.com/22186109). // - the replacement is a workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=407172 - return newLocation || location.href.replace(/%27/g,"'"); + return pendingLocation || location.href.replace(/%27/g,'\''); } }; + /** + * @name $browser#state + * + * @description + * This method is a getter. + * + * Return history.state or null if history.state is undefined. + * + * @returns {object} state + */ + self.state = function() { + return cachedState; + }; + var urlChangeListeners = [], urlChangeInit = false; - function fireUrlChange() { - newLocation = null; - if (lastBrowserUrl == self.url()) return; + function cacheStateAndFireUrlChange() { + pendingLocation = null; + fireStateOrUrlChange(); + } + + // This variable should be used *only* inside the cacheState function. + var lastCachedState = null; + function cacheState() { + // This should be the only place in $browser where `history.state` is read. + cachedState = getCurrentState(); + cachedState = isUndefined(cachedState) ? null : cachedState; + + // Prevent callbacks fo fire twice if both hashchange & popstate were fired. + if (equals(cachedState, lastCachedState)) { + cachedState = lastCachedState; + } + + lastCachedState = cachedState; + lastHistoryState = cachedState; + } + + function fireStateOrUrlChange() { + var prevLastHistoryState = lastHistoryState; + cacheState(); + + if (lastBrowserUrl === self.url() && prevLastHistoryState === cachedState) { + return; + } lastBrowserUrl = self.url(); + lastHistoryState = cachedState; forEach(urlChangeListeners, function(listener) { - listener(self.url()); + listener(self.url(), cachedState); }); } @@ -4449,7 +6616,7 @@ * @description * Register callback function that will be called, when url changes. * - * It's only called when the url is changed from outside of angular: + * It's only called when the url is changed from outside of AngularJS: * - user types different url into address bar * - user clicks on history (forward/back) button * - user clicks on a link @@ -4459,7 +6626,7 @@ * The listener gets called with new url as parameter. * * NOTE: this api is intended for use only by the $location service. Please use the - * {@link ng.$location $location service} to monitor url changes in angular apps. + * {@link ng.$location $location service} to monitor url changes in AngularJS apps. * * @param {function(string)} listener Listener function to be called when url changes. * @return {function(string)} Returns the registered listener fn - handy if the fn is anonymous. @@ -4467,16 +6634,14 @@ self.onUrlChange = function(callback) { // TODO(vojta): refactor to use node's syntax for events if (!urlChangeInit) { - // We listen on both (hashchange/popstate) when available, as some browsers (e.g. Opera) - // don't fire popstate when user change the address bar and don't fire hashchange when url + // We listen on both (hashchange/popstate) when available, as some browsers don't + // fire popstate when user changes the address bar and don't fire hashchange when url // changed by push/replaceState // html5 history api - popstate event - if ($sniffer.history) jqLite(window).on('popstate', fireUrlChange); + if ($sniffer.history) jqLite(window).on('popstate', cacheStateAndFireUrlChange); // hashchange event - if ($sniffer.hashchange) jqLite(window).on('hashchange', fireUrlChange); - // polling - else self.addPollFn(fireUrlChange); + jqLite(window).on('hashchange', cacheStateAndFireUrlChange); urlChangeInit = true; } @@ -4485,6 +6650,23 @@ return callback; }; + /** + * @private + * Remove popstate and hashchange handler from window. + * + * NOTE: this api is intended for use only by $rootScope. + */ + self.$$applicationDestroyed = function() { + jqLite(window).off('hashchange popstate', cacheStateAndFireUrlChange); + }; + + /** + * Checks whether the url has changed outside of AngularJS. + * Needs to be exported to be able to check for changes that have been done in sync, + * as hashchange/popstate events fire in async. + */ + self.$$checkUrlChange = fireStateOrUrlChange; + ////////////////////////////////////////////////////////////// // Misc API ////////////////////////////////////////////////////////////// @@ -4500,85 +6682,9 @@ */ self.baseHref = function() { var href = baseElement.attr('href'); - return href ? href.replace(/^(https?\:)?\/\/[^\/]*/, '') : ''; - }; - - ////////////////////////////////////////////////////////////// - // Cookies API - ////////////////////////////////////////////////////////////// - var lastCookies = {}; - var lastCookieString = ''; - var cookiePath = self.baseHref(); - - /** - * @name $browser#cookies - * - * @param {string=} name Cookie name - * @param {string=} value Cookie value - * - * @description - * The cookies method provides a 'private' low level access to browser cookies. - * It is not meant to be used directly, use the $cookie service instead. - * - * The return values vary depending on the arguments that the method was called with as follows: - * - * - cookies() -> hash of all cookies, this is NOT a copy of the internal state, so do not modify - * it - * - cookies(name, value) -> set name to value, if value is undefined delete the cookie - * - cookies(name) -> the same as (name, undefined) == DELETES (no one calls it right now that - * way) - * - * @returns {Object} Hash of all cookies (if called without any parameter) - */ - self.cookies = function(name, value) { - /* global escape: false, unescape: false */ - var cookieLength, cookieArray, cookie, i, index; - - if (name) { - if (value === undefined) { - rawDocument.cookie = escape(name) + "=;path=" + cookiePath + - ";expires=Thu, 01 Jan 1970 00:00:00 GMT"; - } else { - if (isString(value)) { - cookieLength = (rawDocument.cookie = escape(name) + '=' + escape(value) + - ';path=' + cookiePath).length + 1; - - // per http://www.ietf.org/rfc/rfc2109.txt browser must allow at minimum: - // - 300 cookies - // - 20 cookies per unique domain - // - 4096 bytes per cookie - if (cookieLength > 4096) { - $log.warn("Cookie '"+ name + - "' possibly not set or overflowed because it was too large ("+ - cookieLength + " > 4096 bytes)!"); - } - } - } - } else { - if (rawDocument.cookie !== lastCookieString) { - lastCookieString = rawDocument.cookie; - cookieArray = lastCookieString.split("; "); - lastCookies = {}; - - for (i = 0; i < cookieArray.length; i++) { - cookie = cookieArray[i]; - index = cookie.indexOf('='); - if (index > 0) { //ignore nameless cookies - name = unescape(cookie.substring(0, index)); - // the first value that is seen for a cookie is the most - // specific one. values for the same cookie name that - // follow are for less specific paths. - if (lastCookies[name] === undefined) { - lastCookies[name] = unescape(cookie.substring(index + 1)); - } - } - } - } - return lastCookies; - } + return href ? href.replace(/^(https?:)?\/\/[^/]*/, '') : ''; }; - /** * @name $browser#defer * @param {function()} fn A function, who's execution should be deferred. @@ -4627,9 +6733,10 @@ } - function $BrowserProvider(){ + /** @this */ + function $BrowserProvider() { this.$get = ['$window', '$log', '$sniffer', '$document', - function( $window, $log, $sniffer, $document){ + function($window, $log, $sniffer, $document) { return new Browser($window, $document, $log, $sniffer); }]; } @@ -4637,6 +6744,7 @@ /** * @ngdoc service * @name $cacheFactory + * @this * * @description * Factory that constructs {@link $cacheFactory.Cache Cache} objects and gives access to @@ -4673,7 +6781,7 @@ * - `{void}` `destroy()` — Removes references to this cache from $cacheFactory. * * @example - <example module="cacheExampleApp"> + <example module="cacheExampleApp" name="cache-factory"> <file name="index.html"> <div ng-controller="CacheController"> <input ng-model="newCacheKey" placeholder="Key"> @@ -4701,8 +6809,10 @@ $scope.keys = []; $scope.cache = $cacheFactory('cacheId'); $scope.put = function(key, value) { - $scope.cache.put(key, value); - $scope.keys.push(key); + if (angular.isUndefined($scope.cache.get(key))) { + $scope.keys.push(key); + } + $scope.cache.put(key, angular.isUndefined(value) ? null : value); }; }]); </file> @@ -4720,14 +6830,14 @@ function cacheFactory(cacheId, options) { if (cacheId in caches) { - throw minErr('$cacheFactory')('iid', "CacheId '{0}' is already taken!", cacheId); + throw minErr('$cacheFactory')('iid', 'CacheId \'{0}\' is already taken!', cacheId); } var size = 0, stats = extend({}, options, {id: cacheId}), - data = {}, + data = createMap(), capacity = (options && options.capacity) || Number.MAX_VALUE, - lruHash = {}, + lruHash = createMap(), freshEnd = null, staleEnd = null; @@ -4737,45 +6847,45 @@ * * @description * A cache object used to store and retrieve data, primarily used by - * {@link $http $http} and the {@link ng.directive:script script} directive to cache - * templates and other data. + * {@link $templateRequest $templateRequest} and the {@link ng.directive:script script} + * directive to cache templates and other data. * * ```js * angular.module('superCache') * .factory('superCache', ['$cacheFactory', function($cacheFactory) { - * return $cacheFactory('super-cache'); - * }]); + * return $cacheFactory('super-cache'); + * }]); * ``` * * Example test: * * ```js * it('should behave like a cache', inject(function(superCache) { - * superCache.put('key', 'value'); - * superCache.put('another key', 'another value'); - * - * expect(superCache.info()).toEqual({ - * id: 'super-cache', - * size: 2 - * }); - * - * superCache.remove('another key'); - * expect(superCache.get('another key')).toBeUndefined(); - * - * superCache.removeAll(); - * expect(superCache.info()).toEqual({ - * id: 'super-cache', - * size: 0 - * }); - * })); + * superCache.put('key', 'value'); + * superCache.put('another key', 'another value'); + * + * expect(superCache.info()).toEqual({ + * id: 'super-cache', + * size: 2 + * }); + * + * superCache.remove('another key'); + * expect(superCache.get('another key')).toBeUndefined(); + * + * superCache.removeAll(); + * expect(superCache.info()).toEqual({ + * id: 'super-cache', + * size: 0 + * }); + * })); * ``` */ - return caches[cacheId] = { + return (caches[cacheId] = { /** * @ngdoc method * @name $cacheFactory.Cache#put - * @function + * @kind function * * @description * Inserts a named entry into the {@link $cacheFactory.Cache Cache} object to be @@ -4791,13 +6901,13 @@ * @returns {*} the value stored. */ put: function(key, value) { + if (isUndefined(value)) return; if (capacity < Number.MAX_VALUE) { var lruEntry = lruHash[key] || (lruHash[key] = {key: key}); refresh(lruEntry); } - if (isUndefined(value)) return; if (!(key in data)) size++; data[key] = value; @@ -4811,7 +6921,7 @@ /** * @ngdoc method * @name $cacheFactory.Cache#get - * @function + * @kind function * * @description * Retrieves named data stored in the {@link $cacheFactory.Cache Cache} object. @@ -4835,7 +6945,7 @@ /** * @ngdoc method * @name $cacheFactory.Cache#remove - * @function + * @kind function * * @description * Removes an entry from the {@link $cacheFactory.Cache Cache} object. @@ -4848,13 +6958,15 @@ if (!lruEntry) return; - if (lruEntry == freshEnd) freshEnd = lruEntry.p; - if (lruEntry == staleEnd) staleEnd = lruEntry.n; + if (lruEntry === freshEnd) freshEnd = lruEntry.p; + if (lruEntry === staleEnd) staleEnd = lruEntry.n; link(lruEntry.n,lruEntry.p); delete lruHash[key]; } + if (!(key in data)) return; + delete data[key]; size--; }, @@ -4863,15 +6975,15 @@ /** * @ngdoc method * @name $cacheFactory.Cache#removeAll - * @function + * @kind function * * @description * Clears the cache object of any entries. */ removeAll: function() { - data = {}; + data = createMap(); size = 0; - lruHash = {}; + lruHash = createMap(); freshEnd = staleEnd = null; }, @@ -4879,7 +6991,7 @@ /** * @ngdoc method * @name $cacheFactory.Cache#destroy - * @function + * @kind function * * @description * Destroys the {@link $cacheFactory.Cache Cache} object entirely, @@ -4896,7 +7008,7 @@ /** * @ngdoc method * @name $cacheFactory.Cache#info - * @function + * @kind function * * @description * Retrieve information regarding a particular {@link $cacheFactory.Cache Cache}. @@ -4912,17 +7024,17 @@ info: function() { return extend({}, stats, {size: size}); } - }; + }); /** * makes the `entry` the freshEnd of the LRU linked list */ function refresh(entry) { - if (entry != freshEnd) { + if (entry !== freshEnd) { if (!staleEnd) { staleEnd = entry; - } else if (staleEnd == entry) { + } else if (staleEnd === entry) { staleEnd = entry.n; } @@ -4938,7 +7050,7 @@ * bidirectionally links two entries of the LRU linked list */ function link(nextEntry, prevEntry) { - if (nextEntry != prevEntry) { + if (nextEntry !== prevEntry) { if (nextEntry) nextEntry.p = prevEntry; //p stands for previous, 'prev' didn't minify if (prevEntry) prevEntry.n = nextEntry; //n stands for next, 'next' didn't minify } @@ -4951,7 +7063,7 @@ * @name $cacheFactory#info * * @description - * Get information about all the of the caches that have been created + * Get information about all the caches that have been created * * @returns {Object} - key-value map of `cacheId` to the result of calling `cache#info` */ @@ -4986,11 +7098,15 @@ /** * @ngdoc service * @name $templateCache + * @this * * @description + * `$templateCache` is a {@link $cacheFactory.Cache Cache object} created by the + * {@link ng.$cacheFactory $cacheFactory}. + * * The first time a template is used, it is loaded in the template cache for quick retrieval. You - * can load templates directly into the cache in a `script` tag, or by consuming the - * `$templateCache` service directly. + * can load templates directly into the cache in a `script` tag, by using {@link $templateRequest}, + * or by consuming the `$templateCache` service directly. * * Adding via the `script` tag: * @@ -5001,29 +7117,30 @@ * ``` * * **Note:** the `script` tag containing the template does not need to be included in the `head` of - * the document, but it must be below the `ng-app` definition. + * the document, but it must be a descendent of the {@link ng.$rootElement $rootElement} (e.g. + * element with {@link ngApp} attribute), otherwise the template will be ignored. * - * Adding via the $templateCache service: + * Adding via the `$templateCache` service: * * ```js * var myApp = angular.module('myApp', []); * myApp.run(function($templateCache) { - * $templateCache.put('templateId.html', 'This is the content of the template'); - * }); + * $templateCache.put('templateId.html', 'This is the content of the template'); + * }); * ``` * - * To retrieve the template later, simply use it in your HTML: - * ```html - * <div ng-include=" 'templateId.html' "></div> + * To retrieve the template later, simply use it in your component: + * ```js + * myApp.component('myComponent', { + * templateUrl: 'templateId.html' + * }); * ``` * - * or get it via Javascript: + * or get it via the `$templateCache` service: * ```js * $templateCache.get('templateId.html') * ``` * - * See {@link ng.$cacheFactory $cacheFactory}. - * */ function $TemplateCacheProvider() { this.$get = ['$cacheFactory', function($cacheFactory) { @@ -5031,28 +7148,39 @@ }]; } + /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * Any commits to this file should be reviewed with security in mind. * + * Changes to this file can potentially create security vulnerabilities. * + * An approval from 2 Core members with history of modifying * + * this file is required. * + * * + * Does the change somehow allow for arbitrary javascript to be executed? * + * Or allows for someone to change the prototype of built-in objects? * + * Or gives undesired access to variables like document or window? * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + /* ! VARIABLE/FUNCTION NAMING CONVENTIONS THAT APPLY TO THIS FILE! - * - * DOM-related variables: - * - * - "node" - DOM Node - * - "element" - DOM Element or Node - * - "$node" or "$element" - jqLite-wrapped node or element - * - * - * Compiler related stuff: - * - * - "linkFn" - linking fn of a single directive - * - "nodeLinkFn" - function that aggregates all linking fns for a particular node - * - "childLinkFn" - function that aggregates all linking fns for child nodes of a particular node - * - "compositeLinkFn" - function that aggregates all linking fns for a compilation root (nodeList) - */ + * + * DOM-related variables: + * + * - "node" - DOM Node + * - "element" - DOM Element or Node + * - "$node" or "$element" - jqLite-wrapped node or element + * + * + * Compiler related stuff: + * + * - "linkFn" - linking fn of a single directive + * - "nodeLinkFn" - function that aggregates all linking fns for a particular node + * - "childLinkFn" - function that aggregates all linking fns for child nodes of a particular node + * - "compositeLinkFn" - function that aggregates all linking fns for a compilation root (nodeList) + */ /** * @ngdoc service * @name $compile - * @function + * @kind function * * @description * Compiles an HTML string or DOM into a template and produces a template function, which @@ -5072,8 +7200,9 @@ * There are many different options for a directive. * * The difference resides in the return value of the factory function. - * You can either return a "Directive Definition Object" (see below) that defines the directive properties, - * or just the `postLink` function (all other properties will have the default values). + * You can either return a {@link $compile#directive-definition-object Directive Definition Object (see below)} + * that defines the directive properties, or just the `postLink` function (all other properties will have + * the default values). * * <div class="alert alert-success"> * **Best Practice:** It's recommended to use the "directive definition object" form. @@ -5085,36 +7214,38 @@ * var myModule = angular.module(...); * * myModule.directive('directiveName', function factory(injectables) { - * var directiveDefinitionObject = { - * priority: 0, - * template: '<div></div>', // or // function(tElement, tAttrs) { ... }, - * // or - * // templateUrl: 'directive.html', // or // function(tElement, tAttrs) { ... }, - * replace: false, - * transclude: false, - * restrict: 'A', - * scope: false, - * controller: function($scope, $element, $attrs, $transclude, otherInjectables) { ... }, - * controllerAs: 'stringAlias', - * require: 'siblingDirectiveName', // or // ['^parentDirectiveName', '?optionalDirectiveName', '?^optionalParent'], - * compile: function compile(tElement, tAttrs, transclude) { - * return { - * pre: function preLink(scope, iElement, iAttrs, controller) { ... }, - * post: function postLink(scope, iElement, iAttrs, controller) { ... } - * } - * // or - * // return function postLink( ... ) { ... } - * }, - * // or - * // link: { - * // pre: function preLink(scope, iElement, iAttrs, controller) { ... }, - * // post: function postLink(scope, iElement, iAttrs, controller) { ... } - * // } - * // or - * // link: function postLink( ... ) { ... } - * }; - * return directiveDefinitionObject; - * }); + * var directiveDefinitionObject = { + * {@link $compile#-priority- priority}: 0, + * {@link $compile#-template- template}: '<div></div>', // or // function(tElement, tAttrs) { ... }, + * // or + * // {@link $compile#-templateurl- templateUrl}: 'directive.html', // or // function(tElement, tAttrs) { ... }, + * {@link $compile#-transclude- transclude}: false, + * {@link $compile#-restrict- restrict}: 'A', + * {@link $compile#-templatenamespace- templateNamespace}: 'html', + * {@link $compile#-scope- scope}: false, + * {@link $compile#-controller- controller}: function($scope, $element, $attrs, $transclude, otherInjectables) { ... }, + * {@link $compile#-controlleras- controllerAs}: 'stringIdentifier', + * {@link $compile#-bindtocontroller- bindToController}: false, + * {@link $compile#-require- require}: 'siblingDirectiveName', // or // ['^parentDirectiveName', '?optionalDirectiveName', '?^optionalParent'], + * {@link $compile#-multielement- multiElement}: false, + * {@link $compile#-compile- compile}: function compile(tElement, tAttrs, transclude) { + * return { + * {@link $compile#pre-linking-function pre}: function preLink(scope, iElement, iAttrs, controller) { ... }, + * {@link $compile#post-linking-function post}: function postLink(scope, iElement, iAttrs, controller) { ... } + * } + * // or + * // return function postLink( ... ) { ... } + * }, + * // or + * // {@link $compile#-link- link}: { + * // {@link $compile#pre-linking-function pre}: function preLink(scope, iElement, iAttrs, controller) { ... }, + * // {@link $compile#post-linking-function post}: function postLink(scope, iElement, iAttrs, controller) { ... } + * // } + * // or + * // {@link $compile#-link- link}: function postLink( ... ) { ... } + * }; + * return directiveDefinitionObject; + * }); * ``` * * <div class="alert alert-warning"> @@ -5127,21 +7258,148 @@ * var myModule = angular.module(...); * * myModule.directive('directiveName', function factory(injectables) { - * var directiveDefinitionObject = { - * link: function postLink(scope, iElement, iAttrs) { ... } - * }; - * return directiveDefinitionObject; - * // or - * // return function postLink(scope, iElement, iAttrs) { ... } - * }); + * var directiveDefinitionObject = { + * link: function postLink(scope, iElement, iAttrs) { ... } + * }; + * return directiveDefinitionObject; + * // or + * // return function postLink(scope, iElement, iAttrs) { ... } + * }); * ``` * + * ### Life-cycle hooks + * Directive controllers can provide the following methods that are called by AngularJS at points in the life-cycle of the + * directive: + * * `$onInit()` - Called on each controller after all the controllers on an element have been constructed and + * had their bindings initialized (and before the pre & post linking functions for the directives on + * this element). This is a good place to put initialization code for your controller. + * * `$onChanges(changesObj)` - Called whenever one-way (`<`) or interpolation (`@`) bindings are updated. The + * `changesObj` is a hash whose keys are the names of the bound properties that have changed, and the values are an + * object of the form `{ currentValue, previousValue, isFirstChange() }`. Use this hook to trigger updates within a + * component such as cloning the bound value to prevent accidental mutation of the outer value. Note that this will + * also be called when your bindings are initialized. + * * `$doCheck()` - Called on each turn of the digest cycle. Provides an opportunity to detect and act on + * changes. Any actions that you wish to take in response to the changes that you detect must be + * invoked from this hook; implementing this has no effect on when `$onChanges` is called. For example, this hook + * could be useful if you wish to perform a deep equality check, or to check a Date object, changes to which would not + * be detected by AngularJS's change detector and thus not trigger `$onChanges`. This hook is invoked with no arguments; + * if detecting changes, you must store the previous value(s) for comparison to the current values. + * * `$onDestroy()` - Called on a controller when its containing scope is destroyed. Use this hook for releasing + * external resources, watches and event handlers. Note that components have their `$onDestroy()` hooks called in + * the same order as the `$scope.$broadcast` events are triggered, which is top down. This means that parent + * components will have their `$onDestroy()` hook called before child components. + * * `$postLink()` - Called after this controller's element and its children have been linked. Similar to the post-link + * function this hook can be used to set up DOM event handlers and do direct DOM manipulation. + * Note that child elements that contain `templateUrl` directives will not have been compiled and linked since + * they are waiting for their template to load asynchronously and their own compilation and linking has been + * suspended until that occurs. + * + * #### Comparison with life-cycle hooks in the new Angular + * The new Angular also uses life-cycle hooks for its components. While the AngularJS life-cycle hooks are similar there are + * some differences that you should be aware of, especially when it comes to moving your code from AngularJS to Angular: + * + * * AngularJS hooks are prefixed with `$`, such as `$onInit`. Angular hooks are prefixed with `ng`, such as `ngOnInit`. + * * AngularJS hooks can be defined on the controller prototype or added to the controller inside its constructor. + * In Angular you can only define hooks on the prototype of the Component class. + * * Due to the differences in change-detection, you may get many more calls to `$doCheck` in AngularJS than you would to + * `ngDoCheck` in Angular. + * * Changes to the model inside `$doCheck` will trigger new turns of the digest loop, which will cause the changes to be + * propagated throughout the application. + * Angular does not allow the `ngDoCheck` hook to trigger a change outside of the component. It will either throw an + * error or do nothing depending upon the state of `enableProdMode()`. + * + * #### Life-cycle hook examples + * + * This example shows how you can check for mutations to a Date object even though the identity of the object + * has not changed. + * + * <example name="doCheckDateExample" module="do-check-module"> + * <file name="app.js"> + * angular.module('do-check-module', []) + * .component('app', { + * template: + * 'Month: <input ng-model="$ctrl.month" ng-change="$ctrl.updateDate()">' + + * 'Date: {{ $ctrl.date }}' + + * '<test date="$ctrl.date"></test>', + * controller: function() { + * this.date = new Date(); + * this.month = this.date.getMonth(); + * this.updateDate = function() { + * this.date.setMonth(this.month); + * }; + * } + * }) + * .component('test', { + * bindings: { date: '<' }, + * template: + * '<pre>{{ $ctrl.log | json }}</pre>', + * controller: function() { + * var previousValue; + * this.log = []; + * this.$doCheck = function() { + * var currentValue = this.date && this.date.valueOf(); + * if (previousValue !== currentValue) { + * this.log.push('doCheck: date mutated: ' + this.date); + * previousValue = currentValue; + * } + * }; + * } + * }); + * </file> + * <file name="index.html"> + * <app></app> + * </file> + * </example> * + * This example show how you might use `$doCheck` to trigger changes in your component's inputs even if the + * actual identity of the component doesn't change. (Be aware that cloning and deep equality checks on large + * arrays or objects can have a negative impact on your application performance) * - * ### Directive Definition Object - * - * The directive definition object provides instructions to the {@link ng.$compile - * compiler}. The attributes are: + * <example name="doCheckArrayExample" module="do-check-module"> + * <file name="index.html"> + * <div ng-init="items = []"> + * <button ng-click="items.push(items.length)">Add Item</button> + * <button ng-click="items = []">Reset Items</button> + * <pre>{{ items }}</pre> + * <test items="items"></test> + * </div> + * </file> + * <file name="app.js"> + * angular.module('do-check-module', []) + * .component('test', { + * bindings: { items: '<' }, + * template: + * '<pre>{{ $ctrl.log | json }}</pre>', + * controller: function() { + * this.log = []; + * + * this.$doCheck = function() { + * if (this.items_ref !== this.items) { + * this.log.push('doCheck: items changed'); + * this.items_ref = this.items; + * } + * if (!angular.equals(this.items_clone, this.items)) { + * this.log.push('doCheck: items mutated'); + * this.items_clone = angular.copy(this.items); + * } + * }; + * } + * }); + * </file> + * </example> + * + * + * ### Directive Definition Object + * + * The directive definition object provides instructions to the {@link ng.$compile + * compiler}. The attributes are: + * + * #### `multiElement` + * When this property is set to true (default is `false`), the HTML compiler will collect DOM nodes between + * nodes with the attributes `directive-name-start` and `directive-name-end`, and group them + * together as the directive elements. It is recommended that this feature be used on directives + * which are not strictly behavioral (such as {@link ngClick}), and which + * do not manipulate or replace child nodes (such as {@link ngInclude}). * * #### `priority` * When there are multiple directives defined on a single DOM element, sometimes it @@ -5154,136 +7412,269 @@ * #### `terminal` * If set to true then the current `priority` will be the last set of directives * which will execute (any directives at the current priority will still execute - * as the order of execution on same `priority` is undefined). + * as the order of execution on same `priority` is undefined). Note that expressions + * and other directives used in the directive's template will also be excluded from execution. * * #### `scope` - * **If set to `true`,** then a new scope will be created for this directive. If multiple directives on the - * same element request a new scope, only one new scope is created. The new scope rule does not - * apply for the root of the template since the root of the template always gets a new scope. + * The scope property can be `false`, `true`, or an object: * - * **If set to `{}` (object hash),** then a new "isolate" scope is created. The 'isolate' scope differs from - * normal scope in that it does not prototypically inherit from the parent scope. This is useful - * when creating reusable components, which should not accidentally read or modify data in the - * parent scope. + * * **`false` (default):** No scope will be created for the directive. The directive will use its + * parent's scope. * - * The 'isolate' scope takes an object hash which defines a set of local scope properties - * derived from the parent scope. These local properties are useful for aliasing values for - * templates. Locals definition is a hash of local scope property to its source: + * * **`true`:** A new child scope that prototypically inherits from its parent will be created for + * the directive's element. If multiple directives on the same element request a new scope, + * only one new scope is created. + * + * * **`{...}` (an object hash):** A new "isolate" scope is created for the directive's template. + * The 'isolate' scope differs from normal scope in that it does not prototypically + * inherit from its parent scope. This is useful when creating reusable components, which should not + * accidentally read or modify data in the parent scope. Note that an isolate scope + * directive without a `template` or `templateUrl` will not apply the isolate scope + * to its children elements. + * + * The 'isolate' scope object hash defines a set of local scope properties derived from attributes on the + * directive's element. These local properties are useful for aliasing values for templates. The keys in + * the object hash map to the name of the property on the isolate scope; the values define how the property + * is bound to the parent scope, via matching attributes on the directive's element: * * * `@` or `@attr` - bind a local scope property to the value of DOM attribute. The result is - * always a string since DOM attributes are strings. If no `attr` name is specified then the - * attribute name is assumed to be the same as the local name. - * Given `<widget my-attr="hello {{name}}">` and widget definition - * of `scope: { localName:'@myAttr' }`, then widget scope property `localName` will reflect - * the interpolated value of `hello {{name}}`. As the `name` attribute changes so will the - * `localName` property on the widget scope. The `name` is read from the parent scope (not - * component scope). - * - * * `=` or `=attr` - set up bi-directional binding between a local scope property and the - * parent scope property of name defined via the value of the `attr` attribute. If no `attr` - * name is specified then the attribute name is assumed to be the same as the local name. - * Given `<widget my-attr="parentModel">` and widget definition of - * `scope: { localModel:'=myAttr' }`, then widget scope property `localModel` will reflect the + * always a string since DOM attributes are strings. If no `attr` name is specified then the + * attribute name is assumed to be the same as the local name. Given `<my-component + * my-attr="hello {{name}}">` and the isolate scope definition `scope: { localName:'@myAttr' }`, + * the directive's scope property `localName` will reflect the interpolated value of `hello + * {{name}}`. As the `name` attribute changes so will the `localName` property on the directive's + * scope. The `name` is read from the parent scope (not the directive's scope). + * + * * `=` or `=attr` - set up a bidirectional binding between a local scope property and an expression + * passed via the attribute `attr`. The expression is evaluated in the context of the parent scope. + * If no `attr` name is specified then the attribute name is assumed to be the same as the local + * name. Given `<my-component my-attr="parentModel">` and the isolate scope definition `scope: { + * localModel: '=myAttr' }`, the property `localModel` on the directive's scope will reflect the + * value of `parentModel` on the parent scope. Changes to `parentModel` will be reflected in + * `localModel` and vice versa. Optional attributes should be marked as such with a question mark: + * `=?` or `=?attr`. If the binding expression is non-assignable, or if the attribute isn't + * optional and doesn't exist, an exception ({@link error/$compile/nonassign `$compile:nonassign`}) + * will be thrown upon discovering changes to the local value, since it will be impossible to sync + * them back to the parent scope. By default, the {@link ng.$rootScope.Scope#$watch `$watch`} + * method is used for tracking changes, and the equality check is based on object identity. + * However, if an object literal or an array literal is passed as the binding expression, the + * equality check is done by value (using the {@link angular.equals} function). It's also possible + * to watch the evaluated value shallowly with {@link ng.$rootScope.Scope#$watchCollection + * `$watchCollection`}: use `=*` or `=*attr` (`=*?` or `=*?attr` if the attribute is optional). + * + * * `<` or `<attr` - set up a one-way (one-directional) binding between a local scope property and an + * expression passed via the attribute `attr`. The expression is evaluated in the context of the + * parent scope. If no `attr` name is specified then the attribute name is assumed to be the same as the + * local name. You can also make the binding optional by adding `?`: `<?` or `<?attr`. + * + * For example, given `<my-component my-attr="parentModel">` and directive definition of + * `scope: { localModel:'<myAttr' }`, then the isolated scope property `localModel` will reflect the * value of `parentModel` on the parent scope. Any changes to `parentModel` will be reflected - * in `localModel` and any changes in `localModel` will reflect in `parentModel`. If the parent - * scope property doesn't exist, it will throw a NON_ASSIGNABLE_MODEL_EXPRESSION exception. You - * can avoid this behavior using `=?` or `=?attr` in order to flag the property as optional. + * in `localModel`, but changes in `localModel` will not reflect in `parentModel`. There are however + * two caveats: + * 1. one-way binding does not copy the value from the parent to the isolate scope, it simply + * sets the same value. That means if your bound value is an object, changes to its properties + * in the isolated scope will be reflected in the parent scope (because both reference the same object). + * 2. one-way binding watches changes to the **identity** of the parent value. That means the + * {@link ng.$rootScope.Scope#$watch `$watch`} on the parent value only fires if the reference + * to the value has changed. In most cases, this should not be of concern, but can be important + * to know if you one-way bind to an object, and then replace that object in the isolated scope. + * If you now change a property of the object in your parent scope, the change will not be + * propagated to the isolated scope, because the identity of the object on the parent scope + * has not changed. Instead you must assign a new object. + * + * One-way binding is useful if you do not plan to propagate changes to your isolated scope bindings + * back to the parent. However, it does not make this completely impossible. + * + * * `&` or `&attr` - provides a way to execute an expression in the context of the parent scope. If + * no `attr` name is specified then the attribute name is assumed to be the same as the local name. + * Given `<my-component my-attr="count = count + value">` and the isolate scope definition `scope: { + * localFn:'&myAttr' }`, the isolate scope property `localFn` will point to a function wrapper for + * the `count = count + value` expression. Often it's desirable to pass data from the isolated scope + * via an expression to the parent scope. This can be done by passing a map of local variable names + * and values into the expression wrapper fn. For example, if the expression is `increment(amount)` + * then we can specify the amount value by calling the `localFn` as `localFn({amount: 22})`. + * + * In general it's possible to apply more than one directive to one element, but there might be limitations + * depending on the type of scope required by the directives. The following points will help explain these limitations. + * For simplicity only two directives are taken into account, but it is also applicable for several directives: + * + * * **no scope** + **no scope** => Two directives which don't require their own scope will use their parent's scope + * * **child scope** + **no scope** => Both directives will share one single child scope + * * **child scope** + **child scope** => Both directives will share one single child scope + * * **isolated scope** + **no scope** => The isolated directive will use it's own created isolated scope. The other directive will use + * its parent's scope + * * **isolated scope** + **child scope** => **Won't work!** Only one scope can be related to one element. Therefore these directives cannot + * be applied to the same element. + * * **isolated scope** + **isolated scope** => **Won't work!** Only one scope can be related to one element. Therefore these directives + * cannot be applied to the same element. + * + * + * #### `bindToController` + * This property is used to bind scope properties directly to the controller. It can be either + * `true` or an object hash with the same format as the `scope` property. + * + * When an isolate scope is used for a directive (see above), `bindToController: true` will + * allow a component to have its properties bound to the controller, rather than to scope. + * + * After the controller is instantiated, the initial values of the isolate scope bindings will be bound to the controller + * properties. You can access these bindings once they have been initialized by providing a controller method called + * `$onInit`, which is called after all the controllers on an element have been constructed and had their bindings + * initialized. + * + * <div class="alert alert-warning"> + * **Deprecation warning:** if `$compileProcvider.preAssignBindingsEnabled(true)` was called, bindings for non-ES6 class + * controllers are bound to `this` before the controller constructor is called but this use is now deprecated. Please + * place initialization code that relies upon bindings inside a `$onInit` method on the controller, instead. + * </div> * - * * `&` or `&attr` - provides a way to execute an expression in the context of the parent scope. - * If no `attr` name is specified then the attribute name is assumed to be the same as the - * local name. Given `<widget my-attr="count = count + value">` and widget definition of - * `scope: { localFn:'&myAttr' }`, then isolate scope property `localFn` will point to - * a function wrapper for the `count = count + value` expression. Often it's desirable to - * pass data from the isolated scope via an expression and to the parent scope, this can be - * done by passing a map of local variable names and values into the expression wrapper fn. - * For example, if the expression is `increment(amount)` then we can specify the amount value - * by calling the `localFn` as `localFn({amount: 22})`. + * It is also possible to set `bindToController` to an object hash with the same format as the `scope` property. + * This will set up the scope bindings to the controller directly. Note that `scope` can still be used + * to define which kind of scope is created. By default, no scope is created. Use `scope: {}` to create an isolate + * scope (useful for component directives). * + * If both `bindToController` and `scope` are defined and have object hashes, `bindToController` overrides `scope`. * * * #### `controller` * Controller constructor function. The controller is instantiated before the - * pre-linking phase and it is shared with other directives (see + * pre-linking phase and can be accessed by other directives (see * `require` attribute). This allows the directives to communicate with each other and augment * each other's behavior. The controller is injectable (and supports bracket notation) with the following locals: * * * `$scope` - Current scope associated with the element * * `$element` - Current element * * `$attrs` - Current attributes object for the element - * * `$transclude` - A transclude linking function pre-bound to the correct transclusion scope. - * The scope can be overridden by an optional first argument. - * `function([scope], cloneLinkingFn)`. - * + * * `$transclude` - A transclude linking function pre-bound to the correct transclusion scope: + * `function([scope], cloneLinkingFn, futureParentElement, slotName)`: + * * `scope`: (optional) override the scope. + * * `cloneLinkingFn`: (optional) argument to create clones of the original transcluded content. + * * `futureParentElement` (optional): + * * defines the parent to which the `cloneLinkingFn` will add the cloned elements. + * * default: `$element.parent()` resp. `$element` for `transclude:'element'` resp. `transclude:true`. + * * only needed for transcludes that are allowed to contain non html elements (e.g. SVG elements) + * and when the `cloneLinkingFn` is passed, + * as those elements need to created and cloned in a special way when they are defined outside their + * usual containers (e.g. like `<svg>`). + * * See also the `directive.templateNamespace` property. + * * `slotName`: (optional) the name of the slot to transclude. If falsy (e.g. `null`, `undefined` or `''`) + * then the default transclusion is provided. + * The `$transclude` function also has a method on it, `$transclude.isSlotFilled(slotName)`, which returns + * `true` if the specified slot contains content (i.e. one or more DOM nodes). * * #### `require` * Require another directive and inject its controller as the fourth argument to the linking function. The - * `require` takes a string name (or array of strings) of the directive(s) to pass in. If an array is used, the - * injected argument will be an array in corresponding order. If no such directive can be - * found, or if the directive does not have a controller, then an error is raised. The name can be prefixed with: + * `require` property can be a string, an array or an object: + * * a **string** containing the name of the directive to pass to the linking function + * * an **array** containing the names of directives to pass to the linking function. The argument passed to the + * linking function will be an array of controllers in the same order as the names in the `require` property + * * an **object** whose property values are the names of the directives to pass to the linking function. The argument + * passed to the linking function will also be an object with matching keys, whose values will hold the corresponding + * controllers. + * + * If the `require` property is an object and `bindToController` is truthy, then the required controllers are + * bound to the controller using the keys of the `require` property. This binding occurs after all the controllers + * have been constructed but before `$onInit` is called. + * If the name of the required controller is the same as the local name (the key), the name can be + * omitted. For example, `{parentDir: '^^'}` is equivalent to `{parentDir: '^^parentDir'}`. + * See the {@link $compileProvider#component} helper for an example of how this can be used. + * If no such required directive(s) can be found, or if the directive does not have a controller, then an error is + * raised (unless no link function is specified and the required controllers are not being bound to the directive + * controller, in which case error checking is skipped). The name can be prefixed with: * * * (no prefix) - Locate the required controller on the current element. Throw an error if not found. * * `?` - Attempt to locate the required controller or pass `null` to the `link` fn if not found. - * * `^` - Locate the required controller by searching the element's parents. Throw an error if not found. - * * `?^` - Attempt to locate the required controller by searching the element's parents or pass `null` to the - * `link` fn if not found. + * * `^` - Locate the required controller by searching the element and its parents. Throw an error if not found. + * * `^^` - Locate the required controller by searching the element's parents. Throw an error if not found. + * * `?^` - Attempt to locate the required controller by searching the element and its parents or pass + * `null` to the `link` fn if not found. + * * `?^^` - Attempt to locate the required controller by searching the element's parents, or pass + * `null` to the `link` fn if not found. * * * #### `controllerAs` - * Controller alias at the directive scope. An alias for the controller so it - * can be referenced at the directive template. The directive needs to define a scope for this - * configuration to be used. Useful in the case when directive is used as component. + * Identifier name for a reference to the controller in the directive's scope. + * This allows the controller to be referenced from the directive template. This is especially + * useful when a directive is used as component, i.e. with an `isolate` scope. It's also possible + * to use it in a directive without an `isolate` / `new` scope, but you need to be aware that the + * `controllerAs` reference might overwrite a property that already exists on the parent scope. * * * #### `restrict` * String of subset of `EACM` which restricts the directive to a specific directive - * declaration style. If omitted, the default (attributes only) is used. + * declaration style. If omitted, the defaults (elements and attributes) are used. * - * * `E` - Element name: `<my-directive></my-directive>` + * * `E` - Element name (default): `<my-directive></my-directive>` * * `A` - Attribute (default): `<div my-directive="exp"></div>` * * `C` - Class: `<div class="my-directive: exp;"></div>` * * `M` - Comment: `<!-- directive: my-directive exp -->` * * + * #### `templateNamespace` + * String representing the document type used by the markup in the template. + * AngularJS needs this information as those elements need to be created and cloned + * in a special way when they are defined outside their usual containers like `<svg>` and `<math>`. + * + * * `html` - All root nodes in the template are HTML. Root nodes may also be + * top-level elements such as `<svg>` or `<math>`. + * * `svg` - The root nodes in the template are SVG elements (excluding `<math>`). + * * `math` - The root nodes in the template are MathML elements (excluding `<svg>`). + * + * If no `templateNamespace` is specified, then the namespace is considered to be `html`. + * * #### `template` - * replace the current element with the contents of the HTML. The replacement process - * migrates all of the attributes / classes from the old element to the new one. See the - * {@link guide/directive#creating-custom-directives_creating-directives_template-expanding-directive - * Directives Guide} for an example. + * HTML markup that may: + * * Replace the contents of the directive's element (default). + * * Replace the directive's element itself (if `replace` is true - DEPRECATED). + * * Wrap the contents of the directive's element (if `transclude` is true). * - * You can specify `template` as a string representing the template or as a function which takes - * two arguments `tElement` and `tAttrs` (described in the `compile` function api below) and - * returns a string value representing the template. + * Value may be: + * + * * A string. For example `<div red-on-hover>{{delete_str}}</div>`. + * * A function which takes two arguments `tElement` and `tAttrs` (described in the `compile` + * function api below) and returns a string value. * * * #### `templateUrl` - * Same as `template` but the template is loaded from the specified URL. Because - * the template loading is asynchronous the compilation/linking is suspended until the template - * is loaded. + * This is similar to `template` but the template is loaded from the specified URL, asynchronously. + * + * Because template loading is asynchronous the compiler will suspend compilation of directives on that element + * for later when the template has been resolved. In the meantime it will continue to compile and link + * sibling and parent elements as though this element had not contained any directives. + * + * The compiler does not suspend the entire compilation to wait for templates to be loaded because this + * would result in the whole app "stalling" until all templates are loaded asynchronously - even in the + * case when only one deeply nested directive has `templateUrl`. + * + * Template loading is asynchronous even if the template has been preloaded into the {@link $templateCache} * * You can specify `templateUrl` as a string representing the URL or as a function which takes two * arguments `tElement` and `tAttrs` (described in the `compile` function api below) and returns * a string value representing the url. In either case, the template URL is passed through {@link - * api/ng.$sce#getTrustedResourceUrl $sce.getTrustedResourceUrl}. + * $sce#getTrustedResourceUrl $sce.getTrustedResourceUrl}. * * - * #### `replace` - * specify where the template should be inserted. Defaults to `false`. + * #### `replace` (*DEPRECATED*) * - * * `true` - the template will replace the current element. - * * `false` - the template will replace the contents of the current element. + * `replace` will be removed in next major release - i.e. v2.0). * + * Specifies what the template should replace. Defaults to `false`. * - * #### `transclude` - * compile the content of the element and make it available to the directive. - * Typically used with {@link ng.directive:ngTransclude - * ngTransclude}. The advantage of transclusion is that the linking function receives a - * transclusion function which is pre-bound to the correct scope. In a typical setup the widget - * creates an `isolate` scope, but the transclusion is not a child, but a sibling of the `isolate` - * scope. This makes it possible for the widget to have private state, and the transclusion to - * be bound to the parent (pre-`isolate`) scope. + * * `true` - the template will replace the directive's element. + * * `false` - the template will replace the contents of the directive's element. + * + * The replacement process migrates all of the attributes / classes from the old element to the new + * one. See the {@link guide/directive#template-expanding-directive + * Directives Guide} for an example. + * + * There are very few scenarios where element replacement is required for the application function, + * the main one being reusable custom components that are used within SVG contexts + * (because SVG doesn't work with custom elements in the DOM tree). * - * * `true` - transclude the content of the directive. - * * `'element'` - transclude the whole element including any directives defined at lower priority. + * #### `transclude` + * Extract the contents of the element where the directive appears and make it available to the directive. + * The contents are compiled and provided to the directive as a **transclusion function**. See the + * {@link $compile#transclusion Transclusion} section below. * * * #### `compile` @@ -5293,11 +7684,7 @@ * ``` * * The compile function deals with transforming the template DOM. Since most directives do not do - * template transformation, it is not used often. Examples that require compile functions are - * directives that transform template DOM, such as {@link - * api/ng.directive:ngRepeat ngRepeat}, or load the contents - * asynchronously, such as {@link ngRoute.directive:ngView ngView}. The - * compile function takes the following arguments. + * template transformation, it is not used often. The compile function takes the following arguments: * * * `tElement` - template element - The element where the directive has been declared. It is * safe to do template transformation on the element and child elements only. @@ -5316,7 +7703,7 @@ * <div class="alert alert-warning"> * **Note:** The compile function cannot handle directives that recursively use themselves in their - * own templates or compile functions. Compiling these directives results in an infinite loop and a + * own templates or compile functions. Compiling these directives results in an infinite loop and * stack overflow errors. * * This can be avoided by manually using $compile in the postLink function to imperatively compile @@ -5324,7 +7711,7 @@ * `templateUrl` declaration or manual compilation inside the compile function. * </div> * - * <div class="alert alert-error"> + * <div class="alert alert-danger"> * **Note:** The `transclude` function that is passed to the compile function is deprecated, as it * e.g. does not know about the right outer scope. Please use the transclude function that is passed * to the link function instead. @@ -5361,15 +7748,23 @@ * * `iAttrs` - instance attributes - Normalized list of attributes declared on this element shared * between all directive linking functions. * - * * `controller` - a controller instance - A controller instance if at least one directive on the - * element defines a controller. The controller is shared among all the directives, which allows - * the directives to use the controllers as a communication channel. + * * `controller` - the directive's required controller instance(s) - Instances are shared + * among all directives, which allows the directives to use the controllers as a communication + * channel. The exact value depends on the directive's `require` property: + * * no controller(s) required: the directive's own controller, or `undefined` if it doesn't have one + * * `string`: the controller instance + * * `array`: array of controller instances * - * * `transcludeFn` - A transclude linking function pre-bound to the correct transclusion scope. - * The scope can be overridden by an optional first argument. This is the same as the `$transclude` - * parameter of directive controllers. - * `function([scope], cloneLinkingFn)`. + * If a required controller cannot be found, and it is optional, the instance is `null`, + * otherwise the {@link error:$compile:ctreq Missing Required Controller} error is thrown. * + * Note that you can also require the directive's own controller - it will be made available like + * any other controller. + * + * * `transcludeFn` - A transclude linking function pre-bound to the correct transclusion scope. + * This is the same as the `$transclude` parameter of directive controllers, + * see {@link ng.$compile#-controller- the controller section for details}. + * `function([scope], cloneLinkingFn, futureParentElement)`. * * #### Pre-linking function * @@ -5378,18 +7773,166 @@ * * #### Post-linking function * - * Executed after the child elements are linked. It is safe to do DOM transformation in the post-linking function. + * Executed after the child elements are linked. + * + * Note that child elements that contain `templateUrl` directives will not have been compiled + * and linked since they are waiting for their template to load asynchronously and their own + * compilation and linking has been suspended until that occurs. + * + * It is safe to do DOM transformation in the post-linking function on elements that are not waiting + * for their async templates to be resolved. + * + * + * ### Transclusion + * + * Transclusion is the process of extracting a collection of DOM elements from one part of the DOM and + * copying them to another part of the DOM, while maintaining their connection to the original AngularJS + * scope from where they were taken. + * + * Transclusion is used (often with {@link ngTransclude}) to insert the + * original contents of a directive's element into a specified place in the template of the directive. + * The benefit of transclusion, over simply moving the DOM elements manually, is that the transcluded + * content has access to the properties on the scope from which it was taken, even if the directive + * has isolated scope. + * See the {@link guide/directive#creating-a-directive-that-wraps-other-elements Directives Guide}. + * + * This makes it possible for the widget to have private state for its template, while the transcluded + * content has access to its originating scope. + * + * <div class="alert alert-warning"> + * **Note:** When testing an element transclude directive you must not place the directive at the root of the + * DOM fragment that is being compiled. See {@link guide/unit-testing#testing-transclusion-directives + * Testing Transclusion Directives}. + * </div> + * + * There are three kinds of transclusion depending upon whether you want to transclude just the contents of the + * directive's element, the entire element or multiple parts of the element contents: + * + * * `true` - transclude the content (i.e. the child nodes) of the directive's element. + * * `'element'` - transclude the whole of the directive's element including any directives on this + * element that defined at a lower priority than this directive. When used, the `template` + * property is ignored. + * * **`{...}` (an object hash):** - map elements of the content onto transclusion "slots" in the template. + * + * **Mult-slot transclusion** is declared by providing an object for the `transclude` property. + * + * This object is a map where the keys are the name of the slot to fill and the value is an element selector + * used to match the HTML to the slot. The element selector should be in normalized form (e.g. `myElement`) + * and will match the standard element variants (e.g. `my-element`, `my:element`, `data-my-element`, etc). + * + * For further information check out the guide on {@link guide/directive#matching-directives Matching Directives} + * + * If the element selector is prefixed with a `?` then that slot is optional. + * + * For example, the transclude object `{ slotA: '?myCustomElement' }` maps `<my-custom-element>` elements to + * the `slotA` slot, which can be accessed via the `$transclude` function or via the {@link ngTransclude} directive. + * + * Slots that are not marked as optional (`?`) will trigger a compile time error if there are no matching elements + * in the transclude content. If you wish to know if an optional slot was filled with content, then you can call + * `$transclude.isSlotFilled(slotName)` on the transclude function passed to the directive's link function and + * injectable into the directive's controller. + * + * + * #### Transclusion Functions + * + * When a directive requests transclusion, the compiler extracts its contents and provides a **transclusion + * function** to the directive's `link` function and `controller`. This transclusion function is a special + * **linking function** that will return the compiled contents linked to a new transclusion scope. + * + * <div class="alert alert-info"> + * If you are just using {@link ngTransclude} then you don't need to worry about this function, since + * ngTransclude will deal with it for us. + * </div> + * + * If you want to manually control the insertion and removal of the transcluded content in your directive + * then you must use this transclude function. When you call a transclude function it returns a a jqLite/JQuery + * object that contains the compiled DOM, which is linked to the correct transclusion scope. + * + * When you call a transclusion function you can pass in a **clone attach function**. This function accepts + * two parameters, `function(clone, scope) { ... }`, where the `clone` is a fresh compiled copy of your transcluded + * content and the `scope` is the newly created transclusion scope, which the clone will be linked to. + * + * <div class="alert alert-info"> + * **Best Practice**: Always provide a `cloneFn` (clone attach function) when you call a transclude function + * since you then get a fresh clone of the original DOM and also have access to the new transclusion scope. + * </div> + * + * It is normal practice to attach your transcluded content (`clone`) to the DOM inside your **clone + * attach function**: + * + * ```js + * var transcludedContent, transclusionScope; + * + * $transclude(function(clone, scope) { + * element.append(clone); + * transcludedContent = clone; + * transclusionScope = scope; + * }); + * ``` + * + * Later, if you want to remove the transcluded content from your DOM then you should also destroy the + * associated transclusion scope: + * + * ```js + * transcludedContent.remove(); + * transclusionScope.$destroy(); + * ``` + * + * <div class="alert alert-info"> + * **Best Practice**: if you intend to add and remove transcluded content manually in your directive + * (by calling the transclude function to get the DOM and calling `element.remove()` to remove it), + * then you are also responsible for calling `$destroy` on the transclusion scope. + * </div> + * + * The built-in DOM manipulation directives, such as {@link ngIf}, {@link ngSwitch} and {@link ngRepeat} + * automatically destroy their transcluded clones as necessary so you do not need to worry about this if + * you are simply using {@link ngTransclude} to inject the transclusion into your directive. + * + * + * #### Transclusion Scopes + * + * When you call a transclude function it returns a DOM fragment that is pre-bound to a **transclusion + * scope**. This scope is special, in that it is a child of the directive's scope (and so gets destroyed + * when the directive's scope gets destroyed) but it inherits the properties of the scope from which it + * was taken. + * + * For example consider a directive that uses transclusion and isolated scope. The DOM hierarchy might look + * like this: + * + * ```html + * <div ng-app> + * <div isolate> + * <div transclusion> + * </div> + * </div> + * </div> + * ``` + * + * The `$parent` scope hierarchy will look like this: + * + ``` + - $rootScope + - isolate + - transclusion + ``` + * + * but the scopes will inherit prototypically from different scopes to their `$parent`. + * + ``` + - $rootScope + - transclusion + - isolate + ``` + * * - * <a name="Attributes"></a> * ### Attributes * * The {@link ng.$compile.directive.Attributes Attributes} object - passed as a parameter in the * `link()` or `compile()` functions. It has a variety of uses. * - * accessing *Normalized attribute names:* - * Directives like 'ngBind' can be expressed in many ways: 'ng:bind', `data-ng-bind`, or 'x-ng-bind'. - * the attributes object allows for normalized access to - * the attributes. + * * *Accessing normalized attribute names:* Directives like 'ngBind' can be expressed in many ways: + * 'ng:bind', `data-ng-bind`, or 'x-ng-bind'. The attributes object allows for normalized access + * to the attributes. * * * *Directive inter-communication:* All directives share the same instance of the attributes * object which allows the directives to use the attributes object as inter directive @@ -5405,30 +7948,30 @@ * * ```js * function linkingFn(scope, elm, attrs, ctrl) { - * // get the attribute value - * console.log(attrs.ngModel); - * - * // change the attribute - * attrs.$set('ngModel', 'new value'); - * - * // observe changes to interpolated attribute - * attrs.$observe('ngModel', function(value) { - * console.log('ngModel has changed value to ' + value); - * }); - * } + * // get the attribute value + * console.log(attrs.ngModel); + * + * // change the attribute + * attrs.$set('ngModel', 'new value'); + * + * // observe changes to interpolated attribute + * attrs.$observe('ngModel', function(value) { + * console.log('ngModel has changed value to ' + value); + * }); + * } * ``` * - * Below is an example using `$compileProvider`. + * ## Example * * <div class="alert alert-warning"> * **Note**: Typically directives are registered with `module.directive`. The example below is * to illustrate how `$compile` works. * </div> * - <example module="compile"> + <example module="compileExample" name="compile"> <file name="index.html"> <script> - angular.module('compile', [], function($compileProvider) { + angular.module('compileExample', [], function($compileProvider) { // configure new 'compile' directive by passing a directive // factory function. The factory function injects the '$compile' $compileProvider.directive('compile', function($compile) { @@ -5452,17 +7995,16 @@ } ); }; - }) - }); - - function Ctrl($scope) { - $scope.name = 'Angular'; + }); + }) + .controller('GreeterController', ['$scope', function($scope) { + $scope.name = 'AngularJS'; $scope.html = 'Hello {{name}}'; - } + }]); </script> - <div ng-controller="Ctrl"> - <input ng-model="name"> <br> - <textarea ng-model="html"></textarea> <br> + <div ng-controller="GreeterController"> + <input ng-model="name"> <br/> + <textarea ng-model="html"></textarea> <br/> <div compile="html"></div> </div> </file> @@ -5470,11 +8012,11 @@ it('should auto compile', function() { var textarea = $('textarea'); var output = $('div[compile]'); - // The initial state reads 'Hello Angular'. - expect(output.getText()).toBe('Hello Angular'); + // The initial state reads 'Hello AngularJS'. + expect(output.getText()).toBe('Hello AngularJS'); textarea.clear(); textarea.sendKeys('{{name}}!'); - expect(output.getText()).toBe('Angular!'); + expect(output.getText()).toBe('AngularJS!'); }); </file> </example> @@ -5482,26 +8024,53 @@ * * * @param {string|DOMElement} element Element or HTML string to compile into a template function. - * @param {function(angular.Scope, cloneAttachFn=)} transclude function available to directives. + * @param {function(angular.Scope, cloneAttachFn=)} transclude function available to directives - DEPRECATED. + * + * <div class="alert alert-danger"> + * **Note:** Passing a `transclude` function to the $compile function is deprecated, as it + * e.g. will not use the right outer scope. Please pass the transclude function as a + * `parentBoundTranscludeFn` to the link function instead. + * </div> + * * @param {number} maxPriority only apply directives lower than given priority (Only effects the * root element(s), not their children) - * @returns {function(scope, cloneAttachFn=)} a link function which is used to bind template + * @returns {function(scope, cloneAttachFn=, options=)} a link function which is used to bind template * (a DOM element/tree) to a scope. Where: * * * `scope` - A {@link ng.$rootScope.Scope Scope} to bind to. * * `cloneAttachFn` - If `cloneAttachFn` is provided, then the link function will clone the * `template` and call the `cloneAttachFn` function allowing the caller to attach the * cloned elements to the DOM document at the appropriate place. The `cloneAttachFn` is - * called as: <br> `cloneAttachFn(clonedElement, scope)` where: + * called as: <br/> `cloneAttachFn(clonedElement, scope)` where: * * * `clonedElement` - is a clone of the original `element` passed into the compiler. * * `scope` - is the current scope with which the linking function is working with. * + * * `options` - An optional object hash with linking options. If `options` is provided, then the following + * keys may be used to control linking behavior: + * + * * `parentBoundTranscludeFn` - the transclude function made available to + * directives; if given, it will be passed through to the link functions of + * directives found in `element` during compilation. + * * `transcludeControllers` - an object hash with keys that map controller names + * to a hash with the key `instance`, which maps to the controller instance; + * if given, it will make the controllers available to directives on the compileNode: + * ``` + * { + * parent: { + * instance: parentControllerInstance + * } + * } + * ``` + * * `futureParentElement` - defines the parent to which the `cloneAttachFn` will add + * the cloned elements; only needed for transcludes that are allowed to contain non html + * elements (e.g. SVG elements). See also the directive.controller property. + * * Calling the linking function returns the element of the template. It is either the original * element passed in, or the clone of the element if the `cloneAttachFn` is provided. * * After linking the view is not updated until after a call to $digest which typically is done by - * Angular automatically. + * AngularJS automatically. * * If you need access to the bound view, there are two ways to do it: * @@ -5519,42 +8088,158 @@ * scope = ....; * * var clonedElement = $compile(templateElement)(scope, function(clonedElement, scope) { - * //attach the clone to DOM document at the right place - * }); + * //attach the clone to DOM document at the right place + * }); * * //now we have reference to the cloned DOM via `clonedElement` * ``` * * * For information on how the compiler works, see the - * {@link guide/compiler Angular HTML Compiler} section of the Developer Guide. + * {@link guide/compiler AngularJS HTML Compiler} section of the Developer Guide. + * + * @knownIssue + * + * ### Double Compilation + * + Double compilation occurs when an already compiled part of the DOM gets + compiled again. This is an undesired effect and can lead to misbehaving directives, performance issues, + and memory leaks. Refer to the Compiler Guide {@link guide/compiler#double-compilation-and-how-to-avoid-it + section on double compilation} for an in-depth explanation and ways to avoid it. + * */ var $compileMinErr = minErr('$compile'); + function UNINITIALIZED_VALUE() {} + var _UNINITIALIZED_VALUE = new UNINITIALIZED_VALUE(); + /** * @ngdoc provider * @name $compileProvider - * @function * * @description */ $CompileProvider.$inject = ['$provide', '$$sanitizeUriProvider']; + /** @this */ function $CompileProvider($provide, $$sanitizeUriProvider) { var hasDirectives = {}, Suffix = 'Directive', - COMMENT_DIRECTIVE_REGEXP = /^\s*directive\:\s*([\d\w\-_]+)\s+(.*)$/, - CLASS_DIRECTIVE_REGEXP = /(([\d\w\-_]+)(?:\:([^;]+))?;?)/; + COMMENT_DIRECTIVE_REGEXP = /^\s*directive:\s*([\w-]+)\s+(.*)$/, + CLASS_DIRECTIVE_REGEXP = /(([\w-]+)(?::([^;]+))?;?)/, + ALL_OR_NOTHING_ATTRS = makeMap('ngSrc,ngSrcset,src,srcset'), + REQUIRE_PREFIX_REGEXP = /^(?:(\^\^?)?(\?)?(\^\^?)?)?/; // Ref: http://developers.whatwg.org/webappapis.html#event-handler-idl-attributes // The assumption is that future DOM event attribute names will begin with // 'on' and be composed of only English letters. var EVENT_HANDLER_ATTR_REGEXP = /^(on[a-z]+|formaction)$/; + var bindingCache = createMap(); + + function parseIsolateBindings(scope, directiveName, isController) { + var LOCAL_REGEXP = /^\s*([@&<]|=(\*?))(\??)\s*([\w$]*)\s*$/; + + var bindings = createMap(); + + forEach(scope, function(definition, scopeName) { + if (definition in bindingCache) { + bindings[scopeName] = bindingCache[definition]; + return; + } + var match = definition.match(LOCAL_REGEXP); + + if (!match) { + throw $compileMinErr('iscp', + 'Invalid {3} for directive \'{0}\'.' + + ' Definition: {... {1}: \'{2}\' ...}', + directiveName, scopeName, definition, + (isController ? 'controller bindings definition' : + 'isolate scope definition')); + } + + bindings[scopeName] = { + mode: match[1][0], + collection: match[2] === '*', + optional: match[3] === '?', + attrName: match[4] || scopeName + }; + if (match[4]) { + bindingCache[definition] = bindings[scopeName]; + } + }); + + return bindings; + } + + function parseDirectiveBindings(directive, directiveName) { + var bindings = { + isolateScope: null, + bindToController: null + }; + if (isObject(directive.scope)) { + if (directive.bindToController === true) { + bindings.bindToController = parseIsolateBindings(directive.scope, + directiveName, true); + bindings.isolateScope = {}; + } else { + bindings.isolateScope = parseIsolateBindings(directive.scope, + directiveName, false); + } + } + if (isObject(directive.bindToController)) { + bindings.bindToController = + parseIsolateBindings(directive.bindToController, directiveName, true); + } + if (bindings.bindToController && !directive.controller) { + // There is no controller + throw $compileMinErr('noctrl', + 'Cannot bind to controller without directive \'{0}\'s controller.', + directiveName); + } + return bindings; + } + + function assertValidDirectiveName(name) { + var letter = name.charAt(0); + if (!letter || letter !== lowercase(letter)) { + throw $compileMinErr('baddir', 'Directive/Component name \'{0}\' is invalid. The first character must be a lowercase letter', name); + } + if (name !== name.trim()) { + throw $compileMinErr('baddir', + 'Directive/Component name \'{0}\' is invalid. The name should not contain leading or trailing whitespaces', + name); + } + } + + function getDirectiveRequire(directive) { + var require = directive.require || (directive.controller && directive.name); + + if (!isArray(require) && isObject(require)) { + forEach(require, function(value, key) { + var match = value.match(REQUIRE_PREFIX_REGEXP); + var name = value.substring(match[0].length); + if (!name) require[key] = match[0] + key; + }); + } + + return require; + } + + function getDirectiveRestrict(restrict, name) { + if (restrict && !(isString(restrict) && /[EACM]/.test(restrict))) { + throw $compileMinErr('badrestrict', + 'Restrict property \'{0}\' of directive \'{1}\' is invalid', + restrict, + name); + } + + return restrict || 'EA'; + } /** * @ngdoc method * @name $compileProvider#directive - * @function + * @kind function * * @description * Register a new directive with the compiler. @@ -5562,13 +8247,15 @@ * @param {string|Object} name Name of the directive in camel-case (i.e. <code>ngBind</code> which * will match as <code>ng-bind</code>), or an object map of directives where the keys are the * names and the values are the factories. - * @param {Function|Array} directiveFactory An injectable directive factory function. See - * {@link guide/directive} for more info. + * @param {Function|Array} directiveFactory An injectable directive factory function. See the + * {@link guide/directive directive guide} and the {@link $compile compile API} for more info. * @returns {ng.$compileProvider} Self for chaining. */ this.directive = function registerDirective(name, directiveFactory) { + assertArg(name, 'name'); assertNotHasOwnProperty(name, 'directive'); if (isString(name)) { + assertValidDirectiveName(name); assertArg(directiveFactory, 'directiveFactory'); if (!hasDirectives.hasOwnProperty(name)) { hasDirectives[name] = []; @@ -5586,8 +8273,9 @@ directive.priority = directive.priority || 0; directive.index = index; directive.name = directive.name || name; - directive.require = directive.require || (directive.controller && directive.name); - directive.restrict = directive.restrict || 'A'; + directive.require = getDirectiveRequire(directive); + directive.restrict = getDirectiveRestrict(directive.restrict, name); + directive.$$moduleName = directiveFactory.$$moduleName; directives.push(directive); } catch (e) { $exceptionHandler(e); @@ -5603,17 +8291,164 @@ return this; }; + /** + * @ngdoc method + * @name $compileProvider#component + * @module ng + * @param {string|Object} name Name of the component in camelCase (i.e. `myComp` which will match `<my-comp>`), + * or an object map of components where the keys are the names and the values are the component definition objects. + * @param {Object} options Component definition object (a simplified + * {@link ng.$compile#directive-definition-object directive definition object}), + * with the following properties (all optional): + * + * - `controller` – `{(string|function()=}` – controller constructor function that should be + * associated with newly created scope or the name of a {@link ng.$compile#-controller- + * registered controller} if passed as a string. An empty `noop` function by default. + * - `controllerAs` – `{string=}` – identifier name for to reference the controller in the component's scope. + * If present, the controller will be published to scope under the `controllerAs` name. + * If not present, this will default to be `$ctrl`. + * - `template` – `{string=|function()=}` – html template as a string or a function that + * returns an html template as a string which should be used as the contents of this component. + * Empty string by default. + * + * If `template` is a function, then it is {@link auto.$injector#invoke injected} with + * the following locals: + * + * - `$element` - Current element + * - `$attrs` - Current attributes object for the element + * + * - `templateUrl` – `{string=|function()=}` – path or function that returns a path to an html + * template that should be used as the contents of this component. + * + * If `templateUrl` is a function, then it is {@link auto.$injector#invoke injected} with + * the following locals: + * + * - `$element` - Current element + * - `$attrs` - Current attributes object for the element + * + * - `bindings` – `{object=}` – defines bindings between DOM attributes and component properties. + * Component properties are always bound to the component controller and not to the scope. + * See {@link ng.$compile#-bindtocontroller- `bindToController`}. + * - `transclude` – `{boolean=}` – whether {@link $compile#transclusion content transclusion} is enabled. + * Disabled by default. + * - `require` - `{Object<string, string>=}` - requires the controllers of other directives and binds them to + * this component's controller. The object keys specify the property names under which the required + * controllers (object values) will be bound. See {@link ng.$compile#-require- `require`}. + * - `$...` – additional properties to attach to the directive factory function and the controller + * constructor function. (This is used by the component router to annotate) + * + * @returns {ng.$compileProvider} the compile provider itself, for chaining of function calls. + * @description + * Register a **component definition** with the compiler. This is a shorthand for registering a special + * type of directive, which represents a self-contained UI component in your application. Such components + * are always isolated (i.e. `scope: {}`) and are always restricted to elements (i.e. `restrict: 'E'`). + * + * Component definitions are very simple and do not require as much configuration as defining general + * directives. Component definitions usually consist only of a template and a controller backing it. + * + * In order to make the definition easier, components enforce best practices like use of `controllerAs`, + * `bindToController`. They always have **isolate scope** and are restricted to elements. + * + * Here are a few examples of how you would usually define components: + * + * ```js + * var myMod = angular.module(...); + * myMod.component('myComp', { + * template: '<div>My name is {{$ctrl.name}}</div>', + * controller: function() { + * this.name = 'shahar'; + * } + * }); + * + * myMod.component('myComp', { + * template: '<div>My name is {{$ctrl.name}}</div>', + * bindings: {name: '@'} + * }); + * + * myMod.component('myComp', { + * templateUrl: 'views/my-comp.html', + * controller: 'MyCtrl', + * controllerAs: 'ctrl', + * bindings: {name: '@'} + * }); + * + * ``` + * For more examples, and an in-depth guide, see the {@link guide/component component guide}. + * + * <br /> + * See also {@link ng.$compileProvider#directive $compileProvider.directive()}. + */ + this.component = function registerComponent(name, options) { + if (!isString(name)) { + forEach(name, reverseParams(bind(this, registerComponent))); + return this; + } + + var controller = options.controller || function() {}; + + function factory($injector) { + function makeInjectable(fn) { + if (isFunction(fn) || isArray(fn)) { + return /** @this */ function(tElement, tAttrs) { + return $injector.invoke(fn, this, {$element: tElement, $attrs: tAttrs}); + }; + } else { + return fn; + } + } + + var template = (!options.template && !options.templateUrl ? '' : options.template); + var ddo = { + controller: controller, + controllerAs: identifierForController(options.controller) || options.controllerAs || '$ctrl', + template: makeInjectable(template), + templateUrl: makeInjectable(options.templateUrl), + transclude: options.transclude, + scope: {}, + bindToController: options.bindings || {}, + restrict: 'E', + require: options.require + }; + + // Copy annotations (starting with $) over to the DDO + forEach(options, function(val, key) { + if (key.charAt(0) === '$') ddo[key] = val; + }); + + return ddo; + } + + // TODO(pete) remove the following `forEach` before we release 1.6.0 + // The component-router@0.2.0 looks for the annotations on the controller constructor + // Nothing in AngularJS looks for annotations on the factory function but we can't remove + // it from 1.5.x yet. + + // Copy any annotation properties (starting with $) over to the factory and controller constructor functions + // These could be used by libraries such as the new component router + forEach(options, function(val, key) { + if (key.charAt(0) === '$') { + factory[key] = val; + // Don't try to copy over annotations to named controller + if (isFunction(controller)) controller[key] = val; + } + }); + + factory.$inject = ['$injector']; + + return this.directive(name, factory); + }; + /** * @ngdoc method * @name $compileProvider#aHrefSanitizationWhitelist - * @function + * @kind function * * @description * Retrieves or overrides the default regular expression that is used for whitelisting of safe * urls during a[href] sanitization. * - * The sanitization is a security measure aimed at prevent XSS attacks via html links. + * The sanitization is a security measure aimed at preventing XSS attacks via html links. * * Any url about to be assigned to a[href] via data-binding is first normalized and turned into * an absolute url. Afterwards, the url is matched against the `aHrefSanitizationWhitelist` @@ -5637,7 +8472,7 @@ /** * @ngdoc method * @name $compileProvider#imgSrcSanitizationWhitelist - * @function + * @kind function * * @description * Retrieves or overrides the default regular expression that is used for whitelisting of safe @@ -5663,42 +8498,295 @@ } }; - this.$get = [ - '$injector', '$interpolate', '$exceptionHandler', '$http', '$templateCache', '$parse', - '$controller', '$rootScope', '$document', '$sce', '$animate', '$$sanitizeUri', - function($injector, $interpolate, $exceptionHandler, $http, $templateCache, $parse, - $controller, $rootScope, $document, $sce, $animate, $$sanitizeUri) { - - var Attributes = function(element, attr) { - this.$$element = element; - this.$attr = attr || {}; - }; + /** + * @ngdoc method + * @name $compileProvider#debugInfoEnabled + * + * @param {boolean=} enabled update the debugInfoEnabled state if provided, otherwise just return the + * current debugInfoEnabled state + * @returns {*} current value if used as getter or itself (chaining) if used as setter + * + * @kind function + * + * @description + * Call this method to enable/disable various debug runtime information in the compiler such as adding + * binding information and a reference to the current scope on to DOM elements. + * If enabled, the compiler will add the following to DOM elements that have been bound to the scope + * * `ng-binding` CSS class + * * `ng-scope` and `ng-isolated-scope` CSS classes + * * `$binding` data property containing an array of the binding expressions + * * Data properties used by the {@link angular.element#methods `scope()`/`isolateScope()` methods} to return + * the element's scope. + * * Placeholder comments will contain information about what directive and binding caused the placeholder. + * E.g. `<!-- ngIf: shouldShow() -->`. + * + * You may want to disable this in production for a significant performance boost. See + * {@link guide/production#disabling-debug-data Disabling Debug Data} for more. + * + * The default value is true. + */ + var debugInfoEnabled = true; + this.debugInfoEnabled = function(enabled) { + if (isDefined(enabled)) { + debugInfoEnabled = enabled; + return this; + } + return debugInfoEnabled; + }; - Attributes.prototype = { - $normalize: directiveNormalize, + /** + * @ngdoc method + * @name $compileProvider#preAssignBindingsEnabled + * + * @param {boolean=} enabled update the preAssignBindingsEnabled state if provided, otherwise just return the + * current preAssignBindingsEnabled state + * @returns {*} current value if used as getter or itself (chaining) if used as setter + * + * @kind function + * + * @description + * Call this method to enable/disable whether directive controllers are assigned bindings before + * calling the controller's constructor. + * If enabled (true), the compiler assigns the value of each of the bindings to the + * properties of the controller object before the constructor of this object is called. + * + * If disabled (false), the compiler calls the constructor first before assigning bindings. + * + * The default value is false. + * + * @deprecated + * sinceVersion="1.6.0" + * removeVersion="1.7.0" + * + * This method and the option to assign the bindings before calling the controller's constructor + * will be removed in v1.7.0. + */ + var preAssignBindingsEnabled = false; + this.preAssignBindingsEnabled = function(enabled) { + if (isDefined(enabled)) { + preAssignBindingsEnabled = enabled; + return this; + } + return preAssignBindingsEnabled; + }; + /** + * @ngdoc method + * @name $compileProvider#strictComponentBindingsEnabled + * + * @param {boolean=} enabled update the strictComponentBindingsEnabled state if provided, otherwise just return the + * current strictComponentBindingsEnabled state + * @returns {*} current value if used as getter or itself (chaining) if used as setter + * + * @kind function + * + * @description + * Call this method to enable/disable strict component bindings check. If enabled, the compiler will enforce that + * for all bindings of a component that are not set as optional with `?`, an attribute needs to be provided + * on the component's HTML tag. + * + * The default value is false. + */ + var strictComponentBindingsEnabled = false; + this.strictComponentBindingsEnabled = function(enabled) { + if (isDefined(enabled)) { + strictComponentBindingsEnabled = enabled; + return this; + } + return strictComponentBindingsEnabled; + }; - /** - * @ngdoc method - * @name $compile.directive.Attributes#$addClass - * @function - * - * @description - * Adds the CSS class value specified by the classVal parameter to the element. If animations - * are enabled then an animation will be triggered for the class addition. - * - * @param {string} classVal The className value that will be added to the element - */ - $addClass : function(classVal) { - if(classVal && classVal.length > 0) { - $animate.addClass(this.$$element, classVal); + var TTL = 10; + /** + * @ngdoc method + * @name $compileProvider#onChangesTtl + * @description + * + * Sets the number of times `$onChanges` hooks can trigger new changes before giving up and + * assuming that the model is unstable. + * + * The current default is 10 iterations. + * + * In complex applications it's possible that dependencies between `$onChanges` hooks and bindings will result + * in several iterations of calls to these hooks. However if an application needs more than the default 10 + * iterations to stabilize then you should investigate what is causing the model to continuously change during + * the `$onChanges` hook execution. + * + * Increasing the TTL could have performance implications, so you should not change it without proper justification. + * + * @param {number} limit The number of `$onChanges` hook iterations. + * @returns {number|object} the current limit (or `this` if called as a setter for chaining) + */ + this.onChangesTtl = function(value) { + if (arguments.length) { + TTL = value; + return this; + } + return TTL; + }; + + var commentDirectivesEnabledConfig = true; + /** + * @ngdoc method + * @name $compileProvider#commentDirectivesEnabled + * @description + * + * It indicates to the compiler + * whether or not directives on comments should be compiled. + * Defaults to `true`. + * + * Calling this function with false disables the compilation of directives + * on comments for the whole application. + * This results in a compilation performance gain, + * as the compiler doesn't have to check comments when looking for directives. + * This should however only be used if you are sure that no comment directives are used in + * the application (including any 3rd party directives). + * + * @param {boolean} enabled `false` if the compiler may ignore directives on comments + * @returns {boolean|object} the current value (or `this` if called as a setter for chaining) + */ + this.commentDirectivesEnabled = function(value) { + if (arguments.length) { + commentDirectivesEnabledConfig = value; + return this; + } + return commentDirectivesEnabledConfig; + }; + + + var cssClassDirectivesEnabledConfig = true; + /** + * @ngdoc method + * @name $compileProvider#cssClassDirectivesEnabled + * @description + * + * It indicates to the compiler + * whether or not directives on element classes should be compiled. + * Defaults to `true`. + * + * Calling this function with false disables the compilation of directives + * on element classes for the whole application. + * This results in a compilation performance gain, + * as the compiler doesn't have to check element classes when looking for directives. + * This should however only be used if you are sure that no class directives are used in + * the application (including any 3rd party directives). + * + * @param {boolean} enabled `false` if the compiler may ignore directives on element classes + * @returns {boolean|object} the current value (or `this` if called as a setter for chaining) + */ + this.cssClassDirectivesEnabled = function(value) { + if (arguments.length) { + cssClassDirectivesEnabledConfig = value; + return this; + } + return cssClassDirectivesEnabledConfig; + }; + + this.$get = [ + '$injector', '$interpolate', '$exceptionHandler', '$templateRequest', '$parse', + '$controller', '$rootScope', '$sce', '$animate', '$$sanitizeUri', + function($injector, $interpolate, $exceptionHandler, $templateRequest, $parse, + $controller, $rootScope, $sce, $animate, $$sanitizeUri) { + + var SIMPLE_ATTR_NAME = /^\w/; + var specialAttrHolder = window.document.createElement('div'); + + + var commentDirectivesEnabled = commentDirectivesEnabledConfig; + var cssClassDirectivesEnabled = cssClassDirectivesEnabledConfig; + + + var onChangesTtl = TTL; + // The onChanges hooks should all be run together in a single digest + // When changes occur, the call to trigger their hooks will be added to this queue + var onChangesQueue; + + // This function is called in a $$postDigest to trigger all the onChanges hooks in a single digest + function flushOnChangesQueue() { + try { + if (!(--onChangesTtl)) { + // We have hit the TTL limit so reset everything + onChangesQueue = undefined; + throw $compileMinErr('infchng', '{0} $onChanges() iterations reached. Aborting!\n', TTL); + } + // We must run this hook in an apply since the $$postDigest runs outside apply + $rootScope.$apply(function() { + var errors = []; + for (var i = 0, ii = onChangesQueue.length; i < ii; ++i) { + try { + onChangesQueue[i](); + } catch (e) { + errors.push(e); + } + } + // Reset the queue to trigger a new schedule next time there is a change + onChangesQueue = undefined; + if (errors.length) { + throw errors; + } + }); + } finally { + onChangesTtl++; + } + } + + + function Attributes(element, attributesToCopy) { + if (attributesToCopy) { + var keys = Object.keys(attributesToCopy); + var i, l, key; + + for (i = 0, l = keys.length; i < l; i++) { + key = keys[i]; + this[key] = attributesToCopy[key]; + } + } else { + this.$attr = {}; + } + + this.$$element = element; + } + + Attributes.prototype = { + /** + * @ngdoc method + * @name $compile.directive.Attributes#$normalize + * @kind function + * + * @description + * Converts an attribute name (e.g. dash/colon/underscore-delimited string, optionally prefixed with `x-` or + * `data-`) to its normalized, camelCase form. + * + * Also there is special case for Moz prefix starting with upper case letter. + * + * For further information check out the guide on {@link guide/directive#matching-directives Matching Directives} + * + * @param {string} name Name to normalize + */ + $normalize: directiveNormalize, + + + /** + * @ngdoc method + * @name $compile.directive.Attributes#$addClass + * @kind function + * + * @description + * Adds the CSS class value specified by the classVal parameter to the element. If animations + * are enabled then an animation will be triggered for the class addition. + * + * @param {string} classVal The className value that will be added to the element + */ + $addClass: function(classVal) { + if (classVal && classVal.length > 0) { + $animate.addClass(this.$$element, classVal); } }, /** * @ngdoc method * @name $compile.directive.Attributes#$removeClass - * @function + * @kind function * * @description * Removes the CSS class value specified by the classVal parameter from the element. If @@ -5706,8 +8794,8 @@ * * @param {string} classVal The className value that will be removed from the element */ - $removeClass : function(classVal) { - if(classVal && classVal.length > 0) { + $removeClass: function(classVal) { + if (classVal && classVal.length > 0) { $animate.removeClass(this.$$element, classVal); } }, @@ -5715,7 +8803,7 @@ /** * @ngdoc method * @name $compile.directive.Attributes#$updateClass - * @function + * @kind function * * @description * Adds and removes the appropriate CSS class values to the element based on the difference @@ -5724,16 +8812,15 @@ * @param {string} newClasses The current CSS className value * @param {string} oldClasses The former CSS className value */ - $updateClass : function(newClasses, oldClasses) { + $updateClass: function(newClasses, oldClasses) { var toAdd = tokenDifference(newClasses, oldClasses); - var toRemove = tokenDifference(oldClasses, newClasses); + if (toAdd && toAdd.length) { + $animate.addClass(this.$$element, toAdd); + } - if(toAdd.length === 0) { + var toRemove = tokenDifference(oldClasses, newClasses); + if (toRemove && toRemove.length) { $animate.removeClass(this.$$element, toRemove); - } else if(toRemove.length === 0) { - $animate.addClass(this.$$element, toAdd); - } else { - $animate.setClass(this.$$element, toAdd, toRemove); } }, @@ -5751,13 +8838,18 @@ //is set through this function since it may cause $updateClass to //become unstable. - var booleanKey = getBooleanAttrName(this.$$element[0], key), - normalizedVal, + var node = this.$$element[0], + booleanKey = getBooleanAttrName(node, key), + aliasedKey = getAliasedAttrName(key), + observer = key, nodeName; if (booleanKey) { this.$$element.prop(key, value); attrName = booleanKey; + } else if (aliasedKey) { + this[aliasedKey] = value; + observer = aliasedKey; } this[key] = value; @@ -5774,36 +8866,76 @@ nodeName = nodeName_(this.$$element); - // sanitize a[href] and img[src] values - if ((nodeName === 'A' && key === 'href') || - (nodeName === 'IMG' && key === 'src')) { + if ((nodeName === 'a' && (key === 'href' || key === 'xlinkHref')) || + (nodeName === 'img' && key === 'src')) { + // sanitize a[href] and img[src] values this[key] = value = $$sanitizeUri(value, key === 'src'); + } else if (nodeName === 'img' && key === 'srcset' && isDefined(value)) { + // sanitize img[srcset] values + var result = ''; + + // first check if there are spaces because it's not the same pattern + var trimmedSrcset = trim(value); + // ( 999x ,| 999w ,| ,|, ) + var srcPattern = /(\s+\d+x\s*,|\s+\d+w\s*,|\s+,|,\s+)/; + var pattern = /\s/.test(trimmedSrcset) ? srcPattern : /(,)/; + + // split srcset into tuple of uri and descriptor except for the last item + var rawUris = trimmedSrcset.split(pattern); + + // for each tuples + var nbrUrisWith2parts = Math.floor(rawUris.length / 2); + for (var i = 0; i < nbrUrisWith2parts; i++) { + var innerIdx = i * 2; + // sanitize the uri + result += $$sanitizeUri(trim(rawUris[innerIdx]), true); + // add the descriptor + result += (' ' + trim(rawUris[innerIdx + 1])); + } + + // split the last item into uri and descriptor + var lastTuple = trim(rawUris[i * 2]).split(/\s/); + + // sanitize the last uri + result += $$sanitizeUri(trim(lastTuple[0]), true); + + // and add the last descriptor if any + if (lastTuple.length === 2) { + result += (' ' + trim(lastTuple[1])); + } + this[key] = value = result; } if (writeAttr !== false) { - if (value === null || value === undefined) { + if (value === null || isUndefined(value)) { this.$$element.removeAttr(attrName); } else { - this.$$element.attr(attrName, value); + if (SIMPLE_ATTR_NAME.test(attrName)) { + this.$$element.attr(attrName, value); + } else { + setSpecialAttr(this.$$element[0], attrName, value); + } } } // fire observers var $$observers = this.$$observers; - $$observers && forEach($$observers[key], function(fn) { - try { - fn(value); - } catch (e) { - $exceptionHandler(e); - } - }); + if ($$observers) { + forEach($$observers[observer], function(fn) { + try { + fn(value); + } catch (e) { + $exceptionHandler(e); + } + }); + } }, /** * @ngdoc method * @name $compile.directive.Attributes#$observe - * @function + * @kind function * * @description * Observes an interpolated attribute. @@ -5815,34 +8947,95 @@ * @param {string} key Normalized key. (ie ngAttribute) . * @param {function(interpolatedValue)} fn Function that will be called whenever the interpolated value of the attribute changes. - * See the {@link guide/directive#Attributes Directives} guide for more info. - * @returns {function()} the `fn` parameter. + * See the {@link guide/interpolation#how-text-and-attribute-bindings-work Interpolation + * guide} for more info. + * @returns {function()} Returns a deregistration function for this observer. */ $observe: function(key, fn) { var attrs = this, - $$observers = (attrs.$$observers || (attrs.$$observers = {})), + $$observers = (attrs.$$observers || (attrs.$$observers = createMap())), listeners = ($$observers[key] || ($$observers[key] = [])); listeners.push(fn); $rootScope.$evalAsync(function() { - if (!listeners.$$inter) { + if (!listeners.$$inter && attrs.hasOwnProperty(key) && !isUndefined(attrs[key])) { // no one registered attribute interpolation function, so lets call it manually fn(attrs[key]); } }); - return fn; + + return function() { + arrayRemove(listeners, fn); + }; } }; + function setSpecialAttr(element, attrName, value) { + // Attributes names that do not start with letters (such as `(click)`) cannot be set using `setAttribute` + // so we have to jump through some hoops to get such an attribute + // https://github.com/angular/angular.js/pull/13318 + specialAttrHolder.innerHTML = '<span ' + attrName + '>'; + var attributes = specialAttrHolder.firstChild.attributes; + var attribute = attributes[0]; + // We have to remove the attribute from its container element before we can add it to the destination element + attributes.removeNamedItem(attribute.name); + attribute.value = value; + element.attributes.setNamedItem(attribute); + } + + function safeAddClass($element, className) { + try { + $element.addClass(className); + } catch (e) { + // ignore, since it means that we are trying to set class on + // SVG element, where class name is read-only. + } + } + + var startSymbol = $interpolate.startSymbol(), endSymbol = $interpolate.endSymbol(), - denormalizeTemplate = (startSymbol == '{{' || endSymbol == '}}') + denormalizeTemplate = (startSymbol === '{{' && endSymbol === '}}') ? identity : function denormalizeTemplate(template) { - return template.replace(/\{\{/g, startSymbol).replace(/}}/g, endSymbol); - }, + return template.replace(/\{\{/g, startSymbol).replace(/}}/g, endSymbol); + }, NG_ATTR_BINDING = /^ngAttr[A-Z]/; + var MULTI_ELEMENT_DIR_RE = /^(.+)Start$/; + + compile.$$addBindingInfo = debugInfoEnabled ? function $$addBindingInfo($element, binding) { + var bindings = $element.data('$binding') || []; + if (isArray(binding)) { + bindings = bindings.concat(binding); + } else { + bindings.push(binding); + } + + $element.data('$binding', bindings); + } : noop; + + compile.$$addBindingClass = debugInfoEnabled ? function $$addBindingClass($element) { + safeAddClass($element, 'ng-binding'); + } : noop; + + compile.$$addScopeInfo = debugInfoEnabled ? function $$addScopeInfo($element, scope, isolated, noTemplate) { + var dataName = isolated ? (noTemplate ? '$isolateScopeNoTemplate' : '$isolateScope') : '$scope'; + $element.data(dataName, scope); + } : noop; + + compile.$$addScopeClass = debugInfoEnabled ? function $$addScopeClass($element, isolated) { + safeAddClass($element, isolated ? 'ng-isolate-scope' : 'ng-scope'); + } : noop; + + compile.$$createComment = function(directiveName, comment) { + var content = ''; + if (debugInfoEnabled) { + content = ' ' + (directiveName || '') + ': '; + if (comment) content += comment + ' '; + } + return window.document.createComment(content); + }; return compile; @@ -5855,50 +9048,84 @@ // modify it. $compileNodes = jqLite($compileNodes); } - // We can not compile top level text elements since text nodes can be merged and we will - // not be able to attach scope data to them, so we will wrap them in <span> - forEach($compileNodes, function(node, index){ - if (node.nodeType == 3 /* text node */ && node.nodeValue.match(/\S+/) /* non-empty */ ) { - $compileNodes[index] = node = jqLite(node).wrap('<span></span>').parent()[0]; - } - }); var compositeLinkFn = compileNodes($compileNodes, transcludeFn, $compileNodes, maxPriority, ignoreDirective, previousCompileContext); - safeAddClass($compileNodes, 'ng-scope'); - return function publicLinkFn(scope, cloneConnectFn, transcludeControllers){ + compile.$$addScopeClass($compileNodes); + var namespace = null; + return function publicLinkFn(scope, cloneConnectFn, options) { + if (!$compileNodes) { + throw $compileMinErr('multilink', 'This element has already been linked.'); + } assertArg(scope, 'scope'); - // important!!: we must call our jqLite.clone() since the jQuery one is trying to be smart - // and sometimes changes the structure of the DOM. - var $linkNode = cloneConnectFn - ? JQLitePrototype.clone.call($compileNodes) // IMPORTANT!!! - : $compileNodes; - - forEach(transcludeControllers, function(instance, name) { - $linkNode.data('$' + name + 'Controller', instance); - }); - // Attach scope only to non-text nodes. - for(var i = 0, ii = $linkNode.length; i<ii; i++) { - var node = $linkNode[i], - nodeType = node.nodeType; - if (nodeType === 1 /* element */ || nodeType === 9 /* document */) { - $linkNode.eq(i).data('$scope', scope); + if (previousCompileContext && previousCompileContext.needsNewScope) { + // A parent directive did a replace and a directive on this element asked + // for transclusion, which caused us to lose a layer of element on which + // we could hold the new transclusion scope, so we will create it manually + // here. + scope = scope.$parent.$new(); + } + + options = options || {}; + var parentBoundTranscludeFn = options.parentBoundTranscludeFn, + transcludeControllers = options.transcludeControllers, + futureParentElement = options.futureParentElement; + + // When `parentBoundTranscludeFn` is passed, it is a + // `controllersBoundTransclude` function (it was previously passed + // as `transclude` to directive.link) so we must unwrap it to get + // its `boundTranscludeFn` + if (parentBoundTranscludeFn && parentBoundTranscludeFn.$$boundTransclude) { + parentBoundTranscludeFn = parentBoundTranscludeFn.$$boundTransclude; + } + + if (!namespace) { + namespace = detectNamespaceForChildElements(futureParentElement); + } + var $linkNode; + if (namespace !== 'html') { + // When using a directive with replace:true and templateUrl the $compileNodes + // (or a child element inside of them) + // might change, so we need to recreate the namespace adapted compileNodes + // for call to the link function. + // Note: This will already clone the nodes... + $linkNode = jqLite( + wrapTemplate(namespace, jqLite('<div>').append($compileNodes).html()) + ); + } else if (cloneConnectFn) { + // important!!: we must call our jqLite.clone() since the jQuery one is trying to be smart + // and sometimes changes the structure of the DOM. + $linkNode = JQLitePrototype.clone.call($compileNodes); + } else { + $linkNode = $compileNodes; + } + + if (transcludeControllers) { + for (var controllerName in transcludeControllers) { + $linkNode.data('$' + controllerName + 'Controller', transcludeControllers[controllerName].instance); } } + compile.$$addScopeInfo($linkNode, scope); + if (cloneConnectFn) cloneConnectFn($linkNode, scope); - if (compositeLinkFn) compositeLinkFn(scope, $linkNode, $linkNode); + if (compositeLinkFn) compositeLinkFn(scope, $linkNode, $linkNode, parentBoundTranscludeFn); + + if (!cloneConnectFn) { + $compileNodes = compositeLinkFn = null; + } return $linkNode; }; } - function safeAddClass($element, className) { - try { - $element.addClass(className); - } catch(e) { - // ignore, since it means that we are trying to set class on - // SVG element, where class name is read-only. + function detectNamespaceForChildElements(parentElement) { + // TODO: Make this detect MathML as well... + var node = parentElement && parentElement[0]; + if (!node) { + return 'html'; + } else { + return nodeName_(node) !== 'foreignobject' && toString.call(node).match(/SVG/) ? 'svg' : 'html'; } } @@ -5920,33 +9147,50 @@ function compileNodes(nodeList, transcludeFn, $rootElement, maxPriority, ignoreDirective, previousCompileContext) { var linkFns = [], - attrs, directives, nodeLinkFn, childNodes, childLinkFn, linkFnFound; + // `nodeList` can be either an element's `.childNodes` (live NodeList) + // or a jqLite/jQuery collection or an array + notLiveList = isArray(nodeList) || (nodeList instanceof jqLite), + attrs, directives, nodeLinkFn, childNodes, childLinkFn, linkFnFound, nodeLinkFnFound; + for (var i = 0; i < nodeList.length; i++) { attrs = new Attributes(); - // we must always refer to nodeList[i] since the nodes can be replaced underneath us. + // Support: IE 11 only + // Workaround for #11781 and #14924 + if (msie === 11) { + mergeConsecutiveTextNodes(nodeList, i, notLiveList); + } + + // We must always refer to `nodeList[i]` hereafter, + // since the nodes can be replaced underneath us. directives = collectDirectives(nodeList[i], [], attrs, i === 0 ? maxPriority : undefined, ignoreDirective); nodeLinkFn = (directives.length) ? applyDirectivesToNode(directives, nodeList[i], attrs, transcludeFn, $rootElement, - null, [], [], previousCompileContext) + null, [], [], previousCompileContext) : null; if (nodeLinkFn && nodeLinkFn.scope) { - safeAddClass(jqLite(nodeList[i]), 'ng-scope'); + compile.$$addScopeClass(attrs.$$element); } childLinkFn = (nodeLinkFn && nodeLinkFn.terminal || - !(childNodes = nodeList[i].childNodes) || - !childNodes.length) + !(childNodes = nodeList[i].childNodes) || + !childNodes.length) ? null : compileNodes(childNodes, - nodeLinkFn ? nodeLinkFn.transclude : transcludeFn); + nodeLinkFn ? ( + (nodeLinkFn.transcludeOnThisElement || !nodeLinkFn.templateOnThisElement) + && nodeLinkFn.transclude) : transcludeFn); + + if (nodeLinkFn || childLinkFn) { + linkFns.push(i, nodeLinkFn, childLinkFn); + linkFnFound = true; + nodeLinkFnFound = nodeLinkFnFound || nodeLinkFn; + } - linkFns.push(nodeLinkFn, childLinkFn); - linkFnFound = linkFnFound || nodeLinkFn || childLinkFn; //use the previous context only for the first element in the virtual group previousCompileContext = null; } @@ -5954,60 +9198,115 @@ // return a linking function if we have found anything, null otherwise return linkFnFound ? compositeLinkFn : null; - function compositeLinkFn(scope, nodeList, $rootElement, boundTranscludeFn) { - var nodeLinkFn, childLinkFn, node, $node, childScope, childTranscludeFn, i, ii, n; + function compositeLinkFn(scope, nodeList, $rootElement, parentBoundTranscludeFn) { + var nodeLinkFn, childLinkFn, node, childScope, i, ii, idx, childBoundTranscludeFn; + var stableNodeList; - // copy nodeList so that linking doesn't break due to live list updates. - var nodeListLength = nodeList.length, + + if (nodeLinkFnFound) { + // copy nodeList so that if a nodeLinkFn removes or adds an element at this DOM level our + // offsets don't get screwed up + var nodeListLength = nodeList.length; stableNodeList = new Array(nodeListLength); - for (i = 0; i < nodeListLength; i++) { - stableNodeList[i] = nodeList[i]; + + // create a sparse array by only copying the elements which have a linkFn + for (i = 0; i < linkFns.length; i += 3) { + idx = linkFns[i]; + stableNodeList[idx] = nodeList[idx]; + } + } else { + stableNodeList = nodeList; } - for(i = 0, n = 0, ii = linkFns.length; i < ii; n++) { - node = stableNodeList[n]; + for (i = 0, ii = linkFns.length; i < ii;) { + node = stableNodeList[linkFns[i++]]; nodeLinkFn = linkFns[i++]; childLinkFn = linkFns[i++]; - $node = jqLite(node); if (nodeLinkFn) { if (nodeLinkFn.scope) { childScope = scope.$new(); - $node.data('$scope', childScope); + compile.$$addScopeInfo(jqLite(node), childScope); } else { childScope = scope; } - childTranscludeFn = nodeLinkFn.transclude; - if (childTranscludeFn || (!boundTranscludeFn && transcludeFn)) { - nodeLinkFn(childLinkFn, childScope, node, $rootElement, - createBoundTranscludeFn(scope, childTranscludeFn || transcludeFn) - ); + + if (nodeLinkFn.transcludeOnThisElement) { + childBoundTranscludeFn = createBoundTranscludeFn( + scope, nodeLinkFn.transclude, parentBoundTranscludeFn); + + } else if (!nodeLinkFn.templateOnThisElement && parentBoundTranscludeFn) { + childBoundTranscludeFn = parentBoundTranscludeFn; + + } else if (!parentBoundTranscludeFn && transcludeFn) { + childBoundTranscludeFn = createBoundTranscludeFn(scope, transcludeFn); + } else { - nodeLinkFn(childLinkFn, childScope, node, $rootElement, boundTranscludeFn); + childBoundTranscludeFn = null; } + + nodeLinkFn(childLinkFn, childScope, node, $rootElement, childBoundTranscludeFn); + } else if (childLinkFn) { - childLinkFn(scope, node.childNodes, undefined, boundTranscludeFn); + childLinkFn(scope, node.childNodes, undefined, parentBoundTranscludeFn); } } } } - function createBoundTranscludeFn(scope, transcludeFn) { - return function boundTranscludeFn(transcludedScope, cloneFn, controllers) { - var scopeCreated = false; + function mergeConsecutiveTextNodes(nodeList, idx, notLiveList) { + var node = nodeList[idx]; + var parent = node.parentNode; + var sibling; + + if (node.nodeType !== NODE_TYPE_TEXT) { + return; + } + + while (true) { + sibling = parent ? node.nextSibling : nodeList[idx + 1]; + if (!sibling || sibling.nodeType !== NODE_TYPE_TEXT) { + break; + } + + node.nodeValue = node.nodeValue + sibling.nodeValue; + + if (sibling.parentNode) { + sibling.parentNode.removeChild(sibling); + } + if (notLiveList && sibling === nodeList[idx + 1]) { + nodeList.splice(idx + 1, 1); + } + } + } + + function createBoundTranscludeFn(scope, transcludeFn, previousBoundTranscludeFn) { + function boundTranscludeFn(transcludedScope, cloneFn, controllers, futureParentElement, containingScope) { if (!transcludedScope) { - transcludedScope = scope.$new(); + transcludedScope = scope.$new(false, containingScope); transcludedScope.$$transcluded = true; - scopeCreated = true; } - var clone = transcludeFn(transcludedScope, cloneFn, controllers); - if (scopeCreated) { - clone.on('$destroy', bind(transcludedScope, transcludedScope.$destroy)); + return transcludeFn(transcludedScope, cloneFn, { + parentBoundTranscludeFn: previousBoundTranscludeFn, + transcludeControllers: controllers, + futureParentElement: futureParentElement + }); + } + + // We need to attach the transclusion slots onto the `boundTranscludeFn` + // so that they are available inside the `controllersBoundTransclude` function + var boundSlots = boundTranscludeFn.$$slots = createMap(); + for (var slotName in transcludeFn.$$slots) { + if (transcludeFn.$$slots[slotName]) { + boundSlots[slotName] = createBoundTranscludeFn(scope, transcludeFn.$$slots[slotName], previousBoundTranscludeFn); + } else { + boundSlots[slotName] = null; } - return clone; - }; + } + + return boundTranscludeFn; } /** @@ -6024,52 +9323,73 @@ var nodeType = node.nodeType, attrsMap = attrs.$attr, match, + nodeName, className; - switch(nodeType) { - case 1: /* Element */ + switch (nodeType) { + case NODE_TYPE_ELEMENT: /* Element */ + + nodeName = nodeName_(node); + // use the node name: <directive> addDirective(directives, - directiveNormalize(nodeName_(node).toLowerCase()), 'E', maxPriority, ignoreDirective); + directiveNormalize(nodeName), 'E', maxPriority, ignoreDirective); // iterate over the attributes - for (var attr, name, nName, ngAttrName, value, nAttrs = node.attributes, + for (var attr, name, nName, ngAttrName, value, isNgAttr, nAttrs = node.attributes, j = 0, jj = nAttrs && nAttrs.length; j < jj; j++) { var attrStartName = false; var attrEndName = false; attr = nAttrs[j]; - if (!msie || msie >= 8 || attr.specified) { - name = attr.name; - // support ngAttr attribute binding - ngAttrName = directiveNormalize(name); - if (NG_ATTR_BINDING.test(ngAttrName)) { - name = snake_case(ngAttrName.substr(6), '-'); - } + name = attr.name; + value = attr.value; + + // support ngAttr attribute binding + ngAttrName = directiveNormalize(name); + isNgAttr = NG_ATTR_BINDING.test(ngAttrName); + if (isNgAttr) { + name = name.replace(PREFIX_REGEXP, '') + .substr(8).replace(/_(.)/g, function(match, letter) { + return letter.toUpperCase(); + }); + } - var directiveNName = ngAttrName.replace(/(Start|End)$/, ''); - if (ngAttrName === directiveNName + 'Start') { - attrStartName = name; - attrEndName = name.substr(0, name.length - 5) + 'end'; - name = name.substr(0, name.length - 6); - } + var multiElementMatch = ngAttrName.match(MULTI_ELEMENT_DIR_RE); + if (multiElementMatch && directiveIsMultiElement(multiElementMatch[1])) { + attrStartName = name; + attrEndName = name.substr(0, name.length - 5) + 'end'; + name = name.substr(0, name.length - 6); + } - nName = directiveNormalize(name.toLowerCase()); - attrsMap[nName] = name; - attrs[nName] = value = trim(attr.value); + nName = directiveNormalize(name.toLowerCase()); + attrsMap[nName] = name; + if (isNgAttr || !attrs.hasOwnProperty(nName)) { + attrs[nName] = value; if (getBooleanAttrName(node, nName)) { attrs[nName] = true; // presence means true } - addAttrInterpolateDirective(node, directives, value, nName); - addDirective(directives, nName, 'A', maxPriority, ignoreDirective, attrStartName, - attrEndName); } + addAttrInterpolateDirective(node, directives, value, nName, isNgAttr); + addDirective(directives, nName, 'A', maxPriority, ignoreDirective, attrStartName, + attrEndName); + } + + if (nodeName === 'input' && node.getAttribute('type') === 'hidden') { + // Hidden input elements can have strange behaviour when navigating back to the page + // This tells the browser not to try to cache and reinstate previous values + node.setAttribute('autocomplete', 'off'); } // use class as directive + if (!cssClassDirectivesEnabled) break; className = node.className; + if (isObject(className)) { + // Maybe SVGAnimatedString + className = className.animVal; + } if (isString(className) && className !== '') { - while (match = CLASS_DIRECTIVE_REGEXP.exec(className)) { + while ((match = CLASS_DIRECTIVE_REGEXP.exec(className))) { nName = directiveNormalize(match[2]); if (addDirective(directives, nName, 'C', maxPriority, ignoreDirective)) { attrs[nName] = trim(match[3]); @@ -6078,23 +9398,12 @@ } } break; - case 3: /* Text Node */ + case NODE_TYPE_TEXT: /* Text Node */ addTextInterpolateDirective(directives, node.nodeValue); break; - case 8: /* Comment */ - try { - match = COMMENT_DIRECTIVE_REGEXP.exec(node.nodeValue); - if (match) { - nName = directiveNormalize(match[1]); - if (addDirective(directives, nName, 'M', maxPriority, ignoreDirective)) { - attrs[nName] = trim(match[2]); - } - } - } catch (e) { - // turns out that under some circumstances IE9 throws errors when one attempts to read - // comment's node value. - // Just ignore it and continue. (Can't seem to reproduce in test case.) - } + case NODE_TYPE_COMMENT: /* Comment */ + if (!commentDirectivesEnabled) break; + collectCommentDirectives(node, directives, attrs, maxPriority, ignoreDirective); break; } @@ -6102,8 +9411,26 @@ return directives; } + function collectCommentDirectives(node, directives, attrs, maxPriority, ignoreDirective) { + // function created because of performance, try/catch disables + // the optimization of the whole function #14848 + try { + var match = COMMENT_DIRECTIVE_REGEXP.exec(node.nodeValue); + if (match) { + var nName = directiveNormalize(match[1]); + if (addDirective(directives, nName, 'M', maxPriority, ignoreDirective)) { + attrs[nName] = trim(match[2]); + } + } + } catch (e) { + // turns out that under some circumstances IE9 throws errors when one attempts to read + // comment's node value. + // Just ignore it and continue. (Can't seem to reproduce in test case.) + } + } + /** - * Given a node with an directive-start it collects all of the siblings until it finds + * Given a node with a directive-start it collects all of the siblings until it finds * directive-end. * @param node * @param attrStart @@ -6114,14 +9441,13 @@ var nodes = []; var depth = 0; if (attrStart && node.hasAttribute && node.hasAttribute(attrStart)) { - var startNode = node; do { if (!node) { throw $compileMinErr('uterdir', - "Unterminated attribute, found '{0}' but no matching '{1}' found.", + 'Unterminated attribute, found \'{0}\' but no matching \'{1}\' found.', attrStart, attrEnd); } - if (node.nodeType == 1 /** Element **/) { + if (node.nodeType === NODE_TYPE_ELEMENT) { if (node.hasAttribute(attrStart)) depth++; if (node.hasAttribute(attrEnd)) depth--; } @@ -6144,12 +9470,41 @@ * @returns {Function} */ function groupElementsLinkFnWrapper(linkFn, attrStart, attrEnd) { - return function(scope, element, attrs, controllers, transcludeFn) { + return function groupedElementsLink(scope, element, attrs, controllers, transcludeFn) { element = groupScan(element[0], attrStart, attrEnd); return linkFn(scope, element, attrs, controllers, transcludeFn); }; } + /** + * A function generator that is used to support both eager and lazy compilation + * linking function. + * @param eager + * @param $compileNodes + * @param transcludeFn + * @param maxPriority + * @param ignoreDirective + * @param previousCompileContext + * @returns {Function} + */ + function compilationGenerator(eager, $compileNodes, transcludeFn, maxPriority, ignoreDirective, previousCompileContext) { + var compiled; + + if (eager) { + return compile($compileNodes, transcludeFn, maxPriority, ignoreDirective, previousCompileContext); + } + return /** @this */ function lazyCompilation() { + if (!compiled) { + compiled = compile($compileNodes, transcludeFn, maxPriority, ignoreDirective, previousCompileContext); + + // Null out all of these references in order to make them eligible for garbage collection + // since this is a potentially long lived closure + $compileNodes = transcludeFn = previousCompileContext = null; + } + return compiled.apply(this, arguments); + }; + } + /** * Once the directives have been collected, their compile functions are executed. This method * is responsible for inlining directive templates as well as terminating the application @@ -6179,12 +9534,13 @@ previousCompileContext = previousCompileContext || {}; var terminalPriority = -Number.MAX_VALUE, - newScopeDirective, + newScopeDirective = previousCompileContext.newScopeDirective, controllerDirectives = previousCompileContext.controllerDirectives, newIsolateScopeDirective = previousCompileContext.newIsolateScopeDirective, templateDirective = previousCompileContext.templateDirective, nonTlbTranscludeDirective = previousCompileContext.nonTlbTranscludeDirective, hasTranscludeDirective = false, + hasTemplate = false, hasElementTranscludeDirective = previousCompileContext.hasElementTranscludeDirective, $compileNode = templateAttrs.$$element = jqLite(compileNode), directive, @@ -6193,10 +9549,12 @@ replaceDirective = originalReplaceDirective, childTranscludeFn = transcludeFn, linkFn, + didScanForMultipleTransclusion = false, + mightHaveMultipleTransclusionError = false, directiveValue; // executes all directives on the current element - for(var i = 0, ii = directives.length; i < ii; i++) { + for (var i = 0, ii = directives.length; i < ii; i++) { directive = directives[i]; var attrStart = directive.$$start; var attrEnd = directive.$$end; @@ -6211,31 +9569,63 @@ break; // prevent further processing of directives } - if (directiveValue = directive.scope) { - newScopeDirective = newScopeDirective || directive; + directiveValue = directive.scope; + + if (directiveValue) { // skip the check for directives with async templates, we'll check the derived sync // directive when the template arrives if (!directive.templateUrl) { - assertNoDuplicate('new/isolated scope', newIsolateScopeDirective, directive, - $compileNode); if (isObject(directiveValue)) { + // This directive is trying to add an isolated scope. + // Check that there is no scope of any kind already + assertNoDuplicate('new/isolated scope', newIsolateScopeDirective || newScopeDirective, + directive, $compileNode); newIsolateScopeDirective = directive; + } else { + // This directive is trying to add a child scope. + // Check that there is no isolated scope already + assertNoDuplicate('new/isolated scope', newIsolateScopeDirective, directive, + $compileNode); } } + + newScopeDirective = newScopeDirective || directive; } directiveName = directive.name; + // If we encounter a condition that can result in transclusion on the directive, + // then scan ahead in the remaining directives for others that may cause a multiple + // transclusion error to be thrown during the compilation process. If a matching directive + // is found, then we know that when we encounter a transcluded directive, we need to eagerly + // compile the `transclude` function rather than doing it lazily in order to throw + // exceptions at the correct time + if (!didScanForMultipleTransclusion && ((directive.replace && (directive.templateUrl || directive.template)) + || (directive.transclude && !directive.$$tlb))) { + var candidateDirective; + + for (var scanningIndex = i + 1; (candidateDirective = directives[scanningIndex++]);) { + if ((candidateDirective.transclude && !candidateDirective.$$tlb) + || (candidateDirective.replace && (candidateDirective.templateUrl || candidateDirective.template))) { + mightHaveMultipleTransclusionError = true; + break; + } + } + + didScanForMultipleTransclusion = true; + } + if (!directive.templateUrl && directive.controller) { - directiveValue = directive.controller; - controllerDirectives = controllerDirectives || {}; - assertNoDuplicate("'" + directiveName + "' controller", + controllerDirectives = controllerDirectives || createMap(); + assertNoDuplicate('\'' + directiveName + '\' controller', controllerDirectives[directiveName], directive, $compileNode); controllerDirectives[directiveName] = directive; } - if (directiveValue = directive.transclude) { + directiveValue = directive.transclude; + + if (directiveValue) { hasTranscludeDirective = true; // Special case ngIf and ngRepeat so that we don't complain about duplicate transclusion. @@ -6246,17 +9636,27 @@ nonTlbTranscludeDirective = directive; } - if (directiveValue == 'element') { + if (directiveValue === 'element') { hasElementTranscludeDirective = true; terminalPriority = directive.priority; - $template = groupScan(compileNode, attrStart, attrEnd); + $template = $compileNode; $compileNode = templateAttrs.$$element = - jqLite(document.createComment(' ' + directiveName + ': ' + - templateAttrs[directiveName] + ' ')); + jqLite(compile.$$createComment(directiveName, templateAttrs[directiveName])); compileNode = $compileNode[0]; - replaceWith(jqCollection, jqLite(sliceArgs($template)), compileNode); + replaceWith(jqCollection, sliceArgs($template), compileNode); - childTranscludeFn = compile($template, transcludeFn, terminalPriority, + // Support: Chrome < 50 + // https://github.com/angular/angular.js/issues/14041 + + // In the versions of V8 prior to Chrome 50, the document fragment that is created + // in the `replaceWith` function is improperly garbage collected despite still + // being referenced by the `parentNode` property of all of the child nodes. By adding + // a reference to the fragment via a different property, we can avoid that incorrect + // behavior. + // TODO: remove this line after Chrome 50 has been released + $template[0].$$parentNode = $template[0].parentNode; + + childTranscludeFn = compilationGenerator(mightHaveMultipleTransclusionError, $template, transcludeFn, terminalPriority, replaceDirective && replaceDirective.name, { // Don't pass in: // - controllerDirectives - otherwise we'll create duplicates controllers @@ -6268,19 +9668,80 @@ nonTlbTranscludeDirective: nonTlbTranscludeDirective }); } else { - $template = jqLite(jqLiteClone(compileNode)).contents(); - $compileNode.empty(); // clear contents - childTranscludeFn = compile($template, transcludeFn); - } - } - if (directive.template) { - assertNoDuplicate('template', templateDirective, directive, $compileNode); - templateDirective = directive; + var slots = createMap(); - directiveValue = (isFunction(directive.template)) - ? directive.template($compileNode, templateAttrs) - : directive.template; + if (!isObject(directiveValue)) { + $template = jqLite(jqLiteClone(compileNode)).contents(); + } else { + + // We have transclusion slots, + // collect them up, compile them and store their transclusion functions + $template = []; + + var slotMap = createMap(); + var filledSlots = createMap(); + + // Parse the element selectors + forEach(directiveValue, function(elementSelector, slotName) { + // If an element selector starts with a ? then it is optional + var optional = (elementSelector.charAt(0) === '?'); + elementSelector = optional ? elementSelector.substring(1) : elementSelector; + + slotMap[elementSelector] = slotName; + + // We explicitly assign `null` since this implies that a slot was defined but not filled. + // Later when calling boundTransclusion functions with a slot name we only error if the + // slot is `undefined` + slots[slotName] = null; + + // filledSlots contains `true` for all slots that are either optional or have been + // filled. This is used to check that we have not missed any required slots + filledSlots[slotName] = optional; + }); + + // Add the matching elements into their slot + forEach($compileNode.contents(), function(node) { + var slotName = slotMap[directiveNormalize(nodeName_(node))]; + if (slotName) { + filledSlots[slotName] = true; + slots[slotName] = slots[slotName] || []; + slots[slotName].push(node); + } else { + $template.push(node); + } + }); + + // Check for required slots that were not filled + forEach(filledSlots, function(filled, slotName) { + if (!filled) { + throw $compileMinErr('reqslot', 'Required transclusion slot `{0}` was not filled.', slotName); + } + }); + + for (var slotName in slots) { + if (slots[slotName]) { + // Only define a transclusion function if the slot was filled + slots[slotName] = compilationGenerator(mightHaveMultipleTransclusionError, slots[slotName], transcludeFn); + } + } + } + + $compileNode.empty(); // clear contents + childTranscludeFn = compilationGenerator(mightHaveMultipleTransclusionError, $template, transcludeFn, undefined, + undefined, { needsNewScope: directive.$$isolateScope || directive.$$newScope}); + childTranscludeFn.$$slots = slots; + } + } + + if (directive.template) { + hasTemplate = true; + assertNoDuplicate('template', templateDirective, directive, $compileNode); + templateDirective = directive; + + directiveValue = (isFunction(directive.template)) + ? directive.template($compileNode, templateAttrs) + : directive.template; directiveValue = denormalizeTemplate(directiveValue); @@ -6289,13 +9750,13 @@ if (jqLiteIsTextNode(directiveValue)) { $template = []; } else { - $template = jqLite(directiveValue); + $template = removeComments(wrapTemplate(directive.templateNamespace, trim(directiveValue))); } compileNode = $template[0]; - if ($template.length != 1 || compileNode.nodeType !== 1) { + if ($template.length !== 1 || compileNode.nodeType !== NODE_TYPE_ELEMENT) { throw $compileMinErr('tplrt', - "Template for directive '{0}' must have exactly one root element. {1}", + 'Template for directive \'{0}\' must have exactly one root element. {1}', directiveName, ''); } @@ -6311,8 +9772,11 @@ var templateDirectives = collectDirectives(compileNode, [], newTemplateAttrs); var unprocessedDirectives = directives.splice(i + 1, directives.length - (i + 1)); - if (newIsolateScopeDirective) { - markDirectivesAsIsolate(templateDirectives); + if (newIsolateScopeDirective || newScopeDirective) { + // The original directive caused the current element to be replaced but this element + // also needs to have a new scope, so we need to tell the template directives + // that they would need to get their scope from further up, if they require transclusion + markDirectiveScope(templateDirectives, newIsolateScopeDirective, newScopeDirective); } directives = directives.concat(templateDirectives).concat(unprocessedDirectives); mergeTemplateAttributes(templateAttrs, newTemplateAttrs); @@ -6324,6 +9788,7 @@ } if (directive.templateUrl) { + hasTemplate = true; assertNoDuplicate('template', templateDirective, directive, $compileNode); templateDirective = directive; @@ -6331,9 +9796,11 @@ replaceDirective = directive; } + // eslint-disable-next-line no-func-assign nodeLinkFn = compileTemplateUrl(directives.splice(i, directives.length - i), $compileNode, - templateAttrs, jqCollection, childTranscludeFn, preLinkFns, postLinkFns, { + templateAttrs, jqCollection, hasTranscludeDirective && childTranscludeFn, preLinkFns, postLinkFns, { controllerDirectives: controllerDirectives, + newScopeDirective: (newScopeDirective !== directive) && newScopeDirective, newIsolateScopeDirective: newIsolateScopeDirective, templateDirective: templateDirective, nonTlbTranscludeDirective: nonTlbTranscludeDirective @@ -6342,10 +9809,11 @@ } else if (directive.compile) { try { linkFn = directive.compile($compileNode, templateAttrs, childTranscludeFn); + var context = directive.$$originalDirective || directive; if (isFunction(linkFn)) { - addLinkFns(null, linkFn, attrStart, attrEnd); + addLinkFns(null, bind(context, linkFn), attrStart, attrEnd); } else if (linkFn) { - addLinkFns(linkFn.pre, linkFn.post, attrStart, attrEnd); + addLinkFns(bind(context, linkFn.pre), bind(context, linkFn.post), attrStart, attrEnd); } } catch (e) { $exceptionHandler(e, startingTag($compileNode)); @@ -6360,7 +9828,10 @@ } nodeLinkFn.scope = newScopeDirective && newScopeDirective.scope === true; - nodeLinkFn.transclude = hasTranscludeDirective && childTranscludeFn; + nodeLinkFn.transcludeOnThisElement = hasTranscludeDirective; + nodeLinkFn.templateOnThisElement = hasTemplate; + nodeLinkFn.transclude = childTranscludeFn; + previousCompileContext.hasElementTranscludeDirective = hasElementTranscludeDirective; // might be normal or delayed nodeLinkFn depending on if templateUrl is present @@ -6372,6 +9843,7 @@ if (pre) { if (attrStart) pre = groupElementsLinkFnWrapper(pre, attrStart, attrEnd); pre.require = directive.require; + pre.directiveName = directiveName; if (newIsolateScopeDirective === directive || directive.$$isolateScope) { pre = cloneAndAnnotateFn(pre, {isolateScope: true}); } @@ -6380,6 +9852,7 @@ if (post) { if (attrStart) post = groupElementsLinkFnWrapper(post, attrStart, attrEnd); post.require = directive.require; + post.directiveName = directiveName; if (newIsolateScopeDirective === directive || directive.$$isolateScope) { post = cloneAndAnnotateFn(post, {isolateScope: true}); } @@ -6387,180 +9860,135 @@ } } - - function getControllers(require, $element, elementControllers) { - var value, retrievalMethod = 'data', optional = false; - if (isString(require)) { - while((value = require.charAt(0)) == '^' || value == '?') { - require = require.substr(1); - if (value == '^') { - retrievalMethod = 'inheritedData'; - } - optional = optional || value == '?'; - } - value = null; - - if (elementControllers && retrievalMethod === 'data') { - value = elementControllers[require]; - } - value = value || $element[retrievalMethod]('$' + require + 'Controller'); - - if (!value && !optional) { - throw $compileMinErr('ctreq', - "Controller '{0}', required by directive '{1}', can't be found!", - require, directiveName); - } - return value; - } else if (isArray(require)) { - value = []; - forEach(require, function(require) { - value.push(getControllers(require, $element, elementControllers)); - }); - } - return value; - } - - function nodeLinkFn(childLinkFn, scope, linkNode, $rootElement, boundTranscludeFn) { - var attrs, $element, i, ii, linkFn, controller, isolateScope, elementControllers = {}, transcludeFn; + var i, ii, linkFn, isolateScope, controllerScope, elementControllers, transcludeFn, $element, + attrs, scopeBindingInfo; if (compileNode === linkNode) { attrs = templateAttrs; + $element = templateAttrs.$$element; } else { - attrs = shallowCopy(templateAttrs, new Attributes(jqLite(linkNode), templateAttrs.$attr)); + $element = jqLite(linkNode); + attrs = new Attributes($element, templateAttrs); } - $element = attrs.$$element; + controllerScope = scope; if (newIsolateScopeDirective) { - var LOCAL_REGEXP = /^\s*([@=&])(\??)\s*(\w*)\s*$/; - var $linkNode = jqLite(linkNode); - isolateScope = scope.$new(true); + } else if (newScopeDirective) { + controllerScope = scope.$parent; + } - if (templateDirective && (templateDirective === newIsolateScopeDirective.$$originalDirective)) { - $linkNode.data('$isolateScope', isolateScope) ; - } else { - $linkNode.data('$isolateScopeNoTemplate', isolateScope); - } - - - - safeAddClass($linkNode, 'ng-isolate-scope'); + if (boundTranscludeFn) { + // track `boundTranscludeFn` so it can be unwrapped if `transcludeFn` + // is later passed as `parentBoundTranscludeFn` to `publicLinkFn` + transcludeFn = controllersBoundTransclude; + transcludeFn.$$boundTransclude = boundTranscludeFn; + // expose the slots on the `$transclude` function + transcludeFn.isSlotFilled = function(slotName) { + return !!boundTranscludeFn.$$slots[slotName]; + }; + } - forEach(newIsolateScopeDirective.scope, function(definition, scopeName) { - var match = definition.match(LOCAL_REGEXP) || [], - attrName = match[3] || scopeName, - optional = (match[2] == '?'), - mode = match[1], // @, =, or & - lastValue, - parentGet, parentSet, compare; + if (controllerDirectives) { + elementControllers = setupControllers($element, attrs, transcludeFn, controllerDirectives, isolateScope, scope, newIsolateScopeDirective); + } - isolateScope.$$isolateBindings[scopeName] = mode + attrName; + if (newIsolateScopeDirective) { + // Initialize isolate scope bindings for new isolate scope directive. + compile.$$addScopeInfo($element, isolateScope, true, !(templateDirective && (templateDirective === newIsolateScopeDirective || + templateDirective === newIsolateScopeDirective.$$originalDirective))); + compile.$$addScopeClass($element, true); + isolateScope.$$isolateBindings = + newIsolateScopeDirective.$$isolateBindings; + scopeBindingInfo = initializeDirectiveBindings(scope, attrs, isolateScope, + isolateScope.$$isolateBindings, + newIsolateScopeDirective); + if (scopeBindingInfo.removeWatches) { + isolateScope.$on('$destroy', scopeBindingInfo.removeWatches); + } + } - switch (mode) { + // Initialize bindToController bindings + for (var name in elementControllers) { + var controllerDirective = controllerDirectives[name]; + var controller = elementControllers[name]; + var bindings = controllerDirective.$$bindings.bindToController; - case '@': - attrs.$observe(attrName, function(value) { - isolateScope[scopeName] = value; - }); - attrs.$$observers[attrName].$$scope = scope; - if( attrs[attrName] ) { - // If the attribute has been provided then we trigger an interpolation to ensure - // the value is there for use in the link fn - isolateScope[scopeName] = $interpolate(attrs[attrName])(scope); - } - break; + if (preAssignBindingsEnabled) { + if (bindings) { + controller.bindingInfo = + initializeDirectiveBindings(controllerScope, attrs, controller.instance, bindings, controllerDirective); + } else { + controller.bindingInfo = {}; + } - case '=': - if (optional && !attrs[attrName]) { - return; - } - parentGet = $parse(attrs[attrName]); - if (parentGet.literal) { - compare = equals; - } else { - compare = function(a,b) { return a === b; }; - } - parentSet = parentGet.assign || function() { - // reset the change, or we will throw this exception on every $digest - lastValue = isolateScope[scopeName] = parentGet(scope); - throw $compileMinErr('nonassign', - "Expression '{0}' used with directive '{1}' is non-assignable!", - attrs[attrName], newIsolateScopeDirective.name); - }; - lastValue = isolateScope[scopeName] = parentGet(scope); - isolateScope.$watch(function parentValueWatch() { - var parentValue = parentGet(scope); - if (!compare(parentValue, isolateScope[scopeName])) { - // we are out of sync and need to copy - if (!compare(parentValue, lastValue)) { - // parent changed and it has precedence - isolateScope[scopeName] = parentValue; - } else { - // if the parent can be assigned then do so - parentSet(scope, parentValue = isolateScope[scopeName]); - } - } - return lastValue = parentValue; - }, null, parentGet.literal); - break; - - case '&': - parentGet = $parse(attrs[attrName]); - isolateScope[scopeName] = function(locals) { - return parentGet(scope, locals); - }; - break; - - default: - throw $compileMinErr('iscp', - "Invalid isolate scope definition for directive '{0}'." + - " Definition: {... {1}: '{2}' ...}", - newIsolateScopeDirective.name, scopeName, definition); + var controllerResult = controller(); + if (controllerResult !== controller.instance) { + // If the controller constructor has a return value, overwrite the instance + // from setupControllers + controller.instance = controllerResult; + $element.data('$' + controllerDirective.name + 'Controller', controllerResult); + if (controller.bindingInfo.removeWatches) { + controller.bindingInfo.removeWatches(); + } + controller.bindingInfo = + initializeDirectiveBindings(controllerScope, attrs, controller.instance, bindings, controllerDirective); } - }); + } else { + controller.instance = controller(); + $element.data('$' + controllerDirective.name + 'Controller', controller.instance); + controller.bindingInfo = + initializeDirectiveBindings(controllerScope, attrs, controller.instance, bindings, controllerDirective); + } } - transcludeFn = boundTranscludeFn && controllersBoundTransclude; - if (controllerDirectives) { - forEach(controllerDirectives, function(directive) { - var locals = { - $scope: directive === newIsolateScopeDirective || directive.$$isolateScope ? isolateScope : scope, - $element: $element, - $attrs: attrs, - $transclude: transcludeFn - }, controllerInstance; - - controller = directive.controller; - if (controller == '@') { - controller = attrs[directive.name]; - } - controllerInstance = $controller(controller, locals); - // For directives with element transclusion the element is a comment, - // but jQuery .data doesn't support attaching data to comment nodes as it's hard to - // clean up (http://bugs.jquery.com/ticket/8335). - // Instead, we save the controllers for the element in a local hash and attach to .data - // later, once we have the actual element. - elementControllers[directive.name] = controllerInstance; - if (!hasElementTranscludeDirective) { - $element.data('$' + directive.name + 'Controller', controllerInstance); - } + // Bind the required controllers to the controller, if `require` is an object and `bindToController` is truthy + forEach(controllerDirectives, function(controllerDirective, name) { + var require = controllerDirective.require; + if (controllerDirective.bindToController && !isArray(require) && isObject(require)) { + extend(elementControllers[name].instance, getControllers(name, require, $element, elementControllers)); + } + }); - if (directive.controllerAs) { - locals.$scope[directive.controllerAs] = controllerInstance; + // Handle the init and destroy lifecycle hooks on all controllers that have them + forEach(elementControllers, function(controller) { + var controllerInstance = controller.instance; + if (isFunction(controllerInstance.$onChanges)) { + try { + controllerInstance.$onChanges(controller.bindingInfo.initialChanges); + } catch (e) { + $exceptionHandler(e); } - }); - } + } + if (isFunction(controllerInstance.$onInit)) { + try { + controllerInstance.$onInit(); + } catch (e) { + $exceptionHandler(e); + } + } + if (isFunction(controllerInstance.$doCheck)) { + controllerScope.$watch(function() { controllerInstance.$doCheck(); }); + controllerInstance.$doCheck(); + } + if (isFunction(controllerInstance.$onDestroy)) { + controllerScope.$on('$destroy', function callOnDestroyHook() { + controllerInstance.$onDestroy(); + }); + } + }); // PRELINKING - for(i = 0, ii = preLinkFns.length; i < ii; i++) { - try { - linkFn = preLinkFns[i]; - linkFn(linkFn.isolateScope ? isolateScope : scope, $element, attrs, - linkFn.require && getControllers(linkFn.require, $element, elementControllers), transcludeFn); - } catch (e) { - $exceptionHandler(e, startingTag($element)); - } + for (i = 0, ii = preLinkFns.length; i < ii; i++) { + linkFn = preLinkFns[i]; + invokeLinkFn(linkFn, + linkFn.isolateScope ? isolateScope : scope, + $element, + attrs, + linkFn.require && getControllers(linkFn.directiveName, linkFn.require, $element, elementControllers), + transcludeFn + ); } // RECURSION @@ -6570,25 +9998,38 @@ if (newIsolateScopeDirective && (newIsolateScopeDirective.template || newIsolateScopeDirective.templateUrl === null)) { scopeToChild = isolateScope; } - childLinkFn && childLinkFn(scopeToChild, linkNode.childNodes, undefined, boundTranscludeFn); + if (childLinkFn) { + childLinkFn(scopeToChild, linkNode.childNodes, undefined, boundTranscludeFn); + } // POSTLINKING - for(i = postLinkFns.length - 1; i >= 0; i--) { - try { - linkFn = postLinkFns[i]; - linkFn(linkFn.isolateScope ? isolateScope : scope, $element, attrs, - linkFn.require && getControllers(linkFn.require, $element, elementControllers), transcludeFn); - } catch (e) { - $exceptionHandler(e, startingTag($element)); - } + for (i = postLinkFns.length - 1; i >= 0; i--) { + linkFn = postLinkFns[i]; + invokeLinkFn(linkFn, + linkFn.isolateScope ? isolateScope : scope, + $element, + attrs, + linkFn.require && getControllers(linkFn.directiveName, linkFn.require, $element, elementControllers), + transcludeFn + ); } + // Trigger $postLink lifecycle hooks + forEach(elementControllers, function(controller) { + var controllerInstance = controller.instance; + if (isFunction(controllerInstance.$postLink)) { + controllerInstance.$postLink(); + } + }); + // This is the function that is injected as `$transclude`. - function controllersBoundTransclude(scope, cloneAttachFn) { + // Note: all arguments are optional! + function controllersBoundTransclude(scope, cloneAttachFn, futureParentElement, slotName) { var transcludeControllers; - - // no scope passed - if (arguments.length < 2) { + // No scope passed in: + if (!isScope(scope)) { + slotName = futureParentElement; + futureParentElement = cloneAttachFn; cloneAttachFn = scope; scope = undefined; } @@ -6596,16 +10037,111 @@ if (hasElementTranscludeDirective) { transcludeControllers = elementControllers; } + if (!futureParentElement) { + futureParentElement = hasElementTranscludeDirective ? $element.parent() : $element; + } + if (slotName) { + // slotTranscludeFn can be one of three things: + // * a transclude function - a filled slot + // * `null` - an optional slot that was not filled + // * `undefined` - a slot that was not declared (i.e. invalid) + var slotTranscludeFn = boundTranscludeFn.$$slots[slotName]; + if (slotTranscludeFn) { + return slotTranscludeFn(scope, cloneAttachFn, transcludeControllers, futureParentElement, scopeToChild); + } else if (isUndefined(slotTranscludeFn)) { + throw $compileMinErr('noslot', + 'No parent directive that requires a transclusion with slot name "{0}". ' + + 'Element: {1}', + slotName, startingTag($element)); + } + } else { + return boundTranscludeFn(scope, cloneAttachFn, transcludeControllers, futureParentElement, scopeToChild); + } + } + } + } + + function getControllers(directiveName, require, $element, elementControllers) { + var value; - return boundTranscludeFn(scope, cloneAttachFn, transcludeControllers); + if (isString(require)) { + var match = require.match(REQUIRE_PREFIX_REGEXP); + var name = require.substring(match[0].length); + var inheritType = match[1] || match[3]; + var optional = match[2] === '?'; + + //If only parents then start at the parent element + if (inheritType === '^^') { + $element = $element.parent(); + //Otherwise attempt getting the controller from elementControllers in case + //the element is transcluded (and has no data) and to avoid .data if possible + } else { + value = elementControllers && elementControllers[name]; + value = value && value.instance; + } + + if (!value) { + var dataName = '$' + name + 'Controller'; + value = inheritType ? $element.inheritedData(dataName) : $element.data(dataName); + } + + if (!value && !optional) { + throw $compileMinErr('ctreq', + 'Controller \'{0}\', required by directive \'{1}\', can\'t be found!', + name, directiveName); + } + } else if (isArray(require)) { + value = []; + for (var i = 0, ii = require.length; i < ii; i++) { + value[i] = getControllers(directiveName, require[i], $element, elementControllers); + } + } else if (isObject(require)) { + value = {}; + forEach(require, function(controller, property) { + value[property] = getControllers(directiveName, controller, $element, elementControllers); + }); + } + + return value || null; + } + + function setupControllers($element, attrs, transcludeFn, controllerDirectives, isolateScope, scope, newIsolateScopeDirective) { + var elementControllers = createMap(); + for (var controllerKey in controllerDirectives) { + var directive = controllerDirectives[controllerKey]; + var locals = { + $scope: directive === newIsolateScopeDirective || directive.$$isolateScope ? isolateScope : scope, + $element: $element, + $attrs: attrs, + $transclude: transcludeFn + }; + + var controller = directive.controller; + if (controller === '@') { + controller = attrs[directive.name]; } + + var controllerInstance = $controller(controller, locals, true, directive.controllerAs); + + // For directives with element transclusion the element is a comment. + // In this case .data will not attach any data. + // Instead, we save the controllers for the element in a local hash and attach to .data + // later, once we have the actual element. + elementControllers[directive.name] = controllerInstance; + $element.data('$' + directive.name + 'Controller', controllerInstance.instance); } + return elementControllers; } - function markDirectivesAsIsolate(directives) { - // mark all directives as needing isolate scope. + // Depending upon the context in which a directive finds itself it might need to have a new isolated + // or child scope created. For instance: + // * if the directive has been pulled into a template because another directive with a higher priority + // asked for element transclusion + // * if the directive itself asks for transclusion but it is at the root of a template and the original + // element was replaced. See https://github.com/angular/angular.js/issues/12936 + function markDirectiveScope(directives, isolateScope, newScope) { for (var j = 0, jj = directives.length; j < jj; j++) { - directives[j] = inherit(directives[j], {$$isolateScope: true}); + directives[j] = inherit(directives[j], {$$isolateScope: isolateScope, $$newScope: newScope}); } } @@ -6628,25 +10164,51 @@ if (name === ignoreDirective) return null; var match = null; if (hasDirectives.hasOwnProperty(name)) { - for(var directive, directives = $injector.get(name + Suffix), - i = 0, ii = directives.length; i<ii; i++) { - try { - directive = directives[i]; - if ( (maxPriority === undefined || maxPriority > directive.priority) && - directive.restrict.indexOf(location) != -1) { - if (startAttrName) { - directive = inherit(directive, {$$start: startAttrName, $$end: endAttrName}); + for (var directive, directives = $injector.get(name + Suffix), + i = 0, ii = directives.length; i < ii; i++) { + directive = directives[i]; + if ((isUndefined(maxPriority) || maxPriority > directive.priority) && + directive.restrict.indexOf(location) !== -1) { + if (startAttrName) { + directive = inherit(directive, {$$start: startAttrName, $$end: endAttrName}); + } + if (!directive.$$bindings) { + var bindings = directive.$$bindings = + parseDirectiveBindings(directive, directive.name); + if (isObject(bindings.isolateScope)) { + directive.$$isolateBindings = bindings.isolateScope; } - tDirectives.push(directive); - match = directive; } - } catch(e) { $exceptionHandler(e); } + tDirectives.push(directive); + match = directive; + } } } return match; } + /** + * looks up the directive and returns true if it is a multi-element directive, + * and therefore requires DOM nodes between -start and -end markers to be grouped + * together. + * + * @param {string} name name of the directive to look up. + * @returns true if directive was registered as multi-element. + */ + function directiveIsMultiElement(name) { + if (hasDirectives.hasOwnProperty(name)) { + for (var directive, directives = $injector.get(name + Suffix), + i = 0, ii = directives.length; i < ii; i++) { + directive = directives[i]; + if (directive.multiElement) { + return true; + } + } + } + return false; + } + /** * When the element is replaced with HTML template then the new attributes * on the template need to be merged with the existing attributes in the DOM. @@ -6657,14 +10219,17 @@ */ function mergeTemplateAttributes(dst, src) { var srcAttr = src.$attr, - dstAttr = dst.$attr, - $element = dst.$$element; + dstAttr = dst.$attr; // reapply the old attributes to the new element forEach(dst, function(value, key) { - if (key.charAt(0) != '$') { - if (src[key]) { - value += (key === 'style' ? ';' : ' ') + src[key]; + if (key.charAt(0) !== '$') { + if (src[key] && src[key] !== value) { + if (value.length) { + value += (key === 'style' ? ';' : ' ') + src[key]; + } else { + value = src[key]; + } } dst.$set(key, value, true, srcAttr[key]); } @@ -6672,18 +10237,16 @@ // copy the new attributes on the old attrs object forEach(src, function(value, key) { - if (key == 'class') { - safeAddClass($element, value); - dst['class'] = (dst['class'] ? dst['class'] + ' ' : '') + value; - } else if (key == 'style') { - $element.attr('style', $element.attr('style') + ';' + value); - dst['style'] = (dst['style'] ? dst['style'] + ';' : '') + value; - // `dst` will never contain hasOwnProperty as DOM parser won't let it. - // You will get an "InvalidCharacterError: DOM Exception 5" error if you - // have an attribute like "has-own-property" or "data-has-own-property", etc. - } else if (key.charAt(0) != '$' && !dst.hasOwnProperty(key)) { + // Check if we already set this attribute in the loop above. + // `dst` will never contain hasOwnProperty as DOM parser won't let it. + // You will get an "InvalidCharacterError: DOM Exception 5" error if you + // have an attribute like "has-own-property" or "data-has-own-property", etc. + if (!dst.hasOwnProperty(key) && key.charAt(0) !== '$') { dst[key] = value; - dstAttr[key] = srcAttr[key]; + + if (key !== 'class' && key !== 'style') { + dstAttr[key] = srcAttr[key]; + } } }); } @@ -6696,18 +10259,18 @@ afterTemplateChildLinkFn, beforeTemplateCompileNode = $compileNode[0], origAsyncDirective = directives.shift(), - // The fact that we have to copy and patch the directive seems wrong! - derivedSyncDirective = extend({}, origAsyncDirective, { + derivedSyncDirective = inherit(origAsyncDirective, { templateUrl: null, transclude: null, replace: null, $$originalDirective: origAsyncDirective }), templateUrl = (isFunction(origAsyncDirective.templateUrl)) ? origAsyncDirective.templateUrl($compileNode, tAttrs) - : origAsyncDirective.templateUrl; + : origAsyncDirective.templateUrl, + templateNamespace = origAsyncDirective.templateNamespace; $compileNode.empty(); - $http.get($sce.getTrustedResourceUrl(templateUrl), {cache: $templateCache}). - success(function(content) { + $templateRequest(templateUrl) + .then(function(content) { var compileNode, tempTemplateAttrs, $template, childBoundTranscludeFn; content = denormalizeTemplate(content); @@ -6716,13 +10279,13 @@ if (jqLiteIsTextNode(content)) { $template = []; } else { - $template = jqLite(content); + $template = removeComments(wrapTemplate(templateNamespace, trim(content))); } compileNode = $template[0]; - if ($template.length != 1 || compileNode.nodeType !== 1) { + if ($template.length !== 1 || compileNode.nodeType !== NODE_TYPE_ELEMENT) { throw $compileMinErr('tplrt', - "Template for directive '{0}' must have exactly one root element. {1}", + 'Template for directive \'{0}\' must have exactly one root element. {1}', origAsyncDirective.name, templateUrl); } @@ -6731,7 +10294,9 @@ var templateDirectives = collectDirectives(compileNode, [], tempTemplateAttrs); if (isObject(origAsyncDirective.scope)) { - markDirectivesAsIsolate(templateDirectives); + // the original directive that caused the template to be loaded async required + // an isolate scope + markDirectiveScope(templateDirectives, true); } directives = templateDirectives.concat(directives); mergeTemplateAttributes(tAttrs, tempTemplateAttrs); @@ -6746,20 +10311,21 @@ childTranscludeFn, $compileNode, origAsyncDirective, preLinkFns, postLinkFns, previousCompileContext); forEach($rootElement, function(node, i) { - if (node == compileNode) { + if (node === compileNode) { $rootElement[i] = $compileNode[0]; } }); afterTemplateChildLinkFn = compileNodes($compileNode[0].childNodes, childTranscludeFn); - - while(linkQueue.length) { + while (linkQueue.length) { var scope = linkQueue.shift(), beforeTemplateLinkNode = linkQueue.shift(), linkRootElement = linkQueue.shift(), boundTranscludeFn = linkQueue.shift(), linkNode = $compileNode[0]; + if (scope.$$destroyed) continue; + if (beforeTemplateLinkNode !== beforeTemplateCompileNode) { var oldClasses = beforeTemplateLinkNode.className; @@ -6768,14 +10334,13 @@ // it was cloned therefore we have to clone as well. linkNode = jqLiteClone(compileNode); } - replaceWith(linkRootElement, jqLite(beforeTemplateLinkNode), linkNode); // Copy in CSS classes from original node safeAddClass(jqLite(linkNode), oldClasses); } - if (afterTemplateNodeLinkFn.transclude) { - childBoundTranscludeFn = createBoundTranscludeFn(scope, afterTemplateNodeLinkFn.transclude); + if (afterTemplateNodeLinkFn.transcludeOnThisElement) { + childBoundTranscludeFn = createBoundTranscludeFn(scope, afterTemplateNodeLinkFn.transclude, boundTranscludeFn); } else { childBoundTranscludeFn = boundTranscludeFn; } @@ -6783,19 +10348,25 @@ childBoundTranscludeFn); } linkQueue = null; - }). - error(function(response, code, headers, config) { - throw $compileMinErr('tpload', 'Failed to load template: {0}', config.url); - }); + }).catch(function(error) { + if (isError(error)) { + $exceptionHandler(error); + } + }); return function delayedNodeLinkFn(ignoreChildLinkFn, scope, node, rootElement, boundTranscludeFn) { + var childBoundTranscludeFn = boundTranscludeFn; + if (scope.$$destroyed) return; if (linkQueue) { - linkQueue.push(scope); - linkQueue.push(node); - linkQueue.push(rootElement); - linkQueue.push(boundTranscludeFn); + linkQueue.push(scope, + node, + rootElement, + childBoundTranscludeFn); } else { - afterTemplateNodeLinkFn(afterTemplateChildLinkFn, scope, node, rootElement, boundTranscludeFn); + if (afterTemplateNodeLinkFn.transcludeOnThisElement) { + childBoundTranscludeFn = createBoundTranscludeFn(scope, afterTemplateNodeLinkFn.transclude, boundTranscludeFn); + } + afterTemplateNodeLinkFn(afterTemplateChildLinkFn, scope, node, rootElement, childBoundTranscludeFn); } }; } @@ -6811,11 +10382,18 @@ return a.index - b.index; } - function assertNoDuplicate(what, previousDirective, directive, element) { + + function wrapModuleNameIfDefined(moduleName) { + return moduleName ? + (' (module: ' + moduleName + ')') : + ''; + } + if (previousDirective) { - throw $compileMinErr('multidir', 'Multiple directives [{0}, {1}] asking for {2} on: {3}', - previousDirective.name, directive.name, what, startingTag(element)); + throw $compileMinErr('multidir', 'Multiple directives [{0}{1}, {2}{3}] asking for {4} on: {5}', + previousDirective.name, wrapModuleNameIfDefined(previousDirective.$$moduleName), + directive.name, wrapModuleNameIfDefined(directive.$$moduleName), what, startingTag(element)); } } @@ -6825,87 +10403,127 @@ if (interpolateFn) { directives.push({ priority: 0, - compile: valueFn(function textInterpolateLinkFn(scope, node) { - var parent = node.parent(), - bindings = parent.data('$binding') || []; - bindings.push(interpolateFn); - safeAddClass(parent.data('$binding', bindings), 'ng-binding'); - scope.$watch(interpolateFn, function interpolateFnWatchAction(value) { - node[0].nodeValue = value; - }); - }) + compile: function textInterpolateCompileFn(templateNode) { + var templateNodeParent = templateNode.parent(), + hasCompileParent = !!templateNodeParent.length; + + // When transcluding a template that has bindings in the root + // we don't have a parent and thus need to add the class during linking fn. + if (hasCompileParent) compile.$$addBindingClass(templateNodeParent); + + return function textInterpolateLinkFn(scope, node) { + var parent = node.parent(); + if (!hasCompileParent) compile.$$addBindingClass(parent); + compile.$$addBindingInfo(parent, interpolateFn.expressions); + scope.$watch(interpolateFn, function interpolateFnWatchAction(value) { + node[0].nodeValue = value; + }); + }; + } }); } } + function wrapTemplate(type, template) { + type = lowercase(type || 'html'); + switch (type) { + case 'svg': + case 'math': + var wrapper = window.document.createElement('div'); + wrapper.innerHTML = '<' + type + '>' + template + '</' + type + '>'; + return wrapper.childNodes[0].childNodes; + default: + return template; + } + } + + function getTrustedContext(node, attrNormalizedName) { - if (attrNormalizedName == "srcdoc") { + if (attrNormalizedName === 'srcdoc') { return $sce.HTML; } var tag = nodeName_(node); - // maction[xlink:href] can source SVG. It's not limited to <maction>. - if (attrNormalizedName == "xlinkHref" || - (tag == "FORM" && attrNormalizedName == "action") || - (tag != "IMG" && (attrNormalizedName == "src" || - attrNormalizedName == "ngSrc"))) { + // All tags with src attributes require a RESOURCE_URL value, except for + // img and various html5 media tags. + if (attrNormalizedName === 'src' || attrNormalizedName === 'ngSrc') { + if (['img', 'video', 'audio', 'source', 'track'].indexOf(tag) === -1) { + return $sce.RESOURCE_URL; + } + // maction[xlink:href] can source SVG. It's not limited to <maction>. + } else if (attrNormalizedName === 'xlinkHref' || + (tag === 'form' && attrNormalizedName === 'action') || + // links can be stylesheets or imports, which can run script in the current origin + (tag === 'link' && attrNormalizedName === 'href') + ) { return $sce.RESOURCE_URL; } } - function addAttrInterpolateDirective(node, directives, value, name) { - var interpolateFn = $interpolate(value, true); + function addAttrInterpolateDirective(node, directives, value, name, isNgAttr) { + var trustedContext = getTrustedContext(node, name); + var mustHaveExpression = !isNgAttr; + var allOrNothing = ALL_OR_NOTHING_ATTRS[name] || isNgAttr; + + var interpolateFn = $interpolate(value, mustHaveExpression, trustedContext, allOrNothing); // no interpolation found -> ignore if (!interpolateFn) return; - - if (name === "multiple" && nodeName_(node) === "SELECT") { - throw $compileMinErr("selmulti", - "Binding to the 'multiple' attribute is not supported. Element: {0}", + if (name === 'multiple' && nodeName_(node) === 'select') { + throw $compileMinErr('selmulti', + 'Binding to the \'multiple\' attribute is not supported. Element: {0}', startingTag(node)); } + if (EVENT_HANDLER_ATTR_REGEXP.test(name)) { + throw $compileMinErr('nodomevents', + 'Interpolations for HTML DOM event attributes are disallowed. Please use the ' + + 'ng- versions (such as ng-click instead of onclick) instead.'); + } + directives.push({ priority: 100, compile: function() { return { pre: function attrInterpolatePreLinkFn(scope, element, attr) { - var $$observers = (attr.$$observers || (attr.$$observers = {})); - - if (EVENT_HANDLER_ATTR_REGEXP.test(name)) { - throw $compileMinErr('nodomevents', - "Interpolations for HTML DOM event attributes are disallowed. Please use the " + - "ng- versions (such as ng-click instead of onclick) instead."); + var $$observers = (attr.$$observers || (attr.$$observers = createMap())); + + // If the attribute has changed since last $interpolate()ed + var newValue = attr[name]; + if (newValue !== value) { + // we need to interpolate again since the attribute value has been updated + // (e.g. by another directive's compile function) + // ensure unset/empty values make interpolateFn falsy + interpolateFn = newValue && $interpolate(newValue, true, trustedContext, allOrNothing); + value = newValue; } - // we need to interpolate again, in case the attribute value has been updated - // (e.g. by another directive's compile function) - interpolateFn = $interpolate(attr[name], true, getTrustedContext(node, name)); - // if attribute was updated so that there is no interpolation going on we don't want to // register any observers if (!interpolateFn) return; - // TODO(i): this should likely be attr.$set(name, iterpolateFn(scope) so that we reset the - // actual attr value + // initialize attr object so that it's ready in case we need the value for isolate + // scope initialization, otherwise the value would not be available from isolate + // directive's linking fn during linking phase attr[name] = interpolateFn(scope); + ($$observers[name] || ($$observers[name] = [])).$$inter = true; (attr.$$observers && attr.$$observers[name].$$scope || scope). - $watch(interpolateFn, function interpolateFnWatchAction(newValue, oldValue) { - //special case for class attribute addition + removal - //so that class changes can tap into the animation - //hooks provided by the $animate service. Be sure to - //skip animations when the first digest occurs (when - //both the new and the old values are the same) since - //the CSS classes are the non-interpolated values - if(name === 'class' && newValue != oldValue) { - attr.$updateClass(newValue, oldValue); - } else { - attr.$set(name, newValue); - } - }); + $watch(interpolateFn, function interpolateFnWatchAction(newValue, oldValue) { + //special case for class attribute addition + removal + //so that class changes can tap into the animation + //hooks provided by the $animate service. Be sure to + //skip animations when the first digest occurs (when + //both the new and the old values are the same) since + //the CSS classes are the non-interpolated values + if (name === 'class' && newValue !== oldValue) { + attr.$updateClass(newValue, oldValue); + } else { + attr.$set(name, newValue); + } + }); } }; } @@ -6930,8 +10548,8 @@ i, ii; if ($rootElement) { - for(i = 0, ii = $rootElement.length; i < ii; i++) { - if ($rootElement[i] == firstElementToRemove) { + for (i = 0, ii = $rootElement.length; i < ii; i++) { + if ($rootElement[i] === firstElementToRemove) { $rootElement[i++] = newNode; for (var j = i, j2 = j + removeCount - 1, jj = $rootElement.length; @@ -6943,6 +10561,13 @@ } } $rootElement.length -= removeCount - 1; + + // If the replaced element is also the jQuery .context then replace it + // .context is a deprecated jQuery api, so we should set it only when jQuery set it + // http://api.jquery.com/context/ + if ($rootElement.context === firstElementToRemove) { + $rootElement.context = newNode; + } break; } } @@ -6951,16 +10576,34 @@ if (parent) { parent.replaceChild(newNode, firstElementToRemove); } - var fragment = document.createDocumentFragment(); - fragment.appendChild(firstElementToRemove); - newNode[jqLite.expando] = firstElementToRemove[jqLite.expando]; - for (var k = 1, kk = elementsToRemove.length; k < kk; k++) { - var element = elementsToRemove[k]; - jqLite(element).remove(); // must do this way to clean up expando - fragment.appendChild(element); - delete elementsToRemove[k]; + + // Append all the `elementsToRemove` to a fragment. This will... + // - remove them from the DOM + // - allow them to still be traversed with .nextSibling + // - allow a single fragment.qSA to fetch all elements being removed + var fragment = window.document.createDocumentFragment(); + for (i = 0; i < removeCount; i++) { + fragment.appendChild(elementsToRemove[i]); + } + + if (jqLite.hasData(firstElementToRemove)) { + // Copy over user data (that includes AngularJS's $scope etc.). Don't copy private + // data here because there's no public interface in jQuery to do that and copying over + // event listeners (which is the main use of private data) wouldn't work anyway. + jqLite.data(newNode, jqLite.data(firstElementToRemove)); + + // Remove $destroy event listeners from `firstElementToRemove` + jqLite(firstElementToRemove).off('$destroy'); } + // Cleanup any data/listeners on the elements and children. + // This includes invoking the $destroy event on any elements with listeners. + jqLite.cleanData(fragment.querySelectorAll('*')); + + // Update the jqLite collection to only contain the `newNode` + for (i = 1; i < removeCount; i++) { + delete elementsToRemove[i]; + } elementsToRemove[0] = newNode; elementsToRemove.length = 1; } @@ -6969,102 +10612,332 @@ function cloneAndAnnotateFn(fn, annotation) { return extend(function() { return fn.apply(null, arguments); }, fn, annotation); } - }]; - } - - var PREFIX_REGEXP = /^(x[\:\-_]|data[\:\-_])/i; - /** - * Converts all accepted directives format into proper directive name. - * All of these will become 'myDirective': - * my:Directive - * my-directive - * x-my-directive - * data-my:directive - * - * Also there is special case for Moz prefix starting with upper case letter. - * @param name Name to normalize - */ - function directiveNormalize(name) { - return camelCase(name.replace(PREFIX_REGEXP, '')); - } - /** - * @ngdoc type - * @name $compile.directive.Attributes - * - * @description - * A shared object between directive compile / linking functions which contains normalized DOM - * element attributes. The values reflect current binding state `{{ }}`. The normalization is - * needed since all of these are treated as equivalent in Angular: - * - * <span ng:bind="a" ng-bind="a" data-ng-bind="a" x-ng-bind="a"> - */ - /** - * @ngdoc property - * @name $compile.directive.Attributes#$attr - * @returns {object} A map of DOM element attribute names to the normalized name. This is - * needed to do reverse lookup from normalized name back to actual name. - */ + function invokeLinkFn(linkFn, scope, $element, attrs, controllers, transcludeFn) { + try { + linkFn(scope, $element, attrs, controllers, transcludeFn); + } catch (e) { + $exceptionHandler(e, startingTag($element)); + } + } + function strictBindingsCheck(attrName, directiveName) { + if (strictComponentBindingsEnabled) { + throw $compileMinErr('missingattr', + 'Attribute \'{0}\' of \'{1}\' is non-optional and must be set!', + attrName, directiveName); + } + } - /** - * @ngdoc method - * @name $compile.directive.Attributes#$set - * @function - * - * @description - * Set DOM element attribute value. - * - * - * @param {string} name Normalized element attribute name of the property to modify. The name is - * reverse-translated using the {@link ng.$compile.directive.Attributes#$attr $attr} - * property to the original name. - * @param {string} value Value to set the attribute to. The value can be an interpolated string. - */ + // Set up $watches for isolate scope and controller bindings. + function initializeDirectiveBindings(scope, attrs, destination, bindings, directive) { + var removeWatchCollection = []; + var initialChanges = {}; + var changes; + forEach(bindings, function initializeBinding(definition, scopeName) { + var attrName = definition.attrName, + optional = definition.optional, + mode = definition.mode, // @, =, <, or & + lastValue, + parentGet, parentSet, compare, removeWatch; + switch (mode) { - /** - * Closure compiler type information - */ + case '@': + if (!optional && !hasOwnProperty.call(attrs, attrName)) { + strictBindingsCheck(attrName, directive.name); + destination[scopeName] = attrs[attrName] = undefined; - function nodesetLinkingFn( - /* angular.Scope */ scope, - /* NodeList */ nodeList, - /* Element */ rootElement, - /* function(Function) */ boundTranscludeFn - ){} + } + removeWatch = attrs.$observe(attrName, function(value) { + if (isString(value) || isBoolean(value)) { + var oldValue = destination[scopeName]; + recordChanges(scopeName, value, oldValue); + destination[scopeName] = value; + } + }); + attrs.$$observers[attrName].$$scope = scope; + lastValue = attrs[attrName]; + if (isString(lastValue)) { + // If the attribute has been provided then we trigger an interpolation to ensure + // the value is there for use in the link fn + destination[scopeName] = $interpolate(lastValue)(scope); + } else if (isBoolean(lastValue)) { + // If the attributes is one of the BOOLEAN_ATTR then AngularJS will have converted + // the value to boolean rather than a string, so we special case this situation + destination[scopeName] = lastValue; + } + initialChanges[scopeName] = new SimpleChange(_UNINITIALIZED_VALUE, destination[scopeName]); + removeWatchCollection.push(removeWatch); + break; - function directiveLinkingFn( - /* nodesetLinkingFn */ nodesetLinkingFn, - /* angular.Scope */ scope, - /* Node */ node, - /* Element */ rootElement, - /* function(Function) */ boundTranscludeFn - ){} + case '=': + if (!hasOwnProperty.call(attrs, attrName)) { + if (optional) break; + strictBindingsCheck(attrName, directive.name); + attrs[attrName] = undefined; + } + if (optional && !attrs[attrName]) break; - function tokenDifference(str1, str2) { - var values = '', - tokens1 = str1.split(/\s+/), - tokens2 = str2.split(/\s+/); + parentGet = $parse(attrs[attrName]); + if (parentGet.literal) { + compare = equals; + } else { + compare = simpleCompare; + } + parentSet = parentGet.assign || function() { + // reset the change, or we will throw this exception on every $digest + lastValue = destination[scopeName] = parentGet(scope); + throw $compileMinErr('nonassign', + 'Expression \'{0}\' in attribute \'{1}\' used with directive \'{2}\' is non-assignable!', + attrs[attrName], attrName, directive.name); + }; + lastValue = destination[scopeName] = parentGet(scope); + var parentValueWatch = function parentValueWatch(parentValue) { + if (!compare(parentValue, destination[scopeName])) { + // we are out of sync and need to copy + if (!compare(parentValue, lastValue)) { + // parent changed and it has precedence + destination[scopeName] = parentValue; + } else { + // if the parent can be assigned then do so + parentSet(scope, parentValue = destination[scopeName]); + } + } + lastValue = parentValue; + return lastValue; + }; + parentValueWatch.$stateful = true; + if (definition.collection) { + removeWatch = scope.$watchCollection(attrs[attrName], parentValueWatch); + } else { + removeWatch = scope.$watch($parse(attrs[attrName], parentValueWatch), null, parentGet.literal); + } + removeWatchCollection.push(removeWatch); + break; + + case '<': + if (!hasOwnProperty.call(attrs, attrName)) { + if (optional) break; + strictBindingsCheck(attrName, directive.name); + attrs[attrName] = undefined; + } + if (optional && !attrs[attrName]) break; + + parentGet = $parse(attrs[attrName]); + var deepWatch = parentGet.literal; + + var initialValue = destination[scopeName] = parentGet(scope); + initialChanges[scopeName] = new SimpleChange(_UNINITIALIZED_VALUE, destination[scopeName]); + + removeWatch = scope.$watch(parentGet, function parentValueWatchAction(newValue, oldValue) { + if (oldValue === newValue) { + if (oldValue === initialValue || (deepWatch && equals(oldValue, initialValue))) { + return; + } + oldValue = initialValue; + } + recordChanges(scopeName, newValue, oldValue); + destination[scopeName] = newValue; + }, deepWatch); + + removeWatchCollection.push(removeWatch); + break; + + case '&': + if (!optional && !hasOwnProperty.call(attrs, attrName)) { + strictBindingsCheck(attrName, directive.name); + } + // Don't assign Object.prototype method to scope + parentGet = attrs.hasOwnProperty(attrName) ? $parse(attrs[attrName]) : noop; + + // Don't assign noop to destination if expression is not valid + if (parentGet === noop && optional) break; + + destination[scopeName] = function(locals) { + return parentGet(scope, locals); + }; + break; + } + }); + + function recordChanges(key, currentValue, previousValue) { + if (isFunction(destination.$onChanges) && !simpleCompare(currentValue, previousValue)) { + // If we have not already scheduled the top level onChangesQueue handler then do so now + if (!onChangesQueue) { + scope.$$postDigest(flushOnChangesQueue); + onChangesQueue = []; + } + // If we have not already queued a trigger of onChanges for this controller then do so now + if (!changes) { + changes = {}; + onChangesQueue.push(triggerOnChangesHook); + } + // If the has been a change on this property already then we need to reuse the previous value + if (changes[key]) { + previousValue = changes[key].previousValue; + } + // Store this change + changes[key] = new SimpleChange(previousValue, currentValue); + } + } + + function triggerOnChangesHook() { + destination.$onChanges(changes); + // Now clear the changes so that we schedule onChanges when more changes arrive + changes = undefined; + } + + return { + initialChanges: initialChanges, + removeWatches: removeWatchCollection.length && function removeWatches() { + for (var i = 0, ii = removeWatchCollection.length; i < ii; ++i) { + removeWatchCollection[i](); + } + } + }; + } + }]; + } + + function SimpleChange(previous, current) { + this.previousValue = previous; + this.currentValue = current; + } + SimpleChange.prototype.isFirstChange = function() { return this.previousValue === _UNINITIALIZED_VALUE; }; + + + var PREFIX_REGEXP = /^((?:x|data)[:\-_])/i; + var SPECIAL_CHARS_REGEXP = /[:\-_]+(.)/g; + + /** + * Converts all accepted directives format into proper directive name. + * @param name Name to normalize + */ + function directiveNormalize(name) { + return name + .replace(PREFIX_REGEXP, '') + .replace(SPECIAL_CHARS_REGEXP, function(_, letter, offset) { + return offset ? letter.toUpperCase() : letter; + }); + } + + /** + * @ngdoc type + * @name $compile.directive.Attributes + * + * @description + * A shared object between directive compile / linking functions which contains normalized DOM + * element attributes. The values reflect current binding state `{{ }}`. The normalization is + * needed since all of these are treated as equivalent in AngularJS: + * + * ``` + * <span ng:bind="a" ng-bind="a" data-ng-bind="a" x-ng-bind="a"> + * ``` + */ + + /** + * @ngdoc property + * @name $compile.directive.Attributes#$attr + * + * @description + * A map of DOM element attribute names to the normalized name. This is + * needed to do reverse lookup from normalized name back to actual name. + */ + + + /** + * @ngdoc method + * @name $compile.directive.Attributes#$set + * @kind function + * + * @description + * Set DOM element attribute value. + * + * + * @param {string} name Normalized element attribute name of the property to modify. The name is + * reverse-translated using the {@link ng.$compile.directive.Attributes#$attr $attr} + * property to the original name. + * @param {string} value Value to set the attribute to. The value can be an interpolated string. + */ + + + + /** + * Closure compiler type information + */ + + function nodesetLinkingFn( + /* angular.Scope */ scope, + /* NodeList */ nodeList, + /* Element */ rootElement, + /* function(Function) */ boundTranscludeFn + ) {} + + function directiveLinkingFn( + /* nodesetLinkingFn */ nodesetLinkingFn, + /* angular.Scope */ scope, + /* Node */ node, + /* Element */ rootElement, + /* function(Function) */ boundTranscludeFn + ) {} + + function tokenDifference(str1, str2) { + var values = '', + tokens1 = str1.split(/\s+/), + tokens2 = str2.split(/\s+/); outer: - for(var i = 0; i < tokens1.length; i++) { + for (var i = 0; i < tokens1.length; i++) { var token = tokens1[i]; - for(var j = 0; j < tokens2.length; j++) { - if(token == tokens2[j]) continue outer; + for (var j = 0; j < tokens2.length; j++) { + if (token === tokens2[j]) continue outer; } values += (values.length > 0 ? ' ' : '') + token; } return values; } + function removeComments(jqNodes) { + jqNodes = jqLite(jqNodes); + var i = jqNodes.length; + + if (i <= 1) { + return jqNodes; + } + + while (i--) { + var node = jqNodes[i]; + if (node.nodeType === NODE_TYPE_COMMENT || + (node.nodeType === NODE_TYPE_TEXT && node.nodeValue.trim() === '')) { + splice.call(jqNodes, i, 1); + } + } + return jqNodes; + } + + var $controllerMinErr = minErr('$controller'); + + + var CNTRL_REG = /^(\S+)(\s+as\s+([\w$]+))?$/; + function identifierForController(controller, ident) { + if (ident && isString(ident)) return ident; + if (isString(controller)) { + var match = CNTRL_REG.exec(controller); + if (match) return match[3]; + } + } + + /** * @ngdoc provider * @name $controllerProvider + * @this + * * @description - * The {@link ng.$controller $controller service} is used by Angular to create new + * The {@link ng.$controller $controller service} is used by AngularJS to create new * controllers. * * This provider allows controller registration via the @@ -7072,8 +10945,16 @@ */ function $ControllerProvider() { var controllers = {}, - CNTRL_REG = /^(\S+)(\s+as\s+(\w+))?$/; + globals = false; + /** + * @ngdoc method + * @name $controllerProvider#has + * @param {string} name Controller name to check. + */ + this.has = function(name) { + return controllers.hasOwnProperty(name); + }; /** * @ngdoc method @@ -7092,6 +10973,20 @@ } }; + /** + * @ngdoc method + * @name $controllerProvider#allowGlobals + * @description If called, allows `$controller` to find controller constructors on `window` + * + * @deprecated + * sinceVersion="v1.3.0" + * removeVersion="v1.7.0" + * This method of finding controllers has been deprecated. + */ + this.allowGlobals = function() { + globals = true; + }; + this.$get = ['$injector', '$window', function($injector, $window) { @@ -7106,7 +11001,12 @@ * * * check if a controller with given name is registered via `$controllerProvider` * * check if evaluating the string on the current scope returns a constructor - * * check `window[constructor]` on the global `window` object + * * if $controllerProvider#allowGlobals, check `window[constructor]` on the global + * `window` object (deprecated, not recommended) + * + * The string can use the `controller as property` syntax, where the controller instance is published + * as the specified property on the `scope`; the `scope` must be injected into `locals` param for this + * to work correctly. * * @param {Object} locals Injection locals for Controller. * @return {Object} Instance of given controller. @@ -7117,34 +11017,95 @@ * It's just a simple call to {@link auto.$injector $injector}, but extracted into * a service, so that one can override this service with [BC version](https://gist.github.com/1649788). */ - return function(expression, locals) { + return function $controller(expression, locals, later, ident) { + // PRIVATE API: + // param `later` --- indicates that the controller's constructor is invoked at a later time. + // If true, $controller will allocate the object with the correct + // prototype chain, but will not invoke the controller until a returned + // callback is invoked. + // param `ident` --- An optional label which overrides the label parsed from the controller + // expression, if any. var instance, match, constructor, identifier; + later = later === true; + if (ident && isString(ident)) { + identifier = ident; + } - if(isString(expression)) { - match = expression.match(CNTRL_REG), - constructor = match[1], - identifier = match[3]; + if (isString(expression)) { + match = expression.match(CNTRL_REG); + if (!match) { + throw $controllerMinErr('ctrlfmt', + 'Badly formed controller string \'{0}\'. ' + + 'Must match `__name__ as __id__` or `__name__`.', expression); + } + constructor = match[1]; + identifier = identifier || match[3]; expression = controllers.hasOwnProperty(constructor) ? controllers[constructor] - : getter(locals.$scope, constructor, true) || getter($window, constructor, true); + : getter(locals.$scope, constructor, true) || + (globals ? getter($window, constructor, true) : undefined); + + if (!expression) { + throw $controllerMinErr('ctrlreg', + 'The controller with the name \'{0}\' is not registered.', constructor); + } assertArgFn(expression, constructor, true); } - instance = $injector.instantiate(expression, locals); - - if (identifier) { - if (!(locals && typeof locals.$scope == 'object')) { - throw minErr('$controller')('noscp', - "Cannot export controller '{0}' as '{1}'! No $scope object provided via `locals`.", - constructor || expression.name, identifier); + if (later) { + // Instantiate controller later: + // This machinery is used to create an instance of the object before calling the + // controller's constructor itself. + // + // This allows properties to be added to the controller before the constructor is + // invoked. Primarily, this is used for isolate scope bindings in $compile. + // + // This feature is not intended for use by applications, and is thus not documented + // publicly. + // Object creation: http://jsperf.com/create-constructor/2 + var controllerPrototype = (isArray(expression) ? + expression[expression.length - 1] : expression).prototype; + instance = Object.create(controllerPrototype || null); + + if (identifier) { + addIdentifier(locals, identifier, instance, constructor || expression.name); } - locals.$scope[identifier] = instance; + return extend(function $controllerInit() { + var result = $injector.invoke(expression, instance, locals, constructor); + if (result !== instance && (isObject(result) || isFunction(result))) { + instance = result; + if (identifier) { + // If result changed, re-assign controllerAs value to scope. + addIdentifier(locals, identifier, instance, constructor || expression.name); + } + } + return instance; + }, { + instance: instance, + identifier: identifier + }); + } + + instance = $injector.instantiate(expression, locals, constructor); + + if (identifier) { + addIdentifier(locals, identifier, instance, constructor || expression.name); } return instance; }; + + function addIdentifier(locals, identifier, instance, name) { + if (!(locals && isObject(locals.$scope))) { + throw minErr('$controller')('noscp', + 'Cannot export controller \'{0}\' as \'{1}\'! No $scope object provided via `locals`.', + name, identifier); + } + + locals.$scope[identifier] = instance; + } }]; } @@ -7152,39 +11113,69 @@ * @ngdoc service * @name $document * @requires $window + * @this * * @description * A {@link angular.element jQuery or jqLite} wrapper for the browser's `window.document` object. * * @example - <example> + <example module="documentExample" name="document"> <file name="index.html"> - <div ng-controller="MainCtrl"> + <div ng-controller="ExampleController"> <p>$document title: <b ng-bind="title"></b></p> <p>window.document title: <b ng-bind="windowTitle"></b></p> </div> </file> <file name="script.js"> - function MainCtrl($scope, $document) { - $scope.title = $document[0].title; - $scope.windowTitle = angular.element(window.document)[0].title; - } + angular.module('documentExample', []) + .controller('ExampleController', ['$scope', '$document', function($scope, $document) { + $scope.title = $document[0].title; + $scope.windowTitle = angular.element(window.document)[0].title; + }]); </file> </example> */ - function $DocumentProvider(){ - this.$get = ['$window', function(window){ + function $DocumentProvider() { + this.$get = ['$window', function(window) { return jqLite(window.document); }]; } + + /** + * @private + * @this + * Listens for document visibility change and makes the current status accessible. + */ + function $$IsDocumentHiddenProvider() { + this.$get = ['$document', '$rootScope', function($document, $rootScope) { + var doc = $document[0]; + var hidden = doc && doc.hidden; + + $document.on('visibilitychange', changeListener); + + $rootScope.$on('$destroy', function() { + $document.off('visibilitychange', changeListener); + }); + + function changeListener() { + hidden = doc.hidden; + } + + return function() { + return hidden; + }; + }]; + } + /** * @ngdoc service * @name $exceptionHandler * @requires ng.$log + * @this * * @description - * Any uncaught exception in angular expressions is delegated to this service. + * Any uncaught exception in AngularJS expressions is delegated to this service. * The default implementation simply delegates to `$log.error` which logs it into * the browser console. * @@ -7193,20 +11184,31 @@ * * ## Example: * + * The example below will overwrite the default `$exceptionHandler` in order to (a) log uncaught + * errors to the backend for later inspection by the developers and (b) to use `$log.warn()` instead + * of `$log.error()`. + * * ```js - * angular.module('exceptionOverride', []).factory('$exceptionHandler', function () { - * return function (exception, cause) { - * exception.message += ' (caused by "' + cause + '")'; - * throw exception; - * }; - * }); + * angular. + * module('exceptionOverwrite', []). + * factory('$exceptionHandler', ['$log', 'logErrorsToBackend', function($log, logErrorsToBackend) { + * return function myExceptionHandler(exception, cause) { + * logErrorsToBackend(exception, cause); + * $log.warn(exception, cause); + * }; + * }]); * ``` * - * This example will override the normal action of `$exceptionHandler`, to make angular - * exceptions fail hard when they happen, instead of just logging to the console. + * <hr /> + * Note, that code executed in event-listeners (even those registered using jqLite's `on`/`bind` + * methods) does not delegate exceptions to the {@link ng.$exceptionHandler $exceptionHandler} + * (unless executed during a digest). + * + * If you wish, you can manually delegate exceptions, e.g. + * `try { ... } catch(e) { $exceptionHandler(e); }` * * @param {Error} exception Exception associated with the error. - * @param {string=} cause optional information about the context in which + * @param {string=} cause Optional information about the context in which * the error was thrown. * */ @@ -7218,110 +11220,349 @@ }]; } - /** - * Parse headers into key value object - * - * @param {string} headers Raw headers as a string - * @returns {Object} Parsed headers as key value object - */ - function parseHeaders(headers) { - var parsed = {}, key, val, i; - - if (!headers) return parsed; - - forEach(headers.split('\n'), function(line) { - i = line.indexOf(':'); - key = lowercase(trim(line.substr(0, i))); - val = trim(line.substr(i + 1)); - - if (key) { - if (parsed[key]) { - parsed[key] += ', ' + val; + var $$ForceReflowProvider = /** @this */ function() { + this.$get = ['$document', function($document) { + return function(domNode) { + //the line below will force the browser to perform a repaint so + //that all the animated elements within the animation frame will + //be properly updated and drawn on screen. This is required to + //ensure that the preparation animation is properly flushed so that + //the active state picks up from there. DO NOT REMOVE THIS LINE. + //DO NOT OPTIMIZE THIS LINE. THE MINIFIER WILL REMOVE IT OTHERWISE WHICH + //WILL RESULT IN AN UNPREDICTABLE BUG THAT IS VERY HARD TO TRACK DOWN AND + //WILL TAKE YEARS AWAY FROM YOUR LIFE. + if (domNode) { + if (!domNode.nodeType && domNode instanceof jqLite) { + domNode = domNode[0]; + } } else { - parsed[key] = val; + domNode = $document[0].body; } - } - }); - - return parsed; - } - - - /** - * Returns a function that provides access to parsed headers. - * - * Headers are lazy parsed when first requested. - * @see parseHeaders - * - * @param {(string|Object)} headers Headers to provide access to. - * @returns {function(string=)} Returns a getter function which if called with: - * - * - if called with single an argument returns a single header value or null - * - if called with no arguments returns an object containing all headers. - */ - function headersGetter(headers) { - var headersObj = isObject(headers) ? headers : undefined; - - return function(name) { - if (!headersObj) headersObj = parseHeaders(headers); + return domNode.offsetWidth + 1; + }; + }]; + }; - if (name) { - return headersObj[lowercase(name)] || null; - } + var APPLICATION_JSON = 'application/json'; + var CONTENT_TYPE_APPLICATION_JSON = {'Content-Type': APPLICATION_JSON + ';charset=utf-8'}; + var JSON_START = /^\[|^\{(?!\{)/; + var JSON_ENDS = { + '[': /]$/, + '{': /}$/ + }; + var JSON_PROTECTION_PREFIX = /^\)]\}',?\n/; + var $httpMinErr = minErr('$http'); - return headersObj; - }; + function serializeValue(v) { + if (isObject(v)) { + return isDate(v) ? v.toISOString() : toJson(v); + } + return v; } - /** - * Chain all given functions - * - * This function is used for both request and response transforming - * - * @param {*} data Data to transform. - * @param {function(string=)} headers Http headers getter fn. - * @param {(Function|Array.<Function>)} fns Function or an array of functions. - * @returns {*} Transformed data. - */ - function transformData(data, headers, fns) { - if (isFunction(fns)) - return fns(data, headers); - - forEach(fns, function(fn) { - data = fn(data, headers); - }); - - return data; - } + /** @this */ + function $HttpParamSerializerProvider() { + /** + * @ngdoc service + * @name $httpParamSerializer + * @description + * + * Default {@link $http `$http`} params serializer that converts objects to strings + * according to the following rules: + * + * * `{'foo': 'bar'}` results in `foo=bar` + * * `{'foo': Date.now()}` results in `foo=2015-04-01T09%3A50%3A49.262Z` (`toISOString()` and encoded representation of a Date object) + * * `{'foo': ['bar', 'baz']}` results in `foo=bar&foo=baz` (repeated key for each array element) + * * `{'foo': {'bar':'baz'}}` results in `foo=%7B%22bar%22%3A%22baz%22%7D` (stringified and encoded representation of an object) + * + * Note that serializer will sort the request parameters alphabetically. + * */ + this.$get = function() { + return function ngParamSerializer(params) { + if (!params) return ''; + var parts = []; + forEachSorted(params, function(value, key) { + if (value === null || isUndefined(value) || isFunction(value)) return; + if (isArray(value)) { + forEach(value, function(v) { + parts.push(encodeUriQuery(key) + '=' + encodeUriQuery(serializeValue(v))); + }); + } else { + parts.push(encodeUriQuery(key) + '=' + encodeUriQuery(serializeValue(value))); + } + }); - function isSuccess(status) { - return 200 <= status && status < 300; + return parts.join('&'); + }; + }; } + /** @this */ + function $HttpParamSerializerJQLikeProvider() { + /** + * @ngdoc service + * @name $httpParamSerializerJQLike + * + * @description + * + * Alternative {@link $http `$http`} params serializer that follows + * jQuery's [`param()`](http://api.jquery.com/jquery.param/) method logic. + * The serializer will also sort the params alphabetically. + * + * To use it for serializing `$http` request parameters, set it as the `paramSerializer` property: + * + * ```js + * $http({ + * url: myUrl, + * method: 'GET', + * params: myParams, + * paramSerializer: '$httpParamSerializerJQLike' + * }); + * ``` + * + * It is also possible to set it as the default `paramSerializer` in the + * {@link $httpProvider#defaults `$httpProvider`}. + * + * Additionally, you can inject the serializer and use it explicitly, for example to serialize + * form data for submission: + * + * ```js + * .controller(function($http, $httpParamSerializerJQLike) { + * //... + * + * $http({ + * url: myUrl, + * method: 'POST', + * data: $httpParamSerializerJQLike(myData), + * headers: { + * 'Content-Type': 'application/x-www-form-urlencoded' + * } + * }); + * + * }); + * ``` + * + * */ + this.$get = function() { + return function jQueryLikeParamSerializer(params) { + if (!params) return ''; + var parts = []; + serialize(params, '', true); + return parts.join('&'); + + function serialize(toSerialize, prefix, topLevel) { + if (toSerialize === null || isUndefined(toSerialize)) return; + if (isArray(toSerialize)) { + forEach(toSerialize, function(value, index) { + serialize(value, prefix + '[' + (isObject(value) ? index : '') + ']'); + }); + } else if (isObject(toSerialize) && !isDate(toSerialize)) { + forEachSorted(toSerialize, function(value, key) { + serialize(value, prefix + + (topLevel ? '' : '[') + + key + + (topLevel ? '' : ']')); + }); + } else { + parts.push(encodeUriQuery(prefix) + '=' + encodeUriQuery(serializeValue(toSerialize))); + } + } + }; + }; + } + + function defaultHttpResponseTransform(data, headers) { + if (isString(data)) { + // Strip json vulnerability protection prefix and trim whitespace + var tempData = data.replace(JSON_PROTECTION_PREFIX, '').trim(); + + if (tempData) { + var contentType = headers('Content-Type'); + var hasJsonContentType = contentType && (contentType.indexOf(APPLICATION_JSON) === 0); + + if (hasJsonContentType || isJsonLike(tempData)) { + try { + data = fromJson(tempData); + } catch (e) { + if (!hasJsonContentType) { + return data; + } + throw $httpMinErr('baddata', 'Data must be a valid JSON object. Received: "{0}". ' + + 'Parse error: "{1}"', data, e); + } + } + } + } + + return data; + } + + function isJsonLike(str) { + var jsonStart = str.match(JSON_START); + return jsonStart && JSON_ENDS[jsonStart[0]].test(str); + } + + /** + * Parse headers into key value object + * + * @param {string} headers Raw headers as a string + * @returns {Object} Parsed headers as key value object + */ + function parseHeaders(headers) { + var parsed = createMap(), i; + + function fillInParsed(key, val) { + if (key) { + parsed[key] = parsed[key] ? parsed[key] + ', ' + val : val; + } + } + + if (isString(headers)) { + forEach(headers.split('\n'), function(line) { + i = line.indexOf(':'); + fillInParsed(lowercase(trim(line.substr(0, i))), trim(line.substr(i + 1))); + }); + } else if (isObject(headers)) { + forEach(headers, function(headerVal, headerKey) { + fillInParsed(lowercase(headerKey), trim(headerVal)); + }); + } + + return parsed; + } + + + /** + * Returns a function that provides access to parsed headers. + * + * Headers are lazy parsed when first requested. + * @see parseHeaders + * + * @param {(string|Object)} headers Headers to provide access to. + * @returns {function(string=)} Returns a getter function which if called with: + * + * - if called with an argument returns a single header value or null + * - if called with no arguments returns an object containing all headers. + */ + function headersGetter(headers) { + var headersObj; + + return function(name) { + if (!headersObj) headersObj = parseHeaders(headers); + + if (name) { + var value = headersObj[lowercase(name)]; + if (value === undefined) { + value = null; + } + return value; + } + + return headersObj; + }; + } + + + /** + * Chain all given functions + * + * This function is used for both request and response transforming + * + * @param {*} data Data to transform. + * @param {function(string=)} headers HTTP headers getter fn. + * @param {number} status HTTP status code of the response. + * @param {(Function|Array.<Function>)} fns Function or an array of functions. + * @returns {*} Transformed data. + */ + function transformData(data, headers, status, fns) { + if (isFunction(fns)) { + return fns(data, headers, status); + } + + forEach(fns, function(fn) { + data = fn(data, headers, status); + }); + + return data; + } + + + function isSuccess(status) { + return 200 <= status && status < 300; + } - function $HttpProvider() { - var JSON_START = /^\s*(\[|\{[^\{])/, - JSON_END = /[\}\]]\s*$/, - PROTECTION_PREFIX = /^\)\]\}',?\n/, - CONTENT_TYPE_APPLICATION_JSON = {'Content-Type': 'application/json;charset=utf-8'}; - + + /** + * @ngdoc provider + * @name $httpProvider + * @this + * + * @description + * Use `$httpProvider` to change the default behavior of the {@link ng.$http $http} service. + * */ + function $HttpProvider() { + /** + * @ngdoc property + * @name $httpProvider#defaults + * @description + * + * Object containing default values for all {@link ng.$http $http} requests. + * + * - **`defaults.cache`** - {boolean|Object} - A boolean value or object created with + * {@link ng.$cacheFactory `$cacheFactory`} to enable or disable caching of HTTP responses + * by default. See {@link $http#caching $http Caching} for more information. + * + * - **`defaults.headers`** - {Object} - Default headers for all $http requests. + * Refer to {@link ng.$http#setting-http-headers $http} for documentation on + * setting default headers. + * - **`defaults.headers.common`** + * - **`defaults.headers.post`** + * - **`defaults.headers.put`** + * - **`defaults.headers.patch`** + * + * - **`defaults.jsonpCallbackParam`** - `{string}` - the name of the query parameter that passes the name of the + * callback in a JSONP request. The value of this parameter will be replaced with the expression generated by the + * {@link $jsonpCallbacks} service. Defaults to `'callback'`. + * + * - **`defaults.paramSerializer`** - `{string|function(Object<string,string>):string}` - A function + * used to the prepare string representation of request parameters (specified as an object). + * If specified as string, it is interpreted as a function registered with the {@link auto.$injector $injector}. + * Defaults to {@link ng.$httpParamSerializer $httpParamSerializer}. + * + * - **`defaults.transformRequest`** - + * `{Array<function(data, headersGetter)>|function(data, headersGetter)}` - + * An array of functions (or a single function) which are applied to the request data. + * By default, this is an array with one request transformation function: + * + * - If the `data` property of the request configuration object contains an object, serialize it + * into JSON format. + * + * - **`defaults.transformResponse`** - + * `{Array<function(data, headersGetter, status)>|function(data, headersGetter, status)}` - + * An array of functions (or a single function) which are applied to the response data. By default, + * this is an array which applies one response transformation function that does two things: + * + * - If XSRF prefix is detected, strip it + * (see {@link ng.$http#security-considerations Security Considerations in the $http docs}). + * - If the `Content-Type` is `application/json` or the response looks like JSON, + * deserialize it using a JSON parser. + * + * - **`defaults.xsrfCookieName`** - {string} - Name of cookie containing the XSRF token. + * Defaults value is `'XSRF-TOKEN'`. + * + * - **`defaults.xsrfHeaderName`** - {string} - Name of HTTP header to populate with the + * XSRF token. Defaults value is `'X-XSRF-TOKEN'`. + * + **/ var defaults = this.defaults = { // transform incoming response data - transformResponse: [function(data) { - if (isString(data)) { - // strip json vulnerability protection prefix - data = data.replace(PROTECTION_PREFIX, ''); - if (JSON_START.test(data) && JSON_END.test(data)) - data = fromJson(data); - } - return data; - }], + transformResponse: [defaultHttpResponseTransform], // transform outgoing request data transformRequest: [function(d) { - return isObject(d) && !isFile(d) && !isBlob(d) ? toJson(d) : d; + return isObject(d) && !isFile(d) && !isBlob(d) && !isFormData(d) ? toJson(d) : d; }], // default headers @@ -7329,32 +11570,73 @@ common: { 'Accept': 'application/json, text/plain, */*' }, - post: copy(CONTENT_TYPE_APPLICATION_JSON), - put: copy(CONTENT_TYPE_APPLICATION_JSON), - patch: copy(CONTENT_TYPE_APPLICATION_JSON) + post: shallowCopy(CONTENT_TYPE_APPLICATION_JSON), + put: shallowCopy(CONTENT_TYPE_APPLICATION_JSON), + patch: shallowCopy(CONTENT_TYPE_APPLICATION_JSON) }, xsrfCookieName: 'XSRF-TOKEN', - xsrfHeaderName: 'X-XSRF-TOKEN' + xsrfHeaderName: 'X-XSRF-TOKEN', + + paramSerializer: '$httpParamSerializer', + + jsonpCallbackParam: 'callback' }; + var useApplyAsync = false; /** - * Are ordered by request, i.e. they are applied in the same order as the - * array, on request, but reverse order, on response. - */ - var interceptorFactories = this.interceptors = []; + * @ngdoc method + * @name $httpProvider#useApplyAsync + * @description + * + * Configure $http service to combine processing of multiple http responses received at around + * the same time via {@link ng.$rootScope.Scope#$applyAsync $rootScope.$applyAsync}. This can result in + * significant performance improvement for bigger applications that make many HTTP requests + * concurrently (common during application bootstrap). + * + * Defaults to false. If no value is specified, returns the current configured value. + * + * @param {boolean=} value If true, when requests are loaded, they will schedule a deferred + * "apply" on the next tick, giving time for subsequent requests in a roughly ~10ms window + * to load and share the same digest cycle. + * + * @returns {boolean|Object} If a value is specified, returns the $httpProvider for chaining. + * otherwise, returns the current configured value. + **/ + this.useApplyAsync = function(value) { + if (isDefined(value)) { + useApplyAsync = !!value; + return this; + } + return useApplyAsync; + }; /** - * For historical reasons, response interceptors are ordered by the order in which - * they are applied to the response. (This is the opposite of interceptorFactories) - */ - var responseInterceptorFactories = this.responseInterceptors = []; + * @ngdoc property + * @name $httpProvider#interceptors + * @description + * + * Array containing service factories for all synchronous or asynchronous {@link ng.$http $http} + * pre-processing of request or postprocessing of responses. + * + * These service factories are ordered by request, i.e. they are applied in the same order as the + * array, on request, but reverse order, on response. + * + * {@link ng.$http#interceptors Interceptors detailed info} + **/ + var interceptorFactories = this.interceptors = []; - this.$get = ['$httpBackend', '$browser', '$cacheFactory', '$rootScope', '$q', '$injector', - function($httpBackend, $browser, $cacheFactory, $rootScope, $q, $injector) { + this.$get = ['$browser', '$httpBackend', '$$cookieReader', '$cacheFactory', '$rootScope', '$q', '$injector', '$sce', + function($browser, $httpBackend, $$cookieReader, $cacheFactory, $rootScope, $q, $injector, $sce) { var defaultCache = $cacheFactory('$http'); + /** + * Make sure that default param serializer is exposed as a function + */ + defaults.paramSerializer = isString(defaults.paramSerializer) ? + $injector.get(defaults.paramSerializer) : defaults.paramSerializer; + /** * Interceptors stored in reverse order. Inner interceptors before outer interceptors. * The reversal is needed so that we can build up the interception chain around the @@ -7367,27 +11649,6 @@ ? $injector.get(interceptorFactory) : $injector.invoke(interceptorFactory)); }); - forEach(responseInterceptorFactories, function(interceptorFactory, index) { - var responseFn = isString(interceptorFactory) - ? $injector.get(interceptorFactory) - : $injector.invoke(interceptorFactory); - - /** - * Response interceptors go before "around" interceptors (no real reason, just - * had to pick one.) But they are already reversed, so we can't use unshift, hence - * the splice. - */ - reversedInterceptors.splice(index, 0, { - response: function(response) { - return responseFn($q.when(response)); - }, - responseError: function(response) { - return responseFn($q.reject(response)); - } - }); - }); - - /** * @ngdoc service * @kind function @@ -7399,7 +11660,7 @@ * @requires $injector * * @description - * The `$http` service is a core Angular service that facilitates communication with the remote + * The `$http` service is a core AngularJS service that facilitates communication with the remote * HTTP servers via the browser's [XMLHttpRequest](https://developer.mozilla.org/en/xmlhttprequest) * object or via [JSONP](http://en.wikipedia.org/wiki/JSONP). * @@ -7414,52 +11675,52 @@ * it is important to familiarize yourself with these APIs and the guarantees they provide. * * - * # General usage - * The `$http` service is a function which takes a single argument — a configuration object — - * that is used to generate an HTTP request and returns a {@link ng.$q promise} - * with two $http specific methods: `success` and `error`. + * ## General usage + * The `$http` service is a function which takes a single argument — a {@link $http#usage configuration object} — + * that is used to generate an HTTP request and returns a {@link ng.$q promise}. * * ```js - * $http({method: 'GET', url: '/someUrl'}). - * success(function(data, status, headers, config) { - * // this callback will be called asynchronously - * // when the response is available - * }). - * error(function(data, status, headers, config) { - * // called asynchronously if an error occurs - * // or server returns response with an error status. - * }); + * // Simple GET request example: + * $http({ + * method: 'GET', + * url: '/someUrl' + * }).then(function successCallback(response) { + * // this callback will be called asynchronously + * // when the response is available + * }, function errorCallback(response) { + * // called asynchronously if an error occurs + * // or server returns response with an error status. + * }); * ``` * - * Since the returned value of calling the $http function is a `promise`, you can also use - * the `then` method to register callbacks, and these callbacks will receive a single argument – - * an object representing the response. See the API signature and type info below for more - * details. + * The response object has these properties: * - * A response status code between 200 and 299 is considered a success status and - * will result in the success callback being called. Note that if the response is a redirect, - * XMLHttpRequest will transparently follow it, meaning that the error callback will not be - * called for such responses. + * - **data** – `{string|Object}` – The response body transformed with the transform + * functions. + * - **status** – `{number}` – HTTP status code of the response. + * - **headers** – `{function([headerName])}` – Header getter function. + * - **config** – `{Object}` – The configuration object that was used to generate the request. + * - **statusText** – `{string}` – HTTP status text of the response. + * - **xhrStatus** – `{string}` – Status of the XMLHttpRequest (`complete`, `error`, `timeout` or `abort`). * - * # Writing Unit Tests that use $http - * When unit testing (using {@link ngMock ngMock}), it is necessary to call - * {@link ngMock.$httpBackend#flush $httpBackend.flush()} to flush each pending - * request using trained responses. + * A response status code between 200 and 299 is considered a success status and will result in + * the success callback being called. Any response status code outside of that range is + * considered an error status and will result in the error callback being called. + * Also, status codes less than -1 are normalized to zero. -1 usually means the request was + * aborted, e.g. using a `config.timeout`. + * Note that if the response is a redirect, XMLHttpRequest will transparently follow it, meaning + * that the outcome (success or error) will be determined by the final response status code. * - * ``` - * $httpBackend.expectGET(...); - * $http.get(...); - * $httpBackend.flush(); - * ``` * - * # Shortcut methods + * ## Shortcut methods * * Shortcut methods are also available. All shortcut methods require passing in the URL, and - * request data must be passed in for POST/PUT requests. + * request data must be passed in for POST/PUT requests. An optional config can be passed as the + * last argument. * * ```js - * $http.get('/someUrl').success(successCallback); - * $http.post('/someUrl', data).success(successCallback); + * $http.get('/someUrl', config).then(successCallback, errorCallback); + * $http.post('/someUrl', data, config).then(successCallback, errorCallback); * ``` * * Complete list of shortcut methods: @@ -7470,16 +11731,28 @@ * - {@link ng.$http#put $http.put} * - {@link ng.$http#delete $http.delete} * - {@link ng.$http#jsonp $http.jsonp} + * - {@link ng.$http#patch $http.patch} + * * + * ## Writing Unit Tests that use $http + * When unit testing (using {@link ngMock ngMock}), it is necessary to call + * {@link ngMock.$httpBackend#flush $httpBackend.flush()} to flush each pending + * request using trained responses. + * + * ``` + * $httpBackend.expectGET(...); + * $http.get(...); + * $httpBackend.flush(); + * ``` * - * # Setting HTTP Headers + * ## Setting HTTP Headers * * The $http service will automatically add certain HTTP headers to all requests. These defaults * can be fully configured by accessing the `$httpProvider.defaults.headers` configuration * object, which currently contains this default configuration: * * - `$httpProvider.defaults.headers.common` (headers that are common for all requests): - * - `Accept: application/json, text/plain, * / *` + * - <code>Accept: application/json, text/plain, \*/\*</code> * - `$httpProvider.defaults.headers.post`: (header defaults for POST requests) * - `Content-Type: application/json` * - `$httpProvider.defaults.headers.put` (header defaults for PUT requests) @@ -7488,74 +11761,143 @@ * To add or overwrite these defaults, simply add or remove a property from these configuration * objects. To add headers for an HTTP method other than POST or PUT, simply add a new object * with the lowercased HTTP method name as the key, e.g. - * `$httpProvider.defaults.headers.get = { 'My-Header' : 'value' }. + * `$httpProvider.defaults.headers.get = { 'My-Header' : 'value' }`. * * The defaults can also be set at runtime via the `$http.defaults` object in the same * fashion. For example: * * ``` * module.run(function($http) { - * $http.defaults.headers.common.Authorization = 'Basic YmVlcDpib29w' - * }); + * $http.defaults.headers.common.Authorization = 'Basic YmVlcDpib29w'; + * }); * ``` * * In addition, you can supply a `headers` property in the config object passed when * calling `$http(config)`, which overrides the defaults without changing them globally. * + * To explicitly remove a header automatically added via $httpProvider.defaults.headers on a per request basis, + * Use the `headers` property, setting the desired header to `undefined`. For example: + * + * ```js + * var req = { + * method: 'POST', + * url: 'http://example.com', + * headers: { + * 'Content-Type': undefined + * }, + * data: { test: 'test' } + * } + * + * $http(req).then(function(){...}, function(){...}); + * ``` + * + * ## Transforming Requests and Responses + * + * Both requests and responses can be transformed using transformation functions: `transformRequest` + * and `transformResponse`. These properties can be a single function that returns + * the transformed value (`function(data, headersGetter, status)`) or an array of such transformation functions, + * which allows you to `push` or `unshift` a new transformation function into the transformation chain. * - * # Transforming Requests and Responses + * <div class="alert alert-warning"> + * **Note:** AngularJS does not make a copy of the `data` parameter before it is passed into the `transformRequest` pipeline. + * That means changes to the properties of `data` are not local to the transform function (since Javascript passes objects by reference). + * For example, when calling `$http.get(url, $scope.myObject)`, modifications to the object's properties in a transformRequest + * function will be reflected on the scope and in any templates where the object is data-bound. + * To prevent this, transform functions should have no side-effects. + * If you need to modify properties, it is recommended to make a copy of the data, or create new object to return. + * </div> + * + * ### Default Transformations + * + * The `$httpProvider` provider and `$http` service expose `defaults.transformRequest` and + * `defaults.transformResponse` properties. If a request does not provide its own transformations + * then these will be applied. * - * Both requests and responses can be transformed using transform functions. By default, Angular - * applies these transformations: + * You can augment or replace the default transformations by modifying these properties by adding to or + * replacing the array. * - * Request transformations: + * AngularJS provides the following default transformations: + * + * Request transformations (`$httpProvider.defaults.transformRequest` and `$http.defaults.transformRequest`) is + * an array with one function that does the following: * * - If the `data` property of the request configuration object contains an object, serialize it * into JSON format. * - * Response transformations: + * Response transformations (`$httpProvider.defaults.transformResponse` and `$http.defaults.transformResponse`) is + * an array with one function that does the following: * * - If XSRF prefix is detected, strip it (see Security Considerations section below). - * - If JSON response is detected, deserialize it using a JSON parser. + * - If the `Content-Type` is `application/json` or the response looks like JSON, + * deserialize it using a JSON parser. + * * - * To globally augment or override the default transforms, modify the - * `$httpProvider.defaults.transformRequest` and `$httpProvider.defaults.transformResponse` - * properties. These properties are by default an array of transform functions, which allows you - * to `push` or `unshift` a new transformation function into the transformation chain. You can - * also decide to completely override any default transformations by assigning your - * transformation functions to these properties directly without the array wrapper. These defaults - * are again available on the $http factory at run-time, which may be useful if you have run-time - * services you wish to be involved in your transformations. + * ### Overriding the Default Transformations Per Request * - * Similarly, to locally override the request/response transforms, augment the - * `transformRequest` and/or `transformResponse` properties of the configuration object passed + * If you wish to override the request/response transformations only for a single request then provide + * `transformRequest` and/or `transformResponse` properties on the configuration object passed * into `$http`. * + * Note that if you provide these properties on the config object the default transformations will be + * overwritten. If you wish to augment the default transformations then you must include them in your + * local transformation array. + * + * The following code demonstrates adding a new response transformation to be run after the default response + * transformations have been run. + * + * ```js + * function appendTransform(defaults, transform) { + * + * // We can't guarantee that the default transformation is an array + * defaults = angular.isArray(defaults) ? defaults : [defaults]; + * + * // Append the new transformation to the defaults + * return defaults.concat(transform); + * } + * + * $http({ + * url: '...', + * method: 'GET', + * transformResponse: appendTransform($http.defaults.transformResponse, function(value) { + * return doTransform(value); + * }) + * }); + * ``` + * + * + * ## Caching + * + * {@link ng.$http `$http`} responses are not cached by default. To enable caching, you must + * set the config.cache value or the default cache value to TRUE or to a cache object (created + * with {@link ng.$cacheFactory `$cacheFactory`}). If defined, the value of config.cache takes + * precedence over the default cache value. * - * # Caching + * In order to: + * * cache all responses - set the default cache value to TRUE or to a cache object + * * cache a specific response - set config.cache value to TRUE or to a cache object * - * To enable caching, set the request configuration `cache` property to `true` (to use default - * cache) or to a custom cache object (built with {@link ng.$cacheFactory `$cacheFactory`}). - * When the cache is enabled, `$http` stores the response from the server in the specified - * cache. The next time the same request is made, the response is served from the cache without - * sending a request to the server. + * If caching is enabled, but neither the default cache nor config.cache are set to a cache object, + * then the default `$cacheFactory("$http")` object is used. * - * Note that even if the response is served from cache, delivery of the data is asynchronous in - * the same way that real requests are. + * The default cache value can be set by updating the + * {@link ng.$http#defaults `$http.defaults.cache`} property or the + * {@link $httpProvider#defaults `$httpProvider.defaults.cache`} property. * - * If there are multiple GET requests for the same URL that should be cached using the same - * cache, but the cache is not populated yet, only one request to the server will be made and - * the remaining requests will be fulfilled using the response from the first request. + * When caching is enabled, {@link ng.$http `$http`} stores the response from the server using + * the relevant cache object. The next time the same request is made, the response is returned + * from the cache without sending a request to the server. * - * You can change the default cache to a new object (built with - * {@link ng.$cacheFactory `$cacheFactory`}) by updating the - * {@link ng.$http#properties_defaults `$http.defaults.cache`} property. All requests who set - * their `cache` property to `true` will now use this cache object. + * Take note that: * - * If you set the default cache to `false` then only requests that specify their own custom - * cache object will be cached. + * * Only GET and JSONP requests are cached. + * * The cache key is the request URL including search parameters; headers are not considered. + * * Cached responses are returned asynchronously, in the same way as responses from the server. + * * If multiple identical requests are made using the same cache, which is not yet populated, + * one request will be made to the server and remaining requests will return the same response. + * * A cache-control header on the response does not affect if or how responses are cached. * - * # Interceptors + * + * ## Interceptors * * Before you start creating interceptors, be sure to understand the * {@link ng.$q $q and deferred/promise APIs}. @@ -7573,14 +11915,14 @@ * * There are two kinds of interceptors (and two kinds of rejection interceptors): * - * * `request`: interceptors get called with http `config` object. The function is free to - * modify the `config` or create a new one. The function needs to return the `config` - * directly or as a promise. + * * `request`: interceptors get called with a http {@link $http#usage config} object. The function is free to + * modify the `config` object or create a new one. The function needs to return the `config` + * object directly, or a promise containing the `config` or a new `config` object. * * `requestError`: interceptor gets called when a previous interceptor threw an error or * resolved with a rejection. * * `response`: interceptors get called with http `response` object. The function is free to - * modify the `response` or create a new one. The function needs to return the `response` - * directly or as a promise. + * modify the `response` object or create a new one. The function needs to return the `response` + * object directly, or as a promise containing the `response` or a new `response` object. * * `responseError`: interceptor gets called when a previous interceptor threw an error or * resolved with a rejection. * @@ -7588,121 +11930,76 @@ * ```js * // register the interceptor as a service * $provide.factory('myHttpInterceptor', function($q, dependency1, dependency2) { - * return { - * // optional method - * 'request': function(config) { - * // do something on success - * return config || $q.when(config); - * }, - * - * // optional method - * 'requestError': function(rejection) { - * // do something on error - * if (canRecover(rejection)) { - * return responseOrNewPromise - * } - * return $q.reject(rejection); - * }, - * - * - * - * // optional method - * 'response': function(response) { - * // do something on success - * return response || $q.when(response); - * }, - * - * // optional method - * 'responseError': function(rejection) { - * // do something on error - * if (canRecover(rejection)) { - * return responseOrNewPromise - * } - * return $q.reject(rejection); - * } - * }; - * }); + * return { + * // optional method + * 'request': function(config) { + * // do something on success + * return config; + * }, + * + * // optional method + * 'requestError': function(rejection) { + * // do something on error + * if (canRecover(rejection)) { + * return responseOrNewPromise + * } + * return $q.reject(rejection); + * }, + * + * + * + * // optional method + * 'response': function(response) { + * // do something on success + * return response; + * }, + * + * // optional method + * 'responseError': function(rejection) { + * // do something on error + * if (canRecover(rejection)) { + * return responseOrNewPromise + * } + * return $q.reject(rejection); + * } + * }; + * }); * * $httpProvider.interceptors.push('myHttpInterceptor'); * * * // alternatively, register the interceptor via an anonymous factory * $httpProvider.interceptors.push(function($q, dependency1, dependency2) { - * return { - * 'request': function(config) { - * // same as above - * }, - * - * 'response': function(response) { - * // same as above - * } - * }; - * }); + * return { + * 'request': function(config) { + * // same as above + * }, + * + * 'response': function(response) { + * // same as above + * } + * }; + * }); * ``` * - * # Response interceptors (DEPRECATED) + * ## Security Considerations * - * Before you start creating interceptors, be sure to understand the - * {@link ng.$q $q and deferred/promise APIs}. + * When designing web applications, consider security threats from: * - * For purposes of global error handling, authentication or any kind of synchronous or - * asynchronous preprocessing of received responses, it is desirable to be able to intercept - * responses for http requests before they are handed over to the application code that - * initiated these requests. The response interceptors leverage the {@link ng.$q - * promise apis} to fulfil this need for both synchronous and asynchronous preprocessing. + * - [JSON vulnerability](http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx) + * - [XSRF](http://en.wikipedia.org/wiki/Cross-site_request_forgery) * - * The interceptors are service factories that are registered with the $httpProvider by - * adding them to the `$httpProvider.responseInterceptors` array. The factory is called and - * injected with dependencies (if specified) and returns the interceptor — a function that - * takes a {@link ng.$q promise} and returns the original or a new promise. + * Both server and the client must cooperate in order to eliminate these threats. AngularJS comes + * pre-configured with strategies that address these issues, but for this to work backend server + * cooperation is required. * - * ```js - * // register the interceptor as a service - * $provide.factory('myHttpInterceptor', function($q, dependency1, dependency2) { - * return function(promise) { - * return promise.then(function(response) { - * // do something on success - * return response; - * }, function(response) { - * // do something on error - * if (canRecover(response)) { - * return responseOrNewPromise - * } - * return $q.reject(response); - * }); - * } - * }); - * - * $httpProvider.responseInterceptors.push('myHttpInterceptor'); - * - * - * // register the interceptor via an anonymous factory - * $httpProvider.responseInterceptors.push(function($q, dependency1, dependency2) { - * return function(promise) { - * // same as above - * } - * }); - * ``` - * - * - * # Security Considerations - * - * When designing web applications, consider security threats from: - * - * - [JSON vulnerability](http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx) - * - [XSRF](http://en.wikipedia.org/wiki/Cross-site_request_forgery) - * - * Both server and the client must cooperate in order to eliminate these threats. Angular comes - * pre-configured with strategies that address these issues, but for this to work backend server - * cooperation is required. - * - * ## JSON Vulnerability Protection + * ### JSON Vulnerability Protection * * A [JSON vulnerability](http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx) * allows third party website to turn your JSON resource URL into * [JSONP](http://en.wikipedia.org/wiki/JSONP) request under some conditions. To * counter this your server can prefix all JSON requests with following string `")]}',\n"`. - * Angular will automatically strip the prefix before processing it as JSON. + * AngularJS will automatically strip the prefix before processing it as JSON. * * For example if your server needs to return: * ```js @@ -7715,18 +12012,18 @@ * ['one','two'] * ``` * - * Angular will strip the prefix, before processing the JSON. + * AngularJS will strip the prefix, before processing the JSON. * * - * ## Cross Site Request Forgery (XSRF) Protection + * ### Cross Site Request Forgery (XSRF) Protection * - * [XSRF](http://en.wikipedia.org/wiki/Cross-site_request_forgery) is a technique by which - * an unauthorized site can gain your user's private data. Angular provides a mechanism - * to counter XSRF. When performing XHR requests, the $http service reads a token from a cookie - * (by default, `XSRF-TOKEN`) and sets it as an HTTP header (`X-XSRF-TOKEN`). Since only - * JavaScript that runs on your domain could read the cookie, your server can be assured that - * the XHR came from JavaScript running on your domain. The header will not be set for - * cross-domain requests. + * [XSRF](http://en.wikipedia.org/wiki/Cross-site_request_forgery) is an attack technique by + * which the attacker can trick an authenticated user into unknowingly executing actions on your + * website. AngularJS provides a mechanism to counter XSRF. When performing XHR requests, the + * $http service reads a token from a cookie (by default, `XSRF-TOKEN`) and sets it as an HTTP + * header (`X-XSRF-TOKEN`). Since only JavaScript that runs on your domain could read the + * cookie, your server can be assured that the XHR came from JavaScript running on your domain. + * The header will not be set for cross-domain requests. * * To take advantage of this, your server needs to set a token in a JavaScript readable session * cookie called `XSRF-TOKEN` on the first HTTP GET request. On subsequent XHR requests the @@ -7734,85 +12031,92 @@ * that only JavaScript running on your domain could have sent the request. The token must be * unique for each user and must be verifiable by the server (to prevent the JavaScript from * making up its own tokens). We recommend that the token is a digest of your site's - * authentication cookie with a [salt](https://en.wikipedia.org/wiki/Salt_(cryptography)) + * authentication cookie with a [salt](https://en.wikipedia.org/wiki/Salt_(cryptography)) * for added security. * * The name of the headers can be specified using the xsrfHeaderName and xsrfCookieName * properties of either $httpProvider.defaults at config-time, $http.defaults at run-time, * or the per-request config object. * + * In order to prevent collisions in environments where multiple AngularJS apps share the + * same domain or subdomain, we recommend that each application uses unique cookie name. * * @param {object} config Object describing the request to be made and how it should be * processed. The object has following properties: * * - **method** – `{string}` – HTTP method (e.g. 'GET', 'POST', etc) - * - **url** – `{string}` – Absolute or relative URL of the resource that is being requested. - * - **params** – `{Object.<string|Object>}` – Map of strings or objects which will be turned - * to `?key1=value1&key2=value2` after the url. If the value is not a string, it will be - * JSONified. + * - **url** – `{string|TrustedObject}` – Absolute or relative URL of the resource that is being requested; + * or an object created by a call to `$sce.trustAsResourceUrl(url)`. + * - **params** – `{Object.<string|Object>}` – Map of strings or objects which will be serialized + * with the `paramSerializer` and appended as GET parameters. * - **data** – `{string|Object}` – Data to be sent as the request message data. * - **headers** – `{Object}` – Map of strings or functions which return strings representing * HTTP headers to send to the server. If the return value of a function is null, the - * header will not be sent. + * header will not be sent. Functions accept a config object as an argument. + * - **eventHandlers** - `{Object}` - Event listeners to be bound to the XMLHttpRequest object. + * To bind events to the XMLHttpRequest upload object, use `uploadEventHandlers`. + * The handler will be called in the context of a `$apply` block. + * - **uploadEventHandlers** - `{Object}` - Event listeners to be bound to the XMLHttpRequest upload + * object. To bind events to the XMLHttpRequest object, use `eventHandlers`. + * The handler will be called in the context of a `$apply` block. * - **xsrfHeaderName** – `{string}` – Name of HTTP header to populate with the XSRF token. * - **xsrfCookieName** – `{string}` – Name of cookie containing the XSRF token. * - **transformRequest** – * `{function(data, headersGetter)|Array.<function(data, headersGetter)>}` – * transform function or an array of such functions. The transform function takes the http * request body and headers and returns its transformed (typically serialized) version. + * See {@link ng.$http#overriding-the-default-transformations-per-request + * Overriding the Default Transformations} * - **transformResponse** – - * `{function(data, headersGetter)|Array.<function(data, headersGetter)>}` – + * `{function(data, headersGetter, status)|Array.<function(data, headersGetter, status)>}` – * transform function or an array of such functions. The transform function takes the http - * response body and headers and returns its transformed (typically deserialized) version. - * - **cache** – `{boolean|Cache}` – If true, a default $http cache will be used to cache the - * GET request, otherwise if a cache instance built with - * {@link ng.$cacheFactory $cacheFactory}, this cache will be used for - * caching. + * response body, headers and status and returns its transformed (typically deserialized) version. + * See {@link ng.$http#overriding-the-default-transformations-per-request + * Overriding the Default Transformations} + * - **paramSerializer** - `{string|function(Object<string,string>):string}` - A function used to + * prepare the string representation of request parameters (specified as an object). + * If specified as string, it is interpreted as function registered with the + * {@link $injector $injector}, which means you can create your own serializer + * by registering it as a {@link auto.$provide#service service}. + * The default serializer is the {@link $httpParamSerializer $httpParamSerializer}; + * alternatively, you can use the {@link $httpParamSerializerJQLike $httpParamSerializerJQLike} + * - **cache** – `{boolean|Object}` – A boolean value or object created with + * {@link ng.$cacheFactory `$cacheFactory`} to enable or disable caching of the HTTP response. + * See {@link $http#caching $http Caching} for more information. * - **timeout** – `{number|Promise}` – timeout in milliseconds, or {@link ng.$q promise} * that should abort the request when resolved. * - **withCredentials** - `{boolean}` - whether to set the `withCredentials` flag on the - * XHR object. See [requests with credentials]https://developer.mozilla.org/en/http_access_control#section_5 + * XHR object. See [requests with credentials](https://developer.mozilla.org/docs/Web/HTTP/Access_control_CORS#Requests_with_credentials) * for more information. * - **responseType** - `{string}` - see - * [requestType](https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest#responseType). + * [XMLHttpRequest.responseType](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest#xmlhttprequest-responsetype). * - * @returns {HttpPromise} Returns a {@link ng.$q promise} object with the - * standard `then` method and two http specific methods: `success` and `error`. The `then` - * method takes two arguments a success and an error callback which will be called with a - * response object. The `success` and `error` methods take a single argument - a function that - * will be called when the request succeeds or fails respectively. The arguments passed into - * these functions are destructured representation of the response object passed into the - * `then` method. The response object has these properties: + * @returns {HttpPromise} Returns a {@link ng.$q `Promise}` that will be resolved to a response object + * when the request succeeds or fails. * - * - **data** – `{string|Object}` – The response body transformed with the transform - * functions. - * - **status** – `{number}` – HTTP status code of the response. - * - **headers** – `{function([headerName])}` – Header getter function. - * - **config** – `{Object}` – The configuration object that was used to generate the request. - * - **statusText** – `{string}` – HTTP status text of the response. * * @property {Array.<Object>} pendingRequests Array of config objects for currently pending * requests. This is primarily meant to be used for debugging purposes. * * * @example - <example> + <example module="httpExample" name="http-service"> <file name="index.html"> - <div ng-controller="FetchCtrl"> - <select ng-model="method"> + <div ng-controller="FetchController"> + <select ng-model="method" aria-label="Request method"> <option>GET</option> <option>JSONP</option> </select> - <input type="text" ng-model="url" size="80"/> + <input type="text" ng-model="url" size="80" aria-label="URL" /> <button id="fetchbtn" ng-click="fetch()">fetch</button><br> <button id="samplegetbtn" ng-click="updateModel('GET', 'http-hello.html')">Sample GET</button> <button id="samplejsonpbtn" ng-click="updateModel('JSONP', - 'http://angularjs.org/greet.php?callback=JSON_CALLBACK&name=Super%20Hero')"> + 'https://angularjs.org/greet.php?name=Super%20Hero')"> Sample JSONP </button> <button id="invalidjsonpbtn" - ng-click="updateModel('JSONP', 'http://angularjs.org/doesntexist&callback=JSON_CALLBACK')"> + ng-click="updateModel('JSONP', 'https://angularjs.org/doesntexist')"> Invalid JSONP </button> <pre>http status code: {{status}}</pre> @@ -7820,30 +12124,38 @@ </div> </file> <file name="script.js"> - function FetchCtrl($scope, $http, $templateCache) { - $scope.method = 'GET'; - $scope.url = 'http-hello.html'; - - $scope.fetch = function() { - $scope.code = null; - $scope.response = null; - - $http({method: $scope.method, url: $scope.url, cache: $templateCache}). - success(function(data, status) { - $scope.status = status; - $scope.data = data; - }). - error(function(data, status) { - $scope.data = data || "Request failed"; - $scope.status = status; - }); - }; + angular.module('httpExample', []) + .config(['$sceDelegateProvider', function($sceDelegateProvider) { + // We must whitelist the JSONP endpoint that we are using to show that we trust it + $sceDelegateProvider.resourceUrlWhitelist([ + 'self', + 'https://angularjs.org/**' + ]); + }]) + .controller('FetchController', ['$scope', '$http', '$templateCache', + function($scope, $http, $templateCache) { + $scope.method = 'GET'; + $scope.url = 'http-hello.html'; + + $scope.fetch = function() { + $scope.code = null; + $scope.response = null; + + $http({method: $scope.method, url: $scope.url, cache: $templateCache}). + then(function(response) { + $scope.status = response.status; + $scope.data = response.data; + }, function(response) { + $scope.data = response.data || 'Request failed'; + $scope.status = response.status; + }); + }; - $scope.updateModel = function(method, url) { - $scope.method = method; - $scope.url = url; - }; - } + $scope.updateModel = function(method, url) { + $scope.method = method; + $scope.url = url; + }; + }]); </file> <file name="http-hello.html"> Hello, $http! @@ -7853,7 +12165,6 @@ var data = element(by.binding('data')); var fetchBtn = element(by.id('fetchbtn')); var sampleGetBtn = element(by.id('samplegetbtn')); - var sampleJsonpBtn = element(by.id('samplejsonpbtn')); var invalidJsonpBtn = element(by.id('invalidjsonpbtn')); it('should make an xhr GET request', function() { @@ -7863,12 +12174,14 @@ expect(data.getText()).toMatch(/Hello, \$http!/); }); - it('should make a JSONP request to angularjs.org', function() { - sampleJsonpBtn.click(); - fetchBtn.click(); - expect(status.getText()).toMatch('200'); - expect(data.getText()).toMatch(/Super Hero!/); - }); + // Commented out due to flakes. See https://github.com/angular/angular.js/issues/9185 + // it('should make a JSONP request to angularjs.org', function() { +// var sampleJsonpBtn = element(by.id('samplejsonpbtn')); +// sampleJsonpBtn.click(); +// fetchBtn.click(); +// expect(status.getText()).toMatch('200'); +// expect(data.getText()).toMatch(/Super Hero!/); +// }); it('should make JSONP request to invalid URL and invoke the error handler', function() { @@ -7881,90 +12194,84 @@ </example> */ function $http(requestConfig) { - var config = { - method: 'get', - transformRequest: defaults.transformRequest, - transformResponse: defaults.transformResponse - }; - var headers = mergeHeaders(requestConfig); - - extend(config, requestConfig); - config.headers = headers; - config.method = uppercase(config.method); - var xsrfValue = urlIsSameOrigin(config.url) - ? $browser.cookies()[config.xsrfCookieName || defaults.xsrfCookieName] - : undefined; - if (xsrfValue) { - headers[(config.xsrfHeaderName || defaults.xsrfHeaderName)] = xsrfValue; + if (!isObject(requestConfig)) { + throw minErr('$http')('badreq', 'Http request configuration must be an object. Received: {0}', requestConfig); } + if (!isString($sce.valueOf(requestConfig.url))) { + throw minErr('$http')('badreq', 'Http request configuration url must be a string or a $sce trusted object. Received: {0}', requestConfig.url); + } - var serverRequest = function(config) { - headers = config.headers; - var reqData = transformData(config.data, headersGetter(headers), config.transformRequest); - - // strip content-type if data is undefined - if (isUndefined(config.data)) { - forEach(headers, function(value, header) { - if (lowercase(header) === 'content-type') { - delete headers[header]; - } - }); - } + var config = extend({ + method: 'get', + transformRequest: defaults.transformRequest, + transformResponse: defaults.transformResponse, + paramSerializer: defaults.paramSerializer, + jsonpCallbackParam: defaults.jsonpCallbackParam + }, requestConfig); - if (isUndefined(config.withCredentials) && !isUndefined(defaults.withCredentials)) { - config.withCredentials = defaults.withCredentials; - } + config.headers = mergeHeaders(requestConfig); + config.method = uppercase(config.method); + config.paramSerializer = isString(config.paramSerializer) ? + $injector.get(config.paramSerializer) : config.paramSerializer; - // send request - return sendReq(config, reqData, headers).then(transformResponse, transformResponse); - }; + $browser.$$incOutstandingRequestCount(); - var chain = [serverRequest, undefined]; - var promise = $q.when(config); + var requestInterceptors = []; + var responseInterceptors = []; + var promise = $q.resolve(config); // apply interceptors forEach(reversedInterceptors, function(interceptor) { if (interceptor.request || interceptor.requestError) { - chain.unshift(interceptor.request, interceptor.requestError); + requestInterceptors.unshift(interceptor.request, interceptor.requestError); } if (interceptor.response || interceptor.responseError) { - chain.push(interceptor.response, interceptor.responseError); + responseInterceptors.push(interceptor.response, interceptor.responseError); } }); - while(chain.length) { - var thenFn = chain.shift(); - var rejectFn = chain.shift(); + promise = chainInterceptors(promise, requestInterceptors); + promise = promise.then(serverRequest); + promise = chainInterceptors(promise, responseInterceptors); + promise = promise.finally(completeOutstandingRequest); - promise = promise.then(thenFn, rejectFn); - } + return promise; - promise.success = function(fn) { - promise.then(function(response) { - fn(response.data, response.status, response.headers, config); - }); - return promise; - }; - promise.error = function(fn) { - promise.then(null, function(response) { - fn(response.data, response.status, response.headers, config); - }); + function chainInterceptors(promise, interceptors) { + for (var i = 0, ii = interceptors.length; i < ii;) { + var thenFn = interceptors[i++]; + var rejectFn = interceptors[i++]; + + promise = promise.then(thenFn, rejectFn); + } + + interceptors.length = 0; + return promise; - }; + } - return promise; + function completeOutstandingRequest() { + $browser.$$completeOutstandingRequest(noop); + } - function transformResponse(response) { - // make a copy since the response must be cacheable - var resp = extend({}, response, { - data: transformData(response.data, response.headers, config.transformResponse) + function executeHeaderFns(headers, config) { + var headerContent, processedHeaders = {}; + + forEach(headers, function(headerFn, header) { + if (isFunction(headerFn)) { + headerContent = headerFn(config); + if (headerContent != null) { + processedHeaders[header] = headerContent; + } + } else { + processedHeaders[header] = headerFn; + } }); - return (isSuccess(response.status)) - ? resp - : $q.reject(resp); + + return processedHeaders; } function mergeHeaders(config) { @@ -7974,11 +12281,7 @@ defHeaders = extend({}, defHeaders.common, defHeaders[lowercase(config.method)]); - // execute if header value is function - execHeaders(defHeaders); - execHeaders(reqHeaders); - - // using for-in instead of forEach to avoid unecessary iteration after header has been found + // using for-in instead of forEach to avoid unnecessary iteration after header has been found defaultHeadersIteration: for (defHeaderName in defHeaders) { lowercaseDefHeaderName = lowercase(defHeaderName); @@ -7992,22 +12295,39 @@ reqHeaders[defHeaderName] = defHeaders[defHeaderName]; } - return reqHeaders; + // execute if header value is a function for merged headers + return executeHeaderFns(reqHeaders, shallowCopy(config)); + } - function execHeaders(headers) { - var headerContent; + function serverRequest(config) { + var headers = config.headers; + var reqData = transformData(config.data, headersGetter(headers), undefined, config.transformRequest); - forEach(headers, function(headerFn, header) { - if (isFunction(headerFn)) { - headerContent = headerFn(); - if (headerContent != null) { - headers[header] = headerContent; - } else { - delete headers[header]; - } + // strip content-type if data is undefined + if (isUndefined(reqData)) { + forEach(headers, function(value, header) { + if (lowercase(header) === 'content-type') { + delete headers[header]; } }); } + + if (isUndefined(config.withCredentials) && !isUndefined(defaults.withCredentials)) { + config.withCredentials = defaults.withCredentials; + } + + // send request + return sendReq(config, reqData).then(transformResponse, transformResponse); + } + + function transformResponse(response) { + // make a copy since the response must be cacheable + var resp = extend({}, response); + resp.data = transformData(response.data, response.headers, response.status, + config.transformResponse); + return (isSuccess(response.status)) + ? resp + : $q.reject(resp); } } @@ -8020,8 +12340,9 @@ * @description * Shortcut method to perform `GET` request. * - * @param {string} url Relative or absolute URL specifying the destination of the request - * @param {Object=} config Optional configuration object + * @param {string|TrustedObject} url Absolute or relative URL of the resource that is being requested; + * or an object created by a call to `$sce.trustAsResourceUrl(url)`. + * @param {Object=} config Optional configuration object. See https://docs.angularjs.org/api/ng/service/$http#usage * @returns {HttpPromise} Future object */ @@ -8032,8 +12353,9 @@ * @description * Shortcut method to perform `DELETE` request. * - * @param {string} url Relative or absolute URL specifying the destination of the request - * @param {Object=} config Optional configuration object + * @param {string|TrustedObject} url Absolute or relative URL of the resource that is being requested; + * or an object created by a call to `$sce.trustAsResourceUrl(url)`. + * @param {Object=} config Optional configuration object. See https://docs.angularjs.org/api/ng/service/$http#usage * @returns {HttpPromise} Future object */ @@ -8044,8 +12366,9 @@ * @description * Shortcut method to perform `HEAD` request. * - * @param {string} url Relative or absolute URL specifying the destination of the request - * @param {Object=} config Optional configuration object + * @param {string|TrustedObject} url Absolute or relative URL of the resource that is being requested; + * or an object created by a call to `$sce.trustAsResourceUrl(url)`. + * @param {Object=} config Optional configuration object. See https://docs.angularjs.org/api/ng/service/$http#usage * @returns {HttpPromise} Future object */ @@ -8056,9 +12379,38 @@ * @description * Shortcut method to perform `JSONP` request. * - * @param {string} url Relative or absolute URL specifying the destination of the request. - * Should contain `JSON_CALLBACK` string. - * @param {Object=} config Optional configuration object + * Note that, since JSONP requests are sensitive because the response is given full access to the browser, + * the url must be declared, via {@link $sce} as a trusted resource URL. + * You can trust a URL by adding it to the whitelist via + * {@link $sceDelegateProvider#resourceUrlWhitelist `$sceDelegateProvider.resourceUrlWhitelist`} or + * by explicitly trusting the URL via {@link $sce#trustAsResourceUrl `$sce.trustAsResourceUrl(url)`}. + * + * You should avoid generating the URL for the JSONP request from user provided data. + * Provide additional query parameters via `params` property of the `config` parameter, rather than + * modifying the URL itself. + * + * JSONP requests must specify a callback to be used in the response from the server. This callback + * is passed as a query parameter in the request. You must specify the name of this parameter by + * setting the `jsonpCallbackParam` property on the request config object. + * + * ``` + * $http.jsonp('some/trusted/url', {jsonpCallbackParam: 'callback'}) + * ``` + * + * You can also specify a default callback parameter name in `$http.defaults.jsonpCallbackParam`. + * Initially this is set to `'callback'`. + * + * <div class="alert alert-danger"> + * You can no longer use the `JSON_CALLBACK` string as a placeholder for specifying where the callback + * parameter value should go. + * </div> + * + * If you would like to customise where and how the callbacks are stored then try overriding + * or decorating the {@link $jsonpCallbacks} service. + * + * @param {string|TrustedObject} url Absolute or relative URL of the resource that is being requested; + * or an object created by a call to `$sce.trustAsResourceUrl(url)`. + * @param {Object=} config Optional configuration object. See https://docs.angularjs.org/api/ng/service/$http#usage * @returns {HttpPromise} Future object */ createShortMethods('get', 'delete', 'head', 'jsonp'); @@ -8072,7 +12424,7 @@ * * @param {string} url Relative or absolute URL specifying the destination of the request * @param {*} data Request content - * @param {Object=} config Optional configuration object + * @param {Object=} config Optional configuration object. See https://docs.angularjs.org/api/ng/service/$http#usage * @returns {HttpPromise} Future object */ @@ -8085,10 +12437,23 @@ * * @param {string} url Relative or absolute URL specifying the destination of the request * @param {*} data Request content - * @param {Object=} config Optional configuration object + * @param {Object=} config Optional configuration object. See https://docs.angularjs.org/api/ng/service/$http#usage + * @returns {HttpPromise} Future object + */ + + /** + * @ngdoc method + * @name $http#patch + * + * @description + * Shortcut method to perform `PATCH` request. + * + * @param {string} url Relative or absolute URL specifying the destination of the request + * @param {*} data Request content + * @param {Object=} config Optional configuration object. See https://docs.angularjs.org/api/ng/service/$http#usage * @returns {HttpPromise} Future object */ - createShortMethodsWithData('post', 'put'); + createShortMethodsWithData('post', 'put', 'patch'); /** * @ngdoc property @@ -8109,7 +12474,7 @@ function createShortMethods(names) { forEach(arguments, function(name) { $http[name] = function(url, config) { - return $http(extend(config || {}, { + return $http(extend({}, config || {}, { method: name, url: url })); @@ -8121,7 +12486,7 @@ function createShortMethodsWithData(name) { forEach(arguments, function(name) { $http[name] = function(url, data, config) { - return $http(extend(config || {}, { + return $http(extend({}, config || {}, { method: name, url: url, data: data @@ -8137,36 +12502,54 @@ * !!! ACCESSES CLOSURE VARS: * $httpBackend, defaults, $log, $rootScope, defaultCache, $http.pendingRequests */ - function sendReq(config, reqData, reqHeaders) { + function sendReq(config, reqData) { var deferred = $q.defer(), promise = deferred.promise, cache, cachedResp, - url = buildUrl(config.url, config.params); + reqHeaders = config.headers, + isJsonp = lowercase(config.method) === 'jsonp', + url = config.url; + + if (isJsonp) { + // JSONP is a pretty sensitive operation where we're allowing a script to have full access to + // our DOM and JS space. So we require that the URL satisfies SCE.RESOURCE_URL. + url = $sce.getTrustedResourceUrl(url); + } else if (!isString(url)) { + // If it is not a string then the URL must be a $sce trusted object + url = $sce.valueOf(url); + } + + url = buildUrl(url, config.paramSerializer(config.params)); + + if (isJsonp) { + // Check the url and add the JSONP callback placeholder + url = sanitizeJsonpCallbackParam(url, config.jsonpCallbackParam); + } $http.pendingRequests.push(config); promise.then(removePendingReq, removePendingReq); - - if ((config.cache || defaults.cache) && config.cache !== false && config.method == 'GET') { + if ((config.cache || defaults.cache) && config.cache !== false && + (config.method === 'GET' || config.method === 'JSONP')) { cache = isObject(config.cache) ? config.cache - : isObject(defaults.cache) ? defaults.cache - : defaultCache; + : isObject(/** @type {?} */ (defaults).cache) + ? /** @type {?} */ (defaults).cache + : defaultCache; } if (cache) { cachedResp = cache.get(url); if (isDefined(cachedResp)) { - if (cachedResp.then) { + if (isPromiseLike(cachedResp)) { // cached request has already been sent, but there is no response yet - cachedResp.then(removePendingReq, removePendingReq); - return cachedResp; + cachedResp.then(resolvePromiseWithResult, resolvePromiseWithResult); } else { // serving from cache if (isArray(cachedResp)) { - resolvePromise(cachedResp[1], cachedResp[0], copy(cachedResp[2]), cachedResp[3]); + resolvePromise(cachedResp[1], cachedResp[0], shallowCopy(cachedResp[2]), cachedResp[3], cachedResp[4]); } else { - resolvePromise(cachedResp, 200, {}, 'OK'); + resolvePromise(cachedResp, 200, {}, 'OK', 'complete'); } } } else { @@ -8175,14 +12558,47 @@ } } - // if we won't have the response in cache, send the request to the backend + + // if we won't have the response in cache, set the xsrf headers and + // send the request to the backend if (isUndefined(cachedResp)) { + var xsrfValue = urlIsSameOrigin(config.url) + ? $$cookieReader()[config.xsrfCookieName || defaults.xsrfCookieName] + : undefined; + if (xsrfValue) { + reqHeaders[(config.xsrfHeaderName || defaults.xsrfHeaderName)] = xsrfValue; + } + $httpBackend(config.method, url, reqData, done, reqHeaders, config.timeout, - config.withCredentials, config.responseType); + config.withCredentials, config.responseType, + createApplyHandlers(config.eventHandlers), + createApplyHandlers(config.uploadEventHandlers)); } return promise; + function createApplyHandlers(eventHandlers) { + if (eventHandlers) { + var applyHandlers = {}; + forEach(eventHandlers, function(eventHandler, key) { + applyHandlers[key] = function(event) { + if (useApplyAsync) { + $rootScope.$applyAsync(callEventHandler); + } else if ($rootScope.$$phase) { + callEventHandler(); + } else { + $rootScope.$apply(callEventHandler); + } + + function callEventHandler() { + eventHandler(event); + } + }; + }); + return applyHandlers; + } + } + /** * Callback registered to $httpBackend(): @@ -8190,89 +12606,127 @@ * - resolves the raw $http promise * - calls $apply */ - function done(status, response, headersString, statusText) { + function done(status, response, headersString, statusText, xhrStatus) { if (cache) { if (isSuccess(status)) { - cache.put(url, [status, response, parseHeaders(headersString), statusText]); + cache.put(url, [status, response, parseHeaders(headersString), statusText, xhrStatus]); } else { // remove promise from the cache cache.remove(url); } } - resolvePromise(response, status, headersString, statusText); - if (!$rootScope.$$phase) $rootScope.$apply(); + function resolveHttpPromise() { + resolvePromise(response, status, headersString, statusText, xhrStatus); + } + + if (useApplyAsync) { + $rootScope.$applyAsync(resolveHttpPromise); + } else { + resolveHttpPromise(); + if (!$rootScope.$$phase) $rootScope.$apply(); + } } /** * Resolves the raw $http promise. */ - function resolvePromise(response, status, headers, statusText) { - // normalize internal statuses to 0 - status = Math.max(status, 0); + function resolvePromise(response, status, headers, statusText, xhrStatus) { + //status: HTTP response status code, 0, -1 (aborted by timeout / promise) + status = status >= -1 ? status : 0; (isSuccess(status) ? deferred.resolve : deferred.reject)({ data: response, status: status, headers: headersGetter(headers), config: config, - statusText : statusText + statusText: statusText, + xhrStatus: xhrStatus }); } + function resolvePromiseWithResult(result) { + resolvePromise(result.data, result.status, shallowCopy(result.headers()), result.statusText, result.xhrStatus); + } function removePendingReq() { - var idx = indexOf($http.pendingRequests, config); + var idx = $http.pendingRequests.indexOf(config); if (idx !== -1) $http.pendingRequests.splice(idx, 1); } } - function buildUrl(url, params) { - if (!params) return url; - var parts = []; - forEachSorted(params, function(value, key) { - if (value === null || isUndefined(value)) return; - if (!isArray(value)) value = [value]; - - forEach(value, function(v) { - if (isObject(v)) { - v = toJson(v); - } - parts.push(encodeUriQuery(key) + '=' + - encodeUriQuery(v)); - }); - }); - if(parts.length > 0) { - url += ((url.indexOf('?') == -1) ? '?' : '&') + parts.join('&'); + function buildUrl(url, serializedParams) { + if (serializedParams.length > 0) { + url += ((url.indexOf('?') === -1) ? '?' : '&') + serializedParams; } return url; } + function sanitizeJsonpCallbackParam(url, cbKey) { + var parts = url.split('?'); + if (parts.length > 2) { + // Throw if the url contains more than one `?` query indicator + throw $httpMinErr('badjsonp', 'Illegal use more than one "?", in url, "{1}"', url); + } + var params = parseKeyValue(parts[1]); + forEach(params, function(value, key) { + if (value === 'JSON_CALLBACK') { + // Throw if the url already contains a reference to JSON_CALLBACK + throw $httpMinErr('badjsonp', 'Illegal use of JSON_CALLBACK in url, "{0}"', url); + } + if (key === cbKey) { + // Throw if the callback param was already provided + throw $httpMinErr('badjsonp', 'Illegal use of callback param, "{0}", in url, "{1}"', cbKey, url); + } + }); + + // Add in the JSON_CALLBACK callback param value + url += ((url.indexOf('?') === -1) ? '?' : '&') + cbKey + '=JSON_CALLBACK'; + return url; + } }]; } - function createXhr(method) { - //if IE and the method is not RFC2616 compliant, or if XMLHttpRequest - //is not available, try getting an ActiveXObject. Otherwise, use XMLHttpRequest - //if it is available - if (msie <= 8 && (!method.match(/^(get|post|head|put|delete|options)$/i) || - !window.XMLHttpRequest)) { - return new window.ActiveXObject("Microsoft.XMLHTTP"); - } else if (window.XMLHttpRequest) { - return new window.XMLHttpRequest(); - } - - throw minErr('$httpBackend')('noxhr', "This browser does not support XMLHttpRequest."); + /** + * @ngdoc service + * @name $xhrFactory + * @this + * + * @description + * Factory function used to create XMLHttpRequest objects. + * + * Replace or decorate this service to create your own custom XMLHttpRequest objects. + * + * ``` + * angular.module('myApp', []) + * .factory('$xhrFactory', function() { + * return function createXhr(method, url) { + * return new window.XMLHttpRequest({mozSystem: true}); + * }; + * }); + * ``` + * + * @param {string} method HTTP method of the request (GET, POST, PUT, ..) + * @param {string} url URL of the request. + */ + function $xhrFactoryProvider() { + this.$get = function() { + return function createXhr() { + return new window.XMLHttpRequest(); + }; + }; } /** * @ngdoc service * @name $httpBackend - * @requires $window + * @requires $jsonpCallbacks * @requires $document + * @requires $xhrFactory + * @this * * @description * HTTP backend used by the {@link ng.$http service} that delegates to @@ -8285,38 +12739,27 @@ * $httpBackend} which can be trained with responses. */ function $HttpBackendProvider() { - this.$get = ['$browser', '$window', '$document', function($browser, $window, $document) { - return createHttpBackend($browser, createXhr, $browser.defer, $window.angular.callbacks, $document[0]); + this.$get = ['$browser', '$jsonpCallbacks', '$document', '$xhrFactory', function($browser, $jsonpCallbacks, $document, $xhrFactory) { + return createHttpBackend($browser, $xhrFactory, $browser.defer, $jsonpCallbacks, $document[0]); }]; } function createHttpBackend($browser, createXhr, $browserDefer, callbacks, rawDocument) { - var ABORTED = -1; - // TODO(vojta): fix the signature - return function(method, url, post, callback, headers, timeout, withCredentials, responseType) { - var status; - $browser.$$incOutstandingRequestCount(); + return function(method, url, post, callback, headers, timeout, withCredentials, responseType, eventHandlers, uploadEventHandlers) { url = url || $browser.url(); - if (lowercase(method) == 'jsonp') { - var callbackId = '_' + (callbacks.counter++).toString(36); - callbacks[callbackId] = function(data) { - callbacks[callbackId].data = data; - }; - - var jsonpDone = jsonpReq(url.replace('JSON_CALLBACK', 'angular.callbacks.' + callbackId), - function() { - if (callbacks[callbackId].data) { - completeRequest(callback, 200, callbacks[callbackId].data); - } else { - completeRequest(callback, status || -2); - } - callbacks[callbackId] = angular.noop; - }); + if (lowercase(method) === 'jsonp') { + var callbackPath = callbacks.createCallback(url); + var jsonpDone = jsonpReq(url, callbackPath, function(status, text) { + // jsonpReq only ever sets status to 200 (OK), 404 (ERROR) or -1 (WAITING) + var response = (status === 200) && callbacks.getResponse(callbackPath); + completeRequest(callback, status, response, '', text, 'complete'); + callbacks.removeCallback(callbackPath); + }); } else { - var xhr = createXhr(method); + var xhr = createXhr(method, url); xhr.open(method, url, true); forEach(headers, function(value, key) { @@ -8325,37 +12768,59 @@ } }); - // In IE6 and 7, this might be called synchronously when xhr.send below is called and the - // response is in the cache. the promise api will ensure that to the app code the api is - // always async - xhr.onreadystatechange = function() { - // onreadystatechange might get called multiple times with readyState === 4 on mobile webkit caused by - // xhrs that are resolved while the app is in the background (see #5426). - // since calling completeRequest sets the `xhr` variable to null, we just check if it's not null before - // continuing - // - // we can't set xhr.onreadystatechange to undefined or delete it because that breaks IE8 (method=PATCH) and - // Safari respectively. - if (xhr && xhr.readyState == 4) { - var responseHeaders = null, - response = null; - - if(status !== ABORTED) { - responseHeaders = xhr.getAllResponseHeaders(); - - // responseText is the old-school way of retrieving response (supported by IE8 & 9) - // response/responseType properties were introduced in XHR Level2 spec (supported by IE10) - response = ('response' in xhr) ? xhr.response : xhr.responseText; - } + xhr.onload = function requestLoaded() { + var statusText = xhr.statusText || ''; - completeRequest(callback, - status || xhr.status, - response, - responseHeaders, - xhr.statusText || ''); + // responseText is the old-school way of retrieving response (supported by IE9) + // response/responseType properties were introduced in XHR Level2 spec (supported by IE10) + var response = ('response' in xhr) ? xhr.response : xhr.responseText; + + // normalize IE9 bug (http://bugs.jquery.com/ticket/1450) + var status = xhr.status === 1223 ? 204 : xhr.status; + + // fix status code when it is 0 (0 status is undocumented). + // Occurs when accessing file resources or on Android 4.1 stock browser + // while retrieving files from application cache. + if (status === 0) { + status = response ? 200 : urlResolve(url).protocol === 'file' ? 404 : 0; } + + completeRequest(callback, + status, + response, + xhr.getAllResponseHeaders(), + statusText, + 'complete'); + }; + + var requestError = function() { + // The response is always empty + // See https://xhr.spec.whatwg.org/#request-error-steps and https://fetch.spec.whatwg.org/#concept-network-error + completeRequest(callback, -1, null, null, '', 'error'); + }; + + var requestAborted = function() { + completeRequest(callback, -1, null, null, '', 'abort'); + }; + + var requestTimeout = function() { + // The response is always empty + // See https://xhr.spec.whatwg.org/#request-error-steps and https://fetch.spec.whatwg.org/#concept-network-error + completeRequest(callback, -1, null, null, '', 'timeout'); }; + xhr.onerror = requestError; + xhr.onabort = requestAborted; + xhr.ontimeout = requestTimeout; + + forEach(eventHandlers, function(value, key) { + xhr.addEventListener(key, value); + }); + + forEach(uploadEventHandlers, function(value, key) { + xhr.upload.addEventListener(key, value); + }); + if (withCredentials) { xhr.withCredentials = true; } @@ -8377,87 +12842,105 @@ } } - xhr.send(post || null); + xhr.send(isUndefined(post) ? null : post); } if (timeout > 0) { var timeoutId = $browserDefer(timeoutRequest, timeout); - } else if (timeout && timeout.then) { + } else if (isPromiseLike(timeout)) { timeout.then(timeoutRequest); } function timeoutRequest() { - status = ABORTED; - jsonpDone && jsonpDone(); - xhr && xhr.abort(); + if (jsonpDone) { + jsonpDone(); + } + if (xhr) { + xhr.abort(); + } } - function completeRequest(callback, status, response, headersString, statusText) { + function completeRequest(callback, status, response, headersString, statusText, xhrStatus) { // cancel timeout and subsequent timeout promise resolution - timeoutId && $browserDefer.cancel(timeoutId); - jsonpDone = xhr = null; - - // fix status code when it is 0 (0 status is undocumented). - // Occurs when accessing file resources or on Android 4.1 stock browser - // while retrieving files from application cache. - if (status === 0) { - status = response ? 200 : urlResolve(url).protocol == 'file' ? 404 : 0; + if (isDefined(timeoutId)) { + $browserDefer.cancel(timeoutId); } + jsonpDone = xhr = null; - // normalize IE bug (http://bugs.jquery.com/ticket/1450) - status = status === 1223 ? 204 : status; - statusText = statusText || ''; - - callback(status, response, headersString, statusText); - $browser.$$completeOutstandingRequest(noop); + callback(status, response, headersString, statusText, xhrStatus); } }; - function jsonpReq(url, done) { - // we can't use jQuery/jqLite here because jQuery does crazy shit with script elements, e.g.: + function jsonpReq(url, callbackPath, done) { + url = url.replace('JSON_CALLBACK', callbackPath); + // we can't use jQuery/jqLite here because jQuery does crazy stuff with script elements, e.g.: // - fetches local scripts via XHR and evals them // - adds and immediately removes script elements from the document - var script = rawDocument.createElement('script'), - doneWrapper = function() { - script.onreadystatechange = script.onload = script.onerror = null; - rawDocument.body.removeChild(script); - if (done) done(); - }; - + var script = rawDocument.createElement('script'), callback = null; script.type = 'text/javascript'; script.src = url; - - if (msie && msie <= 8) { - script.onreadystatechange = function() { - if (/loaded|complete/.test(script.readyState)) { - doneWrapper(); + script.async = true; + + callback = function(event) { + script.removeEventListener('load', callback); + script.removeEventListener('error', callback); + rawDocument.body.removeChild(script); + script = null; + var status = -1; + var text = 'unknown'; + + if (event) { + if (event.type === 'load' && !callbacks.wasCalled(callbackPath)) { + event = { type: 'error' }; } - }; - } else { - script.onload = script.onerror = function() { - doneWrapper(); - }; - } + text = event.type; + status = event.type === 'error' ? 404 : 200; + } + if (done) { + done(status, text); + } + }; + + script.addEventListener('load', callback); + script.addEventListener('error', callback); rawDocument.body.appendChild(script); - return doneWrapper; + return callback; } } - var $interpolateMinErr = minErr('$interpolate'); + var $interpolateMinErr = angular.$interpolateMinErr = minErr('$interpolate'); + $interpolateMinErr.throwNoconcat = function(text) { + throw $interpolateMinErr('noconcat', + 'Error while interpolating: {0}\nStrict Contextual Escaping disallows ' + + 'interpolations that concatenate multiple expressions when a trusted value is ' + + 'required. See http://docs.angularjs.org/api/ng.$sce', text); + }; + + $interpolateMinErr.interr = function(text, err) { + return $interpolateMinErr('interr', 'Can\'t interpolate: {0}\n{1}', text, err.toString()); + }; /** * @ngdoc provider * @name $interpolateProvider - * @function + * @this * * @description * * Used for configuring the interpolation markup. Defaults to `{{` and `}}`. * + * <div class="alert alert-danger"> + * This feature is sometimes used to mix different markup languages, e.g. to wrap an AngularJS + * template within a Python Jinja template (or any other template language). Mixing templating + * languages is **very dangerous**. The embedding template language will not safely escape AngularJS + * expressions, so any user-controlled values in the template will cause Cross Site Scripting (XSS) + * security bugs! + * </div> + * * @example - <example module="customInterpolationApp"> + <example name="custom-interpolation-markup" module="customInterpolationApp"> <file name="index.html"> <script> var customInterpolationApp = angular.module('customInterpolationApp', []); @@ -8468,11 +12951,11 @@ }); - customInterpolationApp.controller('DemoController', function DemoController() { + customInterpolationApp.controller('DemoController', function() { this.label = "This binding is brought you by // interpolation symbols."; }); </script> - <div ng-app="App" ng-controller="DemoController as demo"> + <div ng-controller="DemoController as demo"> //demo.label// </div> </file> @@ -8492,11 +12975,11 @@ * @name $interpolateProvider#startSymbol * @description * Symbol to denote start of expression in the interpolated string. Defaults to `{{`. - * - * @param {string=} value new value to set the starting symbol to. + * + * @param {string=} value new value to set the starting symbol to. * @returns {string|self} Returns the symbol when used as getter and self if used as setter. */ - this.startSymbol = function(value){ + this.startSymbol = function(value) { if (value) { startSymbol = value; return this; @@ -8514,7 +12997,7 @@ * @param {string=} value new value to set the ending symbol to. * @returns {string|self} Returns the symbol when used as getter and self if used as setter. */ - this.endSymbol = function(value){ + this.endSymbol = function(value) { if (value) { endSymbol = value; return this; @@ -8526,12 +13009,32 @@ this.$get = ['$parse', '$exceptionHandler', '$sce', function($parse, $exceptionHandler, $sce) { var startSymbolLength = startSymbol.length, - endSymbolLength = endSymbol.length; + endSymbolLength = endSymbol.length, + escapedStartRegexp = new RegExp(startSymbol.replace(/./g, escape), 'g'), + escapedEndRegexp = new RegExp(endSymbol.replace(/./g, escape), 'g'); + + function escape(ch) { + return '\\\\\\' + ch; + } + + function unescapeText(text) { + return text.replace(escapedStartRegexp, startSymbol). + replace(escapedEndRegexp, endSymbol); + } + + // TODO: this is the same as the constantWatchDelegate in parse.js + function constantWatchDelegate(scope, listener, objectEquality, constantInterp) { + var unwatch = scope.$watch(function constantInterpolateWatch(scope) { + unwatch(); + return constantInterp(scope); + }, listener, objectEquality); + return unwatch; + } /** * @ngdoc service * @name $interpolate - * @function + * @kind function * * @requires $parse * @requires $sce @@ -8547,9 +13050,89 @@ * ```js * var $interpolate = ...; // injected * var exp = $interpolate('Hello {{name | uppercase}}!'); - * expect(exp({name:'Angular'}).toEqual('Hello ANGULAR!'); + * expect(exp({name:'AngularJS'})).toEqual('Hello ANGULAR!'); + * ``` + * + * `$interpolate` takes an optional fourth argument, `allOrNothing`. If `allOrNothing` is + * `true`, the interpolation function will return `undefined` unless all embedded expressions + * evaluate to a value other than `undefined`. + * + * ```js + * var $interpolate = ...; // injected + * var context = {greeting: 'Hello', name: undefined }; + * + * // default "forgiving" mode + * var exp = $interpolate('{{greeting}} {{name}}!'); + * expect(exp(context)).toEqual('Hello !'); + * + * // "allOrNothing" mode + * exp = $interpolate('{{greeting}} {{name}}!', false, null, true); + * expect(exp(context)).toBeUndefined(); + * context.name = 'AngularJS'; + * expect(exp(context)).toEqual('Hello AngularJS!'); + * ``` + * + * `allOrNothing` is useful for interpolating URLs. `ngSrc` and `ngSrcset` use this behavior. + * + * #### Escaped Interpolation + * $interpolate provides a mechanism for escaping interpolation markers. Start and end markers + * can be escaped by preceding each of their characters with a REVERSE SOLIDUS U+005C (backslash). + * It will be rendered as a regular start/end marker, and will not be interpreted as an expression + * or binding. + * + * This enables web-servers to prevent script injection attacks and defacing attacks, to some + * degree, while also enabling code examples to work without relying on the + * {@link ng.directive:ngNonBindable ngNonBindable} directive. + * + * **For security purposes, it is strongly encouraged that web servers escape user-supplied data, + * replacing angle brackets (<, >) with &lt; and &gt; respectively, and replacing all + * interpolation start/end markers with their escaped counterparts.** + * + * Escaped interpolation markers are only replaced with the actual interpolation markers in rendered + * output when the $interpolate service processes the text. So, for HTML elements interpolated + * by {@link ng.$compile $compile}, or otherwise interpolated with the `mustHaveExpression` parameter + * set to `true`, the interpolated text must contain an unescaped interpolation expression. As such, + * this is typically useful only when user-data is used in rendering a template from the server, or + * when otherwise untrusted data is used by a directive. + * + * <example name="interpolation"> + * <file name="index.html"> + * <div ng-init="username='A user'"> + * <p ng-init="apptitle='Escaping demo'">{{apptitle}}: \{\{ username = "defaced value"; \}\} + * </p> + * <p><strong>{{username}}</strong> attempts to inject code which will deface the + * application, but fails to accomplish their task, because the server has correctly + * escaped the interpolation start/end markers with REVERSE SOLIDUS U+005C (backslash) + * characters.</p> + * <p>Instead, the result of the attempted script injection is visible, and can be removed + * from the database by an administrator.</p> + * </div> + * </file> + * </example> + * + * @knownIssue + * It is currently not possible for an interpolated expression to contain the interpolation end + * symbol. For example, `{{ '}}' }}` will be incorrectly interpreted as `{{ ' }}` + `' }}`, i.e. + * an interpolated expression consisting of a single-quote (`'`) and the `' }}` string. + * + * @knownIssue + * All directives and components must use the standard `{{` `}}` interpolation symbols + * in their templates. If you change the application interpolation symbols the {@link $compile} + * service will attempt to denormalize the standard symbols to the custom symbols. + * The denormalization process is not clever enough to know not to replace instances of the standard + * symbols where they would not normally be treated as interpolation symbols. For example in the following + * code snippet the closing braces of the literal object will get incorrectly denormalized: + * + * ``` + * <div data-context='{"context":{"id":3,"type":"page"}}"> + * ``` + * + * The workaround is to ensure that such instances are separated by whitespace: + * ``` + * <div data-context='{"context":{"id":3,"type":"page"} }"> * ``` * + * See https://github.com/angular/angular.js/pull/14610#issuecomment-219401099 for more information. * * @param {string} text The text with markup to interpolate. * @param {boolean=} mustHaveExpression if set to true then the interpolation string must have @@ -8559,43 +13142,57 @@ * result through {@link ng.$sce#getTrusted $sce.getTrusted(interpolatedResult, * trustedContext)} before returning it. Refer to the {@link ng.$sce $sce} service that * provides Strict Contextual Escaping for details. + * @param {boolean=} allOrNothing if `true`, then the returned function returns undefined + * unless all embedded expressions evaluate to a value other than `undefined`. * @returns {function(context)} an interpolation function which is used to compute the * interpolated string. The function has these parameters: * - * * `context`: an object against which any expressions embedded in the strings are evaluated - * against. - * + * - `context`: evaluation context for all expressions embedded in the interpolated text */ - function $interpolate(text, mustHaveExpression, trustedContext) { + function $interpolate(text, mustHaveExpression, trustedContext, allOrNothing) { + // Provide a quick exit and simplified result function for text with no interpolation + if (!text.length || text.indexOf(startSymbol) === -1) { + var constantInterp; + if (!mustHaveExpression) { + var unescapedText = unescapeText(text); + constantInterp = valueFn(unescapedText); + constantInterp.exp = text; + constantInterp.expressions = []; + constantInterp.$$watchDelegate = constantWatchDelegate; + } + return constantInterp; + } + + allOrNothing = !!allOrNothing; var startIndex, endIndex, index = 0, - parts = [], - length = text.length, - hasInterpolation = false, - fn, + expressions = [], + parseFns = [], + textLength = text.length, exp, - concat = []; - - while(index < length) { - if ( ((startIndex = text.indexOf(startSymbol, index)) != -1) && - ((endIndex = text.indexOf(endSymbol, startIndex + startSymbolLength)) != -1) ) { - (index != startIndex) && parts.push(text.substring(index, startIndex)); - parts.push(fn = $parse(exp = text.substring(startIndex + startSymbolLength, endIndex))); - fn.exp = exp; + concat = [], + expressionPositions = []; + + while (index < textLength) { + if (((startIndex = text.indexOf(startSymbol, index)) !== -1) && + ((endIndex = text.indexOf(endSymbol, startIndex + startSymbolLength)) !== -1)) { + if (index !== startIndex) { + concat.push(unescapeText(text.substring(index, startIndex))); + } + exp = text.substring(startIndex + startSymbolLength, endIndex); + expressions.push(exp); + parseFns.push($parse(exp, parseStringifyInterceptor)); index = endIndex + endSymbolLength; - hasInterpolation = true; + expressionPositions.push(concat.length); + concat.push(''); } else { - // we did not find anything, so we have to add the remainder to the parts array - (index != length) && parts.push(text.substring(index)); - index = length; - } - } - - if (!(length = parts.length)) { - // we added, nothing, must have been an empty string. - parts.push(''); - length = 1; + // we did not find an interpolation, so we have to add the remainder to the separators array + if (index !== textLength) { + concat.push(unescapeText(text.substring(index))); + } + break; + } } // Concatenating expressions makes it hard to reason about whether some combination of @@ -8604,44 +13201,62 @@ // that's used is assigned or constructed by some JS code somewhere that is more testable or // make it obvious that you bound the value to some user controlled value. This helps reduce // the load when auditing for XSS issues. - if (trustedContext && parts.length > 1) { - throw $interpolateMinErr('noconcat', - "Error while interpolating: {0}\nStrict Contextual Escaping disallows " + - "interpolations that concatenate multiple expressions when a trusted value is " + - "required. See http://docs.angularjs.org/api/ng.$sce", text); + if (trustedContext && concat.length > 1) { + $interpolateMinErr.throwNoconcat(text); } - if (!mustHaveExpression || hasInterpolation) { - concat.length = length; - fn = function(context) { + if (!mustHaveExpression || expressions.length) { + var compute = function(values) { + for (var i = 0, ii = expressions.length; i < ii; i++) { + if (allOrNothing && isUndefined(values[i])) return; + concat[expressionPositions[i]] = values[i]; + } + return concat.join(''); + }; + + var getValue = function(value) { + return trustedContext ? + $sce.getTrusted(trustedContext, value) : + $sce.valueOf(value); + }; + + return extend(function interpolationFn(context) { + var i = 0; + var ii = expressions.length; + var values = new Array(ii); + try { - for(var i = 0, ii = length, part; i<ii; i++) { - if (typeof (part = parts[i]) == 'function') { - part = part(context); - if (trustedContext) { - part = $sce.getTrusted(trustedContext, part); - } else { - part = $sce.valueOf(part); - } - if (part === null || isUndefined(part)) { - part = ''; - } else if (typeof part != 'string') { - part = toJson(part); - } - } - concat[i] = part; + for (; i < ii; i++) { + values[i] = parseFns[i](context); } - return concat.join(''); + + return compute(values); + } catch (err) { + $exceptionHandler($interpolateMinErr.interr(text, err)); } - catch(err) { - var newErr = $interpolateMinErr('interr', "Can't interpolate: {0}\n{1}", text, - err.toString()); - $exceptionHandler(newErr); + + }, { + // all of these properties are undocumented for now + exp: text, //just for compatibility with regular watchers created via $watch + expressions: expressions, + $$watchDelegate: function(scope, listener) { + var lastValue; + return scope.$watchGroup(parseFns, /** @this */ function interpolateFnWatcher(values, oldValues) { + var currValue = compute(values); + listener.call(this, currValue, values !== oldValues ? lastValue : currValue, scope); + lastValue = currValue; + }); } - }; - fn.exp = text; - fn.parts = parts; - return fn; + }); + } + + function parseStringifyInterceptor(value) { + try { + value = getValue(value); + return allOrNothing && !isDefined(value) ? value : stringify(value); + } catch (err) { + $exceptionHandler($interpolateMinErr.interr(text, err)); + } } } @@ -8651,8 +13266,8 @@ * @name $interpolate#startSymbol * @description * Symbol to denote the start of expression in the interpolated string. Defaults to `{{`. - * - * Use {@link ng.$interpolateProvider#startSymbol $interpolateProvider#startSymbol} to change + * + * Use {@link ng.$interpolateProvider#startSymbol `$interpolateProvider.startSymbol`} to change * the symbol. * * @returns {string} start symbol. @@ -8668,7 +13283,7 @@ * @description * Symbol to denote the end of expression in the interpolated string. Defaults to `}}`. * - * Use {@link ng.$interpolateProvider#endSymbol $interpolateProvider#endSymbol} to change + * Use {@link ng.$interpolateProvider#endSymbol `$interpolateProvider.endSymbol`} to change * the symbol. * * @returns {string} end symbol. @@ -8681,9 +13296,10 @@ }]; } + /** @this */ function $IntervalProvider() { - this.$get = ['$rootScope', '$window', '$q', - function($rootScope, $window, $q) { + this.$get = ['$rootScope', '$window', '$q', '$$q', '$browser', + function($rootScope, $window, $q, $$q, $browser) { var intervals = {}; @@ -8692,7 +13308,7 @@ * @name $interval * * @description - * Angular's wrapper for `window.setInterval`. The `fn` function is executed every `delay` + * AngularJS's wrapper for `window.setInterval`. The `fn` function is executed every `delay` * milliseconds. * * The return value of registering an interval function is a promise. This promise will be @@ -8713,116 +13329,124 @@ * appropriate moment. See the example below for more details on how and when to do this. * </div> * - * @param {function()} fn A function that should be called repeatedly. + * @param {function()} fn A function that should be called repeatedly. If no additional arguments + * are passed (see below), the function is called with the current iteration count. * @param {number} delay Number of milliseconds between each function call. * @param {number=} [count=0] Number of times to repeat. If not set, or 0, will repeat * indefinitely. * @param {boolean=} [invokeApply=true] If set to `false` skips model dirty checking, otherwise * will invoke `fn` within the {@link ng.$rootScope.Scope#$apply $apply} block. - * @returns {promise} A promise which will be notified on each iteration. + * @param {...*=} Pass additional parameters to the executed function. + * @returns {promise} A promise which will be notified on each iteration. It will resolve once all iterations of the interval complete. * * @example - * <example module="time"> - * <file name="index.html"> - * <script> - * function Ctrl2($scope,$interval) { - * $scope.format = 'M/d/yy h:mm:ss a'; - * $scope.blood_1 = 100; - * $scope.blood_2 = 120; - * - * var stop; - * $scope.fight = function() { - * // Don't start a new fight if we are already fighting - * if ( angular.isDefined(stop) ) return; - * - * stop = $interval(function() { - * if ($scope.blood_1 > 0 && $scope.blood_2 > 0) { - * $scope.blood_1 = $scope.blood_1 - 3; - * $scope.blood_2 = $scope.blood_2 - 4; - * } else { - * $scope.stopFight(); - * } - * }, 100); - * }; - * - * $scope.stopFight = function() { - * if (angular.isDefined(stop)) { - * $interval.cancel(stop); - * stop = undefined; - * } - * }; - * - * $scope.resetFight = function() { - * $scope.blood_1 = 100; - * $scope.blood_2 = 120; - * } - * - * $scope.$on('$destroy', function() { - * // Make sure that the interval is destroyed too - * $scope.stopFight(); - * }); - * } + * <example module="intervalExample" name="interval-service"> + * <file name="index.html"> + * <script> + * angular.module('intervalExample', []) + * .controller('ExampleController', ['$scope', '$interval', + * function($scope, $interval) { + * $scope.format = 'M/d/yy h:mm:ss a'; + * $scope.blood_1 = 100; + * $scope.blood_2 = 120; + * + * var stop; + * $scope.fight = function() { + * // Don't start a new fight if we are already fighting + * if ( angular.isDefined(stop) ) return; + * + * stop = $interval(function() { + * if ($scope.blood_1 > 0 && $scope.blood_2 > 0) { + * $scope.blood_1 = $scope.blood_1 - 3; + * $scope.blood_2 = $scope.blood_2 - 4; + * } else { + * $scope.stopFight(); + * } + * }, 100); + * }; + * + * $scope.stopFight = function() { + * if (angular.isDefined(stop)) { + * $interval.cancel(stop); + * stop = undefined; + * } + * }; + * + * $scope.resetFight = function() { + * $scope.blood_1 = 100; + * $scope.blood_2 = 120; + * }; + * + * $scope.$on('$destroy', function() { + * // Make sure that the interval is destroyed too + * $scope.stopFight(); + * }); + * }]) + * // Register the 'myCurrentTime' directive factory method. + * // We inject $interval and dateFilter service since the factory method is DI. + * .directive('myCurrentTime', ['$interval', 'dateFilter', + * function($interval, dateFilter) { + * // return the directive link function. (compile function not needed) + * return function(scope, element, attrs) { + * var format, // date format + * stopTime; // so that we can cancel the time updates * - * angular.module('time', []) - * // Register the 'myCurrentTime' directive factory method. - * // We inject $interval and dateFilter service since the factory method is DI. - * .directive('myCurrentTime', function($interval, dateFilter) { - * // return the directive link function. (compile function not needed) - * return function(scope, element, attrs) { - * var format, // date format - * stopTime; // so that we can cancel the time updates - * - * // used to update the UI - * function updateTime() { - * element.text(dateFilter(new Date(), format)); - * } - * - * // watch the expression, and update the UI on change. - * scope.$watch(attrs.myCurrentTime, function(value) { - * format = value; - * updateTime(); - * }); - * - * stopTime = $interval(updateTime, 1000); - * - * // listen on DOM destroy (removal) event, and cancel the next UI update - * // to prevent updating time ofter the DOM element was removed. - * element.bind('$destroy', function() { - * $interval.cancel(stopTime); - * }); - * } - * }); - * </script> + * // used to update the UI + * function updateTime() { + * element.text(dateFilter(new Date(), format)); + * } * - * <div> - * <div ng-controller="Ctrl2"> - * Date format: <input ng-model="format"> <hr/> - * Current time is: <span my-current-time="format"></span> - * <hr/> - * Blood 1 : <font color='red'>{{blood_1}}</font> - * Blood 2 : <font color='red'>{{blood_2}}</font> - * <button type="button" data-ng-click="fight()">Fight</button> - * <button type="button" data-ng-click="stopFight()">StopFight</button> - * <button type="button" data-ng-click="resetFight()">resetFight</button> - * </div> + * // watch the expression, and update the UI on change. + * scope.$watch(attrs.myCurrentTime, function(value) { + * format = value; + * updateTime(); + * }); + * + * stopTime = $interval(updateTime, 1000); + * + * // listen on DOM destroy (removal) event, and cancel the next UI update + * // to prevent updating time after the DOM element was removed. + * element.on('$destroy', function() { + * $interval.cancel(stopTime); + * }); + * } + * }]); + * </script> + * + * <div> + * <div ng-controller="ExampleController"> + * <label>Date format: <input ng-model="format"></label> <hr/> + * Current time is: <span my-current-time="format"></span> + * <hr/> + * Blood 1 : <font color='red'>{{blood_1}}</font> + * Blood 2 : <font color='red'>{{blood_2}}</font> + * <button type="button" data-ng-click="fight()">Fight</button> + * <button type="button" data-ng-click="stopFight()">StopFight</button> + * <button type="button" data-ng-click="resetFight()">resetFight</button> * </div> + * </div> * - * </file> + * </file> * </example> */ function interval(fn, delay, count, invokeApply) { - var setInterval = $window.setInterval, + var hasParams = arguments.length > 4, + args = hasParams ? sliceArgs(arguments, 4) : [], + setInterval = $window.setInterval, clearInterval = $window.clearInterval, - deferred = $q.defer(), - promise = deferred.promise, iteration = 0, - skipApply = (isDefined(invokeApply) && !invokeApply); + skipApply = (isDefined(invokeApply) && !invokeApply), + deferred = (skipApply ? $$q : $q).defer(), + promise = deferred.promise; count = isDefined(count) ? count : 0; - promise.then(null, null, fn); - promise.$$intervalId = setInterval(function tick() { + if (skipApply) { + $browser.defer(callback); + } else { + $rootScope.$evalAsync(callback); + } deferred.notify(iteration++); if (count > 0 && iteration >= count) { @@ -8838,6 +13462,14 @@ intervals[promise.$$intervalId] = deferred; return promise; + + function callback() { + if (!hasParams) { + fn(iteration); + } else { + fn.apply(null, args); + } + } } @@ -8848,13 +13480,15 @@ * @description * Cancels a task associated with the `promise`. * - * @param {promise} promise returned by the `$interval` function. + * @param {Promise=} promise returned by the `$interval` function. * @returns {boolean} Returns `true` if the task was successfully canceled. */ interval.cancel = function(promise) { if (promise && promise.$$intervalId in intervals) { + // Interval cancels should not report as unhandled promise. + markQExceptionHandled(intervals[promise.$$intervalId].promise); intervals[promise.$$intervalId].reject('canceled'); - clearInterval(promise.$$intervalId); + $window.clearInterval(promise.$$intervalId); delete intervals[promise.$$intervalId]; return true; } @@ -8867,77 +13501,97 @@ /** * @ngdoc service - * @name $locale - * + * @name $jsonpCallbacks + * @requires $window * @description - * $locale service provides localization rules for various Angular components. As of right now the - * only public api is: - * - * * `id` – `{string}` – locale id formatted as `languageId-countryId` (e.g. `en-us`) + * This service handles the lifecycle of callbacks to handle JSONP requests. + * Override this service if you wish to customise where the callbacks are stored and + * how they vary compared to the requested url. */ - function $LocaleProvider(){ + var $jsonpCallbacksProvider = /** @this */ function() { this.$get = function() { + var callbacks = angular.callbacks; + var callbackMap = {}; + + function createCallback(callbackId) { + var callback = function(data) { + callback.data = data; + callback.called = true; + }; + callback.id = callbackId; + return callback; + } + return { - id: 'en-us', - - NUMBER_FORMATS: { - DECIMAL_SEP: '.', - GROUP_SEP: ',', - PATTERNS: [ - { // Decimal Pattern - minInt: 1, - minFrac: 0, - maxFrac: 3, - posPre: '', - posSuf: '', - negPre: '-', - negSuf: '', - gSize: 3, - lgSize: 3 - },{ //Currency Pattern - minInt: 1, - minFrac: 2, - maxFrac: 2, - posPre: '\u00A4', - posSuf: '', - negPre: '(\u00A4', - negSuf: ')', - gSize: 3, - lgSize: 3 - } - ], - CURRENCY_SYM: '$' + /** + * @ngdoc method + * @name $jsonpCallbacks#createCallback + * @param {string} url the url of the JSONP request + * @returns {string} the callback path to send to the server as part of the JSONP request + * @description + * {@link $httpBackend} calls this method to create a callback and get hold of the path to the callback + * to pass to the server, which will be used to call the callback with its payload in the JSONP response. + */ + createCallback: function(url) { + var callbackId = '_' + (callbacks.$$counter++).toString(36); + var callbackPath = 'angular.callbacks.' + callbackId; + var callback = createCallback(callbackId); + callbackMap[callbackPath] = callbacks[callbackId] = callback; + return callbackPath; }, - - DATETIME_FORMATS: { - MONTH: - 'January,February,March,April,May,June,July,August,September,October,November,December' - .split(','), - SHORTMONTH: 'Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec'.split(','), - DAY: 'Sunday,Monday,Tuesday,Wednesday,Thursday,Friday,Saturday'.split(','), - SHORTDAY: 'Sun,Mon,Tue,Wed,Thu,Fri,Sat'.split(','), - AMPMS: ['AM','PM'], - medium: 'MMM d, y h:mm:ss a', - short: 'M/d/yy h:mm a', - fullDate: 'EEEE, MMMM d, y', - longDate: 'MMMM d, y', - mediumDate: 'MMM d, y', - shortDate: 'M/d/yy', - mediumTime: 'h:mm:ss a', - shortTime: 'h:mm a' + /** + * @ngdoc method + * @name $jsonpCallbacks#wasCalled + * @param {string} callbackPath the path to the callback that was sent in the JSONP request + * @returns {boolean} whether the callback has been called, as a result of the JSONP response + * @description + * {@link $httpBackend} calls this method to find out whether the JSONP response actually called the + * callback that was passed in the request. + */ + wasCalled: function(callbackPath) { + return callbackMap[callbackPath].called; }, - - pluralCat: function(num) { - if (num === 1) { - return 'one'; - } - return 'other'; + /** + * @ngdoc method + * @name $jsonpCallbacks#getResponse + * @param {string} callbackPath the path to the callback that was sent in the JSONP request + * @returns {*} the data received from the response via the registered callback + * @description + * {@link $httpBackend} calls this method to get hold of the data that was provided to the callback + * in the JSONP response. + */ + getResponse: function(callbackPath) { + return callbackMap[callbackPath].data; + }, + /** + * @ngdoc method + * @name $jsonpCallbacks#removeCallback + * @param {string} callbackPath the path to the callback that was sent in the JSONP request + * @description + * {@link $httpBackend} calls this method to remove the callback after the JSONP request has + * completed or timed-out. + */ + removeCallback: function(callbackPath) { + var callback = callbackMap[callbackPath]; + delete callbacks[callback.id]; + delete callbackMap[callbackPath]; } }; }; - } + }; + + /** + * @ngdoc service + * @name $locale + * + * @description + * $locale service provides localization rules for various AngularJS components. As of right now the + * only public api is: + * + * * `id` – `{string}` – locale id formatted as `languageId-countryId` (e.g. `en-us`) + */ - var PATH_MATCH = /^([^\?#]*)(\?([^#]*))?(#(.*))?$/, + var PATH_MATCH = /^([^?#]*)(\?([^#]*))?(#(.*))?$/, DEFAULT_PORTS = {'http': 80, 'https': 443, 'ftp': 21}; var $locationMinErr = minErr('$location'); @@ -8953,56 +13607,84 @@ i = segments.length; while (i--) { - segments[i] = encodeUriSegment(segments[i]); + // decode forward slashes to prevent them from being double encoded + segments[i] = encodeUriSegment(segments[i].replace(/%2F/g, '/')); + } + + return segments.join('/'); + } + + function decodePath(path, html5Mode) { + var segments = path.split('/'), + i = segments.length; + + while (i--) { + segments[i] = decodeURIComponent(segments[i]); + if (html5Mode) { + // encode forward slashes to prevent them from being mistaken for path separators + segments[i] = segments[i].replace(/\//g, '%2F'); + } } return segments.join('/'); } - function parseAbsoluteUrl(absoluteUrl, locationObj, appBase) { - var parsedUrl = urlResolve(absoluteUrl, appBase); + function parseAbsoluteUrl(absoluteUrl, locationObj) { + var parsedUrl = urlResolve(absoluteUrl); locationObj.$$protocol = parsedUrl.protocol; locationObj.$$host = parsedUrl.hostname; - locationObj.$$port = int(parsedUrl.port) || DEFAULT_PORTS[parsedUrl.protocol] || null; + locationObj.$$port = toInt(parsedUrl.port) || DEFAULT_PORTS[parsedUrl.protocol] || null; } + var DOUBLE_SLASH_REGEX = /^\s*[\\/]{2,}/; + function parseAppUrl(url, locationObj, html5Mode) { + + if (DOUBLE_SLASH_REGEX.test(url)) { + throw $locationMinErr('badpath', 'Invalid url "{0}".', url); + } - function parseAppUrl(relativeUrl, locationObj, appBase) { - var prefixed = (relativeUrl.charAt(0) !== '/'); + var prefixed = (url.charAt(0) !== '/'); if (prefixed) { - relativeUrl = '/' + relativeUrl; + url = '/' + url; } - var match = urlResolve(relativeUrl, appBase); - locationObj.$$path = decodeURIComponent(prefixed && match.pathname.charAt(0) === '/' ? - match.pathname.substring(1) : match.pathname); + var match = urlResolve(url); + var path = prefixed && match.pathname.charAt(0) === '/' ? match.pathname.substring(1) : match.pathname; + locationObj.$$path = decodePath(path, html5Mode); locationObj.$$search = parseKeyValue(match.search); locationObj.$$hash = decodeURIComponent(match.hash); // make sure path starts with '/'; - if (locationObj.$$path && locationObj.$$path.charAt(0) != '/') { + if (locationObj.$$path && locationObj.$$path.charAt(0) !== '/') { locationObj.$$path = '/' + locationObj.$$path; } } + function startsWith(str, search) { + return str.slice(0, search.length) === search; + } /** * - * @param {string} begin - * @param {string} whole - * @returns {string} returns text from whole after begin or undefined if it does not begin with - * expected string. + * @param {string} base + * @param {string} url + * @returns {string} returns text from `url` after `base` or `undefined` if it does not begin with + * the expected string. */ - function beginsWith(begin, whole) { - if (whole.indexOf(begin) === 0) { - return whole.substr(begin.length); + function stripBaseUrl(base, url) { + if (startsWith(url, base)) { + return url.substr(base.length); } } function stripHash(url) { var index = url.indexOf('#'); - return index == -1 ? url : url.substr(0, index); + return index === -1 ? url : url.substr(0, index); + } + + function trimEmptyHash(url) { + return url.replace(/(#.+)|#$/, '$1'); } @@ -9017,33 +13699,33 @@ /** - * LocationHtml5Url represents an url + * LocationHtml5Url represents a URL * This object is exposed as $location service when HTML5 mode is enabled and supported * * @constructor * @param {string} appBase application base URL - * @param {string} basePrefix url path prefix + * @param {string} appBaseNoFile application base URL stripped of any filename + * @param {string} basePrefix URL path prefix */ - function LocationHtml5Url(appBase, basePrefix) { + function LocationHtml5Url(appBase, appBaseNoFile, basePrefix) { this.$$html5 = true; basePrefix = basePrefix || ''; - var appBaseNoFile = stripFile(appBase); - parseAbsoluteUrl(appBase, this, appBase); + parseAbsoluteUrl(appBase, this); /** - * Parse given html5 (regular) url string into properties - * @param {string} newAbsoluteUrl HTML5 url + * Parse given HTML5 (regular) URL string into properties + * @param {string} url HTML5 URL * @private */ this.$$parse = function(url) { - var pathUrl = beginsWith(appBaseNoFile, url); + var pathUrl = stripBaseUrl(appBaseNoFile, url); if (!isString(pathUrl)) { throw $locationMinErr('ipthprfx', 'Invalid url "{0}", missing path prefix "{1}".', url, appBaseNoFile); } - parseAppUrl(pathUrl, this, appBase); + parseAppUrl(pathUrl, this, true); if (!this.$$path) { this.$$path = '/'; @@ -9062,94 +13744,122 @@ this.$$url = encodePath(this.$$path) + (search ? '?' + search : '') + hash; this.$$absUrl = appBaseNoFile + this.$$url.substr(1); // first char is always '/' + + this.$$urlUpdatedByLocation = true; }; - this.$$rewrite = function(url) { + this.$$parseLinkUrl = function(url, relHref) { + if (relHref && relHref[0] === '#') { + // special case for links to hash fragments: + // keep the old url and only replace the hash fragment + this.hash(relHref.slice(1)); + return true; + } var appUrl, prevAppUrl; + var rewrittenUrl; + - if ( (appUrl = beginsWith(appBase, url)) !== undefined ) { + if (isDefined(appUrl = stripBaseUrl(appBase, url))) { prevAppUrl = appUrl; - if ( (appUrl = beginsWith(basePrefix, appUrl)) !== undefined ) { - return appBaseNoFile + (beginsWith('/', appUrl) || appUrl); + if (basePrefix && isDefined(appUrl = stripBaseUrl(basePrefix, appUrl))) { + rewrittenUrl = appBaseNoFile + (stripBaseUrl('/', appUrl) || appUrl); } else { - return appBase + prevAppUrl; + rewrittenUrl = appBase + prevAppUrl; } - } else if ( (appUrl = beginsWith(appBaseNoFile, url)) !== undefined ) { - return appBaseNoFile + appUrl; - } else if (appBaseNoFile == url + '/') { - return appBaseNoFile; + } else if (isDefined(appUrl = stripBaseUrl(appBaseNoFile, url))) { + rewrittenUrl = appBaseNoFile + appUrl; + } else if (appBaseNoFile === url + '/') { + rewrittenUrl = appBaseNoFile; } + if (rewrittenUrl) { + this.$$parse(rewrittenUrl); + } + return !!rewrittenUrl; }; } /** - * LocationHashbangUrl represents url + * LocationHashbangUrl represents URL * This object is exposed as $location service when developer doesn't opt into html5 mode. * It also serves as the base class for html5 mode fallback on legacy browsers. * * @constructor * @param {string} appBase application base URL + * @param {string} appBaseNoFile application base URL stripped of any filename * @param {string} hashPrefix hashbang prefix */ - function LocationHashbangUrl(appBase, hashPrefix) { - var appBaseNoFile = stripFile(appBase); + function LocationHashbangUrl(appBase, appBaseNoFile, hashPrefix) { - parseAbsoluteUrl(appBase, this, appBase); + parseAbsoluteUrl(appBase, this); /** - * Parse given hashbang url into properties - * @param {string} url Hashbang url + * Parse given hashbang URL into properties + * @param {string} url Hashbang URL * @private */ this.$$parse = function(url) { - var withoutBaseUrl = beginsWith(appBase, url) || beginsWith(appBaseNoFile, url); - var withoutHashUrl = withoutBaseUrl.charAt(0) == '#' - ? beginsWith(hashPrefix, withoutBaseUrl) - : (this.$$html5) - ? withoutBaseUrl - : ''; + var withoutBaseUrl = stripBaseUrl(appBase, url) || stripBaseUrl(appBaseNoFile, url); + var withoutHashUrl; + + if (!isUndefined(withoutBaseUrl) && withoutBaseUrl.charAt(0) === '#') { - if (!isString(withoutHashUrl)) { - throw $locationMinErr('ihshprfx', 'Invalid url "{0}", missing hash prefix "{1}".', url, - hashPrefix); + // The rest of the URL starts with a hash so we have + // got either a hashbang path or a plain hash fragment + withoutHashUrl = stripBaseUrl(hashPrefix, withoutBaseUrl); + if (isUndefined(withoutHashUrl)) { + // There was no hashbang prefix so we just have a hash fragment + withoutHashUrl = withoutBaseUrl; + } + + } else { + // There was no hashbang path nor hash fragment: + // If we are in HTML5 mode we use what is left as the path; + // Otherwise we ignore what is left + if (this.$$html5) { + withoutHashUrl = withoutBaseUrl; + } else { + withoutHashUrl = ''; + if (isUndefined(withoutBaseUrl)) { + appBase = url; + /** @type {?} */ (this).replace(); + } + } } - parseAppUrl(withoutHashUrl, this, appBase); + + parseAppUrl(withoutHashUrl, this, false); this.$$path = removeWindowsDriveName(this.$$path, withoutHashUrl, appBase); this.$$compose(); /* - * In Windows, on an anchor node on documents loaded from - * the filesystem, the browser will return a pathname - * prefixed with the drive name ('/C:/path') when a - * pathname without a drive is set: - * * a.setAttribute('href', '/foo') - * * a.pathname === '/C:/foo' //true - * - * Inside of Angular, we're always using pathnames that - * do not include drive names for routing. - */ - function removeWindowsDriveName (path, url, base) { + * In Windows, on an anchor node on documents loaded from + * the filesystem, the browser will return a pathname + * prefixed with the drive name ('/C:/path') when a + * pathname without a drive is set: + * * a.setAttribute('href', '/foo') + * * a.pathname === '/C:/foo' //true + * + * Inside of AngularJS, we're always using pathnames that + * do not include drive names for routing. + */ + function removeWindowsDriveName(path, url, base) { /* - Matches paths for file protocol on windows, - such as /C:/foo/bar, and captures only /foo/bar. - */ - var windowsFilePathExp = /^\/?.*?:(\/.*)/; + Matches paths for file protocol on windows, + such as /C:/foo/bar, and captures only /foo/bar. + */ + var windowsFilePathExp = /^\/[A-Z]:(\/.*)/; var firstPathSegmentMatch; //Get the relative path from the input URL. - if (url.indexOf(base) === 0) { + if (startsWith(url, base)) { url = url.replace(base, ''); } - /* - * The input URL intentionally contains a - * first path segment that ends with a colon. - */ + // The input URL intentionally contains a first path segment that ends with a colon. if (windowsFilePathExp.exec(url)) { return path; } @@ -9160,7 +13870,7 @@ }; /** - * Compose hashbang url and update `absUrl` property + * Compose hashbang URL and update `absUrl` property * @private */ this.$$compose = function() { @@ -9169,250 +13879,415 @@ this.$$url = encodePath(this.$$path) + (search ? '?' + search : '') + hash; this.$$absUrl = appBase + (this.$$url ? hashPrefix + this.$$url : ''); + + this.$$urlUpdatedByLocation = true; }; - this.$$rewrite = function(url) { - if(stripHash(appBase) == stripHash(url)) { - return url; + this.$$parseLinkUrl = function(url, relHref) { + if (stripHash(appBase) === stripHash(url)) { + this.$$parse(url); + return true; } + return false; }; } /** - * LocationHashbangUrl represents url + * LocationHashbangUrl represents URL * This object is exposed as $location service when html5 history api is enabled but the browser * does not support it. * * @constructor * @param {string} appBase application base URL + * @param {string} appBaseNoFile application base URL stripped of any filename * @param {string} hashPrefix hashbang prefix */ - function LocationHashbangInHtml5Url(appBase, hashPrefix) { + function LocationHashbangInHtml5Url(appBase, appBaseNoFile, hashPrefix) { this.$$html5 = true; LocationHashbangUrl.apply(this, arguments); - var appBaseNoFile = stripFile(appBase); + this.$$parseLinkUrl = function(url, relHref) { + if (relHref && relHref[0] === '#') { + // special case for links to hash fragments: + // keep the old url and only replace the hash fragment + this.hash(relHref.slice(1)); + return true; + } - this.$$rewrite = function(url) { + var rewrittenUrl; var appUrl; - if ( appBase == stripHash(url) ) { - return url; - } else if ( (appUrl = beginsWith(appBaseNoFile, url)) ) { - return appBase + hashPrefix + appUrl; - } else if ( appBaseNoFile === url + '/') { - return appBaseNoFile; + if (appBase === stripHash(url)) { + rewrittenUrl = url; + } else if ((appUrl = stripBaseUrl(appBaseNoFile, url))) { + rewrittenUrl = appBase + hashPrefix + appUrl; + } else if (appBaseNoFile === url + '/') { + rewrittenUrl = appBaseNoFile; } + if (rewrittenUrl) { + this.$$parse(rewrittenUrl); + } + return !!rewrittenUrl; }; - } + this.$$compose = function() { + var search = toKeyValue(this.$$search), + hash = this.$$hash ? '#' + encodeUriSegment(this.$$hash) : ''; - LocationHashbangInHtml5Url.prototype = - LocationHashbangUrl.prototype = - LocationHtml5Url.prototype = { + this.$$url = encodePath(this.$$path) + (search ? '?' + search : '') + hash; + // include hashPrefix in $$absUrl when $$url is empty so IE9 does not reload page because of removal of '#' + this.$$absUrl = appBase + hashPrefix + this.$$url; - /** - * Are we in html5 mode? - * @private - */ - $$html5: false, + this.$$urlUpdatedByLocation = true; + }; - /** - * Has any change been replacing ? - * @private - */ - $$replace: false, + } - /** - * @ngdoc method - * @name $location#absUrl - * - * @description - * This method is getter only. - * - * Return full url representation with all segments encoded according to rules specified in - * [RFC 3986](http://www.ietf.org/rfc/rfc3986.txt). - * - * @return {string} full url - */ - absUrl: locationGetter('$$absUrl'), - /** - * @ngdoc method - * @name $location#url - * - * @description - * This method is getter / setter. - * - * Return url (e.g. `/path?a=b#hash`) when called without any parameter. - * - * Change path, search and hash, when called with parameter and return `$location`. - * - * @param {string=} url New url without base prefix (e.g. `/path?a=b#hash`) - * @param {string=} replace The path that will be changed - * @return {string} url - */ - url: function(url, replace) { - if (isUndefined(url)) - return this.$$url; + var locationPrototype = { - var match = PATH_MATCH.exec(url); - if (match[1]) this.path(decodeURIComponent(match[1])); - if (match[2] || match[1]) this.search(match[3] || ''); - this.hash(match[5] || '', replace); + /** + * Ensure absolute URL is initialized. + * @private + */ + $$absUrl:'', - return this; - }, + /** + * Are we in html5 mode? + * @private + */ + $$html5: false, - /** - * @ngdoc method - * @name $location#protocol - * - * @description - * This method is getter only. - * - * Return protocol of current url. - * - * @return {string} protocol of current url - */ - protocol: locationGetter('$$protocol'), + /** + * Has any change been replacing? + * @private + */ + $$replace: false, - /** - * @ngdoc method - * @name $location#host - * - * @description - * This method is getter only. - * - * Return host of current url. - * - * @return {string} host of current url. - */ - host: locationGetter('$$host'), + /** + * @ngdoc method + * @name $location#absUrl + * + * @description + * This method is getter only. + * + * Return full URL representation with all segments encoded according to rules specified in + * [RFC 3986](http://www.ietf.org/rfc/rfc3986.txt). + * + * + * ```js + * // given URL http://example.com/#/some/path?foo=bar&baz=xoxo + * var absUrl = $location.absUrl(); + * // => "http://example.com/#/some/path?foo=bar&baz=xoxo" + * ``` + * + * @return {string} full URL + */ + absUrl: locationGetter('$$absUrl'), - /** - * @ngdoc method - * @name $location#port - * - * @description - * This method is getter only. - * - * Return port of current url. - * - * @return {Number} port - */ - port: locationGetter('$$port'), + /** + * @ngdoc method + * @name $location#url + * + * @description + * This method is getter / setter. + * + * Return URL (e.g. `/path?a=b#hash`) when called without any parameter. + * + * Change path, search and hash, when called with parameter and return `$location`. + * + * + * ```js + * // given URL http://example.com/#/some/path?foo=bar&baz=xoxo + * var url = $location.url(); + * // => "/some/path?foo=bar&baz=xoxo" + * ``` + * + * @param {string=} url New URL without base prefix (e.g. `/path?a=b#hash`) + * @return {string} url + */ + url: function(url) { + if (isUndefined(url)) { + return this.$$url; + } - /** - * @ngdoc method - * @name $location#path - * - * @description - * This method is getter / setter. - * - * Return path of current url when called without any parameter. - * - * Change path when called with parameter and return `$location`. - * - * Note: Path should always begin with forward slash (/), this method will add the forward slash - * if it is missing. - * - * @param {string=} path New path - * @return {string} path - */ - path: locationGetterSetter('$$path', function(path) { - return path.charAt(0) == '/' ? path : '/' + path; - }), + var match = PATH_MATCH.exec(url); + if (match[1] || url === '') this.path(decodeURIComponent(match[1])); + if (match[2] || match[1] || url === '') this.search(match[3] || ''); + this.hash(match[5] || ''); - /** - * @ngdoc method - * @name $location#search - * - * @description - * This method is getter / setter. - * - * Return search part (as object) of current url when called without any parameter. - * - * Change search part when called with parameter and return `$location`. - * - * @param {string|Object.<string>|Object.<Array.<string>>} search New search params - string or - * hash object. Hash object may contain an array of values, which will be decoded as duplicates in - * the url. - * - * @param {(string|Array<string>)=} paramValue If `search` is a string, then `paramValue` will override only a - * single search parameter. If `paramValue` is an array, it will set the parameter as a - * comma-separated value. If `paramValue` is `null`, the parameter will be deleted. - * - * @return {string} search - */ - search: function(search, paramValue) { - switch (arguments.length) { - case 0: - return this.$$search; - case 1: - if (isString(search)) { - this.$$search = parseKeyValue(search); - } else if (isObject(search)) { - this.$$search = search; - } else { - throw $locationMinErr('isrcharg', - 'The first argument of the `$location#search()` call must be a string or an object.'); - } - break; - default: - if (isUndefined(paramValue) || paramValue === null) { - delete this.$$search[search]; - } else { - this.$$search[search] = paramValue; - } - } + return this; + }, - this.$$compose(); - return this; - }, + /** + * @ngdoc method + * @name $location#protocol + * + * @description + * This method is getter only. + * + * Return protocol of current URL. + * + * + * ```js + * // given URL http://example.com/#/some/path?foo=bar&baz=xoxo + * var protocol = $location.protocol(); + * // => "http" + * ``` + * + * @return {string} protocol of current URL + */ + protocol: locationGetter('$$protocol'), - /** - * @ngdoc method - * @name $location#hash - * - * @description - * This method is getter / setter. - * - * Return hash fragment when called without any parameter. - * - * Change hash fragment when called with parameter and return `$location`. - * - * @param {string=} hash New hash fragment - * @return {string} hash - */ - hash: locationGetterSetter('$$hash', identity), + /** + * @ngdoc method + * @name $location#host + * + * @description + * This method is getter only. + * + * Return host of current URL. + * + * Note: compared to the non-AngularJS version `location.host` which returns `hostname:port`, this returns the `hostname` portion only. + * + * + * ```js + * // given URL http://example.com/#/some/path?foo=bar&baz=xoxo + * var host = $location.host(); + * // => "example.com" + * + * // given URL http://user:password@example.com:8080/#/some/path?foo=bar&baz=xoxo + * host = $location.host(); + * // => "example.com" + * host = location.host; + * // => "example.com:8080" + * ``` + * + * @return {string} host of current URL. + */ + host: locationGetter('$$host'), + + /** + * @ngdoc method + * @name $location#port + * + * @description + * This method is getter only. + * + * Return port of current URL. + * + * + * ```js + * // given URL http://example.com/#/some/path?foo=bar&baz=xoxo + * var port = $location.port(); + * // => 80 + * ``` + * + * @return {Number} port + */ + port: locationGetter('$$port'), + + /** + * @ngdoc method + * @name $location#path + * + * @description + * This method is getter / setter. + * + * Return path of current URL when called without any parameter. + * + * Change path when called with parameter and return `$location`. + * + * Note: Path should always begin with forward slash (/), this method will add the forward slash + * if it is missing. + * + * + * ```js + * // given URL http://example.com/#/some/path?foo=bar&baz=xoxo + * var path = $location.path(); + * // => "/some/path" + * ``` + * + * @param {(string|number)=} path New path + * @return {(string|object)} path if called with no parameters, or `$location` if called with a parameter + */ + path: locationGetterSetter('$$path', function(path) { + path = path !== null ? path.toString() : ''; + return path.charAt(0) === '/' ? path : '/' + path; + }), + + /** + * @ngdoc method + * @name $location#search + * + * @description + * This method is getter / setter. + * + * Return search part (as object) of current URL when called without any parameter. + * + * Change search part when called with parameter and return `$location`. + * + * + * ```js + * // given URL http://example.com/#/some/path?foo=bar&baz=xoxo + * var searchObject = $location.search(); + * // => {foo: 'bar', baz: 'xoxo'} + * + * // set foo to 'yipee' + * $location.search('foo', 'yipee'); + * // $location.search() => {foo: 'yipee', baz: 'xoxo'} + * ``` + * + * @param {string|Object.<string>|Object.<Array.<string>>} search New search params - string or + * hash object. + * + * When called with a single argument the method acts as a setter, setting the `search` component + * of `$location` to the specified value. + * + * If the argument is a hash object containing an array of values, these values will be encoded + * as duplicate search parameters in the URL. + * + * @param {(string|Number|Array<string>|boolean)=} paramValue If `search` is a string or number, then `paramValue` + * will override only a single search property. + * + * If `paramValue` is an array, it will override the property of the `search` component of + * `$location` specified via the first argument. + * + * If `paramValue` is `null`, the property specified via the first argument will be deleted. + * + * If `paramValue` is `true`, the property specified via the first argument will be added with no + * value nor trailing equal sign. + * + * @return {Object} If called with no arguments returns the parsed `search` object. If called with + * one or more arguments returns `$location` object itself. + */ + search: function(search, paramValue) { + switch (arguments.length) { + case 0: + return this.$$search; + case 1: + if (isString(search) || isNumber(search)) { + search = search.toString(); + this.$$search = parseKeyValue(search); + } else if (isObject(search)) { + search = copy(search, {}); + // remove object undefined or null properties + forEach(search, function(value, key) { + if (value == null) delete search[key]; + }); + + this.$$search = search; + } else { + throw $locationMinErr('isrcharg', + 'The first argument of the `$location#search()` call must be a string or an object.'); + } + break; + default: + if (isUndefined(paramValue) || paramValue === null) { + delete this.$$search[search]; + } else { + this.$$search[search] = paramValue; + } + } + + this.$$compose(); + return this; + }, + + /** + * @ngdoc method + * @name $location#hash + * + * @description + * This method is getter / setter. + * + * Returns the hash fragment when called without any parameters. + * + * Changes the hash fragment when called with a parameter and returns `$location`. + * + * + * ```js + * // given URL http://example.com/#/some/path?foo=bar&baz=xoxo#hashValue + * var hash = $location.hash(); + * // => "hashValue" + * ``` + * + * @param {(string|number)=} hash New hash fragment + * @return {string} hash + */ + hash: locationGetterSetter('$$hash', function(hash) { + return hash !== null ? hash.toString() : ''; + }), + + /** + * @ngdoc method + * @name $location#replace + * + * @description + * If called, all changes to $location during the current `$digest` will replace the current history + * record, instead of adding a new one. + */ + replace: function() { + this.$$replace = true; + return this; + } + }; + + forEach([LocationHashbangInHtml5Url, LocationHashbangUrl, LocationHtml5Url], function(Location) { + Location.prototype = Object.create(locationPrototype); + + /** + * @ngdoc method + * @name $location#state + * + * @description + * This method is getter / setter. + * + * Return the history state object when called without any parameter. + * + * Change the history state object when called with one parameter and return `$location`. + * The state object is later passed to `pushState` or `replaceState`. + * + * NOTE: This method is supported only in HTML5 mode and only in browsers supporting + * the HTML5 History API (i.e. methods `pushState` and `replaceState`). If you need to support + * older browsers (like IE9 or Android < 4.0), don't use this method. + * + * @param {object=} state State object for pushState or replaceState + * @return {object} state + */ + Location.prototype.state = function(state) { + if (!arguments.length) { + return this.$$state; + } + + if (Location !== LocationHtml5Url || !this.$$html5) { + throw $locationMinErr('nostate', 'History API state support is available only ' + + 'in HTML5 mode and only in browsers supporting HTML5 History API'); + } + // The user might modify `stateObject` after invoking `$location.state(stateObject)` + // but we're changing the $$state reference to $browser.state() during the $digest + // so the modification window is narrow. + this.$$state = isUndefined(state) ? null : state; + this.$$urlUpdatedByLocation = true; + + return this; + }; + }); - /** - * @ngdoc method - * @name $location#replace - * - * @description - * If called, all changes to $location during current `$digest` will be replacing current history - * record, instead of adding new one. - */ - replace: function() { - this.$$replace = true; - return this; - } - }; function locationGetter(property) { - return function() { + return /** @this */ function() { return this[property]; }; } function locationGetterSetter(property, preprocess) { - return function(value) { - if (isUndefined(value)) + return /** @this */ function(value) { + if (isUndefined(value)) { return this[property]; + } this[property] = preprocess(value); this.$$compose(); @@ -9451,17 +14326,24 @@ /** * @ngdoc provider * @name $locationProvider + * @this + * * @description * Use the `$locationProvider` to configure how the application deep linking paths are stored. */ - function $LocationProvider(){ - var hashPrefix = '', - html5Mode = false; + function $LocationProvider() { + var hashPrefix = '!', + html5Mode = { + enabled: false, + requireBase: true, + rewriteLinks: true + }; /** - * @ngdoc property + * @ngdoc method * @name $locationProvider#hashPrefix * @description + * The default value for the prefix is `'!'`. * @param {string=} prefix Prefix for hash part (containing path and search) * @returns {*} current value if used as getter or itself (chaining) if used as setter */ @@ -9475,15 +14357,46 @@ }; /** - * @ngdoc property + * @ngdoc method * @name $locationProvider#html5Mode * @description - * @param {boolean=} mode Use HTML5 strategy if available. - * @returns {*} current value if used as getter or itself (chaining) if used as setter + * @param {(boolean|Object)=} mode If boolean, sets `html5Mode.enabled` to value. + * If object, sets `enabled`, `requireBase` and `rewriteLinks` to respective values. Supported + * properties: + * - **enabled** – `{boolean}` – (default: false) If true, will rely on `history.pushState` to + * change urls where supported. Will fall back to hash-prefixed paths in browsers that do not + * support `pushState`. + * - **requireBase** - `{boolean}` - (default: `true`) When html5Mode is enabled, specifies + * whether or not a <base> tag is required to be present. If `enabled` and `requireBase` are + * true, and a base tag is not present, an error will be thrown when `$location` is injected. + * See the {@link guide/$location $location guide for more information} + * - **rewriteLinks** - `{boolean|string}` - (default: `true`) When html5Mode is enabled, + * enables/disables URL rewriting for relative links. If set to a string, URL rewriting will + * only happen on links with an attribute that matches the given string. For example, if set + * to `'internal-link'`, then the URL will only be rewritten for `<a internal-link>` links. + * Note that [attribute name normalization](guide/directive#normalization) does not apply + * here, so `'internalLink'` will **not** match `'internal-link'`. + * + * @returns {Object} html5Mode object if used as getter or itself (chaining) if used as setter */ this.html5Mode = function(mode) { - if (isDefined(mode)) { - html5Mode = mode; + if (isBoolean(mode)) { + html5Mode.enabled = mode; + return this; + } else if (isObject(mode)) { + + if (isBoolean(mode.enabled)) { + html5Mode.enabled = mode.enabled; + } + + if (isBoolean(mode.requireBase)) { + html5Mode.requireBase = mode.requireBase; + } + + if (isBoolean(mode.rewriteLinks) || isString(mode.rewriteLinks)) { + html5Mode.rewriteLinks = mode.rewriteLinks; + } + return this; } else { return html5Mode; @@ -9495,14 +14408,21 @@ * @name $location#$locationChangeStart * @eventType broadcast on root scope * @description - * Broadcasted before a URL will change. This change can be prevented by calling + * Broadcasted before a URL will change. + * + * This change can be prevented by calling * `preventDefault` method of the event. See {@link ng.$rootScope.Scope#$on} for more * details about event object. Upon successful change - * {@link ng.$location#events_$locationChangeSuccess $locationChangeSuccess} is fired. + * {@link ng.$location#$locationChangeSuccess $locationChangeSuccess} is fired. + * + * The `newState` and `oldState` parameters may be defined only in HTML5 mode and when + * the browser supports the HTML5 History API. * * @param {Object} angularEvent Synthetic event object. * @param {string} newUrl New URL * @param {string=} oldUrl URL that was before it was changed. + * @param {string=} newState New history state object + * @param {string=} oldState History state object that was before it was changed. */ /** @@ -9512,44 +14432,84 @@ * @description * Broadcasted after a URL was changed. * + * The `newState` and `oldState` parameters may be defined only in HTML5 mode and when + * the browser supports the HTML5 History API. + * * @param {Object} angularEvent Synthetic event object. * @param {string} newUrl New URL * @param {string=} oldUrl URL that was before it was changed. + * @param {string=} newState New history state object + * @param {string=} oldState History state object that was before it was changed. */ - this.$get = ['$rootScope', '$browser', '$sniffer', '$rootElement', - function( $rootScope, $browser, $sniffer, $rootElement) { + this.$get = ['$rootScope', '$browser', '$sniffer', '$rootElement', '$window', + function($rootScope, $browser, $sniffer, $rootElement, $window) { var $location, LocationMode, baseHref = $browser.baseHref(), // if base[href] is undefined, it defaults to '' initialUrl = $browser.url(), appBase; - if (html5Mode) { + if (html5Mode.enabled) { + if (!baseHref && html5Mode.requireBase) { + throw $locationMinErr('nobase', + '$location in HTML5 mode requires a <base> tag to be present!'); + } appBase = serverBase(initialUrl) + (baseHref || '/'); LocationMode = $sniffer.history ? LocationHtml5Url : LocationHashbangInHtml5Url; } else { appBase = stripHash(initialUrl); LocationMode = LocationHashbangUrl; } - $location = new LocationMode(appBase, '#' + hashPrefix); - $location.$$parse($location.$$rewrite(initialUrl)); + var appBaseNoFile = stripFile(appBase); + + $location = new LocationMode(appBase, appBaseNoFile, '#' + hashPrefix); + $location.$$parseLinkUrl(initialUrl, initialUrl); + + $location.$$state = $browser.state(); + + var IGNORE_URI_REGEXP = /^\s*(javascript|mailto):/i; + + function setBrowserUrlWithFallback(url, replace, state) { + var oldUrl = $location.url(); + var oldState = $location.$$state; + try { + $browser.url(url, replace, state); + + // Make sure $location.state() returns referentially identical (not just deeply equal) + // state object; this makes possible quick checking if the state changed in the digest + // loop. Checking deep equality would be too expensive. + $location.$$state = $browser.state(); + } catch (e) { + // Restore old values if pushState fails + $location.url(oldUrl); + $location.$$state = oldState; + + throw e; + } + } $rootElement.on('click', function(event) { + var rewriteLinks = html5Mode.rewriteLinks; // TODO(vojta): rewrite link when opening in new tab/window (in legacy browser) // currently we open nice url link and redirect then - if (event.ctrlKey || event.metaKey || event.which == 2) return; + if (!rewriteLinks || event.ctrlKey || event.metaKey || event.shiftKey || event.which === 2 || event.button === 2) return; var elm = jqLite(event.target); // traverse the DOM up to find first A tag - while (lowercase(elm[0].nodeName) !== 'a') { + while (nodeName_(elm[0]) !== 'a') { // ignore rewriting if no A tag (reached root element, or no parent - removed from document) if (elm[0] === $rootElement[0] || !(elm = elm.parent())[0]) return; } + if (isString(rewriteLinks) && isUndefined(elm.attr(rewriteLinks))) return; + var absHref = elm.prop('href'); + // get the actual href attribute - see + // http://msdn.microsoft.com/en-us/library/ie/dd347148(v=vs.85).aspx + var relHref = elm.attr('href') || elm.attr('xlink:href'); if (isObject(absHref) && absHref.toString() === '[object SVGAnimatedString]') { // SVGAnimatedString.animVal should be identical to SVGAnimatedString.baseVal, unless during @@ -9557,72 +14517,118 @@ absHref = urlResolve(absHref.animVal).href; } - var rewrittenUrl = $location.$$rewrite(absHref); + // Ignore when url is started with javascript: or mailto: + if (IGNORE_URI_REGEXP.test(absHref)) return; - if (absHref && !elm.attr('target') && rewrittenUrl && !event.isDefaultPrevented()) { - event.preventDefault(); - if (rewrittenUrl != $browser.url()) { + if (absHref && !elm.attr('target') && !event.isDefaultPrevented()) { + if ($location.$$parseLinkUrl(absHref, relHref)) { + // We do a preventDefault for all urls that are part of the AngularJS application, + // in html5mode and also without, so that we are able to abort navigation without + // getting double entries in the location history. + event.preventDefault(); // update location manually - $location.$$parse(rewrittenUrl); - $rootScope.$apply(); - // hack to work around FF6 bug 684208 when scenario runner clicks on links - window.angular['ff-684208-preventDefault'] = true; + if ($location.absUrl() !== $browser.url()) { + $rootScope.$apply(); + // hack to work around FF6 bug 684208 when scenario runner clicks on links + $window.angular['ff-684208-preventDefault'] = true; + } } } }); // rewrite hashbang url <> html5 url - if ($location.absUrl() != initialUrl) { + if (trimEmptyHash($location.absUrl()) !== trimEmptyHash(initialUrl)) { $browser.url($location.absUrl(), true); } + var initializing = true; + // update $location when $browser url changes - $browser.onUrlChange(function(newUrl) { - if ($location.absUrl() != newUrl) { - $rootScope.$evalAsync(function() { - var oldUrl = $location.absUrl(); + $browser.onUrlChange(function(newUrl, newState) { - $location.$$parse(newUrl); - if ($rootScope.$broadcast('$locationChangeStart', newUrl, - oldUrl).defaultPrevented) { - $location.$$parse(oldUrl); - $browser.url(oldUrl); - } else { - afterLocationChange(oldUrl); - } - }); - if (!$rootScope.$$phase) $rootScope.$digest(); + if (!startsWith(newUrl, appBaseNoFile)) { + // If we are navigating outside of the app then force a reload + $window.location.href = newUrl; + return; } + + $rootScope.$evalAsync(function() { + var oldUrl = $location.absUrl(); + var oldState = $location.$$state; + var defaultPrevented; + newUrl = trimEmptyHash(newUrl); + $location.$$parse(newUrl); + $location.$$state = newState; + + defaultPrevented = $rootScope.$broadcast('$locationChangeStart', newUrl, oldUrl, + newState, oldState).defaultPrevented; + + // if the location was changed by a `$locationChangeStart` handler then stop + // processing this location change + if ($location.absUrl() !== newUrl) return; + + if (defaultPrevented) { + $location.$$parse(oldUrl); + $location.$$state = oldState; + setBrowserUrlWithFallback(oldUrl, false, oldState); + } else { + initializing = false; + afterLocationChange(oldUrl, oldState); + } + }); + if (!$rootScope.$$phase) $rootScope.$digest(); }); // update browser - var changeCounter = 0; $rootScope.$watch(function $locationWatch() { - var oldUrl = $browser.url(); - var currentReplace = $location.$$replace; - - if (!changeCounter || oldUrl != $location.absUrl()) { - changeCounter++; - $rootScope.$evalAsync(function() { - if ($rootScope.$broadcast('$locationChangeStart', $location.absUrl(), oldUrl). - defaultPrevented) { - $location.$$parse(oldUrl); - } else { - $browser.url($location.absUrl(), currentReplace); - afterLocationChange(oldUrl); - } - }); + if (initializing || $location.$$urlUpdatedByLocation) { + $location.$$urlUpdatedByLocation = false; + + var oldUrl = trimEmptyHash($browser.url()); + var newUrl = trimEmptyHash($location.absUrl()); + var oldState = $browser.state(); + var currentReplace = $location.$$replace; + var urlOrStateChanged = oldUrl !== newUrl || + ($location.$$html5 && $sniffer.history && oldState !== $location.$$state); + + if (initializing || urlOrStateChanged) { + initializing = false; + + $rootScope.$evalAsync(function() { + var newUrl = $location.absUrl(); + var defaultPrevented = $rootScope.$broadcast('$locationChangeStart', newUrl, oldUrl, + $location.$$state, oldState).defaultPrevented; + + // if the location was changed by a `$locationChangeStart` handler then stop + // processing this location change + if ($location.absUrl() !== newUrl) return; + + if (defaultPrevented) { + $location.$$parse(oldUrl); + $location.$$state = oldState; + } else { + if (urlOrStateChanged) { + setBrowserUrlWithFallback(newUrl, currentReplace, + oldState === $location.$$state ? null : $location.$$state); + } + afterLocationChange(oldUrl, oldState); + } + }); + } } + $location.$$replace = false; - return changeCounter; + // we don't need to return anything because $evalAsync will make the digest loop dirty when + // there is a change }); return $location; - function afterLocationChange(oldUrl) { - $rootScope.$broadcast('$locationChangeSuccess', $location.absUrl(), oldUrl); + function afterLocationChange(oldUrl, oldState) { + $rootScope.$broadcast('$locationChangeSuccess', $location.absUrl(), oldUrl, + $location.$$state, oldState); } }]; } @@ -9638,26 +14644,36 @@ * * The main purpose of this service is to simplify debugging and troubleshooting. * + * To reveal the location of the calls to `$log` in the JavaScript console, + * you can "blackbox" the AngularJS source in your browser: + * + * [Mozilla description of blackboxing](https://developer.mozilla.org/en-US/docs/Tools/Debugger/How_to/Black_box_a_source). + * [Chrome description of blackboxing](https://developer.chrome.com/devtools/docs/blackboxing). + * + * Note: Not all browsers support blackboxing. + * * The default is to log `debug` messages. You can use * {@link ng.$logProvider ng.$logProvider#debugEnabled} to change this. * * @example - <example> + <example module="logExample" name="log-service"> <file name="script.js"> - function LogCtrl($scope, $log) { - $scope.$log = $log; - $scope.message = 'Hello World!'; - } + angular.module('logExample', []) + .controller('LogController', ['$scope', '$log', function($scope, $log) { + $scope.$log = $log; + $scope.message = 'Hello World!'; + }]); </file> <file name="index.html"> - <div ng-controller="LogCtrl"> + <div ng-controller="LogController"> <p>Reload this page with open console, enter text and hit the log button...</p> - Message: - <input type="text" ng-model="message"/> + <label>Message: + <input type="text" ng-model="message" /></label> <button ng-click="$log.log(message)">log</button> <button ng-click="$log.warn(message)">warn</button> <button ng-click="$log.info(message)">info</button> <button ng-click="$log.error(message)">error</button> + <button ng-click="$log.debug(message)">debug</button> </div> </file> </example> @@ -9666,15 +14682,17 @@ /** * @ngdoc provider * @name $logProvider + * @this + * * @description * Use the `$logProvider` to configure how the application logs messages */ - function $LogProvider(){ + function $LogProvider() { var debug = true, self = this; /** - * @ngdoc property + * @ngdoc method * @name $logProvider#debugEnabled * @description * @param {boolean=} flag enable or disable debug level messages @@ -9689,7 +14707,16 @@ } }; - this.$get = ['$window', function($window){ + this.$get = ['$window', function($window) { + // Support: IE 9-11, Edge 12-14+ + // IE/Edge display errors in such a way that it requires the user to click in 4 places + // to see the stack trace. There is no way to feature-detect it so there's a chance + // of the user agent sniffing to go wrong but since it's only about logging, this shouldn't + // break apps. Other browsers display errors in a sensible way and some of them map stack + // traces along source maps if available so it makes sense to let browsers display it + // as they want. + var formatStackTrace = msie || /\bEdge\//.test($window.navigator && $window.navigator.userAgent); + return { /** * @ngdoc method @@ -9734,7 +14761,7 @@ * @description * Write a debug message */ - debug: (function () { + debug: (function() { var fn = consoleLog('debug'); return function() { @@ -9742,12 +14769,12 @@ fn.apply(self, arguments); } }; - }()) + })() }; function formatError(arg) { - if (arg instanceof Error) { - if (arg.stack) { + if (isError(arg)) { + if (arg.stack && formatStackTrace) { arg = (arg.message && arg.stack.indexOf(arg.message) === -1) ? 'Error: ' + arg.message + '\n' + arg.stack : arg.stack; @@ -9760,139 +14787,74 @@ function consoleLog(type) { var console = $window.console || {}, - logFn = console[type] || console.log || noop, - hasApply = false; - - // Note: reading logFn.apply throws an error in IE11 in IE8 document mode. - // The reason behind this is that console.log has type "object" in IE8... - try { - hasApply = !!logFn.apply; - } catch (e) {} + logFn = console[type] || console.log || noop; - if (hasApply) { - return function() { - var args = []; - forEach(arguments, function(arg) { - args.push(formatError(arg)); - }); - return logFn.apply(console, args); - }; - } - - // we are IE which either doesn't have window.console => this is noop and we do nothing, - // or we are IE where console.log doesn't have apply so we log at least first 2 args - return function(arg1, arg2) { - logFn(arg1, arg2 == null ? '' : arg2); + return function() { + var args = []; + forEach(arguments, function(arg) { + args.push(formatError(arg)); + }); + // Support: IE 9 only + // console methods don't inherit from Function.prototype in IE 9 so we can't + // call `logFn.apply(console, args)` directly. + return Function.prototype.apply.call(logFn, console, args); }; } }]; } + /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * Any commits to this file should be reviewed with security in mind. * + * Changes to this file can potentially create security vulnerabilities. * + * An approval from 2 Core members with history of modifying * + * this file is required. * + * * + * Does the change somehow allow for arbitrary javascript to be executed? * + * Or allows for someone to change the prototype of built-in objects? * + * Or gives undesired access to variables likes document or window? * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + var $parseMinErr = minErr('$parse'); - var promiseWarningCache = {}; - var promiseWarning; -// Sandboxing Angular Expressions + var objectValueOf = {}.constructor.prototype.valueOf; + +// Sandboxing AngularJS Expressions // ------------------------------ -// Angular expressions are generally considered safe because these expressions only have direct -// access to $scope and locals. However, one can obtain the ability to execute arbitrary JS code by -// obtaining a reference to native JS functions such as the Function constructor. -// -// As an example, consider the following Angular expression: -// -// {}.toString.constructor(alert("evil JS code")) -// -// We want to prevent this type of access. For the sake of performance, during the lexing phase we -// disallow any "dotted" access to any member named "constructor". +// AngularJS expressions are no longer sandboxed. So it is now even easier to access arbitrary JS code by +// various means such as obtaining a reference to native JS functions like the Function constructor. // -// For reflective calls (a[b]) we check that the value of the lookup is not the Function constructor -// while evaluating the expression, which is a stronger but more expensive test. Since reflective -// calls are expensive anyway, this is not such a big deal compared to static dereferencing. +// As an example, consider the following AngularJS expression: // -// This sandboxing technique is not perfect and doesn't aim to be. The goal is to prevent exploits -// against the expression language, but not to prevent exploits that were enabled by exposing -// sensitive JavaScript or browser apis on Scope. Exposing such objects on a Scope is never a good -// practice and therefore we are not even trying to protect against interaction with an object -// explicitly exposed in this way. +// {}.toString.constructor('alert("evil JS code")') // -// A developer could foil the name check by aliasing the Function constructor under a different -// name on the scope. +// It is important to realize that if you create an expression from a string that contains user provided +// content then it is possible that your application contains a security vulnerability to an XSS style attack. // -// In general, it is not possible to access a Window object from an angular expression unless a -// window or some DOM object that has a reference to window is published onto a Scope. - - function ensureSafeMemberName(name, fullExpression) { - if (name === "constructor") { - throw $parseMinErr('isecfld', - 'Referencing "constructor" field in Angular expressions is disallowed! Expression: {0}', - fullExpression); - } - return name; +// See https://docs.angularjs.org/guide/security + + + function getStringValue(name) { + // Property names must be strings. This means that non-string objects cannot be used + // as keys in an object. Any non-string object, including a number, is typecasted + // into a string via the toString method. + // -- MDN, https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Property_accessors#Property_names + // + // So, to ensure that we are checking the same `name` that JavaScript would use, we cast it + // to a string. It's not always possible. If `name` is an object and its `toString` method is + // 'broken' (doesn't return a string, isn't a function, etc.), an error will be thrown: + // + // TypeError: Cannot convert object to primitive value + // + // For performance reasons, we don't catch this error here and allow it to propagate up the call + // stack. Note that you'll get the same error in JavaScript if you try to access a property using + // such a 'broken' object as a key. + return name + ''; } - function ensureSafeObject(obj, fullExpression) { - // nifty check if obj is Function that is fast and works across iframes and other contexts - if (obj) { - if (obj.constructor === obj) { - throw $parseMinErr('isecfn', - 'Referencing Function in Angular expressions is disallowed! Expression: {0}', - fullExpression); - } else if (// isWindow(obj) - obj.document && obj.location && obj.alert && obj.setInterval) { - throw $parseMinErr('isecwindow', - 'Referencing the Window in Angular expressions is disallowed! Expression: {0}', - fullExpression); - } else if (// isElement(obj) - obj.children && (obj.nodeName || (obj.prop && obj.attr && obj.find))) { - throw $parseMinErr('isecdom', - 'Referencing DOM nodes in Angular expressions is disallowed! Expression: {0}', - fullExpression); - } - } - return obj; - } - var OPERATORS = { - /* jshint bitwise : false */ - 'null':function(){return null;}, - 'true':function(){return true;}, - 'false':function(){return false;}, - undefined:noop, - '+':function(self, locals, a,b){ - a=a(self, locals); b=b(self, locals); - if (isDefined(a)) { - if (isDefined(b)) { - return a + b; - } - return a; - } - return isDefined(b)?b:undefined;}, - '-':function(self, locals, a,b){ - a=a(self, locals); b=b(self, locals); - return (isDefined(a)?a:0)-(isDefined(b)?b:0); - }, - '*':function(self, locals, a,b){return a(self, locals)*b(self, locals);}, - '/':function(self, locals, a,b){return a(self, locals)/b(self, locals);}, - '%':function(self, locals, a,b){return a(self, locals)%b(self, locals);}, - '^':function(self, locals, a,b){return a(self, locals)^b(self, locals);}, - '=':noop, - '===':function(self, locals, a, b){return a(self, locals)===b(self, locals);}, - '!==':function(self, locals, a, b){return a(self, locals)!==b(self, locals);}, - '==':function(self, locals, a,b){return a(self, locals)==b(self, locals);}, - '!=':function(self, locals, a,b){return a(self, locals)!=b(self, locals);}, - '<':function(self, locals, a,b){return a(self, locals)<b(self, locals);}, - '>':function(self, locals, a,b){return a(self, locals)>b(self, locals);}, - '<=':function(self, locals, a,b){return a(self, locals)<=b(self, locals);}, - '>=':function(self, locals, a,b){return a(self, locals)>=b(self, locals);}, - '&&':function(self, locals, a,b){return a(self, locals)&&b(self, locals);}, - '||':function(self, locals, a,b){return a(self, locals)||b(self, locals);}, - '&':function(self, locals, a,b){return a(self, locals)&b(self, locals);}, -// '|':function(self, locals, a,b){return a|b;}, - '|':function(self, locals, a,b){return b(self, locals)(self, locals, a(self, locals));}, - '!':function(self, locals, a){return !a(self, locals);} - }; - /* jshint bitwise: true */ - var ESCAPE = {"n":"\n", "f":"\f", "r":"\r", "t":"\t", "v":"\v", "'":"'", '"':'"'}; + var OPERATORS = createMap(); + forEach('+ - * / % === !== == != < > <= >= && || ! = |'.split(' '), function(operator) { OPERATORS[operator] = true; }); + var ESCAPE = {'n':'\n', 'f':'\f', 'r':'\r', 't':'\t', 'v':'\v', '\'':'\'', '"':'"'}; ///////////////////////////////////////// @@ -9901,85 +14863,51 @@ /** * @constructor */ - var Lexer = function (options) { + var Lexer = function Lexer(options) { this.options = options; }; Lexer.prototype = { constructor: Lexer, - lex: function (text) { + lex: function(text) { this.text = text; - this.index = 0; - this.ch = undefined; - this.lastCh = ':'; // can start regexp - this.tokens = []; - var token; - var json = []; - while (this.index < this.text.length) { - this.ch = this.text.charAt(this.index); - if (this.is('"\'')) { - this.readString(this.ch); - } else if (this.isNumber(this.ch) || this.is('.') && this.isNumber(this.peek())) { + var ch = this.text.charAt(this.index); + if (ch === '"' || ch === '\'') { + this.readString(ch); + } else if (this.isNumber(ch) || ch === '.' && this.isNumber(this.peek())) { this.readNumber(); - } else if (this.isIdent(this.ch)) { + } else if (this.isIdentifierStart(this.peekMultichar())) { this.readIdent(); - // identifiers can only be if the preceding char was a { or , - if (this.was('{,') && json[0] === '{' && - (token = this.tokens[this.tokens.length - 1])) { - token.json = token.text.indexOf('.') === -1; - } - } else if (this.is('(){}[].,;:?')) { - this.tokens.push({ - index: this.index, - text: this.ch, - json: (this.was(':[,') && this.is('{[')) || this.is('}]:,') - }); - if (this.is('{[')) json.unshift(this.ch); - if (this.is('}]')) json.shift(); + } else if (this.is(ch, '(){}[].,;:?')) { + this.tokens.push({index: this.index, text: ch}); this.index++; - } else if (this.isWhitespace(this.ch)) { + } else if (this.isWhitespace(ch)) { this.index++; - continue; } else { - var ch2 = this.ch + this.peek(); + var ch2 = ch + this.peek(); var ch3 = ch2 + this.peek(2); - var fn = OPERATORS[this.ch]; - var fn2 = OPERATORS[ch2]; - var fn3 = OPERATORS[ch3]; - if (fn3) { - this.tokens.push({index: this.index, text: ch3, fn: fn3}); - this.index += 3; - } else if (fn2) { - this.tokens.push({index: this.index, text: ch2, fn: fn2}); - this.index += 2; - } else if (fn) { - this.tokens.push({ - index: this.index, - text: this.ch, - fn: fn, - json: (this.was('[,:') && this.is('+-')) - }); - this.index += 1; + var op1 = OPERATORS[ch]; + var op2 = OPERATORS[ch2]; + var op3 = OPERATORS[ch3]; + if (op1 || op2 || op3) { + var token = op3 ? ch3 : (op2 ? ch2 : ch); + this.tokens.push({index: this.index, text: token, operator: true}); + this.index += token.length; } else { this.throwError('Unexpected next character ', this.index, this.index + 1); } } - this.lastCh = this.ch; } return this.tokens; }, - is: function(chars) { - return chars.indexOf(this.ch) !== -1; - }, - - was: function(chars) { - return chars.indexOf(this.lastCh) !== -1; + is: function(ch, chars) { + return chars.indexOf(ch) !== -1; }, peek: function(i) { @@ -9988,19 +14916,55 @@ }, isNumber: function(ch) { - return ('0' <= ch && ch <= '9'); + return ('0' <= ch && ch <= '9') && typeof ch === 'string'; }, isWhitespace: function(ch) { // IE treats non-breaking space as \u00A0 return (ch === ' ' || ch === '\r' || ch === '\t' || - ch === '\n' || ch === '\v' || ch === '\u00A0'); + ch === '\n' || ch === '\v' || ch === '\u00A0'); + }, + + isIdentifierStart: function(ch) { + return this.options.isIdentifierStart ? + this.options.isIdentifierStart(ch, this.codePointAt(ch)) : + this.isValidIdentifierStart(ch); }, - isIdent: function(ch) { + isValidIdentifierStart: function(ch) { return ('a' <= ch && ch <= 'z' || - 'A' <= ch && ch <= 'Z' || - '_' === ch || ch === '$'); + 'A' <= ch && ch <= 'Z' || + '_' === ch || ch === '$'); + }, + + isIdentifierContinue: function(ch) { + return this.options.isIdentifierContinue ? + this.options.isIdentifierContinue(ch, this.codePointAt(ch)) : + this.isValidIdentifierContinue(ch); + }, + + isValidIdentifierContinue: function(ch, cp) { + return this.isValidIdentifierStart(ch, cp) || this.isNumber(ch); + }, + + codePointAt: function(ch) { + if (ch.length === 1) return ch.charCodeAt(0); + // eslint-disable-next-line no-bitwise + return (ch.charCodeAt(0) << 10) + ch.charCodeAt(1) - 0x35FDC00; + }, + + peekMultichar: function() { + var ch = this.text.charAt(this.index); + var peek = this.peek(); + if (!peek) { + return ch; + } + var cp1 = ch.charCodeAt(0); + var cp2 = peek.charCodeAt(0); + if (cp1 >= 0xD800 && cp1 <= 0xDBFF && cp2 >= 0xDC00 && cp2 <= 0xDFFF) { + return ch + peek; + } + return ch; }, isExpOperator: function(ch) { @@ -10021,19 +14985,19 @@ var start = this.index; while (this.index < this.text.length) { var ch = lowercase(this.text.charAt(this.index)); - if (ch == '.' || this.isNumber(ch)) { + if (ch === '.' || this.isNumber(ch)) { number += ch; } else { var peekCh = this.peek(); - if (ch == 'e' && this.isExpOperator(peekCh)) { + if (ch === 'e' && this.isExpOperator(peekCh)) { number += ch; } else if (this.isExpOperator(ch) && peekCh && this.isNumber(peekCh) && - number.charAt(number.length - 1) == 'e') { + number.charAt(number.length - 1) === 'e') { number += ch; } else if (this.isExpOperator(ch) && (!peekCh || !this.isNumber(peekCh)) && - number.charAt(number.length - 1) == 'e') { + number.charAt(number.length - 1) === 'e') { this.throwError('Invalid exponent'); } else { break; @@ -10041,89 +15005,30 @@ } this.index++; } - number = 1 * number; this.tokens.push({ index: start, text: number, - json: true, - fn: function() { return number; } + constant: true, + value: Number(number) }); }, readIdent: function() { - var parser = this; - - var ident = ''; var start = this.index; - - var lastDot, peekIndex, methodName, ch; - + this.index += this.peekMultichar().length; while (this.index < this.text.length) { - ch = this.text.charAt(this.index); - if (ch === '.' || this.isIdent(ch) || this.isNumber(ch)) { - if (ch === '.') lastDot = this.index; - ident += ch; - } else { + var ch = this.peekMultichar(); + if (!this.isIdentifierContinue(ch)) { break; } - this.index++; - } - - //check if this is not a method invocation and if it is back out to last dot - if (lastDot) { - peekIndex = this.index; - while (peekIndex < this.text.length) { - ch = this.text.charAt(peekIndex); - if (ch === '(') { - methodName = ident.substr(lastDot - start + 1); - ident = ident.substr(0, lastDot - start); - this.index = peekIndex; - break; - } - if (this.isWhitespace(ch)) { - peekIndex++; - } else { - break; - } - } + this.index += ch.length; } - - - var token = { + this.tokens.push({ index: start, - text: ident - }; - - // OPERATORS is our own object so we don't need to use special hasOwnPropertyFn - if (OPERATORS.hasOwnProperty(ident)) { - token.fn = OPERATORS[ident]; - token.json = OPERATORS[ident]; - } else { - var getter = getterFn(ident, this.options, this.text); - token.fn = extend(function(self, locals) { - return (getter(self, locals)); - }, { - assign: function(self, value) { - return setter(self, ident, value, parser.text, parser.options); - } - }); - } - - this.tokens.push(token); - - if (methodName) { - this.tokens.push({ - index:lastDot, - text: '.', - json: false - }); - this.tokens.push({ - index: lastDot + 1, - text: methodName, - json: false - }); - } - }, + text: this.text.slice(start, this.index), + identifier: true + }); + }, readString: function(quote) { var start = this.index; @@ -10137,17 +15042,14 @@ if (escape) { if (ch === 'u') { var hex = this.text.substring(this.index + 1, this.index + 5); - if (!hex.match(/[\da-f]{4}/i)) + if (!hex.match(/[\da-f]{4}/i)) { this.throwError('Invalid unicode escape [\\u' + hex + ']'); + } this.index += 4; string += String.fromCharCode(parseInt(hex, 16)); } else { var rep = ESCAPE[ch]; - if (rep) { - string += rep; - } else { - string += ch; - } + string = string + (rep || ch); } escape = false; } else if (ch === '\\') { @@ -10157,9 +15059,8 @@ this.tokens.push({ index: start, text: rawString, - string: string, - json: true, - fn: function() { return string; } + constant: true, + value: string }); return; } else { @@ -10171,219 +15072,66 @@ } }; - - /** - * @constructor - */ - var Parser = function (lexer, $filter, options) { + var AST = function AST(lexer, options) { this.lexer = lexer; - this.$filter = $filter; this.options = options; }; - Parser.ZERO = extend(function () { - return 0; - }, { - constant: true - }); - - Parser.prototype = { - constructor: Parser, - - parse: function (text, json) { + AST.Program = 'Program'; + AST.ExpressionStatement = 'ExpressionStatement'; + AST.AssignmentExpression = 'AssignmentExpression'; + AST.ConditionalExpression = 'ConditionalExpression'; + AST.LogicalExpression = 'LogicalExpression'; + AST.BinaryExpression = 'BinaryExpression'; + AST.UnaryExpression = 'UnaryExpression'; + AST.CallExpression = 'CallExpression'; + AST.MemberExpression = 'MemberExpression'; + AST.Identifier = 'Identifier'; + AST.Literal = 'Literal'; + AST.ArrayExpression = 'ArrayExpression'; + AST.Property = 'Property'; + AST.ObjectExpression = 'ObjectExpression'; + AST.ThisExpression = 'ThisExpression'; + AST.LocalsExpression = 'LocalsExpression'; + +// Internal use only + AST.NGValueParameter = 'NGValueParameter'; + + AST.prototype = { + ast: function(text) { this.text = text; - - //TODO(i): strip all the obsolte json stuff from this file - this.json = json; - this.tokens = this.lexer.lex(text); - if (json) { - // The extra level of aliasing is here, just in case the lexer misses something, so that - // we prevent any accidental execution in JSON. - this.assignment = this.logicalOR; - - this.functionCall = - this.fieldAccess = - this.objectIndex = - this.filterChain = function() { - this.throwError('is not valid json', {text: text, index: 0}); - }; - } - - var value = json ? this.primary() : this.statements(); + var value = this.program(); if (this.tokens.length !== 0) { this.throwError('is an unexpected token', this.tokens[0]); } - value.literal = !!value.literal; - value.constant = !!value.constant; - return value; }, - primary: function () { - var primary; - if (this.expect('(')) { - primary = this.filterChain(); - this.consume(')'); - } else if (this.expect('[')) { - primary = this.arrayDeclaration(); - } else if (this.expect('{')) { - primary = this.object(); - } else { - var token = this.expect(); - primary = token.fn; - if (!primary) { - this.throwError('not a primary expression', token); - } - if (token.json) { - primary.constant = true; - primary.literal = true; - } - } - - var next, context; - while ((next = this.expect('(', '[', '.'))) { - if (next.text === '(') { - primary = this.functionCall(primary, context); - context = null; - } else if (next.text === '[') { - context = primary; - primary = this.objectIndex(primary); - } else if (next.text === '.') { - context = primary; - primary = this.fieldAccess(primary); - } else { - this.throwError('IMPOSSIBLE'); - } - } - return primary; - }, - - throwError: function(msg, token) { - throw $parseMinErr('syntax', - 'Syntax Error: Token \'{0}\' {1} at column {2} of the expression [{3}] starting at [{4}].', - token.text, msg, (token.index + 1), this.text, this.text.substring(token.index)); - }, - - peekToken: function() { - if (this.tokens.length === 0) - throw $parseMinErr('ueoe', 'Unexpected end of expression: {0}', this.text); - return this.tokens[0]; - }, - - peek: function(e1, e2, e3, e4) { - if (this.tokens.length > 0) { - var token = this.tokens[0]; - var t = token.text; - if (t === e1 || t === e2 || t === e3 || t === e4 || - (!e1 && !e2 && !e3 && !e4)) { - return token; - } - } - return false; - }, - - expect: function(e1, e2, e3, e4){ - var token = this.peek(e1, e2, e3, e4); - if (token) { - if (this.json && !token.json) { - this.throwError('is not valid json', token); - } - this.tokens.shift(); - return token; - } - return false; - }, - - consume: function(e1){ - if (!this.expect(e1)) { - this.throwError('is unexpected, expecting [' + e1 + ']', this.peek()); - } - }, - - unaryFn: function(fn, right) { - return extend(function(self, locals) { - return fn(self, locals, right); - }, { - constant:right.constant - }); - }, - - ternaryFn: function(left, middle, right){ - return extend(function(self, locals){ - return left(self, locals) ? middle(self, locals) : right(self, locals); - }, { - constant: left.constant && middle.constant && right.constant - }); - }, - - binaryFn: function(left, fn, right) { - return extend(function(self, locals) { - return fn(self, locals, left, right); - }, { - constant:left.constant && right.constant - }); - }, - - statements: function() { - var statements = []; + program: function() { + var body = []; while (true) { if (this.tokens.length > 0 && !this.peek('}', ')', ';', ']')) - statements.push(this.filterChain()); + body.push(this.expressionStatement()); if (!this.expect(';')) { - // optimize for the common case where there is only one statement. - // TODO(size): maybe we should not support multiple statements? - return (statements.length === 1) - ? statements[0] - : function(self, locals) { - var value; - for (var i = 0; i < statements.length; i++) { - var statement = statements[i]; - if (statement) { - value = statement(self, locals); - } - } - return value; - }; + return { type: AST.Program, body: body}; } } }, - filterChain: function() { - var left = this.expression(); - var token; - while (true) { - if ((token = this.expect('|'))) { - left = this.binaryFn(left, token.fn, this.filter()); - } else { - return left; - } - } + expressionStatement: function() { + return { type: AST.ExpressionStatement, expression: this.filterChain() }; }, - filter: function() { - var token = this.expect(); - var fn = this.$filter(token.text); - var argsFn = []; - while (true) { - if ((token = this.expect(':'))) { - argsFn.push(this.expression()); - } else { - var fnInvoke = function(self, locals, input) { - var args = [input]; - for (var i = 0; i < argsFn.length; i++) { - args.push(argsFn[i](self, locals)); - } - return fn.apply(self, args); - }; - return function() { - return fnInvoke; - }; - } + filterChain: function() { + var left = this.expression(); + while (this.expect('|')) { + left = this.filter(left); } + return left; }, expression: function() { @@ -10391,55 +15139,43 @@ }, assignment: function() { - var left = this.ternary(); - var right; - var token; - if ((token = this.expect('='))) { - if (!left.assign) { - this.throwError('implies assignment but [' + - this.text.substring(0, token.index) + '] can not be assigned to', token); - } - right = this.ternary(); - return function(scope, locals) { - return left.assign(scope, right(scope, locals), locals); - }; + var result = this.ternary(); + if (this.expect('=')) { + if (!isAssignable(result)) { + throw $parseMinErr('lval', 'Trying to assign a value to a non l-value'); + } + + result = { type: AST.AssignmentExpression, left: result, right: this.assignment(), operator: '='}; } - return left; + return result; }, ternary: function() { - var left = this.logicalOR(); - var middle; - var token; - if ((token = this.expect('?'))) { - middle = this.ternary(); - if ((token = this.expect(':'))) { - return this.ternaryFn(left, middle, this.ternary()); - } else { - this.throwError('expected :', token); + var test = this.logicalOR(); + var alternate; + var consequent; + if (this.expect('?')) { + alternate = this.expression(); + if (this.consume(':')) { + consequent = this.expression(); + return { type: AST.ConditionalExpression, test: test, alternate: alternate, consequent: consequent}; } - } else { - return left; } + return test; }, logicalOR: function() { var left = this.logicalAND(); - var token; - while (true) { - if ((token = this.expect('||'))) { - left = this.binaryFn(left, token.fn, this.logicalAND()); - } else { - return left; - } + while (this.expect('||')) { + left = { type: AST.LogicalExpression, operator: '||', left: left, right: this.logicalAND() }; } + return left; }, logicalAND: function() { var left = this.equality(); - var token; - if ((token = this.expect('&&'))) { - left = this.binaryFn(left, token.fn, this.logicalAND()); + while (this.expect('&&')) { + left = { type: AST.LogicalExpression, operator: '&&', left: left, right: this.equality()}; } return left; }, @@ -10447,8 +15183,8 @@ equality: function() { var left = this.relational(); var token; - if ((token = this.expect('==','!=','===','!=='))) { - left = this.binaryFn(left, token.fn, this.equality()); + while ((token = this.expect('==','!=','===','!=='))) { + left = { type: AST.BinaryExpression, operator: token.text, left: left, right: this.relational() }; } return left; }, @@ -10456,8 +15192,8 @@ relational: function() { var left = this.additive(); var token; - if ((token = this.expect('<', '>', '<=', '>='))) { - left = this.binaryFn(left, token.fn, this.relational()); + while ((token = this.expect('<', '>', '<=', '>='))) { + left = { type: AST.BinaryExpression, operator: token.text, left: left, right: this.additive() }; } return left; }, @@ -10466,7 +15202,7 @@ var left = this.multiplicative(); var token; while ((token = this.expect('+','-'))) { - left = this.binaryFn(left, token.fn, this.multiplicative()); + left = { type: AST.BinaryExpression, operator: token.text, left: left, right: this.multiplicative() }; } return left; }, @@ -10475,9029 +15211,15853 @@ var left = this.unary(); var token; while ((token = this.expect('*','/','%'))) { - left = this.binaryFn(left, token.fn, this.unary()); + left = { type: AST.BinaryExpression, operator: token.text, left: left, right: this.unary() }; } return left; }, unary: function() { var token; - if (this.expect('+')) { - return this.primary(); - } else if ((token = this.expect('-'))) { - return this.binaryFn(Parser.ZERO, token.fn, this.unary()); - } else if ((token = this.expect('!'))) { - return this.unaryFn(token.fn, this.unary()); + if ((token = this.expect('+', '-', '!'))) { + return { type: AST.UnaryExpression, operator: token.text, prefix: true, argument: this.unary() }; } else { return this.primary(); } }, - fieldAccess: function(object) { - var parser = this; - var field = this.expect().text; - var getter = getterFn(field, this.options, this.text); + primary: function() { + var primary; + if (this.expect('(')) { + primary = this.filterChain(); + this.consume(')'); + } else if (this.expect('[')) { + primary = this.arrayDeclaration(); + } else if (this.expect('{')) { + primary = this.object(); + } else if (this.selfReferential.hasOwnProperty(this.peek().text)) { + primary = copy(this.selfReferential[this.consume().text]); + } else if (this.options.literals.hasOwnProperty(this.peek().text)) { + primary = { type: AST.Literal, value: this.options.literals[this.consume().text]}; + } else if (this.peek().identifier) { + primary = this.identifier(); + } else if (this.peek().constant) { + primary = this.constant(); + } else { + this.throwError('not a primary expression', this.peek()); + } - return extend(function(scope, locals, self) { - return getter(self || object(scope, locals)); - }, { - assign: function(scope, value, locals) { - return setter(object(scope, locals), field, value, parser.text, parser.options); + var next; + while ((next = this.expect('(', '[', '.'))) { + if (next.text === '(') { + primary = {type: AST.CallExpression, callee: primary, arguments: this.parseArguments() }; + this.consume(')'); + } else if (next.text === '[') { + primary = { type: AST.MemberExpression, object: primary, property: this.expression(), computed: true }; + this.consume(']'); + } else if (next.text === '.') { + primary = { type: AST.MemberExpression, object: primary, property: this.identifier(), computed: false }; + } else { + this.throwError('IMPOSSIBLE'); } - }); + } + return primary; }, - objectIndex: function(obj) { - var parser = this; + filter: function(baseExpression) { + var args = [baseExpression]; + var result = {type: AST.CallExpression, callee: this.identifier(), arguments: args, filter: true}; - var indexFn = this.expression(); - this.consume(']'); + while (this.expect(':')) { + args.push(this.expression()); + } - return extend(function(self, locals) { - var o = obj(self, locals), - i = indexFn(self, locals), - v, p; - - if (!o) return undefined; - v = ensureSafeObject(o[i], parser.text); - if (v && v.then && parser.options.unwrapPromises) { - p = v; - if (!('$$v' in v)) { - p.$$v = undefined; - p.then(function(val) { p.$$v = val; }); - } - v = v.$$v; - } - return v; - }, { - assign: function(self, value, locals) { - var key = indexFn(self, locals); - // prevent overwriting of Function.constructor which would break ensureSafeObject check - var safe = ensureSafeObject(obj(self, locals), parser.text); - return safe[key] = value; - } - }); + return result; }, - functionCall: function(fn, contextGetter) { - var argsFn = []; + parseArguments: function() { + var args = []; if (this.peekToken().text !== ')') { do { - argsFn.push(this.expression()); + args.push(this.filterChain()); } while (this.expect(',')); } - this.consume(')'); - - var parser = this; - - return function(scope, locals) { - var args = []; - var context = contextGetter ? contextGetter(scope, locals) : scope; - - for (var i = 0; i < argsFn.length; i++) { - args.push(argsFn[i](scope, locals)); - } - var fnPtr = fn(scope, locals, context) || noop; - - ensureSafeObject(context, parser.text); - ensureSafeObject(fnPtr, parser.text); + return args; + }, - // IE stupidity! (IE doesn't have apply for some native functions) - var v = fnPtr.apply - ? fnPtr.apply(context, args) - : fnPtr(args[0], args[1], args[2], args[3], args[4]); + identifier: function() { + var token = this.consume(); + if (!token.identifier) { + this.throwError('is not a valid identifier', token); + } + return { type: AST.Identifier, name: token.text }; + }, - return ensureSafeObject(v, parser.text); - }; + constant: function() { + // TODO check that it is a constant + return { type: AST.Literal, value: this.consume().value }; }, - // This is used with json array declaration - arrayDeclaration: function () { - var elementFns = []; - var allConstant = true; + arrayDeclaration: function() { + var elements = []; if (this.peekToken().text !== ']') { do { if (this.peek(']')) { // Support trailing commas per ES5.1. break; } - var elementFn = this.expression(); - elementFns.push(elementFn); - if (!elementFn.constant) { - allConstant = false; - } + elements.push(this.expression()); } while (this.expect(',')); } this.consume(']'); - return extend(function(self, locals) { - var array = []; - for (var i = 0; i < elementFns.length; i++) { - array.push(elementFns[i](self, locals)); - } - return array; - }, { - literal: true, - constant: allConstant - }); + return { type: AST.ArrayExpression, elements: elements }; }, - object: function () { - var keyValues = []; - var allConstant = true; + object: function() { + var properties = [], property; if (this.peekToken().text !== '}') { do { if (this.peek('}')) { // Support trailing commas per ES5.1. break; } - var token = this.expect(), - key = token.string || token.text; - this.consume(':'); - var value = this.expression(); - keyValues.push({key: key, value: value}); - if (!value.constant) { - allConstant = false; + property = {type: AST.Property, kind: 'init'}; + if (this.peek().constant) { + property.key = this.constant(); + property.computed = false; + this.consume(':'); + property.value = this.expression(); + } else if (this.peek().identifier) { + property.key = this.identifier(); + property.computed = false; + if (this.peek(':')) { + this.consume(':'); + property.value = this.expression(); + } else { + property.value = property.key; + } + } else if (this.peek('[')) { + this.consume('['); + property.key = this.expression(); + this.consume(']'); + property.computed = true; + this.consume(':'); + property.value = this.expression(); + } else { + this.throwError('invalid key', this.peek()); } + properties.push(property); } while (this.expect(',')); } this.consume('}'); - return extend(function(self, locals) { - var object = {}; - for (var i = 0; i < keyValues.length; i++) { - var keyValue = keyValues[i]; - object[keyValue.key] = keyValue.value(self, locals); - } - return object; - }, { - literal: true, - constant: allConstant - }); - } - }; + return {type: AST.ObjectExpression, properties: properties }; + }, + throwError: function(msg, token) { + throw $parseMinErr('syntax', + 'Syntax Error: Token \'{0}\' {1} at column {2} of the expression [{3}] starting at [{4}].', + token.text, msg, (token.index + 1), this.text, this.text.substring(token.index)); + }, -////////////////////////////////////////////////// -// Parser helper functions -////////////////////////////////////////////////// + consume: function(e1) { + if (this.tokens.length === 0) { + throw $parseMinErr('ueoe', 'Unexpected end of expression: {0}', this.text); + } - function setter(obj, path, setValue, fullExp, options) { - //needed? - options = options || {}; + var token = this.expect(e1); + if (!token) { + this.throwError('is unexpected, expecting [' + e1 + ']', this.peek()); + } + return token; + }, - var element = path.split('.'), key; - for (var i = 0; element.length > 1; i++) { - key = ensureSafeMemberName(element.shift(), fullExp); - var propertyObj = obj[key]; - if (!propertyObj) { - propertyObj = {}; - obj[key] = propertyObj; + peekToken: function() { + if (this.tokens.length === 0) { + throw $parseMinErr('ueoe', 'Unexpected end of expression: {0}', this.text); } - obj = propertyObj; - if (obj.then && options.unwrapPromises) { - promiseWarning(fullExp); - if (!("$$v" in obj)) { - (function(promise) { - promise.then(function(val) { promise.$$v = val; }); } - )(obj); - } - if (obj.$$v === undefined) { - obj.$$v = {}; + return this.tokens[0]; + }, + + peek: function(e1, e2, e3, e4) { + return this.peekAhead(0, e1, e2, e3, e4); + }, + + peekAhead: function(i, e1, e2, e3, e4) { + if (this.tokens.length > i) { + var token = this.tokens[i]; + var t = token.text; + if (t === e1 || t === e2 || t === e3 || t === e4 || + (!e1 && !e2 && !e3 && !e4)) { + return token; } - obj = obj.$$v; } + return false; + }, + + expect: function(e1, e2, e3, e4) { + var token = this.peek(e1, e2, e3, e4); + if (token) { + this.tokens.shift(); + return token; + } + return false; + }, + + selfReferential: { + 'this': {type: AST.ThisExpression }, + '$locals': {type: AST.LocalsExpression } } - key = ensureSafeMemberName(element.shift(), fullExp); - obj[key] = setValue; - return setValue; + }; + + function ifDefined(v, d) { + return typeof v !== 'undefined' ? v : d; + } + + function plusFn(l, r) { + if (typeof l === 'undefined') return r; + if (typeof r === 'undefined') return l; + return l + r; } - var getterFnCache = {}; + function isStateless($filter, filterName) { + var fn = $filter(filterName); + return !fn.$stateful; + } - /** - * Implementation of the "Black Hole" variant from: - * - http://jsperf.com/angularjs-parse-getter/4 - * - http://jsperf.com/path-evaluation-simplified/7 - */ - function cspSafeGetterFn(key0, key1, key2, key3, key4, fullExp, options) { - ensureSafeMemberName(key0, fullExp); - ensureSafeMemberName(key1, fullExp); - ensureSafeMemberName(key2, fullExp); - ensureSafeMemberName(key3, fullExp); - ensureSafeMemberName(key4, fullExp); + var PURITY_ABSOLUTE = 1; + var PURITY_RELATIVE = 2; - return !options.unwrapPromises - ? function cspSafeGetter(scope, locals) { - var pathVal = (locals && locals.hasOwnProperty(key0)) ? locals : scope; +// Detect nodes which could depend on non-shallow state of objects + function isPure(node, parentIsPure) { + switch (node.type) { + // Computed members might invoke a stateful toString() + case AST.MemberExpression: + if (node.computed) { + return false; + } + break; - if (pathVal == null) return pathVal; - pathVal = pathVal[key0]; + // Unary always convert to primative + case AST.UnaryExpression: + return PURITY_ABSOLUTE; - if (!key1) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key1]; + // The binary + operator can invoke a stateful toString(). + case AST.BinaryExpression: + return node.operator !== '+' ? PURITY_ABSOLUTE : false; + + // Functions / filters probably read state from within objects + case AST.CallExpression: + return false; + } - if (!key2) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key2]; + return (undefined === parentIsPure) ? PURITY_RELATIVE : parentIsPure; + } - if (!key3) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key3]; + function findConstantAndWatchExpressions(ast, $filter, parentIsPure) { + var allConstants; + var argsToWatch; + var isStatelessFilter; - if (!key4) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key4]; + var astIsPure = ast.isPure = isPure(ast, parentIsPure); - return pathVal; + switch (ast.type) { + case AST.Program: + allConstants = true; + forEach(ast.body, function(expr) { + findConstantAndWatchExpressions(expr.expression, $filter, astIsPure); + allConstants = allConstants && expr.expression.constant; + }); + ast.constant = allConstants; + break; + case AST.Literal: + ast.constant = true; + ast.toWatch = []; + break; + case AST.UnaryExpression: + findConstantAndWatchExpressions(ast.argument, $filter, astIsPure); + ast.constant = ast.argument.constant; + ast.toWatch = ast.argument.toWatch; + break; + case AST.BinaryExpression: + findConstantAndWatchExpressions(ast.left, $filter, astIsPure); + findConstantAndWatchExpressions(ast.right, $filter, astIsPure); + ast.constant = ast.left.constant && ast.right.constant; + ast.toWatch = ast.left.toWatch.concat(ast.right.toWatch); + break; + case AST.LogicalExpression: + findConstantAndWatchExpressions(ast.left, $filter, astIsPure); + findConstantAndWatchExpressions(ast.right, $filter, astIsPure); + ast.constant = ast.left.constant && ast.right.constant; + ast.toWatch = ast.constant ? [] : [ast]; + break; + case AST.ConditionalExpression: + findConstantAndWatchExpressions(ast.test, $filter, astIsPure); + findConstantAndWatchExpressions(ast.alternate, $filter, astIsPure); + findConstantAndWatchExpressions(ast.consequent, $filter, astIsPure); + ast.constant = ast.test.constant && ast.alternate.constant && ast.consequent.constant; + ast.toWatch = ast.constant ? [] : [ast]; + break; + case AST.Identifier: + ast.constant = false; + ast.toWatch = [ast]; + break; + case AST.MemberExpression: + findConstantAndWatchExpressions(ast.object, $filter, astIsPure); + if (ast.computed) { + findConstantAndWatchExpressions(ast.property, $filter, astIsPure); + } + ast.constant = ast.object.constant && (!ast.computed || ast.property.constant); + ast.toWatch = ast.constant ? [] : [ast]; + break; + case AST.CallExpression: + isStatelessFilter = ast.filter ? isStateless($filter, ast.callee.name) : false; + allConstants = isStatelessFilter; + argsToWatch = []; + forEach(ast.arguments, function(expr) { + findConstantAndWatchExpressions(expr, $filter, astIsPure); + allConstants = allConstants && expr.constant; + argsToWatch.push.apply(argsToWatch, expr.toWatch); + }); + ast.constant = allConstants; + ast.toWatch = isStatelessFilter ? argsToWatch : [ast]; + break; + case AST.AssignmentExpression: + findConstantAndWatchExpressions(ast.left, $filter, astIsPure); + findConstantAndWatchExpressions(ast.right, $filter, astIsPure); + ast.constant = ast.left.constant && ast.right.constant; + ast.toWatch = [ast]; + break; + case AST.ArrayExpression: + allConstants = true; + argsToWatch = []; + forEach(ast.elements, function(expr) { + findConstantAndWatchExpressions(expr, $filter, astIsPure); + allConstants = allConstants && expr.constant; + argsToWatch.push.apply(argsToWatch, expr.toWatch); + }); + ast.constant = allConstants; + ast.toWatch = argsToWatch; + break; + case AST.ObjectExpression: + allConstants = true; + argsToWatch = []; + forEach(ast.properties, function(property) { + findConstantAndWatchExpressions(property.value, $filter, astIsPure); + allConstants = allConstants && property.value.constant; + argsToWatch.push.apply(argsToWatch, property.value.toWatch); + if (property.computed) { + //`{[key]: value}` implicitly does `key.toString()` which may be non-pure + findConstantAndWatchExpressions(property.key, $filter, /*parentIsPure=*/false); + allConstants = allConstants && property.key.constant; + argsToWatch.push.apply(argsToWatch, property.key.toWatch); + } + }); + ast.constant = allConstants; + ast.toWatch = argsToWatch; + break; + case AST.ThisExpression: + ast.constant = false; + ast.toWatch = []; + break; + case AST.LocalsExpression: + ast.constant = false; + ast.toWatch = []; + break; } - : function cspSafePromiseEnabledGetter(scope, locals) { - var pathVal = (locals && locals.hasOwnProperty(key0)) ? locals : scope, - promise; - - if (pathVal == null) return pathVal; - - pathVal = pathVal[key0]; - if (pathVal && pathVal.then) { - promiseWarning(fullExp); - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - - if (!key1) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key1]; - if (pathVal && pathVal.then) { - promiseWarning(fullExp); - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - - if (!key2) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key2]; - if (pathVal && pathVal.then) { - promiseWarning(fullExp); - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - - if (!key3) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key3]; - if (pathVal && pathVal.then) { - promiseWarning(fullExp); - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - - if (!key4) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key4]; - if (pathVal && pathVal.then) { - promiseWarning(fullExp); - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - return pathVal; - }; } - function simpleGetterFn1(key0, fullExp) { - ensureSafeMemberName(key0, fullExp); + function getInputs(body) { + if (body.length !== 1) return; + var lastExpression = body[0].expression; + var candidate = lastExpression.toWatch; + if (candidate.length !== 1) return candidate; + return candidate[0] !== lastExpression ? candidate : undefined; + } - return function simpleGetterFn1(scope, locals) { - if (scope == null) return undefined; - return ((locals && locals.hasOwnProperty(key0)) ? locals : scope)[key0]; - }; + function isAssignable(ast) { + return ast.type === AST.Identifier || ast.type === AST.MemberExpression; } - function simpleGetterFn2(key0, key1, fullExp) { - ensureSafeMemberName(key0, fullExp); - ensureSafeMemberName(key1, fullExp); + function assignableAST(ast) { + if (ast.body.length === 1 && isAssignable(ast.body[0].expression)) { + return {type: AST.AssignmentExpression, left: ast.body[0].expression, right: {type: AST.NGValueParameter}, operator: '='}; + } + } - return function simpleGetterFn2(scope, locals) { - if (scope == null) return undefined; - scope = ((locals && locals.hasOwnProperty(key0)) ? locals : scope)[key0]; - return scope == null ? undefined : scope[key1]; - }; + function isLiteral(ast) { + return ast.body.length === 0 || + ast.body.length === 1 && ( + ast.body[0].expression.type === AST.Literal || + ast.body[0].expression.type === AST.ArrayExpression || + ast.body[0].expression.type === AST.ObjectExpression); } - function getterFn(path, options, fullExp) { - // Check whether the cache has this getter already. - // We can use hasOwnProperty directly on the cache because we ensure, - // see below, that the cache never stores a path called 'hasOwnProperty' - if (getterFnCache.hasOwnProperty(path)) { - return getterFnCache[path]; - } + function isConstant(ast) { + return ast.constant; + } - var pathKeys = path.split('.'), - pathKeysLength = pathKeys.length, - fn; - - // When we have only 1 or 2 tokens, use optimized special case closures. - // http://jsperf.com/angularjs-parse-getter/6 - if (!options.unwrapPromises && pathKeysLength === 1) { - fn = simpleGetterFn1(pathKeys[0], fullExp); - } else if (!options.unwrapPromises && pathKeysLength === 2) { - fn = simpleGetterFn2(pathKeys[0], pathKeys[1], fullExp); - } else if (options.csp) { - if (pathKeysLength < 6) { - fn = cspSafeGetterFn(pathKeys[0], pathKeys[1], pathKeys[2], pathKeys[3], pathKeys[4], fullExp, - options); - } else { - fn = function(scope, locals) { - var i = 0, val; - do { - val = cspSafeGetterFn(pathKeys[i++], pathKeys[i++], pathKeys[i++], pathKeys[i++], - pathKeys[i++], fullExp, options)(scope, locals); + function ASTCompiler($filter) { + this.$filter = $filter; + } - locals = undefined; // clear after first iteration - scope = val; - } while (i < pathKeysLength); - return val; - }; + ASTCompiler.prototype = { + compile: function(ast) { + var self = this; + this.state = { + nextId: 0, + filters: {}, + fn: {vars: [], body: [], own: {}}, + assign: {vars: [], body: [], own: {}}, + inputs: [] + }; + findConstantAndWatchExpressions(ast, self.$filter); + var extra = ''; + var assignable; + this.stage = 'assign'; + if ((assignable = assignableAST(ast))) { + this.state.computing = 'assign'; + var result = this.nextId(); + this.recurse(assignable, result); + this.return_(result); + extra = 'fn.assign=' + this.generateFunction('assign', 's,v,l'); } - } else { - var code = 'var p;\n'; - forEach(pathKeys, function(key, index) { - ensureSafeMemberName(key, fullExp); - code += 'if(s == null) return undefined;\n' + - 's='+ (index - // we simply dereference 's' on any .dot notation - ? 's' - // but if we are first then we check locals first, and if so read it first - : '((k&&k.hasOwnProperty("' + key + '"))?k:s)') + '["' + key + '"]' + ';\n' + - (options.unwrapPromises - ? 'if (s && s.then) {\n' + - ' pw("' + fullExp.replace(/(["\r\n])/g, '\\$1') + '");\n' + - ' if (!("$$v" in s)) {\n' + - ' p=s;\n' + - ' p.$$v = undefined;\n' + - ' p.then(function(v) {p.$$v=v;});\n' + - '}\n' + - ' s=s.$$v\n' + - '}\n' - : ''); + var toWatch = getInputs(ast.body); + self.stage = 'inputs'; + forEach(toWatch, function(watch, key) { + var fnKey = 'fn' + key; + self.state[fnKey] = {vars: [], body: [], own: {}}; + self.state.computing = fnKey; + var intoId = self.nextId(); + self.recurse(watch, intoId); + self.return_(intoId); + self.state.inputs.push({name: fnKey, isPure: watch.isPure}); + watch.watchId = key; }); - code += 'return s;'; - - /* jshint -W054 */ - var evaledFnGetter = new Function('s', 'k', 'pw', code); // s=scope, k=locals, pw=promiseWarning - /* jshint +W054 */ - evaledFnGetter.toString = valueFn(code); - fn = options.unwrapPromises ? function(scope, locals) { - return evaledFnGetter(scope, locals, promiseWarning); - } : evaledFnGetter; - } + this.state.computing = 'fn'; + this.stage = 'main'; + this.recurse(ast); + var fnString = + // The build and minification steps remove the string "use strict" from the code, but this is done using a regex. + // This is a workaround for this until we do a better job at only removing the prefix only when we should. + '"' + this.USE + ' ' + this.STRICT + '";\n' + + this.filterPrefix() + + 'var fn=' + this.generateFunction('fn', 's,l,a,i') + + extra + + this.watchFns() + + 'return fn;'; + + // eslint-disable-next-line no-new-func + var fn = (new Function('$filter', + 'getStringValue', + 'ifDefined', + 'plus', + fnString))( + this.$filter, + getStringValue, + ifDefined, + plusFn); + this.state = this.stage = undefined; + return fn; + }, - // Only cache the value if it's not going to mess up the cache object - // This is more performant that using Object.prototype.hasOwnProperty.call - if (path !== 'hasOwnProperty') { - getterFnCache[path] = fn; - } - return fn; - } + USE: 'use', -/////////////////////////////////// + STRICT: 'strict', - /** - * @ngdoc service - * @name $parse - * @kind function - * - * @description - * - * Converts Angular {@link guide/expression expression} into a function. - * - * ```js - * var getter = $parse('user.name'); - * var setter = getter.assign; - * var context = {user:{name:'angular'}}; - * var locals = {user:{name:'local'}}; - * - * expect(getter(context)).toEqual('angular'); - * setter(context, 'newValue'); - * expect(context.user.name).toEqual('newValue'); - * expect(getter(context, locals)).toEqual('local'); - * ``` - * - * - * @param {string} expression String expression to compile. - * @returns {function(context, locals)} a function which represents the compiled expression: - * - * * `context` – `{object}` – an object against which any expressions embedded in the strings - * are evaluated against (typically a scope object). - * * `locals` – `{object=}` – local variables context object, useful for overriding values in - * `context`. - * - * The returned function also has the following properties: - * * `literal` – `{boolean}` – whether the expression's top-level node is a JavaScript - * literal. - * * `constant` – `{boolean}` – whether the expression is made entirely of JavaScript - * constant literals. - * * `assign` – `{?function(context, value)}` – if the expression is assignable, this will be - * set to a function to change its value on the given context. - * - */ + watchFns: function() { + var result = []; + var inputs = this.state.inputs; + var self = this; + forEach(inputs, function(input) { + result.push('var ' + input.name + '=' + self.generateFunction(input.name, 's')); + if (input.isPure) { + result.push(input.name, '.isPure=' + JSON.stringify(input.isPure) + ';'); + } + }); + if (inputs.length) { + result.push('fn.inputs=[' + inputs.map(function(i) { return i.name; }).join(',') + '];'); + } + return result.join(''); + }, + generateFunction: function(name, params) { + return 'function(' + params + '){' + + this.varsPrefix(name) + + this.body(name) + + '};'; + }, - /** - * @ngdoc provider - * @name $parseProvider - * @function - * - * @description - * `$parseProvider` can be used for configuring the default behavior of the {@link ng.$parse $parse} - * service. - */ - function $ParseProvider() { - var cache = {}; + filterPrefix: function() { + var parts = []; + var self = this; + forEach(this.state.filters, function(id, filter) { + parts.push(id + '=$filter(' + self.escape(filter) + ')'); + }); + if (parts.length) return 'var ' + parts.join(',') + ';'; + return ''; + }, - var $parseOptions = { - csp: false, - unwrapPromises: false, - logPromiseWarnings: true - }; + varsPrefix: function(section) { + return this.state[section].vars.length ? 'var ' + this.state[section].vars.join(',') + ';' : ''; + }, + body: function(section) { + return this.state[section].body.join(''); + }, - /** - * @deprecated Promise unwrapping via $parse is deprecated and will be removed in the future. - * - * @ngdoc method - * @name $parseProvider#unwrapPromises - * @description - * - * **This feature is deprecated, see deprecation notes below for more info** - * - * If set to true (default is false), $parse will unwrap promises automatically when a promise is - * found at any part of the expression. In other words, if set to true, the expression will always - * result in a non-promise value. - * - * While the promise is unresolved, it's treated as undefined, but once resolved and fulfilled, - * the fulfillment value is used in place of the promise while evaluating the expression. - * - * **Deprecation notice** - * - * This is a feature that didn't prove to be wildly useful or popular, primarily because of the - * dichotomy between data access in templates (accessed as raw values) and controller code - * (accessed as promises). - * - * In most code we ended up resolving promises manually in controllers anyway and thus unifying - * the model access there. - * - * Other downsides of automatic promise unwrapping: - * - * - when building components it's often desirable to receive the raw promises - * - adds complexity and slows down expression evaluation - * - makes expression code pre-generation unattractive due to the amount of code that needs to be - * generated - * - makes IDE auto-completion and tool support hard - * - * **Warning Logs** - * - * If the unwrapping is enabled, Angular will log a warning about each expression that unwraps a - * promise (to reduce the noise, each expression is logged only once). To disable this logging use - * `$parseProvider.logPromiseWarnings(false)` api. - * - * - * @param {boolean=} value New value. - * @returns {boolean|self} Returns the current setting when used as getter and self if used as - * setter. - */ - this.unwrapPromises = function(value) { - if (isDefined(value)) { - $parseOptions.unwrapPromises = !!value; - return this; + recurse: function(ast, intoId, nameId, recursionFn, create, skipWatchIdCheck) { + var left, right, self = this, args, expression, computed; + recursionFn = recursionFn || noop; + if (!skipWatchIdCheck && isDefined(ast.watchId)) { + intoId = intoId || this.nextId(); + this.if_('i', + this.lazyAssign(intoId, this.computedMember('i', ast.watchId)), + this.lazyRecurse(ast, intoId, nameId, recursionFn, create, true) + ); + return; + } + switch (ast.type) { + case AST.Program: + forEach(ast.body, function(expression, pos) { + self.recurse(expression.expression, undefined, undefined, function(expr) { right = expr; }); + if (pos !== ast.body.length - 1) { + self.current().body.push(right, ';'); + } else { + self.return_(right); + } + }); + break; + case AST.Literal: + expression = this.escape(ast.value); + this.assign(intoId, expression); + recursionFn(intoId || expression); + break; + case AST.UnaryExpression: + this.recurse(ast.argument, undefined, undefined, function(expr) { right = expr; }); + expression = ast.operator + '(' + this.ifDefined(right, 0) + ')'; + this.assign(intoId, expression); + recursionFn(expression); + break; + case AST.BinaryExpression: + this.recurse(ast.left, undefined, undefined, function(expr) { left = expr; }); + this.recurse(ast.right, undefined, undefined, function(expr) { right = expr; }); + if (ast.operator === '+') { + expression = this.plus(left, right); + } else if (ast.operator === '-') { + expression = this.ifDefined(left, 0) + ast.operator + this.ifDefined(right, 0); + } else { + expression = '(' + left + ')' + ast.operator + '(' + right + ')'; + } + this.assign(intoId, expression); + recursionFn(expression); + break; + case AST.LogicalExpression: + intoId = intoId || this.nextId(); + self.recurse(ast.left, intoId); + self.if_(ast.operator === '&&' ? intoId : self.not(intoId), self.lazyRecurse(ast.right, intoId)); + recursionFn(intoId); + break; + case AST.ConditionalExpression: + intoId = intoId || this.nextId(); + self.recurse(ast.test, intoId); + self.if_(intoId, self.lazyRecurse(ast.alternate, intoId), self.lazyRecurse(ast.consequent, intoId)); + recursionFn(intoId); + break; + case AST.Identifier: + intoId = intoId || this.nextId(); + if (nameId) { + nameId.context = self.stage === 'inputs' ? 's' : this.assign(this.nextId(), this.getHasOwnProperty('l', ast.name) + '?l:s'); + nameId.computed = false; + nameId.name = ast.name; + } + self.if_(self.stage === 'inputs' || self.not(self.getHasOwnProperty('l', ast.name)), + function() { + self.if_(self.stage === 'inputs' || 's', function() { + if (create && create !== 1) { + self.if_( + self.isNull(self.nonComputedMember('s', ast.name)), + self.lazyAssign(self.nonComputedMember('s', ast.name), '{}')); + } + self.assign(intoId, self.nonComputedMember('s', ast.name)); + }); + }, intoId && self.lazyAssign(intoId, self.nonComputedMember('l', ast.name)) + ); + recursionFn(intoId); + break; + case AST.MemberExpression: + left = nameId && (nameId.context = this.nextId()) || this.nextId(); + intoId = intoId || this.nextId(); + self.recurse(ast.object, left, undefined, function() { + self.if_(self.notNull(left), function() { + if (ast.computed) { + right = self.nextId(); + self.recurse(ast.property, right); + self.getStringValue(right); + if (create && create !== 1) { + self.if_(self.not(self.computedMember(left, right)), self.lazyAssign(self.computedMember(left, right), '{}')); + } + expression = self.computedMember(left, right); + self.assign(intoId, expression); + if (nameId) { + nameId.computed = true; + nameId.name = right; + } + } else { + if (create && create !== 1) { + self.if_(self.isNull(self.nonComputedMember(left, ast.property.name)), self.lazyAssign(self.nonComputedMember(left, ast.property.name), '{}')); + } + expression = self.nonComputedMember(left, ast.property.name); + self.assign(intoId, expression); + if (nameId) { + nameId.computed = false; + nameId.name = ast.property.name; + } + } + }, function() { + self.assign(intoId, 'undefined'); + }); + recursionFn(intoId); + }, !!create); + break; + case AST.CallExpression: + intoId = intoId || this.nextId(); + if (ast.filter) { + right = self.filter(ast.callee.name); + args = []; + forEach(ast.arguments, function(expr) { + var argument = self.nextId(); + self.recurse(expr, argument); + args.push(argument); + }); + expression = right + '(' + args.join(',') + ')'; + self.assign(intoId, expression); + recursionFn(intoId); + } else { + right = self.nextId(); + left = {}; + args = []; + self.recurse(ast.callee, right, left, function() { + self.if_(self.notNull(right), function() { + forEach(ast.arguments, function(expr) { + self.recurse(expr, ast.constant ? undefined : self.nextId(), undefined, function(argument) { + args.push(argument); + }); + }); + if (left.name) { + expression = self.member(left.context, left.name, left.computed) + '(' + args.join(',') + ')'; + } else { + expression = right + '(' + args.join(',') + ')'; + } + self.assign(intoId, expression); + }, function() { + self.assign(intoId, 'undefined'); + }); + recursionFn(intoId); + }); + } + break; + case AST.AssignmentExpression: + right = this.nextId(); + left = {}; + this.recurse(ast.left, undefined, left, function() { + self.if_(self.notNull(left.context), function() { + self.recurse(ast.right, right); + expression = self.member(left.context, left.name, left.computed) + ast.operator + right; + self.assign(intoId, expression); + recursionFn(intoId || expression); + }); + }, 1); + break; + case AST.ArrayExpression: + args = []; + forEach(ast.elements, function(expr) { + self.recurse(expr, ast.constant ? undefined : self.nextId(), undefined, function(argument) { + args.push(argument); + }); + }); + expression = '[' + args.join(',') + ']'; + this.assign(intoId, expression); + recursionFn(intoId || expression); + break; + case AST.ObjectExpression: + args = []; + computed = false; + forEach(ast.properties, function(property) { + if (property.computed) { + computed = true; + } + }); + if (computed) { + intoId = intoId || this.nextId(); + this.assign(intoId, '{}'); + forEach(ast.properties, function(property) { + if (property.computed) { + left = self.nextId(); + self.recurse(property.key, left); + } else { + left = property.key.type === AST.Identifier ? + property.key.name : + ('' + property.key.value); + } + right = self.nextId(); + self.recurse(property.value, right); + self.assign(self.member(intoId, left, property.computed), right); + }); + } else { + forEach(ast.properties, function(property) { + self.recurse(property.value, ast.constant ? undefined : self.nextId(), undefined, function(expr) { + args.push(self.escape( + property.key.type === AST.Identifier ? property.key.name : + ('' + property.key.value)) + + ':' + expr); + }); + }); + expression = '{' + args.join(',') + '}'; + this.assign(intoId, expression); + } + recursionFn(intoId || expression); + break; + case AST.ThisExpression: + this.assign(intoId, 's'); + recursionFn(intoId || 's'); + break; + case AST.LocalsExpression: + this.assign(intoId, 'l'); + recursionFn(intoId || 'l'); + break; + case AST.NGValueParameter: + this.assign(intoId, 'v'); + recursionFn(intoId || 'v'); + break; + } + }, + + getHasOwnProperty: function(element, property) { + var key = element + '.' + property; + var own = this.current().own; + if (!own.hasOwnProperty(key)) { + own[key] = this.nextId(false, element + '&&(' + this.escape(property) + ' in ' + element + ')'); + } + return own[key]; + }, + + assign: function(id, value) { + if (!id) return; + this.current().body.push(id, '=', value, ';'); + return id; + }, + + filter: function(filterName) { + if (!this.state.filters.hasOwnProperty(filterName)) { + this.state.filters[filterName] = this.nextId(true); + } + return this.state.filters[filterName]; + }, + + ifDefined: function(id, defaultValue) { + return 'ifDefined(' + id + ',' + this.escape(defaultValue) + ')'; + }, + + plus: function(left, right) { + return 'plus(' + left + ',' + right + ')'; + }, + + return_: function(id) { + this.current().body.push('return ', id, ';'); + }, + + if_: function(test, alternate, consequent) { + if (test === true) { + alternate(); } else { - return $parseOptions.unwrapPromises; + var body = this.current().body; + body.push('if(', test, '){'); + alternate(); + body.push('}'); + if (consequent) { + body.push('else{'); + consequent(); + body.push('}'); + } } - }; + }, + not: function(expression) { + return '!(' + expression + ')'; + }, - /** - * @deprecated Promise unwrapping via $parse is deprecated and will be removed in the future. - * - * @ngdoc method - * @name $parseProvider#logPromiseWarnings - * @description - * - * Controls whether Angular should log a warning on any encounter of a promise in an expression. - * - * The default is set to `true`. - * - * This setting applies only if `$parseProvider.unwrapPromises` setting is set to true as well. - * - * @param {boolean=} value New value. - * @returns {boolean|self} Returns the current setting when used as getter and self if used as - * setter. - */ - this.logPromiseWarnings = function(value) { - if (isDefined(value)) { - $parseOptions.logPromiseWarnings = value; - return this; + isNull: function(expression) { + return expression + '==null'; + }, + + notNull: function(expression) { + return expression + '!=null'; + }, + + nonComputedMember: function(left, right) { + var SAFE_IDENTIFIER = /^[$_a-zA-Z][$_a-zA-Z0-9]*$/; + var UNSAFE_CHARACTERS = /[^$_a-zA-Z0-9]/g; + if (SAFE_IDENTIFIER.test(right)) { + return left + '.' + right; } else { - return $parseOptions.logPromiseWarnings; + return left + '["' + right.replace(UNSAFE_CHARACTERS, this.stringEscapeFn) + '"]'; } - }; + }, + + computedMember: function(left, right) { + return left + '[' + right + ']'; + }, + member: function(left, right, computed) { + if (computed) return this.computedMember(left, right); + return this.nonComputedMember(left, right); + }, - this.$get = ['$filter', '$sniffer', '$log', function($filter, $sniffer, $log) { - $parseOptions.csp = $sniffer.csp; + getStringValue: function(item) { + this.assign(item, 'getStringValue(' + item + ')'); + }, - promiseWarning = function promiseWarningFn(fullExp) { - if (!$parseOptions.logPromiseWarnings || promiseWarningCache.hasOwnProperty(fullExp)) return; - promiseWarningCache[fullExp] = true; - $log.warn('[$parse] Promise found in the expression `' + fullExp + '`. ' + - 'Automatic unwrapping of promises in Angular expressions is deprecated.'); + lazyRecurse: function(ast, intoId, nameId, recursionFn, create, skipWatchIdCheck) { + var self = this; + return function() { + self.recurse(ast, intoId, nameId, recursionFn, create, skipWatchIdCheck); }; + }, - return function(exp) { - var parsedExpression; + lazyAssign: function(id, value) { + var self = this; + return function() { + self.assign(id, value); + }; + }, - switch (typeof exp) { - case 'string': + stringEscapeRegex: /[^ a-zA-Z0-9]/g, - if (cache.hasOwnProperty(exp)) { - return cache[exp]; - } + stringEscapeFn: function(c) { + return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4); + }, - var lexer = new Lexer($parseOptions); - var parser = new Parser(lexer, $filter, $parseOptions); - parsedExpression = parser.parse(exp, false); + escape: function(value) { + if (isString(value)) return '\'' + value.replace(this.stringEscapeRegex, this.stringEscapeFn) + '\''; + if (isNumber(value)) return value.toString(); + if (value === true) return 'true'; + if (value === false) return 'false'; + if (value === null) return 'null'; + if (typeof value === 'undefined') return 'undefined'; - if (exp !== 'hasOwnProperty') { - // Only cache the value if it's not going to mess up the cache object - // This is more performant that using Object.prototype.hasOwnProperty.call - cache[exp] = parsedExpression; - } + throw $parseMinErr('esc', 'IMPOSSIBLE'); + }, - return parsedExpression; + nextId: function(skip, init) { + var id = 'v' + (this.state.nextId++); + if (!skip) { + this.current().vars.push(id + (init ? '=' + init : '')); + } + return id; + }, - case 'function': - return exp; + current: function() { + return this.state[this.state.computing]; + } + }; - default: - return noop; - } - }; - }]; + + function ASTInterpreter($filter) { + this.$filter = $filter; } - /** - * @ngdoc service - * @name $q - * @requires $rootScope - * - * @description - * A promise/deferred implementation inspired by [Kris Kowal's Q](https://github.com/kriskowal/q). - * - * [The CommonJS Promise proposal](http://wiki.commonjs.org/wiki/Promises) describes a promise as an - * interface for interacting with an object that represents the result of an action that is - * performed asynchronously, and may or may not be finished at any given point in time. - * - * From the perspective of dealing with error handling, deferred and promise APIs are to - * asynchronous programming what `try`, `catch` and `throw` keywords are to synchronous programming. - * - * ```js - * // for the purpose of this example let's assume that variables `$q`, `scope` and `okToGreet` - * // are available in the current lexical scope (they could have been injected or passed in). - * - * function asyncGreet(name) { - * var deferred = $q.defer(); - * - * setTimeout(function() { - * // since this fn executes async in a future turn of the event loop, we need to wrap - * // our code into an $apply call so that the model changes are properly observed. - * scope.$apply(function() { - * deferred.notify('About to greet ' + name + '.'); - * - * if (okToGreet(name)) { - * deferred.resolve('Hello, ' + name + '!'); - * } else { - * deferred.reject('Greeting ' + name + ' is not allowed.'); - * } - * }); - * }, 1000); - * - * return deferred.promise; - * } - * - * var promise = asyncGreet('Robin Hood'); - * promise.then(function(greeting) { - * alert('Success: ' + greeting); - * }, function(reason) { - * alert('Failed: ' + reason); - * }, function(update) { - * alert('Got notification: ' + update); - * }); - * ``` - * - * At first it might not be obvious why this extra complexity is worth the trouble. The payoff - * comes in the way of guarantees that promise and deferred APIs make, see - * https://github.com/kriskowal/uncommonjs/blob/master/promises/specification.md. - * - * Additionally the promise api allows for composition that is very hard to do with the - * traditional callback ([CPS](http://en.wikipedia.org/wiki/Continuation-passing_style)) approach. - * For more on this please see the [Q documentation](https://github.com/kriskowal/q) especially the - * section on serial or parallel joining of promises. - * - * - * # The Deferred API - * - * A new instance of deferred is constructed by calling `$q.defer()`. - * - * The purpose of the deferred object is to expose the associated Promise instance as well as APIs - * that can be used for signaling the successful or unsuccessful completion, as well as the status - * of the task. - * - * **Methods** - * - * - `resolve(value)` – resolves the derived promise with the `value`. If the value is a rejection - * constructed via `$q.reject`, the promise will be rejected instead. - * - `reject(reason)` – rejects the derived promise with the `reason`. This is equivalent to - * resolving it with a rejection constructed via `$q.reject`. - * - `notify(value)` - provides updates on the status of the promise's execution. This may be called - * multiple times before the promise is either resolved or rejected. - * - * **Properties** - * - * - promise – `{Promise}` – promise object associated with this deferred. - * - * - * # The Promise API - * - * A new promise instance is created when a deferred instance is created and can be retrieved by - * calling `deferred.promise`. - * - * The purpose of the promise object is to allow for interested parties to get access to the result - * of the deferred task when it completes. - * - * **Methods** - * - * - `then(successCallback, errorCallback, notifyCallback)` – regardless of when the promise was or - * will be resolved or rejected, `then` calls one of the success or error callbacks asynchronously - * as soon as the result is available. The callbacks are called with a single argument: the result - * or rejection reason. Additionally, the notify callback may be called zero or more times to - * provide a progress indication, before the promise is resolved or rejected. - * - * This method *returns a new promise* which is resolved or rejected via the return value of the - * `successCallback`, `errorCallback`. It also notifies via the return value of the - * `notifyCallback` method. The promise can not be resolved or rejected from the notifyCallback - * method. - * - * - `catch(errorCallback)` – shorthand for `promise.then(null, errorCallback)` - * - * - `finally(callback)` – allows you to observe either the fulfillment or rejection of a promise, - * but to do so without modifying the final value. This is useful to release resources or do some - * clean-up that needs to be done whether the promise was rejected or resolved. See the [full - * specification](https://github.com/kriskowal/q/wiki/API-Reference#promisefinallycallback) for - * more information. - * - * Because `finally` is a reserved word in JavaScript and reserved keywords are not supported as - * property names by ES3, you'll need to invoke the method like `promise['finally'](callback)` to - * make your code IE8 and Android 2.x compatible. + ASTInterpreter.prototype = { + compile: function(ast) { + var self = this; + findConstantAndWatchExpressions(ast, self.$filter); + var assignable; + var assign; + if ((assignable = assignableAST(ast))) { + assign = this.recurse(assignable); + } + var toWatch = getInputs(ast.body); + var inputs; + if (toWatch) { + inputs = []; + forEach(toWatch, function(watch, key) { + var input = self.recurse(watch); + input.isPure = watch.isPure; + watch.input = input; + inputs.push(input); + watch.watchId = key; + }); + } + var expressions = []; + forEach(ast.body, function(expression) { + expressions.push(self.recurse(expression.expression)); + }); + var fn = ast.body.length === 0 ? noop : + ast.body.length === 1 ? expressions[0] : + function(scope, locals) { + var lastValue; + forEach(expressions, function(exp) { + lastValue = exp(scope, locals); + }); + return lastValue; + }; + if (assign) { + fn.assign = function(scope, value, locals) { + return assign(scope, locals, value); + }; + } + if (inputs) { + fn.inputs = inputs; + } + return fn; + }, + + recurse: function(ast, context, create) { + var left, right, self = this, args; + if (ast.input) { + return this.inputs(ast.input, ast.watchId); + } + switch (ast.type) { + case AST.Literal: + return this.value(ast.value, context); + case AST.UnaryExpression: + right = this.recurse(ast.argument); + return this['unary' + ast.operator](right, context); + case AST.BinaryExpression: + left = this.recurse(ast.left); + right = this.recurse(ast.right); + return this['binary' + ast.operator](left, right, context); + case AST.LogicalExpression: + left = this.recurse(ast.left); + right = this.recurse(ast.right); + return this['binary' + ast.operator](left, right, context); + case AST.ConditionalExpression: + return this['ternary?:']( + this.recurse(ast.test), + this.recurse(ast.alternate), + this.recurse(ast.consequent), + context + ); + case AST.Identifier: + return self.identifier(ast.name, context, create); + case AST.MemberExpression: + left = this.recurse(ast.object, false, !!create); + if (!ast.computed) { + right = ast.property.name; + } + if (ast.computed) right = this.recurse(ast.property); + return ast.computed ? + this.computedMember(left, right, context, create) : + this.nonComputedMember(left, right, context, create); + case AST.CallExpression: + args = []; + forEach(ast.arguments, function(expr) { + args.push(self.recurse(expr)); + }); + if (ast.filter) right = this.$filter(ast.callee.name); + if (!ast.filter) right = this.recurse(ast.callee, true); + return ast.filter ? + function(scope, locals, assign, inputs) { + var values = []; + for (var i = 0; i < args.length; ++i) { + values.push(args[i](scope, locals, assign, inputs)); + } + var value = right.apply(undefined, values, inputs); + return context ? {context: undefined, name: undefined, value: value} : value; + } : + function(scope, locals, assign, inputs) { + var rhs = right(scope, locals, assign, inputs); + var value; + if (rhs.value != null) { + var values = []; + for (var i = 0; i < args.length; ++i) { + values.push(args[i](scope, locals, assign, inputs)); + } + value = rhs.value.apply(rhs.context, values); + } + return context ? {value: value} : value; + }; + case AST.AssignmentExpression: + left = this.recurse(ast.left, true, 1); + right = this.recurse(ast.right); + return function(scope, locals, assign, inputs) { + var lhs = left(scope, locals, assign, inputs); + var rhs = right(scope, locals, assign, inputs); + lhs.context[lhs.name] = rhs; + return context ? {value: rhs} : rhs; + }; + case AST.ArrayExpression: + args = []; + forEach(ast.elements, function(expr) { + args.push(self.recurse(expr)); + }); + return function(scope, locals, assign, inputs) { + var value = []; + for (var i = 0; i < args.length; ++i) { + value.push(args[i](scope, locals, assign, inputs)); + } + return context ? {value: value} : value; + }; + case AST.ObjectExpression: + args = []; + forEach(ast.properties, function(property) { + if (property.computed) { + args.push({key: self.recurse(property.key), + computed: true, + value: self.recurse(property.value) + }); + } else { + args.push({key: property.key.type === AST.Identifier ? + property.key.name : + ('' + property.key.value), + computed: false, + value: self.recurse(property.value) + }); + } + }); + return function(scope, locals, assign, inputs) { + var value = {}; + for (var i = 0; i < args.length; ++i) { + if (args[i].computed) { + value[args[i].key(scope, locals, assign, inputs)] = args[i].value(scope, locals, assign, inputs); + } else { + value[args[i].key] = args[i].value(scope, locals, assign, inputs); + } + } + return context ? {value: value} : value; + }; + case AST.ThisExpression: + return function(scope) { + return context ? {value: scope} : scope; + }; + case AST.LocalsExpression: + return function(scope, locals) { + return context ? {value: locals} : locals; + }; + case AST.NGValueParameter: + return function(scope, locals, assign) { + return context ? {value: assign} : assign; + }; + } + }, + + 'unary+': function(argument, context) { + return function(scope, locals, assign, inputs) { + var arg = argument(scope, locals, assign, inputs); + if (isDefined(arg)) { + arg = +arg; + } else { + arg = 0; + } + return context ? {value: arg} : arg; + }; + }, + 'unary-': function(argument, context) { + return function(scope, locals, assign, inputs) { + var arg = argument(scope, locals, assign, inputs); + if (isDefined(arg)) { + arg = -arg; + } else { + arg = -0; + } + return context ? {value: arg} : arg; + }; + }, + 'unary!': function(argument, context) { + return function(scope, locals, assign, inputs) { + var arg = !argument(scope, locals, assign, inputs); + return context ? {value: arg} : arg; + }; + }, + 'binary+': function(left, right, context) { + return function(scope, locals, assign, inputs) { + var lhs = left(scope, locals, assign, inputs); + var rhs = right(scope, locals, assign, inputs); + var arg = plusFn(lhs, rhs); + return context ? {value: arg} : arg; + }; + }, + 'binary-': function(left, right, context) { + return function(scope, locals, assign, inputs) { + var lhs = left(scope, locals, assign, inputs); + var rhs = right(scope, locals, assign, inputs); + var arg = (isDefined(lhs) ? lhs : 0) - (isDefined(rhs) ? rhs : 0); + return context ? {value: arg} : arg; + }; + }, + 'binary*': function(left, right, context) { + return function(scope, locals, assign, inputs) { + var arg = left(scope, locals, assign, inputs) * right(scope, locals, assign, inputs); + return context ? {value: arg} : arg; + }; + }, + 'binary/': function(left, right, context) { + return function(scope, locals, assign, inputs) { + var arg = left(scope, locals, assign, inputs) / right(scope, locals, assign, inputs); + return context ? {value: arg} : arg; + }; + }, + 'binary%': function(left, right, context) { + return function(scope, locals, assign, inputs) { + var arg = left(scope, locals, assign, inputs) % right(scope, locals, assign, inputs); + return context ? {value: arg} : arg; + }; + }, + 'binary===': function(left, right, context) { + return function(scope, locals, assign, inputs) { + var arg = left(scope, locals, assign, inputs) === right(scope, locals, assign, inputs); + return context ? {value: arg} : arg; + }; + }, + 'binary!==': function(left, right, context) { + return function(scope, locals, assign, inputs) { + var arg = left(scope, locals, assign, inputs) !== right(scope, locals, assign, inputs); + return context ? {value: arg} : arg; + }; + }, + 'binary==': function(left, right, context) { + return function(scope, locals, assign, inputs) { + // eslint-disable-next-line eqeqeq + var arg = left(scope, locals, assign, inputs) == right(scope, locals, assign, inputs); + return context ? {value: arg} : arg; + }; + }, + 'binary!=': function(left, right, context) { + return function(scope, locals, assign, inputs) { + // eslint-disable-next-line eqeqeq + var arg = left(scope, locals, assign, inputs) != right(scope, locals, assign, inputs); + return context ? {value: arg} : arg; + }; + }, + 'binary<': function(left, right, context) { + return function(scope, locals, assign, inputs) { + var arg = left(scope, locals, assign, inputs) < right(scope, locals, assign, inputs); + return context ? {value: arg} : arg; + }; + }, + 'binary>': function(left, right, context) { + return function(scope, locals, assign, inputs) { + var arg = left(scope, locals, assign, inputs) > right(scope, locals, assign, inputs); + return context ? {value: arg} : arg; + }; + }, + 'binary<=': function(left, right, context) { + return function(scope, locals, assign, inputs) { + var arg = left(scope, locals, assign, inputs) <= right(scope, locals, assign, inputs); + return context ? {value: arg} : arg; + }; + }, + 'binary>=': function(left, right, context) { + return function(scope, locals, assign, inputs) { + var arg = left(scope, locals, assign, inputs) >= right(scope, locals, assign, inputs); + return context ? {value: arg} : arg; + }; + }, + 'binary&&': function(left, right, context) { + return function(scope, locals, assign, inputs) { + var arg = left(scope, locals, assign, inputs) && right(scope, locals, assign, inputs); + return context ? {value: arg} : arg; + }; + }, + 'binary||': function(left, right, context) { + return function(scope, locals, assign, inputs) { + var arg = left(scope, locals, assign, inputs) || right(scope, locals, assign, inputs); + return context ? {value: arg} : arg; + }; + }, + 'ternary?:': function(test, alternate, consequent, context) { + return function(scope, locals, assign, inputs) { + var arg = test(scope, locals, assign, inputs) ? alternate(scope, locals, assign, inputs) : consequent(scope, locals, assign, inputs); + return context ? {value: arg} : arg; + }; + }, + value: function(value, context) { + return function() { return context ? {context: undefined, name: undefined, value: value} : value; }; + }, + identifier: function(name, context, create) { + return function(scope, locals, assign, inputs) { + var base = locals && (name in locals) ? locals : scope; + if (create && create !== 1 && base && base[name] == null) { + base[name] = {}; + } + var value = base ? base[name] : undefined; + if (context) { + return {context: base, name: name, value: value}; + } else { + return value; + } + }; + }, + computedMember: function(left, right, context, create) { + return function(scope, locals, assign, inputs) { + var lhs = left(scope, locals, assign, inputs); + var rhs; + var value; + if (lhs != null) { + rhs = right(scope, locals, assign, inputs); + rhs = getStringValue(rhs); + if (create && create !== 1) { + if (lhs && !(lhs[rhs])) { + lhs[rhs] = {}; + } + } + value = lhs[rhs]; + } + if (context) { + return {context: lhs, name: rhs, value: value}; + } else { + return value; + } + }; + }, + nonComputedMember: function(left, right, context, create) { + return function(scope, locals, assign, inputs) { + var lhs = left(scope, locals, assign, inputs); + if (create && create !== 1) { + if (lhs && lhs[right] == null) { + lhs[right] = {}; + } + } + var value = lhs != null ? lhs[right] : undefined; + if (context) { + return {context: lhs, name: right, value: value}; + } else { + return value; + } + }; + }, + inputs: function(input, watchId) { + return function(scope, value, locals, inputs) { + if (inputs) return inputs[watchId]; + return input(scope, value, locals); + }; + } + }; + + /** + * @constructor + */ + function Parser(lexer, $filter, options) { + this.ast = new AST(lexer, options); + this.astCompiler = options.csp ? new ASTInterpreter($filter) : + new ASTCompiler($filter); + } + + Parser.prototype = { + constructor: Parser, + + parse: function(text) { + var ast = this.getAst(text); + var fn = this.astCompiler.compile(ast.ast); + fn.literal = isLiteral(ast.ast); + fn.constant = isConstant(ast.ast); + fn.oneTime = ast.oneTime; + return fn; + }, + + getAst: function(exp) { + var oneTime = false; + exp = exp.trim(); + + if (exp.charAt(0) === ':' && exp.charAt(1) === ':') { + oneTime = true; + exp = exp.substring(2); + } + return { + ast: this.ast.ast(exp), + oneTime: oneTime + }; + } + }; + + function getValueOf(value) { + return isFunction(value.valueOf) ? value.valueOf() : objectValueOf.call(value); + } + +/////////////////////////////////// + + /** + * @ngdoc service + * @name $parse + * @kind function * - * # Chaining promises + * @description * - * Because calling the `then` method of a promise returns a new derived promise, it is easily - * possible to create a chain of promises: + * Converts AngularJS {@link guide/expression expression} into a function. * * ```js - * promiseB = promiseA.then(function(result) { - * return result + 1; - * }); + * var getter = $parse('user.name'); + * var setter = getter.assign; + * var context = {user:{name:'AngularJS'}}; + * var locals = {user:{name:'local'}}; * - * // promiseB will be resolved immediately after promiseA is resolved and its value - * // will be the result of promiseA incremented by 1 + * expect(getter(context)).toEqual('AngularJS'); + * setter(context, 'newValue'); + * expect(context.user.name).toEqual('newValue'); + * expect(getter(context, locals)).toEqual('local'); * ``` * - * It is possible to create chains of any length and since a promise can be resolved with another - * promise (which will defer its resolution further), it is possible to pause/defer resolution of - * the promises at any point in the chain. This makes it possible to implement powerful APIs like - * $http's response interceptors. * + * @param {string} expression String expression to compile. + * @returns {function(context, locals)} a function which represents the compiled expression: * - * # Differences between Kris Kowal's Q and $q - * - * There are two main differences: - * - * - $q is integrated with the {@link ng.$rootScope.Scope} Scope model observation - * mechanism in angular, which means faster propagation of resolution or rejection into your - * models and avoiding unnecessary browser repaints, which would result in flickering UI. - * - Q has many more features than $q, but that comes at a cost of bytes. $q is tiny, but contains - * all the important functionality needed for common async tasks. + * * `context` – `{object}` – an object against which any expressions embedded in the strings + * are evaluated against (typically a scope object). + * * `locals` – `{object=}` – local variables context object, useful for overriding values in + * `context`. * - * # Testing + * The returned function also has the following properties: + * * `literal` – `{boolean}` – whether the expression's top-level node is a JavaScript + * literal. + * * `constant` – `{boolean}` – whether the expression is made entirely of JavaScript + * constant literals. + * * `assign` – `{?function(context, value)}` – if the expression is assignable, this will be + * set to a function to change its value on the given context. * - * ```js - * it('should simulate promise', inject(function($q, $rootScope) { - * var deferred = $q.defer(); - * var promise = deferred.promise; - * var resolvedValue; - * - * promise.then(function(value) { resolvedValue = value; }); - * expect(resolvedValue).toBeUndefined(); - * - * // Simulate resolving of promise - * deferred.resolve(123); - * // Note that the 'then' function does not get called synchronously. - * // This is because we want the promise API to always be async, whether or not - * // it got called synchronously or asynchronously. - * expect(resolvedValue).toBeUndefined(); - * - * // Propagate promise resolution to 'then' functions using $apply(). - * $rootScope.$apply(); - * expect(resolvedValue).toEqual(123); - * })); - * ``` */ - function $QProvider() { - - this.$get = ['$rootScope', '$exceptionHandler', function($rootScope, $exceptionHandler) { - return qFactory(function(callback) { - $rootScope.$evalAsync(callback); - }, $exceptionHandler); - }]; - } /** - * Constructs a promise manager. + * @ngdoc provider + * @name $parseProvider + * @this * - * @param {function(Function)} nextTick Function for executing functions in the next turn. - * @param {function(...*)} exceptionHandler Function into which unexpected exceptions are passed for - * debugging purposes. - * @returns {object} Promise manager. + * @description + * `$parseProvider` can be used for configuring the default behavior of the {@link ng.$parse $parse} + * service. */ - function qFactory(nextTick, exceptionHandler) { + function $ParseProvider() { + var cache = createMap(); + var literals = { + 'true': true, + 'false': false, + 'null': null, + 'undefined': undefined + }; + var identStart, identContinue; + + /** + * @ngdoc method + * @name $parseProvider#addLiteral + * @description + * + * Configure $parse service to add literal values that will be present as literal at expressions. + * + * @param {string} literalName Token for the literal value. The literal name value must be a valid literal name. + * @param {*} literalValue Value for this literal. All literal values must be primitives or `undefined`. + * + **/ + this.addLiteral = function(literalName, literalValue) { + literals[literalName] = literalValue; + }; /** * @ngdoc method - * @name $q#defer - * @function + * @name $parseProvider#setIdentifierFns * * @description - * Creates a `Deferred` object which represents a task which will finish in the future. * - * @returns {Deferred} Returns a new instance of deferred. + * Allows defining the set of characters that are allowed in AngularJS expressions. The function + * `identifierStart` will get called to know if a given character is a valid character to be the + * first character for an identifier. The function `identifierContinue` will get called to know if + * a given character is a valid character to be a follow-up identifier character. The functions + * `identifierStart` and `identifierContinue` will receive as arguments the single character to be + * identifier and the character code point. These arguments will be `string` and `numeric`. Keep in + * mind that the `string` parameter can be two characters long depending on the character + * representation. It is expected for the function to return `true` or `false`, whether that + * character is allowed or not. + * + * Since this function will be called extensively, keep the implementation of these functions fast, + * as the performance of these functions have a direct impact on the expressions parsing speed. + * + * @param {function=} identifierStart The function that will decide whether the given character is + * a valid identifier start character. + * @param {function=} identifierContinue The function that will decide whether the given character is + * a valid identifier continue character. */ - var defer = function() { - var pending = [], - value, deferred; - - deferred = { - - resolve: function(val) { - if (pending) { - var callbacks = pending; - pending = undefined; - value = ref(val); - - if (callbacks.length) { - nextTick(function() { - var callback; - for (var i = 0, ii = callbacks.length; i < ii; i++) { - callback = callbacks[i]; - value.then(callback[0], callback[1], callback[2]); - } - }); + this.setIdentifierFns = function(identifierStart, identifierContinue) { + identStart = identifierStart; + identContinue = identifierContinue; + return this; + }; + + this.$get = ['$filter', function($filter) { + var noUnsafeEval = csp().noUnsafeEval; + var $parseOptions = { + csp: noUnsafeEval, + literals: copy(literals), + isIdentifierStart: isFunction(identStart) && identStart, + isIdentifierContinue: isFunction(identContinue) && identContinue + }; + $parse.$$getAst = $$getAst; + return $parse; + + function $parse(exp, interceptorFn) { + var parsedExpression, cacheKey; + + switch (typeof exp) { + case 'string': + exp = exp.trim(); + cacheKey = exp; + + parsedExpression = cache[cacheKey]; + + if (!parsedExpression) { + var lexer = new Lexer($parseOptions); + var parser = new Parser(lexer, $filter, $parseOptions); + parsedExpression = parser.parse(exp); + if (parsedExpression.constant) { + parsedExpression.$$watchDelegate = constantWatchDelegate; + } else if (parsedExpression.oneTime) { + parsedExpression.$$watchDelegate = parsedExpression.literal ? + oneTimeLiteralWatchDelegate : oneTimeWatchDelegate; + } else if (parsedExpression.inputs) { + parsedExpression.$$watchDelegate = inputsWatchDelegate; + } + cache[cacheKey] = parsedExpression; } - } - }, + return addInterceptor(parsedExpression, interceptorFn); + case 'function': + return addInterceptor(exp, interceptorFn); - reject: function(reason) { - deferred.resolve(createInternalRejectedPromise(reason)); - }, + default: + return addInterceptor(noop, interceptorFn); + } + } + function $$getAst(exp) { + var lexer = new Lexer($parseOptions); + var parser = new Parser(lexer, $filter, $parseOptions); + return parser.getAst(exp).ast; + } - notify: function(progress) { - if (pending) { - var callbacks = pending; + function expressionInputDirtyCheck(newValue, oldValueOfValue, compareObjectIdentity) { - if (pending.length) { - nextTick(function() { - var callback; - for (var i = 0, ii = callbacks.length; i < ii; i++) { - callback = callbacks[i]; - callback[2](progress); - } - }); - } - } - }, + if (newValue == null || oldValueOfValue == null) { // null/undefined + return newValue === oldValueOfValue; + } + if (typeof newValue === 'object') { - promise: { - then: function(callback, errback, progressback) { - var result = defer(); + // attempt to convert the value to a primitive type + // TODO(docs): add a note to docs that by implementing valueOf even objects and arrays can + // be cheaply dirty-checked + newValue = getValueOf(newValue); - var wrappedCallback = function(value) { - try { - result.resolve((isFunction(callback) ? callback : defaultCallback)(value)); - } catch(e) { - result.reject(e); - exceptionHandler(e); - } - }; + if (typeof newValue === 'object' && !compareObjectIdentity) { + // objects/arrays are not supported - deep-watching them would be too expensive + return false; + } - var wrappedErrback = function(reason) { - try { - result.resolve((isFunction(errback) ? errback : defaultErrback)(reason)); - } catch(e) { - result.reject(e); - exceptionHandler(e); - } - }; + // fall-through to the primitive equality check + } - var wrappedProgressback = function(progress) { - try { - result.notify((isFunction(progressback) ? progressback : defaultCallback)(progress)); - } catch(e) { - exceptionHandler(e); - } - }; + //Primitive or NaN + // eslint-disable-next-line no-self-compare + return newValue === oldValueOfValue || (newValue !== newValue && oldValueOfValue !== oldValueOfValue); + } - if (pending) { - pending.push([wrappedCallback, wrappedErrback, wrappedProgressback]); - } else { - value.then(wrappedCallback, wrappedErrback, wrappedProgressback); + function inputsWatchDelegate(scope, listener, objectEquality, parsedExpression, prettyPrintExpression) { + var inputExpressions = parsedExpression.inputs; + var lastResult; + + if (inputExpressions.length === 1) { + var oldInputValueOf = expressionInputDirtyCheck; // init to something unique so that equals check fails + inputExpressions = inputExpressions[0]; + return scope.$watch(function expressionInputWatch(scope) { + var newInputValue = inputExpressions(scope); + if (!expressionInputDirtyCheck(newInputValue, oldInputValueOf, inputExpressions.isPure)) { + lastResult = parsedExpression(scope, undefined, undefined, [newInputValue]); + oldInputValueOf = newInputValue && getValueOf(newInputValue); } + return lastResult; + }, listener, objectEquality, prettyPrintExpression); + } - return result.promise; - }, - - "catch": function(callback) { - return this.then(null, callback); - }, + var oldInputValueOfValues = []; + var oldInputValues = []; + for (var i = 0, ii = inputExpressions.length; i < ii; i++) { + oldInputValueOfValues[i] = expressionInputDirtyCheck; // init to something unique so that equals check fails + oldInputValues[i] = null; + } - "finally": function(callback) { + return scope.$watch(function expressionInputsWatch(scope) { + var changed = false; - function makePromise(value, resolved) { - var result = defer(); - if (resolved) { - result.resolve(value); - } else { - result.reject(value); - } - return result.promise; - } - - function handleCallback(value, isResolved) { - var callbackOutput = null; - try { - callbackOutput = (callback ||defaultCallback)(); - } catch(e) { - return makePromise(e, false); - } - if (callbackOutput && isFunction(callbackOutput.then)) { - return callbackOutput.then(function() { - return makePromise(value, isResolved); - }, function(error) { - return makePromise(error, false); - }); - } else { - return makePromise(value, isResolved); - } + for (var i = 0, ii = inputExpressions.length; i < ii; i++) { + var newInputValue = inputExpressions[i](scope); + if (changed || (changed = !expressionInputDirtyCheck(newInputValue, oldInputValueOfValues[i], inputExpressions[i].isPure))) { + oldInputValues[i] = newInputValue; + oldInputValueOfValues[i] = newInputValue && getValueOf(newInputValue); } + } - return this.then(function(value) { - return handleCallback(value, true); - }, function(error) { - return handleCallback(error, false); - }); + if (changed) { + lastResult = parsedExpression(scope, undefined, undefined, oldInputValues); } - } - }; - return deferred; - }; + return lastResult; + }, listener, objectEquality, prettyPrintExpression); + } + function oneTimeWatchDelegate(scope, listener, objectEquality, parsedExpression, prettyPrintExpression) { + var unwatch, lastValue; + if (parsedExpression.inputs) { + unwatch = inputsWatchDelegate(scope, oneTimeListener, objectEquality, parsedExpression, prettyPrintExpression); + } else { + unwatch = scope.$watch(oneTimeWatch, oneTimeListener, objectEquality); + } + return unwatch; - var ref = function(value) { - if (value && isFunction(value.then)) return value; - return { - then: function(callback) { - var result = defer(); - nextTick(function() { - result.resolve(callback(value)); - }); - return result.promise; + function oneTimeWatch(scope) { + return parsedExpression(scope); } - }; - }; + function oneTimeListener(value, old, scope) { + lastValue = value; + if (isFunction(listener)) { + listener(value, old, scope); + } + if (isDefined(value)) { + scope.$$postDigest(function() { + if (isDefined(lastValue)) { + unwatch(); + } + }); + } + } + } + function oneTimeLiteralWatchDelegate(scope, listener, objectEquality, parsedExpression) { + var unwatch, lastValue; + unwatch = scope.$watch(function oneTimeWatch(scope) { + return parsedExpression(scope); + }, function oneTimeListener(value, old, scope) { + lastValue = value; + if (isFunction(listener)) { + listener(value, old, scope); + } + if (isAllDefined(value)) { + scope.$$postDigest(function() { + if (isAllDefined(lastValue)) unwatch(); + }); + } + }, objectEquality); - /** - * @ngdoc method - * @name $q#reject - * @function - * - * @description - * Creates a promise that is resolved as rejected with the specified `reason`. This api should be - * used to forward rejection in a chain of promises. If you are dealing with the last promise in - * a promise chain, you don't need to worry about it. - * - * When comparing deferreds/promises to the familiar behavior of try/catch/throw, think of - * `reject` as the `throw` keyword in JavaScript. This also means that if you "catch" an error via - * a promise error callback and you want to forward the error to the promise derived from the - * current promise, you have to "rethrow" the error by returning a rejection constructed via - * `reject`. - * - * ```js - * promiseB = promiseA.then(function(result) { - * // success: do something and resolve promiseB - * // with the old or a new result - * return result; - * }, function(reason) { - * // error: handle the error if possible and - * // resolve promiseB with newPromiseOrValue, - * // otherwise forward the rejection to promiseB - * if (canHandle(reason)) { - * // handle the error and recover - * return newPromiseOrValue; - * } - * return $q.reject(reason); - * }); - * ``` - * - * @param {*} reason Constant, message, exception or an object representing the rejection reason. - * @returns {Promise} Returns a promise that was already resolved as rejected with the `reason`. - */ - var reject = function(reason) { - var result = defer(); - result.reject(reason); - return result.promise; - }; + return unwatch; - var createInternalRejectedPromise = function(reason) { - return { - then: function(callback, errback) { - var result = defer(); - nextTick(function() { - try { - result.resolve((isFunction(errback) ? errback : defaultErrback)(reason)); - } catch(e) { - result.reject(e); - exceptionHandler(e); - } + function isAllDefined(value) { + var allDefined = true; + forEach(value, function(val) { + if (!isDefined(val)) allDefined = false; }); - return result.promise; + return allDefined; } - }; - }; - + } - /** - * @ngdoc method - * @name $q#when - * @function - * - * @description - * Wraps an object that might be a value or a (3rd party) then-able promise into a $q promise. - * This is useful when you are dealing with an object that might or might not be a promise, or if - * the promise comes from a source that can't be trusted. - * - * @param {*} value Value or a promise - * @returns {Promise} Returns a promise of the passed value or promise - */ - var when = function(value, callback, errback, progressback) { - var result = defer(), - done; + function constantWatchDelegate(scope, listener, objectEquality, parsedExpression) { + var unwatch = scope.$watch(function constantWatch(scope) { + unwatch(); + return parsedExpression(scope); + }, listener, objectEquality); + return unwatch; + } - var wrappedCallback = function(value) { - try { - return (isFunction(callback) ? callback : defaultCallback)(value); - } catch (e) { - exceptionHandler(e); - return reject(e); - } - }; + function addInterceptor(parsedExpression, interceptorFn) { + if (!interceptorFn) return parsedExpression; + var watchDelegate = parsedExpression.$$watchDelegate; + var useInputs = false; + + var regularWatch = + watchDelegate !== oneTimeLiteralWatchDelegate && + watchDelegate !== oneTimeWatchDelegate; + + var fn = regularWatch ? function regularInterceptedExpression(scope, locals, assign, inputs) { + var value = useInputs && inputs ? inputs[0] : parsedExpression(scope, locals, assign, inputs); + return interceptorFn(value, scope, locals); + } : function oneTimeInterceptedExpression(scope, locals, assign, inputs) { + var value = parsedExpression(scope, locals, assign, inputs); + var result = interceptorFn(value, scope, locals); + // we only return the interceptor's result if the + // initial value is defined (for bind-once) + return isDefined(value) ? result : value; + }; - var wrappedErrback = function(reason) { - try { - return (isFunction(errback) ? errback : defaultErrback)(reason); - } catch (e) { - exceptionHandler(e); - return reject(e); + // Propagate $$watchDelegates other then inputsWatchDelegate + useInputs = !parsedExpression.inputs; + if (watchDelegate && watchDelegate !== inputsWatchDelegate) { + fn.$$watchDelegate = watchDelegate; + fn.inputs = parsedExpression.inputs; + } else if (!interceptorFn.$stateful) { + // Treat interceptor like filters - assume non-stateful by default and use the inputsWatchDelegate + fn.$$watchDelegate = inputsWatchDelegate; + fn.inputs = parsedExpression.inputs ? parsedExpression.inputs : [parsedExpression]; } - }; - var wrappedProgressback = function(progress) { - try { - return (isFunction(progressback) ? progressback : defaultCallback)(progress); - } catch (e) { - exceptionHandler(e); + if (fn.inputs) { + fn.inputs = fn.inputs.map(function(e) { + // Remove the isPure flag of inputs when it is not absolute because they are now wrapped in a + // potentially non-pure interceptor function. + if (e.isPure === PURITY_RELATIVE) { + return function depurifier(s) { return e(s); }; + } + return e; + }); } - }; - - nextTick(function() { - ref(value).then(function(value) { - if (done) return; - done = true; - result.resolve(ref(value).then(wrappedCallback, wrappedErrback, wrappedProgressback)); - }, function(reason) { - if (done) return; - done = true; - result.resolve(wrappedErrback(reason)); - }, function(progress) { - if (done) return; - result.notify(wrappedProgressback(progress)); - }); - }); - - return result.promise; - }; - - - function defaultCallback(value) { - return value; - } - - - function defaultErrback(reason) { - return reject(reason); - } - - - /** - * @ngdoc method - * @name $q#all - * @function - * - * @description - * Combines multiple promises into a single promise that is resolved when all of the input - * promises are resolved. - * - * @param {Array.<Promise>|Object.<Promise>} promises An array or hash of promises. - * @returns {Promise} Returns a single promise that will be resolved with an array/hash of values, - * each value corresponding to the promise at the same index/key in the `promises` array/hash. - * If any of the promises is resolved with a rejection, this resulting promise will be rejected - * with the same rejection value. - */ - function all(promises) { - var deferred = defer(), - counter = 0, - results = isArray(promises) ? [] : {}; - - forEach(promises, function(promise, key) { - counter++; - ref(promise).then(function(value) { - if (results.hasOwnProperty(key)) return; - results[key] = value; - if (!(--counter)) deferred.resolve(results); - }, function(reason) { - if (results.hasOwnProperty(key)) return; - deferred.reject(reason); - }); - }); - - if (counter === 0) { - deferred.resolve(results); - } - - return deferred.promise; - } - - return { - defer: defer, - reject: reject, - when: when, - all: all - }; - } - - function $$RAFProvider(){ //rAF - this.$get = ['$window', '$timeout', function($window, $timeout) { - var requestAnimationFrame = $window.requestAnimationFrame || - $window.webkitRequestAnimationFrame || - $window.mozRequestAnimationFrame; - - var cancelAnimationFrame = $window.cancelAnimationFrame || - $window.webkitCancelAnimationFrame || - $window.mozCancelAnimationFrame || - $window.webkitCancelRequestAnimationFrame; - var rafSupported = !!requestAnimationFrame; - var raf = rafSupported - ? function(fn) { - var id = requestAnimationFrame(fn); - return function() { - cancelAnimationFrame(id); - }; + return fn; } - : function(fn) { - var timer = $timeout(fn, 16.66, false); // 1000 / 60 = 16.666 - return function() { - $timeout.cancel(timer); - }; - }; - - raf.supported = rafSupported; - - return raf; }]; } /** - * DESIGN NOTES + * @ngdoc service + * @name $q + * @requires $rootScope * - * The design decisions behind the scope are heavily favored for speed and memory consumption. + * @description + * A service that helps you run functions asynchronously, and use their return values (or exceptions) + * when they are done processing. * - * The typical use of scope is to watch the expressions, which most of the time return the same - * value as last time so we optimize the operation. + * This is a [Promises/A+](https://promisesaplus.com/)-compliant implementation of promises/deferred + * objects inspired by [Kris Kowal's Q](https://github.com/kriskowal/q). * - * Closures construction is expensive in terms of speed as well as memory: - * - No closures, instead use prototypical inheritance for API - * - Internal state needs to be stored on scope directly, which means that private state is - * exposed as $$____ properties + * $q can be used in two fashions --- one which is more similar to Kris Kowal's Q or jQuery's Deferred + * implementations, and the other which resembles ES6 (ES2015) promises to some degree. * - * Loop operations are optimized by using while(count--) { ... } - * - this means that in order to keep the same order of execution as addition we have to add - * items to the array at the beginning (shift) instead of at the end (push) + * ## $q constructor * - * Child scopes are created and removed often - * - Using an array would be slow since inserts in middle are expensive so we use linked list + * The streamlined ES6 style promise is essentially just using $q as a constructor which takes a `resolver` + * function as the first argument. This is similar to the native Promise implementation from ES6, + * see [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise). * - * There are few watches then a lot of observers. This is why you don't want the observer to be - * implemented in the same way as watch. Watch requires return of initialization function which - * are expensive to construct. - */ - - - /** - * @ngdoc provider - * @name $rootScopeProvider - * @description + * While the constructor-style use is supported, not all of the supporting methods from ES6 promises are + * available yet. * - * Provider for the $rootScope service. - */ - - /** - * @ngdoc method - * @name $rootScopeProvider#digestTtl - * @description + * It can be used like so: * - * Sets the number of `$digest` iterations the scope should attempt to execute before giving up and - * assuming that the model is unstable. + * ```js + * // for the purpose of this example let's assume that variables `$q` and `okToGreet` + * // are available in the current lexical scope (they could have been injected or passed in). * - * The current default is 10 iterations. + * function asyncGreet(name) { + * // perform some asynchronous operation, resolve or reject the promise when appropriate. + * return $q(function(resolve, reject) { + * setTimeout(function() { + * if (okToGreet(name)) { + * resolve('Hello, ' + name + '!'); + * } else { + * reject('Greeting ' + name + ' is not allowed.'); + * } + * }, 1000); + * }); + * } * - * In complex applications it's possible that the dependencies between `$watch`s will result in - * several digest iterations. However if an application needs more than the default 10 digest - * iterations for its model to stabilize then you should investigate what is causing the model to - * continuously change during the digest. + * var promise = asyncGreet('Robin Hood'); + * promise.then(function(greeting) { + * alert('Success: ' + greeting); + * }, function(reason) { + * alert('Failed: ' + reason); + * }); + * ``` * - * Increasing the TTL could have performance implications, so you should not change it without - * proper justification. + * Note: progress/notify callbacks are not currently supported via the ES6-style interface. * - * @param {number} limit The number of digest iterations. - */ - - - /** - * @ngdoc service - * @name $rootScope - * @description + * Note: unlike ES6 behavior, an exception thrown in the constructor function will NOT implicitly reject the promise. * - * Every application has a single root {@link ng.$rootScope.Scope scope}. - * All other scopes are descendant scopes of the root scope. Scopes provide separation - * between the model and the view, via a mechanism for watching the model for changes. - * They also provide an event emission/broadcast and subscription facility. See the - * {@link guide/scope developer guide on scopes}. - */ - function $RootScopeProvider(){ - var TTL = 10; - var $rootScopeMinErr = minErr('$rootScope'); - var lastDirtyWatch = null; - - this.digestTtl = function(value) { - if (arguments.length) { - TTL = value; - } - return TTL; - }; - - this.$get = ['$injector', '$exceptionHandler', '$parse', '$browser', - function( $injector, $exceptionHandler, $parse, $browser) { - - /** - * @ngdoc type - * @name $rootScope.Scope - * - * @description - * A root scope can be retrieved using the {@link ng.$rootScope $rootScope} key from the - * {@link auto.$injector $injector}. Child scopes are created using the - * {@link ng.$rootScope.Scope#$new $new()} method. (Most scopes are created automatically when - * compiled HTML template is executed.) - * - * Here is a simple scope snippet to show how you can interact with the scope. - * ```html - * <file src="./test/ng/rootScopeSpec.js" tag="docs1" /> - * ``` - * - * # Inheritance - * A scope can inherit from a parent scope, as in this example: - * ```js - var parent = $rootScope; - var child = parent.$new(); - - parent.salutation = "Hello"; - child.name = "World"; - expect(child.salutation).toEqual('Hello'); - - child.salutation = "Welcome"; - expect(child.salutation).toEqual('Welcome'); - expect(parent.salutation).toEqual('Hello'); - * ``` - * - * - * @param {Object.<string, function()>=} providers Map of service factory which need to be - * provided for the current scope. Defaults to {@link ng}. - * @param {Object.<string, *>=} instanceCache Provides pre-instantiated services which should - * append/override services provided by `providers`. This is handy - * when unit-testing and having the need to override a default - * service. - * @returns {Object} Newly created scope. - * - */ - function Scope() { - this.$id = nextUid(); - this.$$phase = this.$parent = this.$$watchers = - this.$$nextSibling = this.$$prevSibling = - this.$$childHead = this.$$childTail = null; - this['this'] = this.$root = this; - this.$$destroyed = false; - this.$$asyncQueue = []; - this.$$postDigestQueue = []; - this.$$listeners = {}; - this.$$listenerCount = {}; - this.$$isolateBindings = {}; - } + * However, the more traditional CommonJS-style usage is still available, and documented below. + * + * [The CommonJS Promise proposal](http://wiki.commonjs.org/wiki/Promises) describes a promise as an + * interface for interacting with an object that represents the result of an action that is + * performed asynchronously, and may or may not be finished at any given point in time. + * + * From the perspective of dealing with error handling, deferred and promise APIs are to + * asynchronous programming what `try`, `catch` and `throw` keywords are to synchronous programming. + * + * ```js + * // for the purpose of this example let's assume that variables `$q` and `okToGreet` + * // are available in the current lexical scope (they could have been injected or passed in). + * + * function asyncGreet(name) { + * var deferred = $q.defer(); + * + * setTimeout(function() { + * deferred.notify('About to greet ' + name + '.'); + * + * if (okToGreet(name)) { + * deferred.resolve('Hello, ' + name + '!'); + * } else { + * deferred.reject('Greeting ' + name + ' is not allowed.'); + * } + * }, 1000); + * + * return deferred.promise; + * } + * + * var promise = asyncGreet('Robin Hood'); + * promise.then(function(greeting) { + * alert('Success: ' + greeting); + * }, function(reason) { + * alert('Failed: ' + reason); + * }, function(update) { + * alert('Got notification: ' + update); + * }); + * ``` + * + * At first it might not be obvious why this extra complexity is worth the trouble. The payoff + * comes in the way of guarantees that promise and deferred APIs make, see + * https://github.com/kriskowal/uncommonjs/blob/master/promises/specification.md. + * + * Additionally the promise api allows for composition that is very hard to do with the + * traditional callback ([CPS](http://en.wikipedia.org/wiki/Continuation-passing_style)) approach. + * For more on this please see the [Q documentation](https://github.com/kriskowal/q) especially the + * section on serial or parallel joining of promises. + * + * ## The Deferred API + * + * A new instance of deferred is constructed by calling `$q.defer()`. + * + * The purpose of the deferred object is to expose the associated Promise instance as well as APIs + * that can be used for signaling the successful or unsuccessful completion, as well as the status + * of the task. + * + * **Methods** + * + * - `resolve(value)` – resolves the derived promise with the `value`. If the value is a rejection + * constructed via `$q.reject`, the promise will be rejected instead. + * - `reject(reason)` – rejects the derived promise with the `reason`. This is equivalent to + * resolving it with a rejection constructed via `$q.reject`. + * - `notify(value)` - provides updates on the status of the promise's execution. This may be called + * multiple times before the promise is either resolved or rejected. + * + * **Properties** + * + * - promise – `{Promise}` – promise object associated with this deferred. + * + * + * ## The Promise API + * + * A new promise instance is created when a deferred instance is created and can be retrieved by + * calling `deferred.promise`. + * + * The purpose of the promise object is to allow for interested parties to get access to the result + * of the deferred task when it completes. + * + * **Methods** + * + * - `then(successCallback, [errorCallback], [notifyCallback])` – regardless of when the promise was or + * will be resolved or rejected, `then` calls one of the success or error callbacks asynchronously + * as soon as the result is available. The callbacks are called with a single argument: the result + * or rejection reason. Additionally, the notify callback may be called zero or more times to + * provide a progress indication, before the promise is resolved or rejected. + * + * This method *returns a new promise* which is resolved or rejected via the return value of the + * `successCallback`, `errorCallback` (unless that value is a promise, in which case it is resolved + * with the value which is resolved in that promise using + * [promise chaining](http://www.html5rocks.com/en/tutorials/es6/promises/#toc-promises-queues)). + * It also notifies via the return value of the `notifyCallback` method. The promise cannot be + * resolved or rejected from the notifyCallback method. The errorCallback and notifyCallback + * arguments are optional. + * + * - `catch(errorCallback)` – shorthand for `promise.then(null, errorCallback)` + * + * - `finally(callback, notifyCallback)` – allows you to observe either the fulfillment or rejection of a promise, + * but to do so without modifying the final value. This is useful to release resources or do some + * clean-up that needs to be done whether the promise was rejected or resolved. See the [full + * specification](https://github.com/kriskowal/q/wiki/API-Reference#promisefinallycallback) for + * more information. + * + * ## Chaining promises + * + * Because calling the `then` method of a promise returns a new derived promise, it is easily + * possible to create a chain of promises: + * + * ```js + * promiseB = promiseA.then(function(result) { + * return result + 1; + * }); + * + * // promiseB will be resolved immediately after promiseA is resolved and its value + * // will be the result of promiseA incremented by 1 + * ``` + * + * It is possible to create chains of any length and since a promise can be resolved with another + * promise (which will defer its resolution further), it is possible to pause/defer resolution of + * the promises at any point in the chain. This makes it possible to implement powerful APIs like + * $http's response interceptors. + * + * + * ## Differences between Kris Kowal's Q and $q + * + * There are two main differences: + * + * - $q is integrated with the {@link ng.$rootScope.Scope} Scope model observation + * mechanism in AngularJS, which means faster propagation of resolution or rejection into your + * models and avoiding unnecessary browser repaints, which would result in flickering UI. + * - Q has many more features than $q, but that comes at a cost of bytes. $q is tiny, but contains + * all the important functionality needed for common async tasks. + * + * ## Testing + * + * ```js + * it('should simulate promise', inject(function($q, $rootScope) { + * var deferred = $q.defer(); + * var promise = deferred.promise; + * var resolvedValue; + * + * promise.then(function(value) { resolvedValue = value; }); + * expect(resolvedValue).toBeUndefined(); + * + * // Simulate resolving of promise + * deferred.resolve(123); + * // Note that the 'then' function does not get called synchronously. + * // This is because we want the promise API to always be async, whether or not + * // it got called synchronously or asynchronously. + * expect(resolvedValue).toBeUndefined(); + * + * // Propagate promise resolution to 'then' functions using $apply(). + * $rootScope.$apply(); + * expect(resolvedValue).toEqual(123); + * })); + * ``` + * + * @param {function(function, function)} resolver Function which is responsible for resolving or + * rejecting the newly created promise. The first parameter is a function which resolves the + * promise, the second parameter is a function which rejects the promise. + * + * @returns {Promise} The newly created promise. + */ + /** + * @ngdoc provider + * @name $qProvider + * @this + * + * @description + */ + function $QProvider() { + var errorOnUnhandledRejections = true; + this.$get = ['$rootScope', '$exceptionHandler', function($rootScope, $exceptionHandler) { + return qFactory(function(callback) { + $rootScope.$evalAsync(callback); + }, $exceptionHandler, errorOnUnhandledRejections); + }]; - /** - * @ngdoc property - * @name $rootScope.Scope#$id - * @returns {number} Unique scope ID (monotonically increasing alphanumeric sequence) useful for - * debugging. - */ + /** + * @ngdoc method + * @name $qProvider#errorOnUnhandledRejections + * @kind function + * + * @description + * Retrieves or overrides whether to generate an error when a rejected promise is not handled. + * This feature is enabled by default. + * + * @param {boolean=} value Whether to generate an error when a rejected promise is not handled. + * @returns {boolean|ng.$qProvider} Current value when called without a new value or self for + * chaining otherwise. + */ + this.errorOnUnhandledRejections = function(value) { + if (isDefined(value)) { + errorOnUnhandledRejections = value; + return this; + } else { + return errorOnUnhandledRejections; + } + }; + } + /** @this */ + function $$QProvider() { + var errorOnUnhandledRejections = true; + this.$get = ['$browser', '$exceptionHandler', function($browser, $exceptionHandler) { + return qFactory(function(callback) { + $browser.defer(callback); + }, $exceptionHandler, errorOnUnhandledRejections); + }]; - Scope.prototype = { - constructor: Scope, - /** - * @ngdoc method - * @name $rootScope.Scope#$new - * @function - * - * @description - * Creates a new child {@link ng.$rootScope.Scope scope}. - * - * The parent scope will propagate the {@link ng.$rootScope.Scope#$digest $digest()} and - * {@link ng.$rootScope.Scope#$digest $digest()} events. The scope can be removed from the - * scope hierarchy using {@link ng.$rootScope.Scope#$destroy $destroy()}. - * - * {@link ng.$rootScope.Scope#$destroy $destroy()} must be called on a scope when it is - * desired for the scope and its child scopes to be permanently detached from the parent and - * thus stop participating in model change detection and listener notification by invoking. - * - * @param {boolean} isolate If true, then the scope does not prototypically inherit from the - * parent scope. The scope is isolated, as it can not see parent scope properties. - * When creating widgets, it is useful for the widget to not accidentally read parent - * state. - * - * @returns {Object} The newly created child scope. - * - */ - $new: function(isolate) { - var ChildScope, - child; + this.errorOnUnhandledRejections = function(value) { + if (isDefined(value)) { + errorOnUnhandledRejections = value; + return this; + } else { + return errorOnUnhandledRejections; + } + }; + } - if (isolate) { - child = new Scope(); - child.$root = this.$root; - // ensure that there is just one async queue per $rootScope and its children - child.$$asyncQueue = this.$$asyncQueue; - child.$$postDigestQueue = this.$$postDigestQueue; - } else { - ChildScope = function() {}; // should be anonymous; This is so that when the minifier munges - // the name it does not become random set of chars. This will then show up as class - // name in the web inspector. - ChildScope.prototype = this; - child = new ChildScope(); - child.$id = nextUid(); - } - child['this'] = child; - child.$$listeners = {}; - child.$$listenerCount = {}; - child.$parent = this; - child.$$watchers = child.$$nextSibling = child.$$childHead = child.$$childTail = null; - child.$$prevSibling = this.$$childTail; - if (this.$$childHead) { - this.$$childTail.$$nextSibling = child; - this.$$childTail = child; - } else { - this.$$childHead = this.$$childTail = child; - } - return child; - }, + /** + * Constructs a promise manager. + * + * @param {function(function)} nextTick Function for executing functions in the next turn. + * @param {function(...*)} exceptionHandler Function into which unexpected exceptions are passed for + * debugging purposes. + * @param {boolean=} errorOnUnhandledRejections Whether an error should be generated on unhandled + * promises rejections. + * @returns {object} Promise manager. + */ + function qFactory(nextTick, exceptionHandler, errorOnUnhandledRejections) { + var $qMinErr = minErr('$q', TypeError); + var queueSize = 0; + var checkQueue = []; - /** - * @ngdoc method - * @name $rootScope.Scope#$watch - * @function - * - * @description - * Registers a `listener` callback to be executed whenever the `watchExpression` changes. - * - * - The `watchExpression` is called on every call to {@link ng.$rootScope.Scope#$digest - * $digest()} and should return the value that will be watched. (Since - * {@link ng.$rootScope.Scope#$digest $digest()} reruns when it detects changes the - * `watchExpression` can execute multiple times per - * {@link ng.$rootScope.Scope#$digest $digest()} and should be idempotent.) - * - The `listener` is called only when the value from the current `watchExpression` and the - * previous call to `watchExpression` are not equal (with the exception of the initial run, - * see below). The inequality is determined according to - * {@link angular.equals} function. To save the value of the object for later comparison, - * the {@link angular.copy} function is used. It also means that watching complex options - * will have adverse memory and performance implications. - * - The watch `listener` may change the model, which may trigger other `listener`s to fire. - * This is achieved by rerunning the watchers until no changes are detected. The rerun - * iteration limit is 10 to prevent an infinite loop deadlock. - * - * - * If you want to be notified whenever {@link ng.$rootScope.Scope#$digest $digest} is called, - * you can register a `watchExpression` function with no `listener`. (Since `watchExpression` - * can execute multiple times per {@link ng.$rootScope.Scope#$digest $digest} cycle when a - * change is detected, be prepared for multiple calls to your listener.) - * - * After a watcher is registered with the scope, the `listener` fn is called asynchronously - * (via {@link ng.$rootScope.Scope#$evalAsync $evalAsync}) to initialize the - * watcher. In rare cases, this is undesirable because the listener is called when the result - * of `watchExpression` didn't change. To detect this scenario within the `listener` fn, you - * can compare the `newVal` and `oldVal`. If these two values are identical (`===`) then the - * listener was called due to initialization. - * - * The example below contains an illustration of using a function as your $watch listener - * - * - * # Example - * ```js - // let's assume that scope was dependency injected as the $rootScope - var scope = $rootScope; - scope.name = 'misko'; - scope.counter = 0; + /** + * @ngdoc method + * @name ng.$q#defer + * @kind function + * + * @description + * Creates a `Deferred` object which represents a task which will finish in the future. + * + * @returns {Deferred} Returns a new instance of deferred. + */ + function defer() { + return new Deferred(); + } - expect(scope.counter).toEqual(0); - scope.$watch('name', function(newValue, oldValue) { - scope.counter = scope.counter + 1; - }); - expect(scope.counter).toEqual(0); + function Deferred() { + var promise = this.promise = new Promise(); + //Non prototype methods necessary to support unbound execution :/ + this.resolve = function(val) { resolvePromise(promise, val); }; + this.reject = function(reason) { rejectPromise(promise, reason); }; + this.notify = function(progress) { notifyPromise(promise, progress); }; + } - scope.$digest(); - // no variable change - expect(scope.counter).toEqual(0); - scope.name = 'adam'; - scope.$digest(); - expect(scope.counter).toEqual(1); + function Promise() { + this.$$state = { status: 0 }; + } + extend(Promise.prototype, { + then: function(onFulfilled, onRejected, progressBack) { + if (isUndefined(onFulfilled) && isUndefined(onRejected) && isUndefined(progressBack)) { + return this; + } + var result = new Promise(); + this.$$state.pending = this.$$state.pending || []; + this.$$state.pending.push([result, onFulfilled, onRejected, progressBack]); + if (this.$$state.status > 0) scheduleProcessQueue(this.$$state); - // Using a listener function - var food; - scope.foodCounter = 0; - expect(scope.foodCounter).toEqual(0); - scope.$watch( - // This is the listener function - function() { return food; }, - // This is the change handler - function(newValue, oldValue) { - if ( newValue !== oldValue ) { - // Only increment the counter if the value changed - scope.foodCounter = scope.foodCounter + 1; - } - } - ); - // No digest has been run so the counter will be zero - expect(scope.foodCounter).toEqual(0); - - // Run the digest but since food has not changed count will still be zero - scope.$digest(); - expect(scope.foodCounter).toEqual(0); + return result; + }, - // Update food and run digest. Now the counter will increment - food = 'cheeseburger'; - scope.$digest(); - expect(scope.foodCounter).toEqual(1); + 'catch': function(callback) { + return this.then(null, callback); + }, - * ``` - * - * - * - * @param {(function()|string)} watchExpression Expression that is evaluated on each - * {@link ng.$rootScope.Scope#$digest $digest} cycle. A change in the return value triggers - * a call to the `listener`. - * - * - `string`: Evaluated as {@link guide/expression expression} - * - `function(scope)`: called with current `scope` as a parameter. - * @param {(function()|string)=} listener Callback called whenever the return value of - * the `watchExpression` changes. - * - * - `string`: Evaluated as {@link guide/expression expression} - * - `function(newValue, oldValue, scope)`: called with current and previous values as - * parameters. - * - * @param {boolean=} objectEquality Compare for object equality using {@link angular.equals} instead of - * comparing for reference equality. - * @returns {function()} Returns a deregistration function for this listener. - */ - $watch: function(watchExp, listener, objectEquality) { - var scope = this, - get = compileToFn(watchExp, 'watch'), - array = scope.$$watchers, - watcher = { - fn: listener, - last: initWatchVal, - get: get, - exp: watchExp, - eq: !!objectEquality - }; + 'finally': function(callback, progressBack) { + return this.then(function(value) { + return handleCallback(value, resolve, callback); + }, function(error) { + return handleCallback(error, reject, callback); + }, progressBack); + } + }); - lastDirtyWatch = null; + function processQueue(state) { + var fn, promise, pending; - // in the case user pass string, we need to compile it, do we really need this ? - if (!isFunction(listener)) { - var listenFn = compileToFn(listener || noop, 'listener'); - watcher.fn = function(newVal, oldVal, scope) {listenFn(scope);}; + pending = state.pending; + state.processScheduled = false; + state.pending = undefined; + try { + for (var i = 0, ii = pending.length; i < ii; ++i) { + markQStateExceptionHandled(state); + promise = pending[i][0]; + fn = pending[i][state.status]; + try { + if (isFunction(fn)) { + resolvePromise(promise, fn(state.value)); + } else if (state.status === 1) { + resolvePromise(promise, state.value); + } else { + rejectPromise(promise, state.value); } - - if (typeof watchExp == 'string' && get.constant) { - var originalFn = watcher.fn; - watcher.fn = function(newVal, oldVal, scope) { - originalFn.call(this, newVal, oldVal, scope); - arrayRemove(array, watcher); - }; + } catch (e) { + rejectPromise(promise, e); + // This error is explicitly marked for being passed to the $exceptionHandler + if (e && e.$$passToExceptionHandler === true) { + exceptionHandler(e); } + } + } + } finally { + --queueSize; + if (errorOnUnhandledRejections && queueSize === 0) { + nextTick(processChecks); + } + } + } - if (!array) { - array = scope.$$watchers = []; - } - // we use unshift since we use a while loop in $digest for speed. - // the while loop reads in reverse order. - array.unshift(watcher); + function processChecks() { + // eslint-disable-next-line no-unmodified-loop-condition + while (!queueSize && checkQueue.length) { + var toCheck = checkQueue.shift(); + if (!isStateExceptionHandled(toCheck)) { + markQStateExceptionHandled(toCheck); + var errorMessage = 'Possibly unhandled rejection: ' + toDebugString(toCheck.value); + if (isError(toCheck.value)) { + exceptionHandler(toCheck.value, errorMessage); + } else { + exceptionHandler(errorMessage); + } + } + } + } - return function() { - arrayRemove(array, watcher); - lastDirtyWatch = null; - }; - }, + function scheduleProcessQueue(state) { + if (errorOnUnhandledRejections && !state.pending && state.status === 2 && !isStateExceptionHandled(state)) { + if (queueSize === 0 && checkQueue.length === 0) { + nextTick(processChecks); + } + checkQueue.push(state); + } + if (state.processScheduled || !state.pending) return; + state.processScheduled = true; + ++queueSize; + nextTick(function() { processQueue(state); }); + } + function resolvePromise(promise, val) { + if (promise.$$state.status) return; + if (val === promise) { + $$reject(promise, $qMinErr( + 'qcycle', + 'Expected promise to be resolved with value other than itself \'{0}\'', + val)); + } else { + $$resolve(promise, val); + } - /** - * @ngdoc method - * @name $rootScope.Scope#$watchCollection - * @function - * - * @description - * Shallow watches the properties of an object and fires whenever any of the properties change - * (for arrays, this implies watching the array items; for object maps, this implies watching - * the properties). If a change is detected, the `listener` callback is fired. - * - * - The `obj` collection is observed via standard $watch operation and is examined on every - * call to $digest() to see if any items have been added, removed, or moved. - * - The `listener` is called whenever anything within the `obj` has changed. Examples include - * adding, removing, and moving items belonging to an object or array. - * - * - * # Example - * ```js - $scope.names = ['igor', 'matias', 'misko', 'james']; - $scope.dataCount = 4; + } - $scope.$watchCollection('names', function(newNames, oldNames) { - $scope.dataCount = newNames.length; - }); + function $$resolve(promise, val) { + var then; + var done = false; + try { + if (isObject(val) || isFunction(val)) then = val.then; + if (isFunction(then)) { + promise.$$state.status = -1; + then.call(val, doResolve, doReject, doNotify); + } else { + promise.$$state.value = val; + promise.$$state.status = 1; + scheduleProcessQueue(promise.$$state); + } + } catch (e) { + doReject(e); + } - expect($scope.dataCount).toEqual(4); - $scope.$digest(); + function doResolve(val) { + if (done) return; + done = true; + $$resolve(promise, val); + } + function doReject(val) { + if (done) return; + done = true; + $$reject(promise, val); + } + function doNotify(progress) { + notifyPromise(promise, progress); + } + } - //still at 4 ... no changes - expect($scope.dataCount).toEqual(4); + function rejectPromise(promise, reason) { + if (promise.$$state.status) return; + $$reject(promise, reason); + } - $scope.names.pop(); - $scope.$digest(); + function $$reject(promise, reason) { + promise.$$state.value = reason; + promise.$$state.status = 2; + scheduleProcessQueue(promise.$$state); + } - //now there's been a change - expect($scope.dataCount).toEqual(3); - * ``` - * - * - * @param {string|function(scope)} obj Evaluated as {@link guide/expression expression}. The - * expression value should evaluate to an object or an array which is observed on each - * {@link ng.$rootScope.Scope#$digest $digest} cycle. Any shallow change within the - * collection will trigger a call to the `listener`. - * - * @param {function(newCollection, oldCollection, scope)} listener a callback function called - * when a change is detected. - * - The `newCollection` object is the newly modified data obtained from the `obj` expression - * - The `oldCollection` object is a copy of the former collection data. - * Due to performance considerations, the`oldCollection` value is computed only if the - * `listener` function declares two or more arguments. - * - The `scope` argument refers to the current scope. - * - * @returns {function()} Returns a de-registration function for this listener. When the - * de-registration function is executed, the internal watch operation is terminated. - */ - $watchCollection: function(obj, listener) { - var self = this; - // the current value, updated on each dirty-check run - var newValue; - // a shallow copy of the newValue from the last dirty-check run, - // updated to match newValue during dirty-check run - var oldValue; - // a shallow copy of the newValue from when the last change happened - var veryOldValue; - // only track veryOldValue if the listener is asking for it - var trackVeryOldValue = (listener.length > 1); - var changeDetected = 0; - var objGetter = $parse(obj); - var internalArray = []; - var internalObject = {}; - var initRun = true; - var oldLength = 0; + function notifyPromise(promise, progress) { + var callbacks = promise.$$state.pending; - function $watchCollectionWatch() { - newValue = objGetter(self); - var newLength, key; + if ((promise.$$state.status <= 0) && callbacks && callbacks.length) { + nextTick(function() { + var callback, result; + for (var i = 0, ii = callbacks.length; i < ii; i++) { + result = callbacks[i][0]; + callback = callbacks[i][3]; + try { + notifyPromise(result, isFunction(callback) ? callback(progress) : progress); + } catch (e) { + exceptionHandler(e); + } + } + }); + } + } - if (!isObject(newValue)) { // if primitive - if (oldValue !== newValue) { - oldValue = newValue; - changeDetected++; - } - } else if (isArrayLike(newValue)) { - if (oldValue !== internalArray) { - // we are transitioning from something which was not an array into array. - oldValue = internalArray; - oldLength = oldValue.length = 0; - changeDetected++; - } + /** + * @ngdoc method + * @name $q#reject + * @kind function + * + * @description + * Creates a promise that is resolved as rejected with the specified `reason`. This api should be + * used to forward rejection in a chain of promises. If you are dealing with the last promise in + * a promise chain, you don't need to worry about it. + * + * When comparing deferreds/promises to the familiar behavior of try/catch/throw, think of + * `reject` as the `throw` keyword in JavaScript. This also means that if you "catch" an error via + * a promise error callback and you want to forward the error to the promise derived from the + * current promise, you have to "rethrow" the error by returning a rejection constructed via + * `reject`. + * + * ```js + * promiseB = promiseA.then(function(result) { + * // success: do something and resolve promiseB + * // with the old or a new result + * return result; + * }, function(reason) { + * // error: handle the error if possible and + * // resolve promiseB with newPromiseOrValue, + * // otherwise forward the rejection to promiseB + * if (canHandle(reason)) { + * // handle the error and recover + * return newPromiseOrValue; + * } + * return $q.reject(reason); + * }); + * ``` + * + * @param {*} reason Constant, message, exception or an object representing the rejection reason. + * @returns {Promise} Returns a promise that was already resolved as rejected with the `reason`. + */ + function reject(reason) { + var result = new Promise(); + rejectPromise(result, reason); + return result; + } - newLength = newValue.length; + function handleCallback(value, resolver, callback) { + var callbackOutput = null; + try { + if (isFunction(callback)) callbackOutput = callback(); + } catch (e) { + return reject(e); + } + if (isPromiseLike(callbackOutput)) { + return callbackOutput.then(function() { + return resolver(value); + }, reject); + } else { + return resolver(value); + } + } - if (oldLength !== newLength) { - // if lengths do not match we need to trigger change notification - changeDetected++; - oldValue.length = oldLength = newLength; - } - // copy the items to oldValue and look for changes. - for (var i = 0; i < newLength; i++) { - var bothNaN = (oldValue[i] !== oldValue[i]) && - (newValue[i] !== newValue[i]); - if (!bothNaN && (oldValue[i] !== newValue[i])) { - changeDetected++; - oldValue[i] = newValue[i]; - } - } - } else { - if (oldValue !== internalObject) { - // we are transitioning from something which was not an object into object. - oldValue = internalObject = {}; - oldLength = 0; - changeDetected++; - } - // copy the items to oldValue and look for changes. - newLength = 0; - for (key in newValue) { - if (newValue.hasOwnProperty(key)) { - newLength++; - if (oldValue.hasOwnProperty(key)) { - if (oldValue[key] !== newValue[key]) { - changeDetected++; - oldValue[key] = newValue[key]; - } - } else { - oldLength++; - oldValue[key] = newValue[key]; - changeDetected++; - } - } - } - if (oldLength > newLength) { - // we used to have more keys, need to find them and destroy them. - changeDetected++; - for(key in oldValue) { - if (oldValue.hasOwnProperty(key) && !newValue.hasOwnProperty(key)) { - oldLength--; - delete oldValue[key]; - } - } - } - } - return changeDetected; - } + /** + * @ngdoc method + * @name $q#when + * @kind function + * + * @description + * Wraps an object that might be a value or a (3rd party) then-able promise into a $q promise. + * This is useful when you are dealing with an object that might or might not be a promise, or if + * the promise comes from a source that can't be trusted. + * + * @param {*} value Value or a promise + * @param {Function=} successCallback + * @param {Function=} errorCallback + * @param {Function=} progressCallback + * @returns {Promise} Returns a promise of the passed value or promise + */ - function $watchCollectionAction() { - if (initRun) { - initRun = false; - listener(newValue, newValue, self); - } else { - listener(newValue, veryOldValue, self); - } - - // make a copy for the next time a collection is changed - if (trackVeryOldValue) { - if (!isObject(newValue)) { - //primitive - veryOldValue = newValue; - } else if (isArrayLike(newValue)) { - veryOldValue = new Array(newValue.length); - for (var i = 0; i < newValue.length; i++) { - veryOldValue[i] = newValue[i]; - } - } else { // if object - veryOldValue = {}; - for (var key in newValue) { - if (hasOwnProperty.call(newValue, key)) { - veryOldValue[key] = newValue[key]; - } - } - } - } - } - return this.$watch($watchCollectionWatch, $watchCollectionAction); - }, - - /** - * @ngdoc method - * @name $rootScope.Scope#$digest - * @function - * - * @description - * Processes all of the {@link ng.$rootScope.Scope#$watch watchers} of the current scope and - * its children. Because a {@link ng.$rootScope.Scope#$watch watcher}'s listener can change - * the model, the `$digest()` keeps calling the {@link ng.$rootScope.Scope#$watch watchers} - * until no more listeners are firing. This means that it is possible to get into an infinite - * loop. This function will throw `'Maximum iteration limit exceeded.'` if the number of - * iterations exceeds 10. - * - * Usually, you don't call `$digest()` directly in - * {@link ng.directive:ngController controllers} or in - * {@link ng.$compileProvider#directive directives}. - * Instead, you should call {@link ng.$rootScope.Scope#$apply $apply()} (typically from within - * a {@link ng.$compileProvider#directive directives}), which will force a `$digest()`. - * - * If you want to be notified whenever `$digest()` is called, - * you can register a `watchExpression` function with - * {@link ng.$rootScope.Scope#$watch $watch()} with no `listener`. - * - * In unit tests, you may need to call `$digest()` to simulate the scope life cycle. - * - * # Example - * ```js - var scope = ...; - scope.name = 'misko'; - scope.counter = 0; + function when(value, callback, errback, progressBack) { + var result = new Promise(); + resolvePromise(result, value); + return result.then(callback, errback, progressBack); + } - expect(scope.counter).toEqual(0); - scope.$watch('name', function(newValue, oldValue) { - scope.counter = scope.counter + 1; - }); - expect(scope.counter).toEqual(0); + /** + * @ngdoc method + * @name $q#resolve + * @kind function + * + * @description + * Alias of {@link ng.$q#when when} to maintain naming consistency with ES6. + * + * @param {*} value Value or a promise + * @param {Function=} successCallback + * @param {Function=} errorCallback + * @param {Function=} progressCallback + * @returns {Promise} Returns a promise of the passed value or promise + */ + var resolve = when; - scope.$digest(); - // no variable change - expect(scope.counter).toEqual(0); + /** + * @ngdoc method + * @name $q#all + * @kind function + * + * @description + * Combines multiple promises into a single promise that is resolved when all of the input + * promises are resolved. + * + * @param {Array.<Promise>|Object.<Promise>} promises An array or hash of promises. + * @returns {Promise} Returns a single promise that will be resolved with an array/hash of values, + * each value corresponding to the promise at the same index/key in the `promises` array/hash. + * If any of the promises is resolved with a rejection, this resulting promise will be rejected + * with the same rejection value. + */ - scope.name = 'adam'; - scope.$digest(); - expect(scope.counter).toEqual(1); - * ``` - * - */ - $digest: function() { - var watch, value, last, - watchers, - asyncQueue = this.$$asyncQueue, - postDigestQueue = this.$$postDigestQueue, - length, - dirty, ttl = TTL, - next, current, target = this, - watchLog = [], - logIdx, logMsg, asyncTask; + function all(promises) { + var result = new Promise(), + counter = 0, + results = isArray(promises) ? [] : {}; - beginPhase('$digest'); + forEach(promises, function(promise, key) { + counter++; + when(promise).then(function(value) { + results[key] = value; + if (!(--counter)) resolvePromise(result, results); + }, function(reason) { + rejectPromise(result, reason); + }); + }); - lastDirtyWatch = null; + if (counter === 0) { + resolvePromise(result, results); + } - do { // "while dirty" loop - dirty = false; - current = target; + return result; + } - while(asyncQueue.length) { - try { - asyncTask = asyncQueue.shift(); - asyncTask.scope.$eval(asyncTask.expression); - } catch (e) { - clearPhase(); - $exceptionHandler(e); - } - lastDirtyWatch = null; - } + /** + * @ngdoc method + * @name $q#race + * @kind function + * + * @description + * Returns a promise that resolves or rejects as soon as one of those promises + * resolves or rejects, with the value or reason from that promise. + * + * @param {Array.<Promise>|Object.<Promise>} promises An array or hash of promises. + * @returns {Promise} a promise that resolves or rejects as soon as one of the `promises` + * resolves or rejects, with the value or reason from that promise. + */ - traverseScopesLoop: - do { // "traverse the scopes" loop - if ((watchers = current.$$watchers)) { - // process our watches - length = watchers.length; - while (length--) { - try { - watch = watchers[length]; - // Most common watches are on primitives, in which case we can short - // circuit it with === operator, only when === fails do we use .equals - if (watch) { - if ((value = watch.get(current)) !== (last = watch.last) && - !(watch.eq - ? equals(value, last) - : (typeof value == 'number' && typeof last == 'number' - && isNaN(value) && isNaN(last)))) { - dirty = true; - lastDirtyWatch = watch; - watch.last = watch.eq ? copy(value) : value; - watch.fn(value, ((last === initWatchVal) ? value : last), current); - if (ttl < 5) { - logIdx = 4 - ttl; - if (!watchLog[logIdx]) watchLog[logIdx] = []; - logMsg = (isFunction(watch.exp)) - ? 'fn: ' + (watch.exp.name || watch.exp.toString()) - : watch.exp; - logMsg += '; newVal: ' + toJson(value) + '; oldVal: ' + toJson(last); - watchLog[logIdx].push(logMsg); - } - } else if (watch === lastDirtyWatch) { - // If the most recently dirty watcher is now clean, short circuit since the remaining watchers - // have already been tested. - dirty = false; - break traverseScopesLoop; - } - } - } catch (e) { - clearPhase(); - $exceptionHandler(e); - } - } - } + function race(promises) { + var deferred = defer(); - // Insanity Warning: scope depth-first traversal - // yes, this code is a bit crazy, but it works and we have tests to prove it! - // this piece should be kept in sync with the traversal in $broadcast - if (!(next = (current.$$childHead || - (current !== target && current.$$nextSibling)))) { - while(current !== target && !(next = current.$$nextSibling)) { - current = current.$parent; - } - } - } while ((current = next)); + forEach(promises, function(promise) { + when(promise).then(deferred.resolve, deferred.reject); + }); - // `break traverseScopesLoop;` takes us to here + return deferred.promise; + } - if((dirty || asyncQueue.length) && !(ttl--)) { - clearPhase(); - throw $rootScopeMinErr('infdig', - '{0} $digest() iterations reached. Aborting!\n' + - 'Watchers fired in the last 5 iterations: {1}', - TTL, toJson(watchLog)); - } + function $Q(resolver) { + if (!isFunction(resolver)) { + throw $qMinErr('norslvr', 'Expected resolverFn, got \'{0}\'', resolver); + } - } while (dirty || asyncQueue.length); + var promise = new Promise(); - clearPhase(); + function resolveFn(value) { + resolvePromise(promise, value); + } - while(postDigestQueue.length) { - try { - postDigestQueue.shift()(); - } catch (e) { - $exceptionHandler(e); - } - } - }, + function rejectFn(reason) { + rejectPromise(promise, reason); + } + resolver(resolveFn, rejectFn); - /** - * @ngdoc event - * @name $rootScope.Scope#$destroy - * @eventType broadcast on scope being destroyed - * - * @description - * Broadcasted when a scope and its children are being destroyed. - * - * Note that, in AngularJS, there is also a `$destroy` jQuery event, which can be used to - * clean up DOM bindings before an element is removed from the DOM. - */ + return promise; + } - /** - * @ngdoc method - * @name $rootScope.Scope#$destroy - * @function - * - * @description - * Removes the current scope (and all of its children) from the parent scope. Removal implies - * that calls to {@link ng.$rootScope.Scope#$digest $digest()} will no longer - * propagate to the current scope and its children. Removal also implies that the current - * scope is eligible for garbage collection. - * - * The `$destroy()` is usually used by directives such as - * {@link ng.directive:ngRepeat ngRepeat} for managing the - * unrolling of the loop. - * - * Just before a scope is destroyed, a `$destroy` event is broadcasted on this scope. - * Application code can register a `$destroy` event handler that will give it a chance to - * perform any necessary cleanup. - * - * Note that, in AngularJS, there is also a `$destroy` jQuery event, which can be used to - * clean up DOM bindings before an element is removed from the DOM. - */ - $destroy: function() { - // we can't destroy the root scope or a scope that has been already destroyed - if (this.$$destroyed) return; - var parent = this.$parent; + // Let's make the instanceof operator work for promises, so that + // `new $q(fn) instanceof $q` would evaluate to true. + $Q.prototype = Promise.prototype; - this.$broadcast('$destroy'); - this.$$destroyed = true; - if (this === $rootScope) return; + $Q.defer = defer; + $Q.reject = reject; + $Q.when = when; + $Q.resolve = resolve; + $Q.all = all; + $Q.race = race; - forEach(this.$$listenerCount, bind(null, decrementListenerCount, this)); + return $Q; + } - // sever all the references to parent scopes (after this cleanup, the current scope should - // not be retained by any of our references and should be eligible for garbage collection) - if (parent.$$childHead == this) parent.$$childHead = this.$$nextSibling; - if (parent.$$childTail == this) parent.$$childTail = this.$$prevSibling; - if (this.$$prevSibling) this.$$prevSibling.$$nextSibling = this.$$nextSibling; - if (this.$$nextSibling) this.$$nextSibling.$$prevSibling = this.$$prevSibling; + function isStateExceptionHandled(state) { + return !!state.pur; + } + function markQStateExceptionHandled(state) { + state.pur = true; + } + function markQExceptionHandled(q) { + markQStateExceptionHandled(q.$$state); + } + /** @this */ + function $$RAFProvider() { //rAF + this.$get = ['$window', '$timeout', function($window, $timeout) { + var requestAnimationFrame = $window.requestAnimationFrame || + $window.webkitRequestAnimationFrame; - // All of the code below is bogus code that works around V8's memory leak via optimized code - // and inline caches. - // - // see: - // - https://code.google.com/p/v8/issues/detail?id=2073#c26 - // - https://github.com/angular/angular.js/issues/6794#issuecomment-38648909 - // - https://github.com/angular/angular.js/issues/1313#issuecomment-10378451 + var cancelAnimationFrame = $window.cancelAnimationFrame || + $window.webkitCancelAnimationFrame || + $window.webkitCancelRequestAnimationFrame; - this.$parent = this.$$nextSibling = this.$$prevSibling = this.$$childHead = - this.$$childTail = this.$root = null; + var rafSupported = !!requestAnimationFrame; + var raf = rafSupported + ? function(fn) { + var id = requestAnimationFrame(fn); + return function() { + cancelAnimationFrame(id); + }; + } + : function(fn) { + var timer = $timeout(fn, 16.66, false); // 1000 / 60 = 16.666 + return function() { + $timeout.cancel(timer); + }; + }; - // don't reset these to null in case some async task tries to register a listener/watch/task - this.$$listeners = {}; - this.$$watchers = this.$$asyncQueue = this.$$postDigestQueue = []; + raf.supported = rafSupported; - // prevent NPEs since these methods have references to properties we nulled out - this.$destroy = this.$digest = this.$apply = noop; - this.$on = this.$watch = function() { return noop; }; - }, + return raf; + }]; + } - /** - * @ngdoc method - * @name $rootScope.Scope#$eval - * @function - * - * @description - * Executes the `expression` on the current scope and returns the result. Any exceptions in - * the expression are propagated (uncaught). This is useful when evaluating Angular - * expressions. - * - * # Example - * ```js - var scope = ng.$rootScope.Scope(); - scope.a = 1; - scope.b = 2; + /** + * DESIGN NOTES + * + * The design decisions behind the scope are heavily favored for speed and memory consumption. + * + * The typical use of scope is to watch the expressions, which most of the time return the same + * value as last time so we optimize the operation. + * + * Closures construction is expensive in terms of speed as well as memory: + * - No closures, instead use prototypical inheritance for API + * - Internal state needs to be stored on scope directly, which means that private state is + * exposed as $$____ properties + * + * Loop operations are optimized by using while(count--) { ... } + * - This means that in order to keep the same order of execution as addition we have to add + * items to the array at the beginning (unshift) instead of at the end (push) + * + * Child scopes are created and removed often + * - Using an array would be slow since inserts in the middle are expensive; so we use linked lists + * + * There are fewer watches than observers. This is why you don't want the observer to be implemented + * in the same way as watch. Watch requires return of the initialization function which is expensive + * to construct. + */ - expect(scope.$eval('a+b')).toEqual(3); - expect(scope.$eval(function(scope){ return scope.a + scope.b; })).toEqual(3); - * ``` - * - * @param {(string|function())=} expression An angular expression to be executed. - * - * - `string`: execute using the rules as defined in {@link guide/expression expression}. - * - `function(scope)`: execute the function with the current `scope` parameter. - * - * @param {(object)=} locals Local variables object, useful for overriding values in scope. - * @returns {*} The result of evaluating the expression. - */ - $eval: function(expr, locals) { - return $parse(expr)(this, locals); - }, + /** + * @ngdoc provider + * @name $rootScopeProvider + * @description + * + * Provider for the $rootScope service. + */ + + /** + * @ngdoc method + * @name $rootScopeProvider#digestTtl + * @description + * + * Sets the number of `$digest` iterations the scope should attempt to execute before giving up and + * assuming that the model is unstable. + * + * The current default is 10 iterations. + * + * In complex applications it's possible that the dependencies between `$watch`s will result in + * several digest iterations. However if an application needs more than the default 10 digest + * iterations for its model to stabilize then you should investigate what is causing the model to + * continuously change during the digest. + * + * Increasing the TTL could have performance implications, so you should not change it without + * proper justification. + * + * @param {number} limit The number of digest iterations. + */ + + + /** + * @ngdoc service + * @name $rootScope + * @this + * + * @description + * + * Every application has a single root {@link ng.$rootScope.Scope scope}. + * All other scopes are descendant scopes of the root scope. Scopes provide separation + * between the model and the view, via a mechanism for watching the model for changes. + * They also provide event emission/broadcast and subscription facility. See the + * {@link guide/scope developer guide on scopes}. + */ + function $RootScopeProvider() { + var TTL = 10; + var $rootScopeMinErr = minErr('$rootScope'); + var lastDirtyWatch = null; + var applyAsyncId = null; + + this.digestTtl = function(value) { + if (arguments.length) { + TTL = value; + } + return TTL; + }; + + function createChildScopeClass(parent) { + function ChildScope() { + this.$$watchers = this.$$nextSibling = + this.$$childHead = this.$$childTail = null; + this.$$listeners = {}; + this.$$listenerCount = {}; + this.$$watchersCount = 0; + this.$id = nextUid(); + this.$$ChildScope = null; + } + ChildScope.prototype = parent; + return ChildScope; + } + + this.$get = ['$exceptionHandler', '$parse', '$browser', + function($exceptionHandler, $parse, $browser) { + + function destroyChildScope($event) { + $event.currentScope.$$destroyed = true; + } + + function cleanUpScope($scope) { + + // Support: IE 9 only + if (msie === 9) { + // There is a memory leak in IE9 if all child scopes are not disconnected + // completely when a scope is destroyed. So this code will recurse up through + // all this scopes children + // + // See issue https://github.com/angular/angular.js/issues/10706 + if ($scope.$$childHead) { + cleanUpScope($scope.$$childHead); + } + if ($scope.$$nextSibling) { + cleanUpScope($scope.$$nextSibling); + } + } + + // The code below works around IE9 and V8's memory leaks + // + // See: + // - https://code.google.com/p/v8/issues/detail?id=2073#c26 + // - https://github.com/angular/angular.js/issues/6794#issuecomment-38648909 + // - https://github.com/angular/angular.js/issues/1313#issuecomment-10378451 + + $scope.$parent = $scope.$$nextSibling = $scope.$$prevSibling = $scope.$$childHead = + $scope.$$childTail = $scope.$root = $scope.$$watchers = null; + } + + /** + * @ngdoc type + * @name $rootScope.Scope + * + * @description + * A root scope can be retrieved using the {@link ng.$rootScope $rootScope} key from the + * {@link auto.$injector $injector}. Child scopes are created using the + * {@link ng.$rootScope.Scope#$new $new()} method. (Most scopes are created automatically when + * compiled HTML template is executed.) See also the {@link guide/scope Scopes guide} for + * an in-depth introduction and usage examples. + * + * + * ## Inheritance + * A scope can inherit from a parent scope, as in this example: + * ```js + var parent = $rootScope; + var child = parent.$new(); + + parent.salutation = "Hello"; + expect(child.salutation).toEqual('Hello'); + + child.salutation = "Welcome"; + expect(child.salutation).toEqual('Welcome'); + expect(parent.salutation).toEqual('Hello'); + * ``` + * + * When interacting with `Scope` in tests, additional helper methods are available on the + * instances of `Scope` type. See {@link ngMock.$rootScope.Scope ngMock Scope} for additional + * details. + * + * + * @param {Object.<string, function()>=} providers Map of service factory which need to be + * provided for the current scope. Defaults to {@link ng}. + * @param {Object.<string, *>=} instanceCache Provides pre-instantiated services which should + * append/override services provided by `providers`. This is handy + * when unit-testing and having the need to override a default + * service. + * @returns {Object} Newly created scope. + * + */ + function Scope() { + this.$id = nextUid(); + this.$$phase = this.$parent = this.$$watchers = + this.$$nextSibling = this.$$prevSibling = + this.$$childHead = this.$$childTail = null; + this.$root = this; + this.$$destroyed = false; + this.$$listeners = {}; + this.$$listenerCount = {}; + this.$$watchersCount = 0; + this.$$isolateBindings = null; + } + + /** + * @ngdoc property + * @name $rootScope.Scope#$id + * + * @description + * Unique scope ID (monotonically increasing) useful for debugging. + */ + + /** + * @ngdoc property + * @name $rootScope.Scope#$parent + * + * @description + * Reference to the parent scope. + */ + + /** + * @ngdoc property + * @name $rootScope.Scope#$root + * + * @description + * Reference to the root scope. + */ + + Scope.prototype = { + constructor: Scope, /** * @ngdoc method - * @name $rootScope.Scope#$evalAsync - * @function + * @name $rootScope.Scope#$new + * @kind function * * @description - * Executes the expression on the current scope at a later point in time. - * - * The `$evalAsync` makes no guarantees as to when the `expression` will be executed, only - * that: + * Creates a new child {@link ng.$rootScope.Scope scope}. * - * - it will execute after the function that scheduled the evaluation (preferably before DOM - * rendering). - * - at least one {@link ng.$rootScope.Scope#$digest $digest cycle} will be performed after - * `expression` execution. + * The parent scope will propagate the {@link ng.$rootScope.Scope#$digest $digest()} event. + * The scope can be removed from the scope hierarchy using {@link ng.$rootScope.Scope#$destroy $destroy()}. * - * Any exceptions from the execution of the expression are forwarded to the - * {@link ng.$exceptionHandler $exceptionHandler} service. + * {@link ng.$rootScope.Scope#$destroy $destroy()} must be called on a scope when it is + * desired for the scope and its child scopes to be permanently detached from the parent and + * thus stop participating in model change detection and listener notification by invoking. * - * __Note:__ if this function is called outside of a `$digest` cycle, a new `$digest` cycle - * will be scheduled. However, it is encouraged to always call code that changes the model - * from within an `$apply` call. That includes code evaluated via `$evalAsync`. + * @param {boolean} isolate If true, then the scope does not prototypically inherit from the + * parent scope. The scope is isolated, as it can not see parent scope properties. + * When creating widgets, it is useful for the widget to not accidentally read parent + * state. * - * @param {(string|function())=} expression An angular expression to be executed. + * @param {Scope} [parent=this] The {@link ng.$rootScope.Scope `Scope`} that will be the `$parent` + * of the newly created scope. Defaults to `this` scope if not provided. + * This is used when creating a transclude scope to correctly place it + * in the scope hierarchy while maintaining the correct prototypical + * inheritance. * - * - `string`: execute using the rules as defined in {@link guide/expression expression}. - * - `function(scope)`: execute the function with the current `scope` parameter. + * @returns {Object} The newly created child scope. * */ - $evalAsync: function(expr) { - // if we are outside of an $digest loop and this is the first time we are scheduling async - // task also schedule async auto-flush - if (!$rootScope.$$phase && !$rootScope.$$asyncQueue.length) { - $browser.defer(function() { - if ($rootScope.$$asyncQueue.length) { - $rootScope.$digest(); - } - }); + $new: function(isolate, parent) { + var child; + + parent = parent || this; + + if (isolate) { + child = new Scope(); + child.$root = this.$root; + } else { + // Only create a child scope class if somebody asks for one, + // but cache it to allow the VM to optimize lookups. + if (!this.$$ChildScope) { + this.$$ChildScope = createChildScopeClass(this); + } + child = new this.$$ChildScope(); + } + child.$parent = parent; + child.$$prevSibling = parent.$$childTail; + if (parent.$$childHead) { + parent.$$childTail.$$nextSibling = child; + parent.$$childTail = child; + } else { + parent.$$childHead = parent.$$childTail = child; } - this.$$asyncQueue.push({scope: this, expression: expr}); - }, + // When the new scope is not isolated or we inherit from `this`, and + // the parent scope is destroyed, the property `$$destroyed` is inherited + // prototypically. In all other cases, this property needs to be set + // when the parent scope is destroyed. + // The listener needs to be added after the parent is set + if (isolate || parent !== this) child.$on('$destroy', destroyChildScope); - $$postDigest : function(fn) { - this.$$postDigestQueue.push(fn); + return child; }, /** * @ngdoc method - * @name $rootScope.Scope#$apply - * @function + * @name $rootScope.Scope#$watch + * @kind function * * @description - * `$apply()` is used to execute an expression in angular from outside of the angular - * framework. (For example from browser DOM events, setTimeout, XHR or third party libraries). - * Because we are calling into the angular framework we need to perform proper scope life - * cycle of {@link ng.$exceptionHandler exception handling}, - * {@link ng.$rootScope.Scope#$digest executing watches}. - * - * ## Life cycle + * Registers a `listener` callback to be executed whenever the `watchExpression` changes. * - * # Pseudo-Code of `$apply()` - * ```js - function $apply(expr) { - try { - return $eval(expr); - } catch (e) { - $exceptionHandler(e); - } finally { - $root.$digest(); - } - } - * ``` + * - The `watchExpression` is called on every call to {@link ng.$rootScope.Scope#$digest + * $digest()} and should return the value that will be watched. (`watchExpression` should not change + * its value when executed multiple times with the same input because it may be executed multiple + * times by {@link ng.$rootScope.Scope#$digest $digest()}. That is, `watchExpression` should be + * [idempotent](http://en.wikipedia.org/wiki/Idempotence).) + * - The `listener` is called only when the value from the current `watchExpression` and the + * previous call to `watchExpression` are not equal (with the exception of the initial run, + * see below). Inequality is determined according to reference inequality, + * [strict comparison](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Comparison_Operators) + * via the `!==` Javascript operator, unless `objectEquality == true` + * (see next point) + * - When `objectEquality == true`, inequality of the `watchExpression` is determined + * according to the {@link angular.equals} function. To save the value of the object for + * later comparison, the {@link angular.copy} function is used. This therefore means that + * watching complex objects will have adverse memory and performance implications. + * - This should not be used to watch for changes in objects that are + * or contain [File](https://developer.mozilla.org/docs/Web/API/File) objects due to limitations with {@link angular.copy `angular.copy`}. + * - The watch `listener` may change the model, which may trigger other `listener`s to fire. + * This is achieved by rerunning the watchers until no changes are detected. The rerun + * iteration limit is 10 to prevent an infinite loop deadlock. * * - * Scope's `$apply()` method transitions through the following stages: + * If you want to be notified whenever {@link ng.$rootScope.Scope#$digest $digest} is called, + * you can register a `watchExpression` function with no `listener`. (Be prepared for + * multiple calls to your `watchExpression` because it will execute multiple times in a + * single {@link ng.$rootScope.Scope#$digest $digest} cycle if a change is detected.) * - * 1. The {@link guide/expression expression} is executed using the - * {@link ng.$rootScope.Scope#$eval $eval()} method. - * 2. Any exceptions from the execution of the expression are forwarded to the - * {@link ng.$exceptionHandler $exceptionHandler} service. - * 3. The {@link ng.$rootScope.Scope#$watch watch} listeners are fired immediately after the - * expression was executed using the {@link ng.$rootScope.Scope#$digest $digest()} method. + * After a watcher is registered with the scope, the `listener` fn is called asynchronously + * (via {@link ng.$rootScope.Scope#$evalAsync $evalAsync}) to initialize the + * watcher. In rare cases, this is undesirable because the listener is called when the result + * of `watchExpression` didn't change. To detect this scenario within the `listener` fn, you + * can compare the `newVal` and `oldVal`. If these two values are identical (`===`) then the + * listener was called due to initialization. * * - * @param {(string|function())=} exp An angular expression to be executed. * - * - `string`: execute using the rules as defined in {@link guide/expression expression}. - * - `function(scope)`: execute the function with current `scope` parameter. - * - * @returns {*} The result of evaluating the expression. - */ - $apply: function(expr) { - try { - beginPhase('$apply'); - return this.$eval(expr); - } catch (e) { - $exceptionHandler(e); - } finally { - clearPhase(); - try { - $rootScope.$digest(); - } catch (e) { - $exceptionHandler(e); - throw e; - } - } - }, + * @example + * ```js + // let's assume that scope was dependency injected as the $rootScope + var scope = $rootScope; + scope.name = 'misko'; + scope.counter = 0; - /** - * @ngdoc method - * @name $rootScope.Scope#$on - * @function + expect(scope.counter).toEqual(0); + scope.$watch('name', function(newValue, oldValue) { + scope.counter = scope.counter + 1; + }); + expect(scope.counter).toEqual(0); + + scope.$digest(); + // the listener is always called during the first $digest loop after it was registered + expect(scope.counter).toEqual(1); + + scope.$digest(); + // but now it will not be called unless the value changes + expect(scope.counter).toEqual(1); + + scope.name = 'adam'; + scope.$digest(); + expect(scope.counter).toEqual(2); + + + + // Using a function as a watchExpression + var food; + scope.foodCounter = 0; + expect(scope.foodCounter).toEqual(0); + scope.$watch( + // This function returns the value being watched. It is called for each turn of the $digest loop + function() { return food; }, + // This is the change listener, called when the value returned from the above function changes + function(newValue, oldValue) { + if ( newValue !== oldValue ) { + // Only increment the counter if the value changed + scope.foodCounter = scope.foodCounter + 1; + } + } + ); + // No digest has been run so the counter will be zero + expect(scope.foodCounter).toEqual(0); + + // Run the digest but since food has not changed count will still be zero + scope.$digest(); + expect(scope.foodCounter).toEqual(0); + + // Update food and run digest. Now the counter will increment + food = 'cheeseburger'; + scope.$digest(); + expect(scope.foodCounter).toEqual(1); + + * ``` * - * @description - * Listens on events of a given type. See {@link ng.$rootScope.Scope#$emit $emit} for - * discussion of event life cycle. * - * The event listener function format is: `function(event, args...)`. The `event` object - * passed into the listener has the following attributes: * - * - `targetScope` - `{Scope}`: the scope on which the event was `$emit`-ed or - * `$broadcast`-ed. - * - `currentScope` - `{Scope}`: the current scope which is handling the event. - * - `name` - `{string}`: name of the event. - * - `stopPropagation` - `{function=}`: calling `stopPropagation` function will cancel - * further event propagation (available only for events that were `$emit`-ed). - * - `preventDefault` - `{function}`: calling `preventDefault` sets `defaultPrevented` flag - * to true. - * - `defaultPrevented` - `{boolean}`: true if `preventDefault` was called. + * @param {(function()|string)} watchExpression Expression that is evaluated on each + * {@link ng.$rootScope.Scope#$digest $digest} cycle. A change in the return value triggers + * a call to the `listener`. * - * @param {string} name Event name to listen on. - * @param {function(event, ...args)} listener Function to call when the event is emitted. + * - `string`: Evaluated as {@link guide/expression expression} + * - `function(scope)`: called with current `scope` as a parameter. + * @param {function(newVal, oldVal, scope)} listener Callback called whenever the value + * of `watchExpression` changes. + * + * - `newVal` contains the current value of the `watchExpression` + * - `oldVal` contains the previous value of the `watchExpression` + * - `scope` refers to the current scope + * @param {boolean=} [objectEquality=false] Compare for object equality using {@link angular.equals} instead of + * comparing for reference equality. * @returns {function()} Returns a deregistration function for this listener. */ - $on: function(name, listener) { - var namedListeners = this.$$listeners[name]; - if (!namedListeners) { - this.$$listeners[name] = namedListeners = []; + $watch: function(watchExp, listener, objectEquality, prettyPrintExpression) { + var get = $parse(watchExp); + var fn = isFunction(listener) ? listener : noop; + + if (get.$$watchDelegate) { + return get.$$watchDelegate(this, fn, objectEquality, get, watchExp); } - namedListeners.push(listener); + var scope = this, + array = scope.$$watchers, + watcher = { + fn: fn, + last: initWatchVal, + get: get, + exp: prettyPrintExpression || watchExp, + eq: !!objectEquality + }; - var current = this; - do { - if (!current.$$listenerCount[name]) { - current.$$listenerCount[name] = 0; - } - current.$$listenerCount[name]++; - } while ((current = current.$parent)); + lastDirtyWatch = null; - var self = this; - return function() { - namedListeners[indexOf(namedListeners, listener)] = null; - decrementListenerCount(self, 1, name); + if (!array) { + array = scope.$$watchers = []; + array.$$digestWatchIndex = -1; + } + // we use unshift since we use a while loop in $digest for speed. + // the while loop reads in reverse order. + array.unshift(watcher); + array.$$digestWatchIndex++; + incrementWatchersCount(this, 1); + + return function deregisterWatch() { + var index = arrayRemove(array, watcher); + if (index >= 0) { + incrementWatchersCount(scope, -1); + if (index < array.$$digestWatchIndex) { + array.$$digestWatchIndex--; + } + } + lastDirtyWatch = null; }; }, - /** * @ngdoc method - * @name $rootScope.Scope#$emit - * @function + * @name $rootScope.Scope#$watchGroup + * @kind function * * @description - * Dispatches an event `name` upwards through the scope hierarchy notifying the - * registered {@link ng.$rootScope.Scope#$on} listeners. + * A variant of {@link ng.$rootScope.Scope#$watch $watch()} where it watches an array of `watchExpressions`. + * If any one expression in the collection changes the `listener` is executed. * - * The event life cycle starts at the scope on which `$emit` was called. All - * {@link ng.$rootScope.Scope#$on listeners} listening for `name` event on this scope get - * notified. Afterwards, the event traverses upwards toward the root scope and calls all - * registered listeners along the way. The event will stop propagating if one of the listeners - * cancels it. + * - The items in the `watchExpressions` array are observed via the standard `$watch` operation. Their return + * values are examined for changes on every call to `$digest`. + * - The `listener` is called whenever any expression in the `watchExpressions` array changes. * - * Any exception emitted from the {@link ng.$rootScope.Scope#$on listeners} will be passed - * onto the {@link ng.$exceptionHandler $exceptionHandler} service. + * `$watchGroup` is more performant than watching each expression individually, and should be + * used when the listener does not need to know which expression has changed. + * If the listener needs to know which expression has changed, + * {@link ng.$rootScope.Scope#$watch $watch()} or + * {@link ng.$rootScope.Scope#$watchCollection $watchCollection()} should be used. * - * @param {string} name Event name to emit. - * @param {...*} args Optional one or more arguments which will be passed onto the event listeners. - * @return {Object} Event object (see {@link ng.$rootScope.Scope#$on}). + * @param {Array.<string|Function(scope)>} watchExpressions Array of expressions that will be individually + * watched using {@link ng.$rootScope.Scope#$watch $watch()} + * + * @param {function(newValues, oldValues, scope)} listener Callback called whenever the return value of any + * expression in `watchExpressions` changes + * The `newValues` array contains the current values of the `watchExpressions`, with the indexes matching + * those of `watchExpression` + * and the `oldValues` array contains the previous values of the `watchExpressions`, with the indexes matching + * those of `watchExpression`. + * + * Note that `newValues` and `oldValues` reflect the differences in each **individual** + * expression, and not the difference of the values between each call of the listener. + * That means the difference between `newValues` and `oldValues` cannot be used to determine + * which expression has changed / remained stable: + * + * ```js + * + * $scope.$watchGroup(['v1', 'v2'], function(newValues, oldValues) { + * console.log(newValues, oldValues); + * }); + * + * // newValues, oldValues initially + * // [undefined, undefined], [undefined, undefined] + * + * $scope.v1 = 'a'; + * $scope.v2 = 'a'; + * + * // ['a', 'a'], [undefined, undefined] + * + * $scope.v2 = 'b' + * + * // v1 hasn't changed since it became `'a'`, therefore its oldValue is still `undefined` + * // ['a', 'b'], [undefined, 'a'] + * + * ``` + * + * The `scope` refers to the current scope. + * @returns {function()} Returns a de-registration function for all listeners. */ - $emit: function(name, args) { - var empty = [], - namedListeners, - scope = this, - stopPropagation = false, - event = { - name: name, - targetScope: scope, - stopPropagation: function() {stopPropagation = true;}, - preventDefault: function() { - event.defaultPrevented = true; - }, - defaultPrevented: false - }, - listenerArgs = concat([event], arguments, 1), - i, length; + $watchGroup: function(watchExpressions, listener) { + var oldValues = new Array(watchExpressions.length); + var newValues = new Array(watchExpressions.length); + var deregisterFns = []; + var self = this; + var changeReactionScheduled = false; + var firstRun = true; + + if (!watchExpressions.length) { + // No expressions means we call the listener ASAP + var shouldCall = true; + self.$evalAsync(function() { + if (shouldCall) listener(newValues, newValues, self); + }); + return function deregisterWatchGroup() { + shouldCall = false; + }; + } - do { - namedListeners = scope.$$listeners[name] || empty; - event.currentScope = scope; - for (i=0, length=namedListeners.length; i<length; i++) { + if (watchExpressions.length === 1) { + // Special case size of one + return this.$watch(watchExpressions[0], function watchGroupAction(value, oldValue, scope) { + newValues[0] = value; + oldValues[0] = oldValue; + listener(newValues, (value === oldValue) ? newValues : oldValues, scope); + }); + } - // if listeners were deregistered, defragment the array - if (!namedListeners[i]) { - namedListeners.splice(i, 1); - i--; - length--; - continue; - } - try { - //allow all listeners attached to the current scope to run - namedListeners[i].apply(null, listenerArgs); - } catch (e) { - $exceptionHandler(e); + forEach(watchExpressions, function(expr, i) { + var unwatchFn = self.$watch(expr, function watchGroupSubAction(value, oldValue) { + newValues[i] = value; + oldValues[i] = oldValue; + if (!changeReactionScheduled) { + changeReactionScheduled = true; + self.$evalAsync(watchGroupAction); } + }); + deregisterFns.push(unwatchFn); + }); + + function watchGroupAction() { + changeReactionScheduled = false; + + if (firstRun) { + firstRun = false; + listener(newValues, newValues, self); + } else { + listener(newValues, oldValues, self); } - //if any listener on the current scope stops propagation, prevent bubbling - if (stopPropagation) return event; - //traverse upwards - scope = scope.$parent; - } while (scope); + } - return event; + return function deregisterWatchGroup() { + while (deregisterFns.length) { + deregisterFns.shift()(); + } + }; }, /** * @ngdoc method - * @name $rootScope.Scope#$broadcast - * @function + * @name $rootScope.Scope#$watchCollection + * @kind function * * @description - * Dispatches an event `name` downwards to all child scopes (and their children) notifying the - * registered {@link ng.$rootScope.Scope#$on} listeners. + * Shallow watches the properties of an object and fires whenever any of the properties change + * (for arrays, this implies watching the array items; for object maps, this implies watching + * the properties). If a change is detected, the `listener` callback is fired. * - * The event life cycle starts at the scope on which `$broadcast` was called. All - * {@link ng.$rootScope.Scope#$on listeners} listening for `name` event on this scope get - * notified. Afterwards, the event propagates to all direct and indirect scopes of the current - * scope and calls all registered listeners along the way. The event cannot be canceled. + * - The `obj` collection is observed via standard $watch operation and is examined on every + * call to $digest() to see if any items have been added, removed, or moved. + * - The `listener` is called whenever anything within the `obj` has changed. Examples include + * adding, removing, and moving items belonging to an object or array. * - * Any exception emitted from the {@link ng.$rootScope.Scope#$on listeners} will be passed - * onto the {@link ng.$exceptionHandler $exceptionHandler} service. * - * @param {string} name Event name to broadcast. - * @param {...*} args Optional one or more arguments which will be passed onto the event listeners. - * @return {Object} Event object, see {@link ng.$rootScope.Scope#$on} - */ - $broadcast: function(name, args) { - console.debug(name); - var target = this, - current = target, - next = target, - event = { - name: name, - targetScope: target, - preventDefault: function() { - event.defaultPrevented = true; - }, - defaultPrevented: false - }, - listenerArgs = concat([event], arguments, 1), - listeners, i, length; + * @example + * ```js + $scope.names = ['igor', 'matias', 'misko', 'james']; + $scope.dataCount = 4; - //down while you can, then up and next sibling or up and next sibling until back at root - while ((current = next)) { - event.currentScope = current; - listeners = current.$$listeners[name] || []; - for (i=0, length = listeners.length; i<length; i++) { - // if listeners were deregistered, defragment the array - if (!listeners[i]) { - listeners.splice(i, 1); - i--; - length--; - continue; - } + $scope.$watchCollection('names', function(newNames, oldNames) { + $scope.dataCount = newNames.length; + }); - try { - listeners[i].apply(null, listenerArgs); - } catch(e) { - $exceptionHandler(e); - } - } + expect($scope.dataCount).toEqual(4); + $scope.$digest(); - // Insanity Warning: scope depth-first traversal - // yes, this code is a bit crazy, but it works and we have tests to prove it! - // this piece should be kept in sync with the traversal in $digest - // (though it differs due to having the extra check for $$listenerCount) - if (!(next = ((current.$$listenerCount[name] && current.$$childHead) || - (current !== target && current.$$nextSibling)))) { - while(current !== target && !(next = current.$$nextSibling)) { - current = current.$parent; - } - } - } + //still at 4 ... no changes + expect($scope.dataCount).toEqual(4); - return event; - } - }; + $scope.names.pop(); + $scope.$digest(); - var $rootScope = new Scope(); + //now there's been a change + expect($scope.dataCount).toEqual(3); + * ``` + * + * + * @param {string|function(scope)} obj Evaluated as {@link guide/expression expression}. The + * expression value should evaluate to an object or an array which is observed on each + * {@link ng.$rootScope.Scope#$digest $digest} cycle. Any shallow change within the + * collection will trigger a call to the `listener`. + * + * @param {function(newCollection, oldCollection, scope)} listener a callback function called + * when a change is detected. + * - The `newCollection` object is the newly modified data obtained from the `obj` expression + * - The `oldCollection` object is a copy of the former collection data. + * Due to performance considerations, the`oldCollection` value is computed only if the + * `listener` function declares two or more arguments. + * - The `scope` argument refers to the current scope. + * + * @returns {function()} Returns a de-registration function for this listener. When the + * de-registration function is executed, the internal watch operation is terminated. + */ + $watchCollection: function(obj, listener) { + $watchCollectionInterceptor.$stateful = true; - return $rootScope; + var self = this; + // the current value, updated on each dirty-check run + var newValue; + // a shallow copy of the newValue from the last dirty-check run, + // updated to match newValue during dirty-check run + var oldValue; + // a shallow copy of the newValue from when the last change happened + var veryOldValue; + // only track veryOldValue if the listener is asking for it + var trackVeryOldValue = (listener.length > 1); + var changeDetected = 0; + var changeDetector = $parse(obj, $watchCollectionInterceptor); + var internalArray = []; + var internalObject = {}; + var initRun = true; + var oldLength = 0; + function $watchCollectionInterceptor(_value) { + newValue = _value; + var newLength, key, bothNaN, newItem, oldItem; - function beginPhase(phase) { - if ($rootScope.$$phase) { - throw $rootScopeMinErr('inprog', '{0} already in progress', $rootScope.$$phase); - } + // If the new value is undefined, then return undefined as the watch may be a one-time watch + if (isUndefined(newValue)) return; - $rootScope.$$phase = phase; - } + if (!isObject(newValue)) { // if primitive + if (oldValue !== newValue) { + oldValue = newValue; + changeDetected++; + } + } else if (isArrayLike(newValue)) { + if (oldValue !== internalArray) { + // we are transitioning from something which was not an array into array. + oldValue = internalArray; + oldLength = oldValue.length = 0; + changeDetected++; + } - function clearPhase() { - $rootScope.$$phase = null; - } + newLength = newValue.length; - function compileToFn(exp, name) { - var fn = $parse(exp); - assertArgFn(fn, name); - return fn; - } + if (oldLength !== newLength) { + // if lengths do not match we need to trigger change notification + changeDetected++; + oldValue.length = oldLength = newLength; + } + // copy the items to oldValue and look for changes. + for (var i = 0; i < newLength; i++) { + oldItem = oldValue[i]; + newItem = newValue[i]; - function decrementListenerCount(current, count, name) { - do { - current.$$listenerCount[name] -= count; + // eslint-disable-next-line no-self-compare + bothNaN = (oldItem !== oldItem) && (newItem !== newItem); + if (!bothNaN && (oldItem !== newItem)) { + changeDetected++; + oldValue[i] = newItem; + } + } + } else { + if (oldValue !== internalObject) { + // we are transitioning from something which was not an object into object. + oldValue = internalObject = {}; + oldLength = 0; + changeDetected++; + } + // copy the items to oldValue and look for changes. + newLength = 0; + for (key in newValue) { + if (hasOwnProperty.call(newValue, key)) { + newLength++; + newItem = newValue[key]; + oldItem = oldValue[key]; - if (current.$$listenerCount[name] === 0) { - delete current.$$listenerCount[name]; + if (key in oldValue) { + // eslint-disable-next-line no-self-compare + bothNaN = (oldItem !== oldItem) && (newItem !== newItem); + if (!bothNaN && (oldItem !== newItem)) { + changeDetected++; + oldValue[key] = newItem; + } + } else { + oldLength++; + oldValue[key] = newItem; + changeDetected++; + } + } + } + if (oldLength > newLength) { + // we used to have more keys, need to find them and destroy them. + changeDetected++; + for (key in oldValue) { + if (!hasOwnProperty.call(newValue, key)) { + oldLength--; + delete oldValue[key]; + } + } + } + } + return changeDetected; } - } while ((current = current.$parent)); - } - /** - * function used as an initial value for watchers. - * because it's unique we can easily tell it apart from other values - */ - function initWatchVal() {} - }]; - } + function $watchCollectionAction() { + if (initRun) { + initRun = false; + listener(newValue, newValue, self); + } else { + listener(newValue, veryOldValue, self); + } - /** - * @description - * Private service to sanitize uris for links and images. Used by $compile and $sanitize. - */ - function $$SanitizeUriProvider() { - var aHrefSanitizationWhitelist = /^\s*(https?|ftp|mailto|tel|file):/, - imgSrcSanitizationWhitelist = /^\s*(https?|ftp|file):|data:image\//; + // make a copy for the next time a collection is changed + if (trackVeryOldValue) { + if (!isObject(newValue)) { + //primitive + veryOldValue = newValue; + } else if (isArrayLike(newValue)) { + veryOldValue = new Array(newValue.length); + for (var i = 0; i < newValue.length; i++) { + veryOldValue[i] = newValue[i]; + } + } else { // if object + veryOldValue = {}; + for (var key in newValue) { + if (hasOwnProperty.call(newValue, key)) { + veryOldValue[key] = newValue[key]; + } + } + } + } + } - /** - * @description - * Retrieves or overrides the default regular expression that is used for whitelisting of safe - * urls during a[href] sanitization. - * - * The sanitization is a security measure aimed at prevent XSS attacks via html links. - * - * Any url about to be assigned to a[href] via data-binding is first normalized and turned into - * an absolute url. Afterwards, the url is matched against the `aHrefSanitizationWhitelist` - * regular expression. If a match is found, the original url is written into the dom. Otherwise, - * the absolute url is prefixed with `'unsafe:'` string and only then is it written into the DOM. - * - * @param {RegExp=} regexp New regexp to whitelist urls with. - * @returns {RegExp|ng.$compileProvider} Current RegExp if called without value or self for - * chaining otherwise. - */ - this.aHrefSanitizationWhitelist = function(regexp) { - if (isDefined(regexp)) { - aHrefSanitizationWhitelist = regexp; - return this; - } - return aHrefSanitizationWhitelist; - }; + return this.$watch(changeDetector, $watchCollectionAction); + }, + /** + * @ngdoc method + * @name $rootScope.Scope#$digest + * @kind function + * + * @description + * Processes all of the {@link ng.$rootScope.Scope#$watch watchers} of the current scope and + * its children. Because a {@link ng.$rootScope.Scope#$watch watcher}'s listener can change + * the model, the `$digest()` keeps calling the {@link ng.$rootScope.Scope#$watch watchers} + * until no more listeners are firing. This means that it is possible to get into an infinite + * loop. This function will throw `'Maximum iteration limit exceeded.'` if the number of + * iterations exceeds 10. + * + * Usually, you don't call `$digest()` directly in + * {@link ng.directive:ngController controllers} or in + * {@link ng.$compileProvider#directive directives}. + * Instead, you should call {@link ng.$rootScope.Scope#$apply $apply()} (typically from within + * a {@link ng.$compileProvider#directive directive}), which will force a `$digest()`. + * + * If you want to be notified whenever `$digest()` is called, + * you can register a `watchExpression` function with + * {@link ng.$rootScope.Scope#$watch $watch()} with no `listener`. + * + * In unit tests, you may need to call `$digest()` to simulate the scope life cycle. + * + * @example + * ```js + var scope = ...; + scope.name = 'misko'; + scope.counter = 0; - /** - * @description - * Retrieves or overrides the default regular expression that is used for whitelisting of safe - * urls during img[src] sanitization. - * - * The sanitization is a security measure aimed at prevent XSS attacks via html links. - * - * Any url about to be assigned to img[src] via data-binding is first normalized and turned into - * an absolute url. Afterwards, the url is matched against the `imgSrcSanitizationWhitelist` - * regular expression. If a match is found, the original url is written into the dom. Otherwise, - * the absolute url is prefixed with `'unsafe:'` string and only then is it written into the DOM. - * - * @param {RegExp=} regexp New regexp to whitelist urls with. - * @returns {RegExp|ng.$compileProvider} Current RegExp if called without value or self for - * chaining otherwise. - */ - this.imgSrcSanitizationWhitelist = function(regexp) { - if (isDefined(regexp)) { - imgSrcSanitizationWhitelist = regexp; - return this; - } - return imgSrcSanitizationWhitelist; - }; + expect(scope.counter).toEqual(0); + scope.$watch('name', function(newValue, oldValue) { + scope.counter = scope.counter + 1; + }); + expect(scope.counter).toEqual(0); - this.$get = function() { - return function sanitizeUri(uri, isImage) { - var regex = isImage ? imgSrcSanitizationWhitelist : aHrefSanitizationWhitelist; - var normalizedVal; - // NOTE: urlResolve() doesn't support IE < 8 so we don't sanitize for that case. - if (!msie || msie >= 8 ) { - normalizedVal = urlResolve(uri).href; - if (normalizedVal !== '' && !normalizedVal.match(regex)) { - return 'unsafe:'+normalizedVal; - } - } - return uri; - }; - }; - } + scope.$digest(); + // the listener is always called during the first $digest loop after it was registered + expect(scope.counter).toEqual(1); - var $sceMinErr = minErr('$sce'); - - var SCE_CONTEXTS = { - HTML: 'html', - CSS: 'css', - URL: 'url', - // RESOURCE_URL is a subtype of URL used in contexts where a privileged resource is sourced from a - // url. (e.g. ng-include, script src, templateUrl) - RESOURCE_URL: 'resourceUrl', - JS: 'js' - }; - -// Helper functions follow. - -// Copied from: -// http://docs.closure-library.googlecode.com/git/closure_goog_string_string.js.source.html#line962 -// Prereq: s is a string. - function escapeForRegexp(s) { - return s.replace(/([-()\[\]{}+?*.$\^|,:#<!\\])/g, '\\$1'). - replace(/\x08/g, '\\x08'); - } - - - function adjustMatcher(matcher) { - if (matcher === 'self') { - return matcher; - } else if (isString(matcher)) { - // Strings match exactly except for 2 wildcards - '*' and '**'. - // '*' matches any character except those from the set ':/.?&'. - // '**' matches any character (like .* in a RegExp). - // More than 2 *'s raises an error as it's ill defined. - if (matcher.indexOf('***') > -1) { - throw $sceMinErr('iwcard', - 'Illegal sequence *** in string matcher. String: {0}', matcher); - } - matcher = escapeForRegexp(matcher). - replace('\\*\\*', '.*'). - replace('\\*', '[^:/.?&;]*'); - return new RegExp('^' + matcher + '$'); - } else if (isRegExp(matcher)) { - // The only other type of matcher allowed is a Regexp. - // Match entire URL / disallow partial matches. - // Flags are reset (i.e. no global, ignoreCase or multiline) - return new RegExp('^' + matcher.source + '$'); - } else { - throw $sceMinErr('imatcher', - 'Matchers may only be "self", string patterns or RegExp objects'); - } - } + scope.$digest(); + // but now it will not be called unless the value changes + expect(scope.counter).toEqual(1); + scope.name = 'adam'; + scope.$digest(); + expect(scope.counter).toEqual(2); + * ``` + * + */ + $digest: function() { + var watch, value, last, fn, get, + watchers, + dirty, ttl = TTL, + next, current, target = this, + watchLog = [], + logIdx, asyncTask; - function adjustMatchers(matchers) { - var adjustedMatchers = []; - if (isDefined(matchers)) { - forEach(matchers, function(matcher) { - adjustedMatchers.push(adjustMatcher(matcher)); - }); - } - return adjustedMatchers; - } + beginPhase('$digest'); + // Check for changes to browser url that happened in sync before the call to $digest + $browser.$$checkUrlChange(); + + if (this === $rootScope && applyAsyncId !== null) { + // If this is the root scope, and $applyAsync has scheduled a deferred $apply(), then + // cancel the scheduled $apply and flush the queue of expressions to be evaluated. + $browser.defer.cancel(applyAsyncId); + flushApplyAsync(); + } + lastDirtyWatch = null; - /** - * @ngdoc service - * @name $sceDelegate - * @function - * - * @description - * - * `$sceDelegate` is a service that is used by the `$sce` service to provide {@link ng.$sce Strict - * Contextual Escaping (SCE)} services to AngularJS. - * - * Typically, you would configure or override the {@link ng.$sceDelegate $sceDelegate} instead of - * the `$sce` service to customize the way Strict Contextual Escaping works in AngularJS. This is - * because, while the `$sce` provides numerous shorthand methods, etc., you really only need to - * override 3 core functions (`trustAs`, `getTrusted` and `valueOf`) to replace the way things - * work because `$sce` delegates to `$sceDelegate` for these operations. - * - * Refer {@link ng.$sceDelegateProvider $sceDelegateProvider} to configure this service. - * - * The default instance of `$sceDelegate` should work out of the box with little pain. While you - * can override it completely to change the behavior of `$sce`, the common case would - * involve configuring the {@link ng.$sceDelegateProvider $sceDelegateProvider} instead by setting - * your own whitelists and blacklists for trusting URLs used for loading AngularJS resources such as - * templates. Refer {@link ng.$sceDelegateProvider#resourceUrlWhitelist - * $sceDelegateProvider.resourceUrlWhitelist} and {@link - * ng.$sceDelegateProvider#resourceUrlBlacklist $sceDelegateProvider.resourceUrlBlacklist} - */ + do { // "while dirty" loop + dirty = false; + current = target; - /** - * @ngdoc provider - * @name $sceDelegateProvider - * @description - * - * The `$sceDelegateProvider` provider allows developers to configure the {@link ng.$sceDelegate - * $sceDelegate} service. This allows one to get/set the whitelists and blacklists used to ensure - * that the URLs used for sourcing Angular templates are safe. Refer {@link - * ng.$sceDelegateProvider#resourceUrlWhitelist $sceDelegateProvider.resourceUrlWhitelist} and - * {@link ng.$sceDelegateProvider#resourceUrlBlacklist $sceDelegateProvider.resourceUrlBlacklist} - * - * For the general details about this service in Angular, read the main page for {@link ng.$sce - * Strict Contextual Escaping (SCE)}. - * - * **Example**: Consider the following case. <a name="example"></a> - * - * - your app is hosted at url `http://myapp.example.com/` - * - but some of your templates are hosted on other domains you control such as - * `http://srv01.assets.example.com/`, `http://srv02.assets.example.com/`, etc. - * - and you have an open redirect at `http://myapp.example.com/clickThru?...`. - * - * Here is what a secure configuration for this scenario might look like: - * - * <pre class="prettyprint"> - * angular.module('myApp', []).config(function($sceDelegateProvider) { - * $sceDelegateProvider.resourceUrlWhitelist([ - * // Allow same origin resource loads. - * 'self', - * // Allow loading from our assets domain. Notice the difference between * and **. - * 'http://srv*.assets.example.com/**']); - * - * // The blacklist overrides the whitelist so the open redirect here is blocked. - * $sceDelegateProvider.resourceUrlBlacklist([ - * 'http://myapp.example.com/clickThru**']); - * }); - * </pre> - */ + // It's safe for asyncQueuePosition to be a local variable here because this loop can't + // be reentered recursively. Calling $digest from a function passed to $evalAsync would + // lead to a '$digest already in progress' error. + for (var asyncQueuePosition = 0; asyncQueuePosition < asyncQueue.length; asyncQueuePosition++) { + try { + asyncTask = asyncQueue[asyncQueuePosition]; + fn = asyncTask.fn; + fn(asyncTask.scope, asyncTask.locals); + } catch (e) { + $exceptionHandler(e); + } + lastDirtyWatch = null; + } + asyncQueue.length = 0; - function $SceDelegateProvider() { - this.SCE_CONTEXTS = SCE_CONTEXTS; + traverseScopesLoop: + do { // "traverse the scopes" loop + if ((watchers = current.$$watchers)) { + // process our watches + watchers.$$digestWatchIndex = watchers.length; + while (watchers.$$digestWatchIndex--) { + try { + watch = watchers[watchers.$$digestWatchIndex]; + // Most common watches are on primitives, in which case we can short + // circuit it with === operator, only when === fails do we use .equals + if (watch) { + get = watch.get; + if ((value = get(current)) !== (last = watch.last) && + !(watch.eq + ? equals(value, last) + : (isNumberNaN(value) && isNumberNaN(last)))) { + dirty = true; + lastDirtyWatch = watch; + watch.last = watch.eq ? copy(value, null) : value; + fn = watch.fn; + fn(value, ((last === initWatchVal) ? value : last), current); + if (ttl < 5) { + logIdx = 4 - ttl; + if (!watchLog[logIdx]) watchLog[logIdx] = []; + watchLog[logIdx].push({ + msg: isFunction(watch.exp) ? 'fn: ' + (watch.exp.name || watch.exp.toString()) : watch.exp, + newVal: value, + oldVal: last + }); + } + } else if (watch === lastDirtyWatch) { + // If the most recently dirty watcher is now clean, short circuit since the remaining watchers + // have already been tested. + dirty = false; + break traverseScopesLoop; + } + } + } catch (e) { + $exceptionHandler(e); + } + } + } - // Resource URLs can also be trusted by policy. - var resourceUrlWhitelist = ['self'], - resourceUrlBlacklist = []; + // Insanity Warning: scope depth-first traversal + // yes, this code is a bit crazy, but it works and we have tests to prove it! + // this piece should be kept in sync with the traversal in $broadcast + if (!(next = ((current.$$watchersCount && current.$$childHead) || + (current !== target && current.$$nextSibling)))) { + while (current !== target && !(next = current.$$nextSibling)) { + current = current.$parent; + } + } + } while ((current = next)); - /** - * @ngdoc method - * @name $sceDelegateProvider#resourceUrlWhitelist - * @function - * - * @param {Array=} whitelist When provided, replaces the resourceUrlWhitelist with the value - * provided. This must be an array or null. A snapshot of this array is used so further - * changes to the array are ignored. - * - * Follow {@link ng.$sce#resourceUrlPatternItem this link} for a description of the items - * allowed in this array. - * - * Note: **an empty whitelist array will block all URLs**! - * - * @return {Array} the currently set whitelist array. - * - * The **default value** when no whitelist has been explicitly set is `['self']` allowing only - * same origin resource requests. - * - * @description - * Sets/Gets the whitelist of trusted resource URLs. - */ - this.resourceUrlWhitelist = function (value) { - if (arguments.length) { - resourceUrlWhitelist = adjustMatchers(value); - } - return resourceUrlWhitelist; - }; + // `break traverseScopesLoop;` takes us to here - /** - * @ngdoc method - * @name $sceDelegateProvider#resourceUrlBlacklist - * @function - * - * @param {Array=} blacklist When provided, replaces the resourceUrlBlacklist with the value - * provided. This must be an array or null. A snapshot of this array is used so further - * changes to the array are ignored. - * - * Follow {@link ng.$sce#resourceUrlPatternItem this link} for a description of the items - * allowed in this array. - * - * The typical usage for the blacklist is to **block - * [open redirects](http://cwe.mitre.org/data/definitions/601.html)** served by your domain as - * these would otherwise be trusted but actually return content from the redirected domain. - * - * Finally, **the blacklist overrides the whitelist** and has the final say. - * - * @return {Array} the currently set blacklist array. - * - * The **default value** when no whitelist has been explicitly set is the empty array (i.e. there - * is no blacklist.) - * - * @description - * Sets/Gets the blacklist of trusted resource URLs. - */ + if ((dirty || asyncQueue.length) && !(ttl--)) { + clearPhase(); + throw $rootScopeMinErr('infdig', + '{0} $digest() iterations reached. Aborting!\n' + + 'Watchers fired in the last 5 iterations: {1}', + TTL, watchLog); + } - this.resourceUrlBlacklist = function (value) { - if (arguments.length) { - resourceUrlBlacklist = adjustMatchers(value); - } - return resourceUrlBlacklist; - }; + } while (dirty || asyncQueue.length); - this.$get = ['$injector', function($injector) { + clearPhase(); - var htmlSanitizer = function htmlSanitizer(html) { - throw $sceMinErr('unsafe', 'Attempting to use an unsafe value in a safe context.'); - }; + // postDigestQueuePosition isn't local here because this loop can be reentered recursively. + while (postDigestQueuePosition < postDigestQueue.length) { + try { + postDigestQueue[postDigestQueuePosition++](); + } catch (e) { + $exceptionHandler(e); + } + } + postDigestQueue.length = postDigestQueuePosition = 0; - if ($injector.has('$sanitize')) { - htmlSanitizer = $injector.get('$sanitize'); - } + // Check for changes to browser url that happened during the $digest + // (for which no event is fired; e.g. via `history.pushState()`) + $browser.$$checkUrlChange(); + }, - function matchUrl(matcher, parsedUrl) { - if (matcher === 'self') { - return urlIsSameOrigin(parsedUrl); - } else { - // definitely a regex. See adjustMatchers() - return !!matcher.exec(parsedUrl.href); - } - } + /** + * @ngdoc event + * @name $rootScope.Scope#$destroy + * @eventType broadcast on scope being destroyed + * + * @description + * Broadcasted when a scope and its children are being destroyed. + * + * Note that, in AngularJS, there is also a `$destroy` jQuery event, which can be used to + * clean up DOM bindings before an element is removed from the DOM. + */ - function isResourceUrlAllowedByPolicy(url) { - var parsedUrl = urlResolve(url.toString()); - var i, n, allowed = false; - // Ensure that at least one item from the whitelist allows this url. - for (i = 0, n = resourceUrlWhitelist.length; i < n; i++) { - if (matchUrl(resourceUrlWhitelist[i], parsedUrl)) { - allowed = true; - break; - } - } - if (allowed) { - // Ensure that no item from the blacklist blocked this url. - for (i = 0, n = resourceUrlBlacklist.length; i < n; i++) { - if (matchUrl(resourceUrlBlacklist[i], parsedUrl)) { - allowed = false; - break; - } - } - } - return allowed; - } + /** + * @ngdoc method + * @name $rootScope.Scope#$destroy + * @kind function + * + * @description + * Removes the current scope (and all of its children) from the parent scope. Removal implies + * that calls to {@link ng.$rootScope.Scope#$digest $digest()} will no longer + * propagate to the current scope and its children. Removal also implies that the current + * scope is eligible for garbage collection. + * + * The `$destroy()` is usually used by directives such as + * {@link ng.directive:ngRepeat ngRepeat} for managing the + * unrolling of the loop. + * + * Just before a scope is destroyed, a `$destroy` event is broadcasted on this scope. + * Application code can register a `$destroy` event handler that will give it a chance to + * perform any necessary cleanup. + * + * Note that, in AngularJS, there is also a `$destroy` jQuery event, which can be used to + * clean up DOM bindings before an element is removed from the DOM. + */ + $destroy: function() { + // We can't destroy a scope that has been already destroyed. + if (this.$$destroyed) return; + var parent = this.$parent; - function generateHolderType(Base) { - var holderType = function TrustedValueHolderType(trustedValue) { - this.$$unwrapTrustedValue = function() { - return trustedValue; - }; - }; - if (Base) { - holderType.prototype = new Base(); - } - holderType.prototype.valueOf = function sceValueOf() { - return this.$$unwrapTrustedValue(); - }; - holderType.prototype.toString = function sceToString() { - return this.$$unwrapTrustedValue().toString(); - }; - return holderType; - } + this.$broadcast('$destroy'); + this.$$destroyed = true; - var trustedValueHolderBase = generateHolderType(), - byType = {}; + if (this === $rootScope) { + //Remove handlers attached to window when $rootScope is removed + $browser.$$applicationDestroyed(); + } - byType[SCE_CONTEXTS.HTML] = generateHolderType(trustedValueHolderBase); - byType[SCE_CONTEXTS.CSS] = generateHolderType(trustedValueHolderBase); - byType[SCE_CONTEXTS.URL] = generateHolderType(trustedValueHolderBase); - byType[SCE_CONTEXTS.JS] = generateHolderType(trustedValueHolderBase); - byType[SCE_CONTEXTS.RESOURCE_URL] = generateHolderType(byType[SCE_CONTEXTS.URL]); + incrementWatchersCount(this, -this.$$watchersCount); + for (var eventName in this.$$listenerCount) { + decrementListenerCount(this, this.$$listenerCount[eventName], eventName); + } - /** - * @ngdoc method - * @name $sceDelegate#trustAs - * - * @description - * Returns an object that is trusted by angular for use in specified strict - * contextual escaping contexts (such as ng-bind-html, ng-include, any src - * attribute interpolation, any dom event binding attribute interpolation - * such as for onclick, etc.) that uses the provided value. - * See {@link ng.$sce $sce} for enabling strict contextual escaping. - * - * @param {string} type The kind of context in which this value is safe for use. e.g. url, - * resourceUrl, html, js and css. - * @param {*} value The value that that should be considered trusted/safe. - * @returns {*} A value that can be used to stand in for the provided `value` in places - * where Angular expects a $sce.trustAs() return value. - */ - function trustAs(type, trustedValue) { - var Constructor = (byType.hasOwnProperty(type) ? byType[type] : null); - if (!Constructor) { - throw $sceMinErr('icontext', - 'Attempted to trust a value in invalid context. Context: {0}; Value: {1}', - type, trustedValue); - } - if (trustedValue === null || trustedValue === undefined || trustedValue === '') { - return trustedValue; - } - // All the current contexts in SCE_CONTEXTS happen to be strings. In order to avoid trusting - // mutable objects, we ensure here that the value passed in is actually a string. - if (typeof trustedValue !== 'string') { - throw $sceMinErr('itype', - 'Attempted to trust a non-string value in a content requiring a string: Context: {0}', - type); - } - return new Constructor(trustedValue); - } + // sever all the references to parent scopes (after this cleanup, the current scope should + // not be retained by any of our references and should be eligible for garbage collection) + if (parent && parent.$$childHead === this) parent.$$childHead = this.$$nextSibling; + if (parent && parent.$$childTail === this) parent.$$childTail = this.$$prevSibling; + if (this.$$prevSibling) this.$$prevSibling.$$nextSibling = this.$$nextSibling; + if (this.$$nextSibling) this.$$nextSibling.$$prevSibling = this.$$prevSibling; - /** - * @ngdoc method - * @name $sceDelegate#valueOf - * - * @description - * If the passed parameter had been returned by a prior call to {@link ng.$sceDelegate#trustAs - * `$sceDelegate.trustAs`}, returns the value that had been passed to {@link - * ng.$sceDelegate#trustAs `$sceDelegate.trustAs`}. - * - * If the passed parameter is not a value that had been returned by {@link - * ng.$sceDelegate#trustAs `$sceDelegate.trustAs`}, returns it as-is. - * - * @param {*} value The result of a prior {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs`} - * call or anything else. - * @returns {*} The `value` that was originally provided to {@link ng.$sceDelegate#trustAs - * `$sceDelegate.trustAs`} if `value` is the result of such a call. Otherwise, returns - * `value` unchanged. - */ - function valueOf(maybeTrusted) { - if (maybeTrusted instanceof trustedValueHolderBase) { - return maybeTrusted.$$unwrapTrustedValue(); - } else { - return maybeTrusted; - } - } + // Disable listeners, watchers and apply/digest methods + this.$destroy = this.$digest = this.$apply = this.$evalAsync = this.$applyAsync = noop; + this.$on = this.$watch = this.$watchGroup = function() { return noop; }; + this.$$listeners = {}; - /** - * @ngdoc method - * @name $sceDelegate#getTrusted - * - * @description - * Takes the result of a {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs`} call and - * returns the originally supplied value if the queried context type is a supertype of the - * created type. If this condition isn't satisfied, throws an exception. - * - * @param {string} type The kind of context in which this value is to be used. - * @param {*} maybeTrusted The result of a prior {@link ng.$sceDelegate#trustAs - * `$sceDelegate.trustAs`} call. - * @returns {*} The value the was originally provided to {@link ng.$sceDelegate#trustAs - * `$sceDelegate.trustAs`} if valid in this context. Otherwise, throws an exception. - */ - function getTrusted(type, maybeTrusted) { - if (maybeTrusted === null || maybeTrusted === undefined || maybeTrusted === '') { - return maybeTrusted; - } - var constructor = (byType.hasOwnProperty(type) ? byType[type] : null); - if (constructor && maybeTrusted instanceof constructor) { - return maybeTrusted.$$unwrapTrustedValue(); - } - // If we get here, then we may only take one of two actions. - // 1. sanitize the value for the requested type, or - // 2. throw an exception. - if (type === SCE_CONTEXTS.RESOURCE_URL) { - if (isResourceUrlAllowedByPolicy(maybeTrusted)) { - return maybeTrusted; - } else { - throw $sceMinErr('insecurl', - 'Blocked loading resource from url not allowed by $sceDelegate policy. URL: {0}', - maybeTrusted.toString()); - } - } else if (type === SCE_CONTEXTS.HTML) { - return htmlSanitizer(maybeTrusted); - } - throw $sceMinErr('unsafe', 'Attempting to use an unsafe value in a safe context.'); - } + // Disconnect the next sibling to prevent `cleanUpScope` destroying those too + this.$$nextSibling = null; + cleanUpScope(this); + }, - return { trustAs: trustAs, - getTrusted: getTrusted, - valueOf: valueOf }; - }]; - } + /** + * @ngdoc method + * @name $rootScope.Scope#$eval + * @kind function + * + * @description + * Executes the `expression` on the current scope and returns the result. Any exceptions in + * the expression are propagated (uncaught). This is useful when evaluating AngularJS + * expressions. + * + * @example + * ```js + var scope = ng.$rootScope.Scope(); + scope.a = 1; + scope.b = 2; + expect(scope.$eval('a+b')).toEqual(3); + expect(scope.$eval(function(scope){ return scope.a + scope.b; })).toEqual(3); + * ``` + * + * @param {(string|function())=} expression An AngularJS expression to be executed. + * + * - `string`: execute using the rules as defined in {@link guide/expression expression}. + * - `function(scope)`: execute the function with the current `scope` parameter. + * + * @param {(object)=} locals Local variables object, useful for overriding values in scope. + * @returns {*} The result of evaluating the expression. + */ + $eval: function(expr, locals) { + return $parse(expr)(this, locals); + }, - /** - * @ngdoc provider - * @name $sceProvider - * @description - * - * The $sceProvider provider allows developers to configure the {@link ng.$sce $sce} service. - * - enable/disable Strict Contextual Escaping (SCE) in a module - * - override the default implementation with a custom delegate - * - * Read more about {@link ng.$sce Strict Contextual Escaping (SCE)}. - */ + /** + * @ngdoc method + * @name $rootScope.Scope#$evalAsync + * @kind function + * + * @description + * Executes the expression on the current scope at a later point in time. + * + * The `$evalAsync` makes no guarantees as to when the `expression` will be executed, only + * that: + * + * - it will execute after the function that scheduled the evaluation (preferably before DOM + * rendering). + * - at least one {@link ng.$rootScope.Scope#$digest $digest cycle} will be performed after + * `expression` execution. + * + * Any exceptions from the execution of the expression are forwarded to the + * {@link ng.$exceptionHandler $exceptionHandler} service. + * + * __Note:__ if this function is called outside of a `$digest` cycle, a new `$digest` cycle + * will be scheduled. However, it is encouraged to always call code that changes the model + * from within an `$apply` call. That includes code evaluated via `$evalAsync`. + * + * @param {(string|function())=} expression An AngularJS expression to be executed. + * + * - `string`: execute using the rules as defined in {@link guide/expression expression}. + * - `function(scope)`: execute the function with the current `scope` parameter. + * + * @param {(object)=} locals Local variables object, useful for overriding values in scope. + */ + $evalAsync: function(expr, locals) { + // if we are outside of an $digest loop and this is the first time we are scheduling async + // task also schedule async auto-flush + if (!$rootScope.$$phase && !asyncQueue.length) { + $browser.defer(function() { + if (asyncQueue.length) { + $rootScope.$digest(); + } + }); + } - /* jshint maxlen: false*/ + asyncQueue.push({scope: this, fn: $parse(expr), locals: locals}); + }, - /** - * @ngdoc service - * @name $sce - * @function - * - * @description - * - * `$sce` is a service that provides Strict Contextual Escaping services to AngularJS. - * - * # Strict Contextual Escaping - * - * Strict Contextual Escaping (SCE) is a mode in which AngularJS requires bindings in certain - * contexts to result in a value that is marked as safe to use for that context. One example of - * such a context is binding arbitrary html controlled by the user via `ng-bind-html`. We refer - * to these contexts as privileged or SCE contexts. - * - * As of version 1.2, Angular ships with SCE enabled by default. - * - * Note: When enabled (the default), IE8 in quirks mode is not supported. In this mode, IE8 allows - * one to execute arbitrary javascript by the use of the expression() syntax. Refer - * <http://blogs.msdn.com/b/ie/archive/2008/10/16/ending-expressions.aspx> to learn more about them. - * You can ensure your document is in standards mode and not quirks mode by adding `<!doctype html>` - * to the top of your HTML document. - * - * SCE assists in writing code in way that (a) is secure by default and (b) makes auditing for - * security vulnerabilities such as XSS, clickjacking, etc. a lot easier. - * - * Here's an example of a binding in a privileged context: - * - * <pre class="prettyprint"> - * <input ng-model="userHtml"> - * <div ng-bind-html="userHtml"> - * </pre> - * - * Notice that `ng-bind-html` is bound to `userHtml` controlled by the user. With SCE - * disabled, this application allows the user to render arbitrary HTML into the DIV. - * In a more realistic example, one may be rendering user comments, blog articles, etc. via - * bindings. (HTML is just one example of a context where rendering user controlled input creates - * security vulnerabilities.) - * - * For the case of HTML, you might use a library, either on the client side, or on the server side, - * to sanitize unsafe HTML before binding to the value and rendering it in the document. - * - * How would you ensure that every place that used these types of bindings was bound to a value that - * was sanitized by your library (or returned as safe for rendering by your server?) How can you - * ensure that you didn't accidentally delete the line that sanitized the value, or renamed some - * properties/fields and forgot to update the binding to the sanitized value? + $$postDigest: function(fn) { + postDigestQueue.push(fn); + }, + + /** + * @ngdoc method + * @name $rootScope.Scope#$apply + * @kind function + * + * @description + * `$apply()` is used to execute an expression in AngularJS from outside of the AngularJS + * framework. (For example from browser DOM events, setTimeout, XHR or third party libraries). + * Because we are calling into the AngularJS framework we need to perform proper scope life + * cycle of {@link ng.$exceptionHandler exception handling}, + * {@link ng.$rootScope.Scope#$digest executing watches}. + * + * **Life cycle: Pseudo-Code of `$apply()`** + * + * ```js + function $apply(expr) { + try { + return $eval(expr); + } catch (e) { + $exceptionHandler(e); + } finally { + $root.$digest(); + } + } + * ``` + * + * + * Scope's `$apply()` method transitions through the following stages: + * + * 1. The {@link guide/expression expression} is executed using the + * {@link ng.$rootScope.Scope#$eval $eval()} method. + * 2. Any exceptions from the execution of the expression are forwarded to the + * {@link ng.$exceptionHandler $exceptionHandler} service. + * 3. The {@link ng.$rootScope.Scope#$watch watch} listeners are fired immediately after the + * expression was executed using the {@link ng.$rootScope.Scope#$digest $digest()} method. + * + * + * @param {(string|function())=} exp An AngularJS expression to be executed. + * + * - `string`: execute using the rules as defined in {@link guide/expression expression}. + * - `function(scope)`: execute the function with current `scope` parameter. + * + * @returns {*} The result of evaluating the expression. + */ + $apply: function(expr) { + try { + beginPhase('$apply'); + try { + return this.$eval(expr); + } finally { + clearPhase(); + } + } catch (e) { + $exceptionHandler(e); + } finally { + try { + $rootScope.$digest(); + } catch (e) { + $exceptionHandler(e); + // eslint-disable-next-line no-unsafe-finally + throw e; + } + } + }, + + /** + * @ngdoc method + * @name $rootScope.Scope#$applyAsync + * @kind function + * + * @description + * Schedule the invocation of $apply to occur at a later time. The actual time difference + * varies across browsers, but is typically around ~10 milliseconds. + * + * This can be used to queue up multiple expressions which need to be evaluated in the same + * digest. + * + * @param {(string|function())=} exp An AngularJS expression to be executed. + * + * - `string`: execute using the rules as defined in {@link guide/expression expression}. + * - `function(scope)`: execute the function with current `scope` parameter. + */ + $applyAsync: function(expr) { + var scope = this; + if (expr) { + applyAsyncQueue.push($applyAsyncExpression); + } + expr = $parse(expr); + scheduleApplyAsync(); + + function $applyAsyncExpression() { + scope.$eval(expr); + } + }, + + /** + * @ngdoc method + * @name $rootScope.Scope#$on + * @kind function + * + * @description + * Listens on events of a given type. See {@link ng.$rootScope.Scope#$emit $emit} for + * discussion of event life cycle. + * + * The event listener function format is: `function(event, args...)`. The `event` object + * passed into the listener has the following attributes: + * + * - `targetScope` - `{Scope}`: the scope on which the event was `$emit`-ed or + * `$broadcast`-ed. + * - `currentScope` - `{Scope}`: the scope that is currently handling the event. Once the + * event propagates through the scope hierarchy, this property is set to null. + * - `name` - `{string}`: name of the event. + * - `stopPropagation` - `{function=}`: calling `stopPropagation` function will cancel + * further event propagation (available only for events that were `$emit`-ed). + * - `preventDefault` - `{function}`: calling `preventDefault` sets `defaultPrevented` flag + * to true. + * - `defaultPrevented` - `{boolean}`: true if `preventDefault` was called. + * + * @param {string} name Event name to listen on. + * @param {function(event, ...args)} listener Function to call when the event is emitted. + * @returns {function()} Returns a deregistration function for this listener. + */ + $on: function(name, listener) { + var namedListeners = this.$$listeners[name]; + if (!namedListeners) { + this.$$listeners[name] = namedListeners = []; + } + namedListeners.push(listener); + + var current = this; + do { + if (!current.$$listenerCount[name]) { + current.$$listenerCount[name] = 0; + } + current.$$listenerCount[name]++; + } while ((current = current.$parent)); + + var self = this; + return function() { + var indexOfListener = namedListeners.indexOf(listener); + if (indexOfListener !== -1) { + // Use delete in the hope of the browser deallocating the memory for the array entry, + // while not shifting the array indexes of other listeners. + // See issue https://github.com/angular/angular.js/issues/16135 + delete namedListeners[indexOfListener]; + decrementListenerCount(self, 1, name); + } + }; + }, + + + /** + * @ngdoc method + * @name $rootScope.Scope#$emit + * @kind function + * + * @description + * Dispatches an event `name` upwards through the scope hierarchy notifying the + * registered {@link ng.$rootScope.Scope#$on} listeners. + * + * The event life cycle starts at the scope on which `$emit` was called. All + * {@link ng.$rootScope.Scope#$on listeners} listening for `name` event on this scope get + * notified. Afterwards, the event traverses upwards toward the root scope and calls all + * registered listeners along the way. The event will stop propagating if one of the listeners + * cancels it. + * + * Any exception emitted from the {@link ng.$rootScope.Scope#$on listeners} will be passed + * onto the {@link ng.$exceptionHandler $exceptionHandler} service. + * + * @param {string} name Event name to emit. + * @param {...*} args Optional one or more arguments which will be passed onto the event listeners. + * @return {Object} Event object (see {@link ng.$rootScope.Scope#$on}). + */ + $emit: function(name, args) { + var empty = [], + namedListeners, + scope = this, + stopPropagation = false, + event = { + name: name, + targetScope: scope, + stopPropagation: function() {stopPropagation = true;}, + preventDefault: function() { + event.defaultPrevented = true; + }, + defaultPrevented: false + }, + listenerArgs = concat([event], arguments, 1), + i, length; + + do { + namedListeners = scope.$$listeners[name] || empty; + event.currentScope = scope; + for (i = 0, length = namedListeners.length; i < length; i++) { + + // if listeners were deregistered, defragment the array + if (!namedListeners[i]) { + namedListeners.splice(i, 1); + i--; + length--; + continue; + } + try { + //allow all listeners attached to the current scope to run + namedListeners[i].apply(null, listenerArgs); + } catch (e) { + $exceptionHandler(e); + } + } + //if any listener on the current scope stops propagation, prevent bubbling + if (stopPropagation) { + break; + } + //traverse upwards + scope = scope.$parent; + } while (scope); + + event.currentScope = null; + + return event; + }, + + + /** + * @ngdoc method + * @name $rootScope.Scope#$broadcast + * @kind function + * + * @description + * Dispatches an event `name` downwards to all child scopes (and their children) notifying the + * registered {@link ng.$rootScope.Scope#$on} listeners. + * + * The event life cycle starts at the scope on which `$broadcast` was called. All + * {@link ng.$rootScope.Scope#$on listeners} listening for `name` event on this scope get + * notified. Afterwards, the event propagates to all direct and indirect scopes of the current + * scope and calls all registered listeners along the way. The event cannot be canceled. + * + * Any exception emitted from the {@link ng.$rootScope.Scope#$on listeners} will be passed + * onto the {@link ng.$exceptionHandler $exceptionHandler} service. + * + * @param {string} name Event name to broadcast. + * @param {...*} args Optional one or more arguments which will be passed onto the event listeners. + * @return {Object} Event object, see {@link ng.$rootScope.Scope#$on} + */ + $broadcast: function(name, args) { + var target = this, + current = target, + next = target, + event = { + name: name, + targetScope: target, + preventDefault: function() { + event.defaultPrevented = true; + }, + defaultPrevented: false + }; + + if (!target.$$listenerCount[name]) return event; + + var listenerArgs = concat([event], arguments, 1), + listeners, i, length; + + //down while you can, then up and next sibling or up and next sibling until back at root + while ((current = next)) { + event.currentScope = current; + listeners = current.$$listeners[name] || []; + for (i = 0, length = listeners.length; i < length; i++) { + // if listeners were deregistered, defragment the array + if (!listeners[i]) { + listeners.splice(i, 1); + i--; + length--; + continue; + } + + try { + listeners[i].apply(null, listenerArgs); + } catch (e) { + $exceptionHandler(e); + } + } + + // Insanity Warning: scope depth-first traversal + // yes, this code is a bit crazy, but it works and we have tests to prove it! + // this piece should be kept in sync with the traversal in $digest + // (though it differs due to having the extra check for $$listenerCount) + if (!(next = ((current.$$listenerCount[name] && current.$$childHead) || + (current !== target && current.$$nextSibling)))) { + while (current !== target && !(next = current.$$nextSibling)) { + current = current.$parent; + } + } + } + + event.currentScope = null; + return event; + } + }; + + var $rootScope = new Scope(); + + //The internal queues. Expose them on the $rootScope for debugging/testing purposes. + var asyncQueue = $rootScope.$$asyncQueue = []; + var postDigestQueue = $rootScope.$$postDigestQueue = []; + var applyAsyncQueue = $rootScope.$$applyAsyncQueue = []; + + var postDigestQueuePosition = 0; + + return $rootScope; + + + function beginPhase(phase) { + if ($rootScope.$$phase) { + throw $rootScopeMinErr('inprog', '{0} already in progress', $rootScope.$$phase); + } + + $rootScope.$$phase = phase; + } + + function clearPhase() { + $rootScope.$$phase = null; + } + + function incrementWatchersCount(current, count) { + do { + current.$$watchersCount += count; + } while ((current = current.$parent)); + } + + function decrementListenerCount(current, count, name) { + do { + current.$$listenerCount[name] -= count; + + if (current.$$listenerCount[name] === 0) { + delete current.$$listenerCount[name]; + } + } while ((current = current.$parent)); + } + + /** + * function used as an initial value for watchers. + * because it's unique we can easily tell it apart from other values + */ + function initWatchVal() {} + + function flushApplyAsync() { + while (applyAsyncQueue.length) { + try { + applyAsyncQueue.shift()(); + } catch (e) { + $exceptionHandler(e); + } + } + applyAsyncId = null; + } + + function scheduleApplyAsync() { + if (applyAsyncId === null) { + applyAsyncId = $browser.defer(function() { + $rootScope.$apply(flushApplyAsync); + }); + } + } + }]; + } + + /** + * @ngdoc service + * @name $rootElement + * + * @description + * The root element of AngularJS application. This is either the element where {@link + * ng.directive:ngApp ngApp} was declared or the element passed into + * {@link angular.bootstrap}. The element represents the root element of application. It is also the + * location where the application's {@link auto.$injector $injector} service gets + * published, and can be retrieved using `$rootElement.injector()`. + */ + + +// the implementation is in angular.bootstrap + + /** + * @this + * @description + * Private service to sanitize uris for links and images. Used by $compile and $sanitize. + */ + function $$SanitizeUriProvider() { + var aHrefSanitizationWhitelist = /^\s*(https?|s?ftp|mailto|tel|file):/, + imgSrcSanitizationWhitelist = /^\s*((https?|ftp|file|blob):|data:image\/)/; + + /** + * @description + * Retrieves or overrides the default regular expression that is used for whitelisting of safe + * urls during a[href] sanitization. + * + * The sanitization is a security measure aimed at prevent XSS attacks via html links. + * + * Any url about to be assigned to a[href] via data-binding is first normalized and turned into + * an absolute url. Afterwards, the url is matched against the `aHrefSanitizationWhitelist` + * regular expression. If a match is found, the original url is written into the dom. Otherwise, + * the absolute url is prefixed with `'unsafe:'` string and only then is it written into the DOM. + * + * @param {RegExp=} regexp New regexp to whitelist urls with. + * @returns {RegExp|ng.$compileProvider} Current RegExp if called without value or self for + * chaining otherwise. + */ + this.aHrefSanitizationWhitelist = function(regexp) { + if (isDefined(regexp)) { + aHrefSanitizationWhitelist = regexp; + return this; + } + return aHrefSanitizationWhitelist; + }; + + + /** + * @description + * Retrieves or overrides the default regular expression that is used for whitelisting of safe + * urls during img[src] sanitization. + * + * The sanitization is a security measure aimed at prevent XSS attacks via html links. + * + * Any url about to be assigned to img[src] via data-binding is first normalized and turned into + * an absolute url. Afterwards, the url is matched against the `imgSrcSanitizationWhitelist` + * regular expression. If a match is found, the original url is written into the dom. Otherwise, + * the absolute url is prefixed with `'unsafe:'` string and only then is it written into the DOM. + * + * @param {RegExp=} regexp New regexp to whitelist urls with. + * @returns {RegExp|ng.$compileProvider} Current RegExp if called without value or self for + * chaining otherwise. + */ + this.imgSrcSanitizationWhitelist = function(regexp) { + if (isDefined(regexp)) { + imgSrcSanitizationWhitelist = regexp; + return this; + } + return imgSrcSanitizationWhitelist; + }; + + this.$get = function() { + return function sanitizeUri(uri, isImage) { + var regex = isImage ? imgSrcSanitizationWhitelist : aHrefSanitizationWhitelist; + var normalizedVal; + normalizedVal = urlResolve(uri && uri.trim()).href; + if (normalizedVal !== '' && !normalizedVal.match(regex)) { + return 'unsafe:' + normalizedVal; + } + return uri; + }; + }; + } + + /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * Any commits to this file should be reviewed with security in mind. * + * Changes to this file can potentially create security vulnerabilities. * + * An approval from 2 Core members with history of modifying * + * this file is required. * + * * + * Does the change somehow allow for arbitrary javascript to be executed? * + * Or allows for someone to change the prototype of built-in objects? * + * Or gives undesired access to variables likes document or window? * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + + /* exported $SceProvider, $SceDelegateProvider */ + + var $sceMinErr = minErr('$sce'); + + var SCE_CONTEXTS = { + // HTML is used when there's HTML rendered (e.g. ng-bind-html, iframe srcdoc binding). + HTML: 'html', + + // Style statements or stylesheets. Currently unused in AngularJS. + CSS: 'css', + + // An URL used in a context where it does not refer to a resource that loads code. Currently + // unused in AngularJS. + URL: 'url', + + // RESOURCE_URL is a subtype of URL used where the referred-to resource could be interpreted as + // code. (e.g. ng-include, script src binding, templateUrl) + RESOURCE_URL: 'resourceUrl', + + // Script. Currently unused in AngularJS. + JS: 'js' + }; + +// Helper functions follow. + + var UNDERSCORE_LOWERCASE_REGEXP = /_([a-z])/g; + + function snakeToCamel(name) { + return name + .replace(UNDERSCORE_LOWERCASE_REGEXP, fnCamelCaseReplace); + } + + function adjustMatcher(matcher) { + if (matcher === 'self') { + return matcher; + } else if (isString(matcher)) { + // Strings match exactly except for 2 wildcards - '*' and '**'. + // '*' matches any character except those from the set ':/.?&'. + // '**' matches any character (like .* in a RegExp). + // More than 2 *'s raises an error as it's ill defined. + if (matcher.indexOf('***') > -1) { + throw $sceMinErr('iwcard', + 'Illegal sequence *** in string matcher. String: {0}', matcher); + } + matcher = escapeForRegexp(matcher). + replace(/\\\*\\\*/g, '.*'). + replace(/\\\*/g, '[^:/.?&;]*'); + return new RegExp('^' + matcher + '$'); + } else if (isRegExp(matcher)) { + // The only other type of matcher allowed is a Regexp. + // Match entire URL / disallow partial matches. + // Flags are reset (i.e. no global, ignoreCase or multiline) + return new RegExp('^' + matcher.source + '$'); + } else { + throw $sceMinErr('imatcher', + 'Matchers may only be "self", string patterns or RegExp objects'); + } + } + + + function adjustMatchers(matchers) { + var adjustedMatchers = []; + if (isDefined(matchers)) { + forEach(matchers, function(matcher) { + adjustedMatchers.push(adjustMatcher(matcher)); + }); + } + return adjustedMatchers; + } + + + /** + * @ngdoc service + * @name $sceDelegate + * @kind function + * + * @description + * + * `$sceDelegate` is a service that is used by the `$sce` service to provide {@link ng.$sce Strict + * Contextual Escaping (SCE)} services to AngularJS. + * + * For an overview of this service and the functionnality it provides in AngularJS, see the main + * page for {@link ng.$sce SCE}. The current page is targeted for developers who need to alter how + * SCE works in their application, which shouldn't be needed in most cases. + * + * <div class="alert alert-danger"> + * AngularJS strongly relies on contextual escaping for the security of bindings: disabling or + * modifying this might cause cross site scripting (XSS) vulnerabilities. For libraries owners, + * changes to this service will also influence users, so be extra careful and document your changes. + * </div> + * + * Typically, you would configure or override the {@link ng.$sceDelegate $sceDelegate} instead of + * the `$sce` service to customize the way Strict Contextual Escaping works in AngularJS. This is + * because, while the `$sce` provides numerous shorthand methods, etc., you really only need to + * override 3 core functions (`trustAs`, `getTrusted` and `valueOf`) to replace the way things + * work because `$sce` delegates to `$sceDelegate` for these operations. + * + * Refer {@link ng.$sceDelegateProvider $sceDelegateProvider} to configure this service. + * + * The default instance of `$sceDelegate` should work out of the box with little pain. While you + * can override it completely to change the behavior of `$sce`, the common case would + * involve configuring the {@link ng.$sceDelegateProvider $sceDelegateProvider} instead by setting + * your own whitelists and blacklists for trusting URLs used for loading AngularJS resources such as + * templates. Refer {@link ng.$sceDelegateProvider#resourceUrlWhitelist + * $sceDelegateProvider.resourceUrlWhitelist} and {@link + * ng.$sceDelegateProvider#resourceUrlBlacklist $sceDelegateProvider.resourceUrlBlacklist} + */ + + /** + * @ngdoc provider + * @name $sceDelegateProvider + * @this + * + * @description + * + * The `$sceDelegateProvider` provider allows developers to configure the {@link ng.$sceDelegate + * $sceDelegate service}, used as a delegate for {@link ng.$sce Strict Contextual Escaping (SCE)}. + * + * The `$sceDelegateProvider` allows one to get/set the whitelists and blacklists used to ensure + * that the URLs used for sourcing AngularJS templates and other script-running URLs are safe (all + * places that use the `$sce.RESOURCE_URL` context). See + * {@link ng.$sceDelegateProvider#resourceUrlWhitelist $sceDelegateProvider.resourceUrlWhitelist} + * and + * {@link ng.$sceDelegateProvider#resourceUrlBlacklist $sceDelegateProvider.resourceUrlBlacklist}, + * + * For the general details about this service in AngularJS, read the main page for {@link ng.$sce + * Strict Contextual Escaping (SCE)}. + * + * **Example**: Consider the following case. <a name="example"></a> + * + * - your app is hosted at url `http://myapp.example.com/` + * - but some of your templates are hosted on other domains you control such as + * `http://srv01.assets.example.com/`, `http://srv02.assets.example.com/`, etc. + * - and you have an open redirect at `http://myapp.example.com/clickThru?...`. + * + * Here is what a secure configuration for this scenario might look like: + * + * ``` + * angular.module('myApp', []).config(function($sceDelegateProvider) { + * $sceDelegateProvider.resourceUrlWhitelist([ + * // Allow same origin resource loads. + * 'self', + * // Allow loading from our assets domain. Notice the difference between * and **. + * 'http://srv*.assets.example.com/**' + * ]); + * + * // The blacklist overrides the whitelist so the open redirect here is blocked. + * $sceDelegateProvider.resourceUrlBlacklist([ + * 'http://myapp.example.com/clickThru**' + * ]); + * }); + * ``` + * Note that an empty whitelist will block every resource URL from being loaded, and will require + * you to manually mark each one as trusted with `$sce.trustAsResourceUrl`. However, templates + * requested by {@link ng.$templateRequest $templateRequest} that are present in + * {@link ng.$templateCache $templateCache} will not go through this check. If you have a mechanism + * to populate your templates in that cache at config time, then it is a good idea to remove 'self' + * from that whitelist. This helps to mitigate the security impact of certain types of issues, like + * for instance attacker-controlled `ng-includes`. + */ + + function $SceDelegateProvider() { + this.SCE_CONTEXTS = SCE_CONTEXTS; + + // Resource URLs can also be trusted by policy. + var resourceUrlWhitelist = ['self'], + resourceUrlBlacklist = []; + + /** + * @ngdoc method + * @name $sceDelegateProvider#resourceUrlWhitelist + * @kind function + * + * @param {Array=} whitelist When provided, replaces the resourceUrlWhitelist with the value + * provided. This must be an array or null. A snapshot of this array is used so further + * changes to the array are ignored. + * Follow {@link ng.$sce#resourceUrlPatternItem this link} for a description of the items + * allowed in this array. + * + * @return {Array} The currently set whitelist array. + * + * @description + * Sets/Gets the whitelist of trusted resource URLs. + * + * The **default value** when no whitelist has been explicitly set is `['self']` allowing only + * same origin resource requests. + * + * <div class="alert alert-warning"> + * **Note:** the default whitelist of 'self' is not recommended if your app shares its origin + * with other apps! It is a good idea to limit it to only your application's directory. + * </div> + */ + this.resourceUrlWhitelist = function(value) { + if (arguments.length) { + resourceUrlWhitelist = adjustMatchers(value); + } + return resourceUrlWhitelist; + }; + + /** + * @ngdoc method + * @name $sceDelegateProvider#resourceUrlBlacklist + * @kind function + * + * @param {Array=} blacklist When provided, replaces the resourceUrlBlacklist with the value + * provided. This must be an array or null. A snapshot of this array is used so further + * changes to the array are ignored.</p><p> + * Follow {@link ng.$sce#resourceUrlPatternItem this link} for a description of the items + * allowed in this array.</p><p> + * The typical usage for the blacklist is to **block + * [open redirects](http://cwe.mitre.org/data/definitions/601.html)** served by your domain as + * these would otherwise be trusted but actually return content from the redirected domain. + * </p><p> + * Finally, **the blacklist overrides the whitelist** and has the final say. + * + * @return {Array} The currently set blacklist array. + * + * @description + * Sets/Gets the blacklist of trusted resource URLs. + * + * The **default value** when no whitelist has been explicitly set is the empty array (i.e. there + * is no blacklist.) + */ + + this.resourceUrlBlacklist = function(value) { + if (arguments.length) { + resourceUrlBlacklist = adjustMatchers(value); + } + return resourceUrlBlacklist; + }; + + this.$get = ['$injector', function($injector) { + + var htmlSanitizer = function htmlSanitizer(html) { + throw $sceMinErr('unsafe', 'Attempting to use an unsafe value in a safe context.'); + }; + + if ($injector.has('$sanitize')) { + htmlSanitizer = $injector.get('$sanitize'); + } + + + function matchUrl(matcher, parsedUrl) { + if (matcher === 'self') { + return urlIsSameOrigin(parsedUrl); + } else { + // definitely a regex. See adjustMatchers() + return !!matcher.exec(parsedUrl.href); + } + } + + function isResourceUrlAllowedByPolicy(url) { + var parsedUrl = urlResolve(url.toString()); + var i, n, allowed = false; + // Ensure that at least one item from the whitelist allows this url. + for (i = 0, n = resourceUrlWhitelist.length; i < n; i++) { + if (matchUrl(resourceUrlWhitelist[i], parsedUrl)) { + allowed = true; + break; + } + } + if (allowed) { + // Ensure that no item from the blacklist blocked this url. + for (i = 0, n = resourceUrlBlacklist.length; i < n; i++) { + if (matchUrl(resourceUrlBlacklist[i], parsedUrl)) { + allowed = false; + break; + } + } + } + return allowed; + } + + function generateHolderType(Base) { + var holderType = function TrustedValueHolderType(trustedValue) { + this.$$unwrapTrustedValue = function() { + return trustedValue; + }; + }; + if (Base) { + holderType.prototype = new Base(); + } + holderType.prototype.valueOf = function sceValueOf() { + return this.$$unwrapTrustedValue(); + }; + holderType.prototype.toString = function sceToString() { + return this.$$unwrapTrustedValue().toString(); + }; + return holderType; + } + + var trustedValueHolderBase = generateHolderType(), + byType = {}; + + byType[SCE_CONTEXTS.HTML] = generateHolderType(trustedValueHolderBase); + byType[SCE_CONTEXTS.CSS] = generateHolderType(trustedValueHolderBase); + byType[SCE_CONTEXTS.URL] = generateHolderType(trustedValueHolderBase); + byType[SCE_CONTEXTS.JS] = generateHolderType(trustedValueHolderBase); + byType[SCE_CONTEXTS.RESOURCE_URL] = generateHolderType(byType[SCE_CONTEXTS.URL]); + + /** + * @ngdoc method + * @name $sceDelegate#trustAs + * + * @description + * Returns a trusted representation of the parameter for the specified context. This trusted + * object will later on be used as-is, without any security check, by bindings or directives + * that require this security context. + * For instance, marking a string as trusted for the `$sce.HTML` context will entirely bypass + * the potential `$sanitize` call in corresponding `$sce.HTML` bindings or directives, such as + * `ng-bind-html`. Note that in most cases you won't need to call this function: if you have the + * sanitizer loaded, passing the value itself will render all the HTML that does not pose a + * security risk. + * + * See {@link ng.$sceDelegate#getTrusted getTrusted} for the function that will consume those + * trusted values, and {@link ng.$sce $sce} for general documentation about strict contextual + * escaping. + * + * @param {string} type The context in which this value is safe for use, e.g. `$sce.URL`, + * `$sce.RESOURCE_URL`, `$sce.HTML`, `$sce.JS` or `$sce.CSS`. + * + * @param {*} value The value that should be considered trusted. + * @return {*} A trusted representation of value, that can be used in the given context. + */ + function trustAs(type, trustedValue) { + var Constructor = (byType.hasOwnProperty(type) ? byType[type] : null); + if (!Constructor) { + throw $sceMinErr('icontext', + 'Attempted to trust a value in invalid context. Context: {0}; Value: {1}', + type, trustedValue); + } + if (trustedValue === null || isUndefined(trustedValue) || trustedValue === '') { + return trustedValue; + } + // All the current contexts in SCE_CONTEXTS happen to be strings. In order to avoid trusting + // mutable objects, we ensure here that the value passed in is actually a string. + if (typeof trustedValue !== 'string') { + throw $sceMinErr('itype', + 'Attempted to trust a non-string value in a content requiring a string: Context: {0}', + type); + } + return new Constructor(trustedValue); + } + + /** + * @ngdoc method + * @name $sceDelegate#valueOf + * + * @description + * If the passed parameter had been returned by a prior call to {@link ng.$sceDelegate#trustAs + * `$sceDelegate.trustAs`}, returns the value that had been passed to {@link + * ng.$sceDelegate#trustAs `$sceDelegate.trustAs`}. + * + * If the passed parameter is not a value that had been returned by {@link + * ng.$sceDelegate#trustAs `$sceDelegate.trustAs`}, it must be returned as-is. + * + * @param {*} value The result of a prior {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs`} + * call or anything else. + * @return {*} The `value` that was originally provided to {@link ng.$sceDelegate#trustAs + * `$sceDelegate.trustAs`} if `value` is the result of such a call. Otherwise, returns + * `value` unchanged. + */ + function valueOf(maybeTrusted) { + if (maybeTrusted instanceof trustedValueHolderBase) { + return maybeTrusted.$$unwrapTrustedValue(); + } else { + return maybeTrusted; + } + } + + /** + * @ngdoc method + * @name $sceDelegate#getTrusted + * + * @description + * Takes any input, and either returns a value that's safe to use in the specified context, or + * throws an exception. + * + * In practice, there are several cases. When given a string, this function runs checks + * and sanitization to make it safe without prior assumptions. When given the result of a {@link + * ng.$sceDelegate#trustAs `$sceDelegate.trustAs`} call, it returns the originally supplied + * value if that value's context is valid for this call's context. Finally, this function can + * also throw when there is no way to turn `maybeTrusted` in a safe value (e.g., no sanitization + * is available or possible.) + * + * @param {string} type The context in which this value is to be used (such as `$sce.HTML`). + * @param {*} maybeTrusted The result of a prior {@link ng.$sceDelegate#trustAs + * `$sceDelegate.trustAs`} call, or anything else (which will not be considered trusted.) + * @return {*} A version of the value that's safe to use in the given context, or throws an + * exception if this is impossible. + */ + function getTrusted(type, maybeTrusted) { + if (maybeTrusted === null || isUndefined(maybeTrusted) || maybeTrusted === '') { + return maybeTrusted; + } + var constructor = (byType.hasOwnProperty(type) ? byType[type] : null); + // If maybeTrusted is a trusted class instance or subclass instance, then unwrap and return + // as-is. + if (constructor && maybeTrusted instanceof constructor) { + return maybeTrusted.$$unwrapTrustedValue(); + } + // Otherwise, if we get here, then we may either make it safe, or throw an exception. This + // depends on the context: some are sanitizatible (HTML), some use whitelists (RESOURCE_URL), + // some are impossible to do (JS). This step isn't implemented for CSS and URL, as AngularJS + // has no corresponding sinks. + if (type === SCE_CONTEXTS.RESOURCE_URL) { + // RESOURCE_URL uses a whitelist. + if (isResourceUrlAllowedByPolicy(maybeTrusted)) { + return maybeTrusted; + } else { + throw $sceMinErr('insecurl', + 'Blocked loading resource from url not allowed by $sceDelegate policy. URL: {0}', + maybeTrusted.toString()); + } + } else if (type === SCE_CONTEXTS.HTML) { + // htmlSanitizer throws its own error when no sanitizer is available. + return htmlSanitizer(maybeTrusted); + } + // Default error when the $sce service has no way to make the input safe. + throw $sceMinErr('unsafe', 'Attempting to use an unsafe value in a safe context.'); + } + + return { trustAs: trustAs, + getTrusted: getTrusted, + valueOf: valueOf }; + }]; + } + + + /** + * @ngdoc provider + * @name $sceProvider + * @this + * + * @description + * + * The $sceProvider provider allows developers to configure the {@link ng.$sce $sce} service. + * - enable/disable Strict Contextual Escaping (SCE) in a module + * - override the default implementation with a custom delegate + * + * Read more about {@link ng.$sce Strict Contextual Escaping (SCE)}. + */ + + /** + * @ngdoc service + * @name $sce + * @kind function + * + * @description + * + * `$sce` is a service that provides Strict Contextual Escaping services to AngularJS. + * + * ## Strict Contextual Escaping + * + * Strict Contextual Escaping (SCE) is a mode in which AngularJS constrains bindings to only render + * trusted values. Its goal is to assist in writing code in a way that (a) is secure by default, and + * (b) makes auditing for security vulnerabilities such as XSS, clickjacking, etc. a lot easier. + * + * ### Overview + * + * To systematically block XSS security bugs, AngularJS treats all values as untrusted by default in + * HTML or sensitive URL bindings. When binding untrusted values, AngularJS will automatically + * run security checks on them (sanitizations, whitelists, depending on context), or throw when it + * cannot guarantee the security of the result. That behavior depends strongly on contexts: HTML + * can be sanitized, but template URLs cannot, for instance. + * + * To illustrate this, consider the `ng-bind-html` directive. It renders its value directly as HTML: + * we call that the *context*. When given an untrusted input, AngularJS will attempt to sanitize it + * before rendering if a sanitizer is available, and throw otherwise. To bypass sanitization and + * render the input as-is, you will need to mark it as trusted for that context before attempting + * to bind it. + * + * As of version 1.2, AngularJS ships with SCE enabled by default. + * + * ### In practice + * + * Here's an example of a binding in a privileged context: + * + * ``` + * <input ng-model="userHtml" aria-label="User input"> + * <div ng-bind-html="userHtml"></div> + * ``` + * + * Notice that `ng-bind-html` is bound to `userHtml` controlled by the user. With SCE + * disabled, this application allows the user to render arbitrary HTML into the DIV, which would + * be an XSS security bug. In a more realistic example, one may be rendering user comments, blog + * articles, etc. via bindings. (HTML is just one example of a context where rendering user + * controlled input creates security vulnerabilities.) + * + * For the case of HTML, you might use a library, either on the client side, or on the server side, + * to sanitize unsafe HTML before binding to the value and rendering it in the document. + * + * How would you ensure that every place that used these types of bindings was bound to a value that + * was sanitized by your library (or returned as safe for rendering by your server?) How can you + * ensure that you didn't accidentally delete the line that sanitized the value, or renamed some + * properties/fields and forgot to update the binding to the sanitized value? + * + * To be secure by default, AngularJS makes sure bindings go through that sanitization, or + * any similar validation process, unless there's a good reason to trust the given value in this + * context. That trust is formalized with a function call. This means that as a developer, you + * can assume all untrusted bindings are safe. Then, to audit your code for binding security issues, + * you just need to ensure the values you mark as trusted indeed are safe - because they were + * received from your server, sanitized by your library, etc. You can organize your codebase to + * help with this - perhaps allowing only the files in a specific directory to do this. + * Ensuring that the internal API exposed by that code doesn't markup arbitrary values as safe then + * becomes a more manageable task. + * + * In the case of AngularJS' SCE service, one uses {@link ng.$sce#trustAs $sce.trustAs} + * (and shorthand methods such as {@link ng.$sce#trustAsHtml $sce.trustAsHtml}, etc.) to + * build the trusted versions of your values. + * + * ### How does it work? + * + * In privileged contexts, directives and code will bind to the result of {@link ng.$sce#getTrusted + * $sce.getTrusted(context, value)} rather than to the value directly. Think of this function as + * a way to enforce the required security context in your data sink. Directives use {@link + * ng.$sce#parseAs $sce.parseAs} rather than `$parse` to watch attribute bindings, which performs + * the {@link ng.$sce#getTrusted $sce.getTrusted} behind the scenes on non-constant literals. Also, + * when binding without directives, AngularJS will understand the context of your bindings + * automatically. + * + * As an example, {@link ng.directive:ngBindHtml ngBindHtml} uses {@link + * ng.$sce#parseAsHtml $sce.parseAsHtml(binding expression)}. Here's the actual code (slightly + * simplified): + * + * ``` + * var ngBindHtmlDirective = ['$sce', function($sce) { + * return function(scope, element, attr) { + * scope.$watch($sce.parseAsHtml(attr.ngBindHtml), function(value) { + * element.html(value || ''); + * }); + * }; + * }]; + * ``` + * + * ### Impact on loading templates + * + * This applies both to the {@link ng.directive:ngInclude `ng-include`} directive as well as + * `templateUrl`'s specified by {@link guide/directive directives}. + * + * By default, AngularJS only loads templates from the same domain and protocol as the application + * document. This is done by calling {@link ng.$sce#getTrustedResourceUrl + * $sce.getTrustedResourceUrl} on the template URL. To load templates from other domains and/or + * protocols, you may either {@link ng.$sceDelegateProvider#resourceUrlWhitelist whitelist + * them} or {@link ng.$sce#trustAsResourceUrl wrap it} into a trusted value. + * + * *Please note*: + * The browser's + * [Same Origin Policy](https://code.google.com/p/browsersec/wiki/Part2#Same-origin_policy_for_XMLHttpRequest) + * and [Cross-Origin Resource Sharing (CORS)](http://www.w3.org/TR/cors/) + * policy apply in addition to this and may further restrict whether the template is successfully + * loaded. This means that without the right CORS policy, loading templates from a different domain + * won't work on all browsers. Also, loading templates from `file://` URL does not work on some + * browsers. + * + * ### This feels like too much overhead + * + * It's important to remember that SCE only applies to interpolation expressions. + * + * If your expressions are constant literals, they're automatically trusted and you don't need to + * call `$sce.trustAs` on them (e.g. + * `<div ng-bind-html="'<b>implicitly trusted</b>'"></div>`) just works. The `$sceDelegate` will + * also use the `$sanitize` service if it is available when binding untrusted values to + * `$sce.HTML` context. AngularJS provides an implementation in `angular-sanitize.js`, and if you + * wish to use it, you will also need to depend on the {@link ngSanitize `ngSanitize`} module in + * your application. + * + * The included {@link ng.$sceDelegate $sceDelegate} comes with sane defaults to allow you to load + * templates in `ng-include` from your application's domain without having to even know about SCE. + * It blocks loading templates from other domains or loading templates over http from an https + * served document. You can change these by setting your own custom {@link + * ng.$sceDelegateProvider#resourceUrlWhitelist whitelists} and {@link + * ng.$sceDelegateProvider#resourceUrlBlacklist blacklists} for matching such URLs. + * + * This significantly reduces the overhead. It is far easier to pay the small overhead and have an + * application that's secure and can be audited to verify that with much more ease than bolting + * security onto an application later. + * + * <a name="contexts"></a> + * ### What trusted context types are supported? + * + * | Context | Notes | + * |---------------------|----------------| + * | `$sce.HTML` | For HTML that's safe to source into the application. The {@link ng.directive:ngBindHtml ngBindHtml} directive uses this context for bindings. If an unsafe value is encountered, and the {@link ngSanitize.$sanitize $sanitize} service is available (implemented by the {@link ngSanitize ngSanitize} module) this will sanitize the value instead of throwing an error. | + * | `$sce.CSS` | For CSS that's safe to source into the application. Currently, no bindings require this context. Feel free to use it in your own directives. | + * | `$sce.URL` | For URLs that are safe to follow as links. Currently unused (`<a href=`, `<img src=`, and some others sanitize their urls and don't constitute an SCE context.) | + * | `$sce.RESOURCE_URL` | For URLs that are not only safe to follow as links, but whose contents are also safe to include in your application. Examples include `ng-include`, `src` / `ngSrc` bindings for tags other than `IMG`, `VIDEO`, `AUDIO`, `SOURCE`, and `TRACK` (e.g. `IFRAME`, `OBJECT`, etc.) <br><br>Note that `$sce.RESOURCE_URL` makes a stronger statement about the URL than `$sce.URL` does (it's not just the URL that matters, but also what is at the end of it), and therefore contexts requiring values trusted for `$sce.RESOURCE_URL` can be used anywhere that values trusted for `$sce.URL` are required. | + * | `$sce.JS` | For JavaScript that is safe to execute in your application's context. Currently, no bindings require this context. Feel free to use it in your own directives. | + * + * + * Be aware that `a[href]` and `img[src]` automatically sanitize their URLs and do not pass them + * through {@link ng.$sce#getTrusted $sce.getTrusted}. There's no CSS-, URL-, or JS-context bindings + * in AngularJS currently, so their corresponding `$sce.trustAs` functions aren't useful yet. This + * might evolve. + * + * ### Format of items in {@link ng.$sceDelegateProvider#resourceUrlWhitelist resourceUrlWhitelist}/{@link ng.$sceDelegateProvider#resourceUrlBlacklist Blacklist} <a name="resourceUrlPatternItem"></a> + * + * Each element in these arrays must be one of the following: + * + * - **'self'** + * - The special **string**, `'self'`, can be used to match against all URLs of the **same + * domain** as the application document using the **same protocol**. + * - **String** (except the special value `'self'`) + * - The string is matched against the full *normalized / absolute URL* of the resource + * being tested (substring matches are not good enough.) + * - There are exactly **two wildcard sequences** - `*` and `**`. All other characters + * match themselves. + * - `*`: matches zero or more occurrences of any character other than one of the following 6 + * characters: '`:`', '`/`', '`.`', '`?`', '`&`' and '`;`'. It's a useful wildcard for use + * in a whitelist. + * - `**`: matches zero or more occurrences of *any* character. As such, it's not + * appropriate for use in a scheme, domain, etc. as it would match too much. (e.g. + * http://**.example.com/ would match http://evil.com/?ignore=.example.com/ and that might + * not have been the intention.) Its usage at the very end of the path is ok. (e.g. + * http://foo.example.com/templates/**). + * - **RegExp** (*see caveat below*) + * - *Caveat*: While regular expressions are powerful and offer great flexibility, their syntax + * (and all the inevitable escaping) makes them *harder to maintain*. It's easy to + * accidentally introduce a bug when one updates a complex expression (imho, all regexes should + * have good test coverage). For instance, the use of `.` in the regex is correct only in a + * small number of cases. A `.` character in the regex used when matching the scheme or a + * subdomain could be matched against a `:` or literal `.` that was likely not intended. It + * is highly recommended to use the string patterns and only fall back to regular expressions + * as a last resort. + * - The regular expression must be an instance of RegExp (i.e. not a string.) It is + * matched against the **entire** *normalized / absolute URL* of the resource being tested + * (even when the RegExp did not have the `^` and `$` codes.) In addition, any flags + * present on the RegExp (such as multiline, global, ignoreCase) are ignored. + * - If you are generating your JavaScript from some other templating engine (not + * recommended, e.g. in issue [#4006](https://github.com/angular/angular.js/issues/4006)), + * remember to escape your regular expression (and be aware that you might need more than + * one level of escaping depending on your templating engine and the way you interpolated + * the value.) Do make use of your platform's escaping mechanism as it might be good + * enough before coding your own. E.g. Ruby has + * [Regexp.escape(str)](http://www.ruby-doc.org/core-2.0.0/Regexp.html#method-c-escape) + * and Python has [re.escape](http://docs.python.org/library/re.html#re.escape). + * Javascript lacks a similar built in function for escaping. Take a look at Google + * Closure library's [goog.string.regExpEscape(s)]( + * http://docs.closure-library.googlecode.com/git/closure_goog_string_string.js.source.html#line962). + * + * Refer {@link ng.$sceDelegateProvider $sceDelegateProvider} for an example. + * + * ### Show me an example using SCE. + * + * <example module="mySceApp" deps="angular-sanitize.js" name="sce-service"> + * <file name="index.html"> + * <div ng-controller="AppController as myCtrl"> + * <i ng-bind-html="myCtrl.explicitlyTrustedHtml" id="explicitlyTrustedHtml"></i><br><br> + * <b>User comments</b><br> + * By default, HTML that isn't explicitly trusted (e.g. Alice's comment) is sanitized when + * $sanitize is available. If $sanitize isn't available, this results in an error instead of an + * exploit. + * <div class="well"> + * <div ng-repeat="userComment in myCtrl.userComments"> + * <b>{{userComment.name}}</b>: + * <span ng-bind-html="userComment.htmlComment" class="htmlComment"></span> + * <br> + * </div> + * </div> + * </div> + * </file> + * + * <file name="script.js"> + * angular.module('mySceApp', ['ngSanitize']) + * .controller('AppController', ['$http', '$templateCache', '$sce', + * function AppController($http, $templateCache, $sce) { + * var self = this; + * $http.get('test_data.json', {cache: $templateCache}).then(function(response) { + * self.userComments = response.data; + * }); + * self.explicitlyTrustedHtml = $sce.trustAsHtml( + * '<span onmouseover="this.textContent="Explicitly trusted HTML bypasses ' + + * 'sanitization."">Hover over this text.</span>'); + * }]); + * </file> + * + * <file name="test_data.json"> + * [ + * { "name": "Alice", + * "htmlComment": + * "<span onmouseover='this.textContent=\"PWN3D!\"'>Is <i>anyone</i> reading this?</span>" + * }, + * { "name": "Bob", + * "htmlComment": "<i>Yes!</i> Am I the only other one?" + * } + * ] + * </file> + * + * <file name="protractor.js" type="protractor"> + * describe('SCE doc demo', function() { + * it('should sanitize untrusted values', function() { + * expect(element.all(by.css('.htmlComment')).first().getAttribute('innerHTML')) + * .toBe('<span>Is <i>anyone</i> reading this?</span>'); + * }); + * + * it('should NOT sanitize explicitly trusted values', function() { + * expect(element(by.id('explicitlyTrustedHtml')).getAttribute('innerHTML')).toBe( + * '<span onmouseover="this.textContent="Explicitly trusted HTML bypasses ' + + * 'sanitization."">Hover over this text.</span>'); + * }); + * }); + * </file> + * </example> + * + * + * + * ## Can I disable SCE completely? + * + * Yes, you can. However, this is strongly discouraged. SCE gives you a lot of security benefits + * for little coding overhead. It will be much harder to take an SCE disabled application and + * either secure it on your own or enable SCE at a later stage. It might make sense to disable SCE + * for cases where you have a lot of existing code that was written before SCE was introduced and + * you're migrating them a module at a time. Also do note that this is an app-wide setting, so if + * you are writing a library, you will cause security bugs applications using it. + * + * That said, here's how you can completely disable SCE: + * + * ``` + * angular.module('myAppWithSceDisabledmyApp', []).config(function($sceProvider) { + * // Completely disable SCE. For demonstration purposes only! + * // Do not use in new projects or libraries. + * $sceProvider.enabled(false); + * }); + * ``` + * + */ + + function $SceProvider() { + var enabled = true; + + /** + * @ngdoc method + * @name $sceProvider#enabled + * @kind function + * + * @param {boolean=} value If provided, then enables/disables SCE application-wide. + * @return {boolean} True if SCE is enabled, false otherwise. + * + * @description + * Enables/disables SCE and returns the current value. + */ + this.enabled = function(value) { + if (arguments.length) { + enabled = !!value; + } + return enabled; + }; + + + /* Design notes on the default implementation for SCE. + * + * The API contract for the SCE delegate + * ------------------------------------- + * The SCE delegate object must provide the following 3 methods: + * + * - trustAs(contextEnum, value) + * This method is used to tell the SCE service that the provided value is OK to use in the + * contexts specified by contextEnum. It must return an object that will be accepted by + * getTrusted() for a compatible contextEnum and return this value. + * + * - valueOf(value) + * For values that were not produced by trustAs(), return them as is. For values that were + * produced by trustAs(), return the corresponding input value to trustAs. Basically, if + * trustAs is wrapping the given values into some type, this operation unwraps it when given + * such a value. + * + * - getTrusted(contextEnum, value) + * This function should return the a value that is safe to use in the context specified by + * contextEnum or throw and exception otherwise. + * + * NOTE: This contract deliberately does NOT state that values returned by trustAs() must be + * opaque or wrapped in some holder object. That happens to be an implementation detail. For + * instance, an implementation could maintain a registry of all trusted objects by context. In + * such a case, trustAs() would return the same object that was passed in. getTrusted() would + * return the same object passed in if it was found in the registry under a compatible context or + * throw an exception otherwise. An implementation might only wrap values some of the time based + * on some criteria. getTrusted() might return a value and not throw an exception for special + * constants or objects even if not wrapped. All such implementations fulfill this contract. + * + * + * A note on the inheritance model for SCE contexts + * ------------------------------------------------ + * I've used inheritance and made RESOURCE_URL wrapped types a subtype of URL wrapped types. This + * is purely an implementation details. + * + * The contract is simply this: + * + * getTrusted($sce.RESOURCE_URL, value) succeeding implies that getTrusted($sce.URL, value) + * will also succeed. + * + * Inheritance happens to capture this in a natural way. In some future, we may not use + * inheritance anymore. That is OK because no code outside of sce.js and sceSpecs.js would need to + * be aware of this detail. + */ + + this.$get = ['$parse', '$sceDelegate', function( + $parse, $sceDelegate) { + // Support: IE 9-11 only + // Prereq: Ensure that we're not running in IE<11 quirks mode. In that mode, IE < 11 allow + // the "expression(javascript expression)" syntax which is insecure. + if (enabled && msie < 8) { + throw $sceMinErr('iequirks', + 'Strict Contextual Escaping does not support Internet Explorer version < 11 in quirks ' + + 'mode. You can fix this by adding the text <!doctype html> to the top of your HTML ' + + 'document. See http://docs.angularjs.org/api/ng.$sce for more information.'); + } + + var sce = shallowCopy(SCE_CONTEXTS); + + /** + * @ngdoc method + * @name $sce#isEnabled + * @kind function + * + * @return {Boolean} True if SCE is enabled, false otherwise. If you want to set the value, you + * have to do it at module config time on {@link ng.$sceProvider $sceProvider}. + * + * @description + * Returns a boolean indicating if SCE is enabled. + */ + sce.isEnabled = function() { + return enabled; + }; + sce.trustAs = $sceDelegate.trustAs; + sce.getTrusted = $sceDelegate.getTrusted; + sce.valueOf = $sceDelegate.valueOf; + + if (!enabled) { + sce.trustAs = sce.getTrusted = function(type, value) { return value; }; + sce.valueOf = identity; + } + + /** + * @ngdoc method + * @name $sce#parseAs + * + * @description + * Converts AngularJS {@link guide/expression expression} into a function. This is like {@link + * ng.$parse $parse} and is identical when the expression is a literal constant. Otherwise, it + * wraps the expression in a call to {@link ng.$sce#getTrusted $sce.getTrusted(*type*, + * *result*)} + * + * @param {string} type The SCE context in which this result will be used. + * @param {string} expression String expression to compile. + * @return {function(context, locals)} A function which represents the compiled expression: + * + * * `context` – `{object}` – an object against which any expressions embedded in the + * strings are evaluated against (typically a scope object). + * * `locals` – `{object=}` – local variables context object, useful for overriding values + * in `context`. + */ + sce.parseAs = function sceParseAs(type, expr) { + var parsed = $parse(expr); + if (parsed.literal && parsed.constant) { + return parsed; + } else { + return $parse(expr, function(value) { + return sce.getTrusted(type, value); + }); + } + }; + + /** + * @ngdoc method + * @name $sce#trustAs + * + * @description + * Delegates to {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs`}. As such, returns a + * wrapped object that represents your value, and the trust you have in its safety for the given + * context. AngularJS can then use that value as-is in bindings of the specified secure context. + * This is used in bindings for `ng-bind-html`, `ng-include`, and most `src` attribute + * interpolations. See {@link ng.$sce $sce} for strict contextual escaping. + * + * @param {string} type The context in which this value is safe for use, e.g. `$sce.URL`, + * `$sce.RESOURCE_URL`, `$sce.HTML`, `$sce.JS` or `$sce.CSS`. + * + * @param {*} value The value that that should be considered trusted. + * @return {*} A wrapped version of value that can be used as a trusted variant of your `value` + * in the context you specified. + */ + + /** + * @ngdoc method + * @name $sce#trustAsHtml + * + * @description + * Shorthand method. `$sce.trustAsHtml(value)` → + * {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs($sce.HTML, value)`} + * + * @param {*} value The value to mark as trusted for `$sce.HTML` context. + * @return {*} A wrapped version of value that can be used as a trusted variant of your `value` + * in `$sce.HTML` context (like `ng-bind-html`). + */ + + /** + * @ngdoc method + * @name $sce#trustAsCss + * + * @description + * Shorthand method. `$sce.trustAsCss(value)` → + * {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs($sce.CSS, value)`} + * + * @param {*} value The value to mark as trusted for `$sce.CSS` context. + * @return {*} A wrapped version of value that can be used as a trusted variant + * of your `value` in `$sce.CSS` context. This context is currently unused, so there are + * almost no reasons to use this function so far. + */ + + /** + * @ngdoc method + * @name $sce#trustAsUrl + * + * @description + * Shorthand method. `$sce.trustAsUrl(value)` → + * {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs($sce.URL, value)`} + * + * @param {*} value The value to mark as trusted for `$sce.URL` context. + * @return {*} A wrapped version of value that can be used as a trusted variant of your `value` + * in `$sce.URL` context. That context is currently unused, so there are almost no reasons + * to use this function so far. + */ + + /** + * @ngdoc method + * @name $sce#trustAsResourceUrl + * + * @description + * Shorthand method. `$sce.trustAsResourceUrl(value)` → + * {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs($sce.RESOURCE_URL, value)`} + * + * @param {*} value The value to mark as trusted for `$sce.RESOURCE_URL` context. + * @return {*} A wrapped version of value that can be used as a trusted variant of your `value` + * in `$sce.RESOURCE_URL` context (template URLs in `ng-include`, most `src` attribute + * bindings, ...) + */ + + /** + * @ngdoc method + * @name $sce#trustAsJs + * + * @description + * Shorthand method. `$sce.trustAsJs(value)` → + * {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs($sce.JS, value)`} + * + * @param {*} value The value to mark as trusted for `$sce.JS` context. + * @return {*} A wrapped version of value that can be used as a trusted variant of your `value` + * in `$sce.JS` context. That context is currently unused, so there are almost no reasons to + * use this function so far. + */ + + /** + * @ngdoc method + * @name $sce#getTrusted + * + * @description + * Delegates to {@link ng.$sceDelegate#getTrusted `$sceDelegate.getTrusted`}. As such, + * takes any input, and either returns a value that's safe to use in the specified context, + * or throws an exception. This function is aware of trusted values created by the `trustAs` + * function and its shorthands, and when contexts are appropriate, returns the unwrapped value + * as-is. Finally, this function can also throw when there is no way to turn `maybeTrusted` in a + * safe value (e.g., no sanitization is available or possible.) + * + * @param {string} type The context in which this value is to be used. + * @param {*} maybeTrusted The result of a prior {@link ng.$sce#trustAs + * `$sce.trustAs`} call, or anything else (which will not be considered trusted.) + * @return {*} A version of the value that's safe to use in the given context, or throws an + * exception if this is impossible. + */ + + /** + * @ngdoc method + * @name $sce#getTrustedHtml + * + * @description + * Shorthand method. `$sce.getTrustedHtml(value)` → + * {@link ng.$sceDelegate#getTrusted `$sceDelegate.getTrusted($sce.HTML, value)`} + * + * @param {*} value The value to pass to `$sce.getTrusted`. + * @return {*} The return value of `$sce.getTrusted($sce.HTML, value)` + */ + + /** + * @ngdoc method + * @name $sce#getTrustedCss + * + * @description + * Shorthand method. `$sce.getTrustedCss(value)` → + * {@link ng.$sceDelegate#getTrusted `$sceDelegate.getTrusted($sce.CSS, value)`} + * + * @param {*} value The value to pass to `$sce.getTrusted`. + * @return {*} The return value of `$sce.getTrusted($sce.CSS, value)` + */ + + /** + * @ngdoc method + * @name $sce#getTrustedUrl + * + * @description + * Shorthand method. `$sce.getTrustedUrl(value)` → + * {@link ng.$sceDelegate#getTrusted `$sceDelegate.getTrusted($sce.URL, value)`} + * + * @param {*} value The value to pass to `$sce.getTrusted`. + * @return {*} The return value of `$sce.getTrusted($sce.URL, value)` + */ + + /** + * @ngdoc method + * @name $sce#getTrustedResourceUrl + * + * @description + * Shorthand method. `$sce.getTrustedResourceUrl(value)` → + * {@link ng.$sceDelegate#getTrusted `$sceDelegate.getTrusted($sce.RESOURCE_URL, value)`} + * + * @param {*} value The value to pass to `$sceDelegate.getTrusted`. + * @return {*} The return value of `$sce.getTrusted($sce.RESOURCE_URL, value)` + */ + + /** + * @ngdoc method + * @name $sce#getTrustedJs + * + * @description + * Shorthand method. `$sce.getTrustedJs(value)` → + * {@link ng.$sceDelegate#getTrusted `$sceDelegate.getTrusted($sce.JS, value)`} + * + * @param {*} value The value to pass to `$sce.getTrusted`. + * @return {*} The return value of `$sce.getTrusted($sce.JS, value)` + */ + + /** + * @ngdoc method + * @name $sce#parseAsHtml + * + * @description + * Shorthand method. `$sce.parseAsHtml(expression string)` → + * {@link ng.$sce#parseAs `$sce.parseAs($sce.HTML, value)`} + * + * @param {string} expression String expression to compile. + * @return {function(context, locals)} A function which represents the compiled expression: + * + * * `context` – `{object}` – an object against which any expressions embedded in the + * strings are evaluated against (typically a scope object). + * * `locals` – `{object=}` – local variables context object, useful for overriding values + * in `context`. + */ + + /** + * @ngdoc method + * @name $sce#parseAsCss + * + * @description + * Shorthand method. `$sce.parseAsCss(value)` → + * {@link ng.$sce#parseAs `$sce.parseAs($sce.CSS, value)`} + * + * @param {string} expression String expression to compile. + * @return {function(context, locals)} A function which represents the compiled expression: + * + * * `context` – `{object}` – an object against which any expressions embedded in the + * strings are evaluated against (typically a scope object). + * * `locals` – `{object=}` – local variables context object, useful for overriding values + * in `context`. + */ + + /** + * @ngdoc method + * @name $sce#parseAsUrl + * + * @description + * Shorthand method. `$sce.parseAsUrl(value)` → + * {@link ng.$sce#parseAs `$sce.parseAs($sce.URL, value)`} + * + * @param {string} expression String expression to compile. + * @return {function(context, locals)} A function which represents the compiled expression: + * + * * `context` – `{object}` – an object against which any expressions embedded in the + * strings are evaluated against (typically a scope object). + * * `locals` – `{object=}` – local variables context object, useful for overriding values + * in `context`. + */ + + /** + * @ngdoc method + * @name $sce#parseAsResourceUrl + * + * @description + * Shorthand method. `$sce.parseAsResourceUrl(value)` → + * {@link ng.$sce#parseAs `$sce.parseAs($sce.RESOURCE_URL, value)`} + * + * @param {string} expression String expression to compile. + * @return {function(context, locals)} A function which represents the compiled expression: + * + * * `context` – `{object}` – an object against which any expressions embedded in the + * strings are evaluated against (typically a scope object). + * * `locals` – `{object=}` – local variables context object, useful for overriding values + * in `context`. + */ + + /** + * @ngdoc method + * @name $sce#parseAsJs + * + * @description + * Shorthand method. `$sce.parseAsJs(value)` → + * {@link ng.$sce#parseAs `$sce.parseAs($sce.JS, value)`} + * + * @param {string} expression String expression to compile. + * @return {function(context, locals)} A function which represents the compiled expression: + * + * * `context` – `{object}` – an object against which any expressions embedded in the + * strings are evaluated against (typically a scope object). + * * `locals` – `{object=}` – local variables context object, useful for overriding values + * in `context`. + */ + + // Shorthand delegations. + var parse = sce.parseAs, + getTrusted = sce.getTrusted, + trustAs = sce.trustAs; + + forEach(SCE_CONTEXTS, function(enumValue, name) { + var lName = lowercase(name); + sce[snakeToCamel('parse_as_' + lName)] = function(expr) { + return parse(enumValue, expr); + }; + sce[snakeToCamel('get_trusted_' + lName)] = function(value) { + return getTrusted(enumValue, value); + }; + sce[snakeToCamel('trust_as_' + lName)] = function(value) { + return trustAs(enumValue, value); + }; + }); + + return sce; + }]; + } + + /* exported $SnifferProvider */ + + /** + * !!! This is an undocumented "private" service !!! + * + * @name $sniffer + * @requires $window + * @requires $document + * @this + * + * @property {boolean} history Does the browser support html5 history api ? + * @property {boolean} transitions Does the browser support CSS transition events ? + * @property {boolean} animations Does the browser support CSS animation events ? + * + * @description + * This is very simple implementation of testing browser's features. + */ + function $SnifferProvider() { + this.$get = ['$window', '$document', function($window, $document) { + var eventSupport = {}, + // Chrome Packaged Apps are not allowed to access `history.pushState`. + // If not sandboxed, they can be detected by the presence of `chrome.app.runtime` + // (see https://developer.chrome.com/apps/api_index). If sandboxed, they can be detected by + // the presence of an extension runtime ID and the absence of other Chrome runtime APIs + // (see https://developer.chrome.com/apps/manifest/sandbox). + // (NW.js apps have access to Chrome APIs, but do support `history`.) + isNw = $window.nw && $window.nw.process, + isChromePackagedApp = + !isNw && + $window.chrome && + ($window.chrome.app && $window.chrome.app.runtime || + !$window.chrome.app && $window.chrome.runtime && $window.chrome.runtime.id), + hasHistoryPushState = !isChromePackagedApp && $window.history && $window.history.pushState, + android = + toInt((/android (\d+)/.exec(lowercase(($window.navigator || {}).userAgent)) || [])[1]), + boxee = /Boxee/i.test(($window.navigator || {}).userAgent), + document = $document[0] || {}, + bodyStyle = document.body && document.body.style, + transitions = false, + animations = false; + + if (bodyStyle) { + // Support: Android <5, Blackberry Browser 10, default Chrome in Android 4.4.x + // Mentioned browsers need a -webkit- prefix for transitions & animations. + transitions = !!('transition' in bodyStyle || 'webkitTransition' in bodyStyle); + animations = !!('animation' in bodyStyle || 'webkitAnimation' in bodyStyle); + } + + + return { + // Android has history.pushState, but it does not update location correctly + // so let's not use the history API at all. + // http://code.google.com/p/android/issues/detail?id=17471 + // https://github.com/angular/angular.js/issues/904 + + // older webkit browser (533.9) on Boxee box has exactly the same problem as Android has + // so let's not use the history API also + // We are purposefully using `!(android < 4)` to cover the case when `android` is undefined + history: !!(hasHistoryPushState && !(android < 4) && !boxee), + hasEvent: function(event) { + // Support: IE 9-11 only + // IE9 implements 'input' event it's so fubared that we rather pretend that it doesn't have + // it. In particular the event is not fired when backspace or delete key are pressed or + // when cut operation is performed. + // IE10+ implements 'input' event but it erroneously fires under various situations, + // e.g. when placeholder changes, or a form is focused. + if (event === 'input' && msie) return false; + + if (isUndefined(eventSupport[event])) { + var divElm = document.createElement('div'); + eventSupport[event] = 'on' + event in divElm; + } + + return eventSupport[event]; + }, + csp: csp(), + transitions: transitions, + animations: animations, + android: android + }; + }]; + } + + var $templateRequestMinErr = minErr('$compile'); + + /** + * @ngdoc provider + * @name $templateRequestProvider + * @this + * + * @description + * Used to configure the options passed to the {@link $http} service when making a template request. + * + * For example, it can be used for specifying the "Accept" header that is sent to the server, when + * requesting a template. + */ + function $TemplateRequestProvider() { + + var httpOptions; + + /** + * @ngdoc method + * @name $templateRequestProvider#httpOptions + * @description + * The options to be passed to the {@link $http} service when making the request. + * You can use this to override options such as the "Accept" header for template requests. + * + * The {@link $templateRequest} will set the `cache` and the `transformResponse` properties of the + * options if not overridden here. + * + * @param {string=} value new value for the {@link $http} options. + * @returns {string|self} Returns the {@link $http} options when used as getter and self if used as setter. + */ + this.httpOptions = function(val) { + if (val) { + httpOptions = val; + return this; + } + return httpOptions; + }; + + /** + * @ngdoc service + * @name $templateRequest + * + * @description + * The `$templateRequest` service runs security checks then downloads the provided template using + * `$http` and, upon success, stores the contents inside of `$templateCache`. If the HTTP request + * fails or the response data of the HTTP request is empty, a `$compile` error will be thrown (the + * exception can be thwarted by setting the 2nd parameter of the function to true). Note that the + * contents of `$templateCache` are trusted, so the call to `$sce.getTrustedUrl(tpl)` is omitted + * when `tpl` is of type string and `$templateCache` has the matching entry. + * + * If you want to pass custom options to the `$http` service, such as setting the Accept header you + * can configure this via {@link $templateRequestProvider#httpOptions}. + * + * `$templateRequest` is used internally by {@link $compile}, {@link ngRoute.$route}, and directives such + * as {@link ngInclude} to download and cache templates. + * + * 3rd party modules should use `$templateRequest` if their services or directives are loading + * templates. + * + * @param {string|TrustedResourceUrl} tpl The HTTP request template URL + * @param {boolean=} ignoreRequestError Whether or not to ignore the exception when the request fails or the template is empty + * + * @return {Promise} a promise for the HTTP response data of the given URL. + * + * @property {number} totalPendingRequests total amount of pending template requests being downloaded. + */ + this.$get = ['$exceptionHandler', '$templateCache', '$http', '$q', '$sce', + function($exceptionHandler, $templateCache, $http, $q, $sce) { + + function handleRequestFn(tpl, ignoreRequestError) { + handleRequestFn.totalPendingRequests++; + + // We consider the template cache holds only trusted templates, so + // there's no need to go through whitelisting again for keys that already + // are included in there. This also makes AngularJS accept any script + // directive, no matter its name. However, we still need to unwrap trusted + // types. + if (!isString(tpl) || isUndefined($templateCache.get(tpl))) { + tpl = $sce.getTrustedResourceUrl(tpl); + } + + var transformResponse = $http.defaults && $http.defaults.transformResponse; + + if (isArray(transformResponse)) { + transformResponse = transformResponse.filter(function(transformer) { + return transformer !== defaultHttpResponseTransform; + }); + } else if (transformResponse === defaultHttpResponseTransform) { + transformResponse = null; + } + + return $http.get(tpl, extend({ + cache: $templateCache, + transformResponse: transformResponse + }, httpOptions)) + .finally(function() { + handleRequestFn.totalPendingRequests--; + }) + .then(function(response) { + $templateCache.put(tpl, response.data); + return response.data; + }, handleError); + + function handleError(resp) { + if (!ignoreRequestError) { + resp = $templateRequestMinErr('tpload', + 'Failed to load template: {0} (HTTP status: {1} {2})', + tpl, resp.status, resp.statusText); + + $exceptionHandler(resp); + } + + return $q.reject(resp); + } + } + + handleRequestFn.totalPendingRequests = 0; + + return handleRequestFn; + } + ]; + } + + /** @this */ + function $$TestabilityProvider() { + this.$get = ['$rootScope', '$browser', '$location', + function($rootScope, $browser, $location) { + + /** + * @name $testability + * + * @description + * The private $$testability service provides a collection of methods for use when debugging + * or by automated test and debugging tools. + */ + var testability = {}; + + /** + * @name $$testability#findBindings + * + * @description + * Returns an array of elements that are bound (via ng-bind or {{}}) + * to expressions matching the input. + * + * @param {Element} element The element root to search from. + * @param {string} expression The binding expression to match. + * @param {boolean} opt_exactMatch If true, only returns exact matches + * for the expression. Filters and whitespace are ignored. + */ + testability.findBindings = function(element, expression, opt_exactMatch) { + var bindings = element.getElementsByClassName('ng-binding'); + var matches = []; + forEach(bindings, function(binding) { + var dataBinding = angular.element(binding).data('$binding'); + if (dataBinding) { + forEach(dataBinding, function(bindingName) { + if (opt_exactMatch) { + var matcher = new RegExp('(^|\\s)' + escapeForRegexp(expression) + '(\\s|\\||$)'); + if (matcher.test(bindingName)) { + matches.push(binding); + } + } else { + if (bindingName.indexOf(expression) !== -1) { + matches.push(binding); + } + } + }); + } + }); + return matches; + }; + + /** + * @name $$testability#findModels + * + * @description + * Returns an array of elements that are two-way found via ng-model to + * expressions matching the input. + * + * @param {Element} element The element root to search from. + * @param {string} expression The model expression to match. + * @param {boolean} opt_exactMatch If true, only returns exact matches + * for the expression. + */ + testability.findModels = function(element, expression, opt_exactMatch) { + var prefixes = ['ng-', 'data-ng-', 'ng\\:']; + for (var p = 0; p < prefixes.length; ++p) { + var attributeEquals = opt_exactMatch ? '=' : '*='; + var selector = '[' + prefixes[p] + 'model' + attributeEquals + '"' + expression + '"]'; + var elements = element.querySelectorAll(selector); + if (elements.length) { + return elements; + } + } + }; + + /** + * @name $$testability#getLocation + * + * @description + * Shortcut for getting the location in a browser agnostic way. Returns + * the path, search, and hash. (e.g. /path?a=b#hash) + */ + testability.getLocation = function() { + return $location.url(); + }; + + /** + * @name $$testability#setLocation + * + * @description + * Shortcut for navigating to a location without doing a full page reload. + * + * @param {string} url The location url (path, search and hash, + * e.g. /path?a=b#hash) to go to. + */ + testability.setLocation = function(url) { + if (url !== $location.url()) { + $location.url(url); + $rootScope.$digest(); + } + }; + + /** + * @name $$testability#whenStable + * + * @description + * Calls the callback when $timeout and $http requests are completed. + * + * @param {function} callback + */ + testability.whenStable = function(callback) { + $browser.notifyWhenNoOutstandingRequests(callback); + }; + + return testability; + }]; + } + + /** @this */ + function $TimeoutProvider() { + this.$get = ['$rootScope', '$browser', '$q', '$$q', '$exceptionHandler', + function($rootScope, $browser, $q, $$q, $exceptionHandler) { + + var deferreds = {}; + + + /** + * @ngdoc service + * @name $timeout + * + * @description + * AngularJS's wrapper for `window.setTimeout`. The `fn` function is wrapped into a try/catch + * block and delegates any exceptions to + * {@link ng.$exceptionHandler $exceptionHandler} service. + * + * The return value of calling `$timeout` is a promise, which will be resolved when + * the delay has passed and the timeout function, if provided, is executed. + * + * To cancel a timeout request, call `$timeout.cancel(promise)`. + * + * In tests you can use {@link ngMock.$timeout `$timeout.flush()`} to + * synchronously flush the queue of deferred functions. + * + * If you only want a promise that will be resolved after some specified delay + * then you can call `$timeout` without the `fn` function. + * + * @param {function()=} fn A function, whose execution should be delayed. + * @param {number=} [delay=0] Delay in milliseconds. + * @param {boolean=} [invokeApply=true] If set to `false` skips model dirty checking, otherwise + * will invoke `fn` within the {@link ng.$rootScope.Scope#$apply $apply} block. + * @param {...*=} Pass additional parameters to the executed function. + * @returns {Promise} Promise that will be resolved when the timeout is reached. The promise + * will be resolved with the return value of the `fn` function. + * + */ + function timeout(fn, delay, invokeApply) { + if (!isFunction(fn)) { + invokeApply = delay; + delay = fn; + fn = noop; + } + + var args = sliceArgs(arguments, 3), + skipApply = (isDefined(invokeApply) && !invokeApply), + deferred = (skipApply ? $$q : $q).defer(), + promise = deferred.promise, + timeoutId; + + timeoutId = $browser.defer(function() { + try { + deferred.resolve(fn.apply(null, args)); + } catch (e) { + deferred.reject(e); + $exceptionHandler(e); + } finally { + delete deferreds[promise.$$timeoutId]; + } + + if (!skipApply) $rootScope.$apply(); + }, delay); + + promise.$$timeoutId = timeoutId; + deferreds[timeoutId] = deferred; + + return promise; + } + + + /** + * @ngdoc method + * @name $timeout#cancel + * + * @description + * Cancels a task associated with the `promise`. As a result of this, the promise will be + * resolved with a rejection. + * + * @param {Promise=} promise Promise returned by the `$timeout` function. + * @returns {boolean} Returns `true` if the task hasn't executed yet and was successfully + * canceled. + */ + timeout.cancel = function(promise) { + if (promise && promise.$$timeoutId in deferreds) { + // Timeout cancels should not report an unhandled promise. + markQExceptionHandled(deferreds[promise.$$timeoutId].promise); + deferreds[promise.$$timeoutId].reject('canceled'); + delete deferreds[promise.$$timeoutId]; + return $browser.defer.cancel(promise.$$timeoutId); + } + return false; + }; + + return timeout; + }]; + } + +// NOTE: The usage of window and document instead of $window and $document here is +// deliberate. This service depends on the specific behavior of anchor nodes created by the +// browser (resolving and parsing URLs) that is unlikely to be provided by mock objects and +// cause us to break tests. In addition, when the browser resolves a URL for XHR, it +// doesn't know about mocked locations and resolves URLs to the real document - which is +// exactly the behavior needed here. There is little value is mocking these out for this +// service. + var urlParsingNode = window.document.createElement('a'); + var originUrl = urlResolve(window.location.href); + + + /** + * + * Implementation Notes for non-IE browsers + * ---------------------------------------- + * Assigning a URL to the href property of an anchor DOM node, even one attached to the DOM, + * results both in the normalizing and parsing of the URL. Normalizing means that a relative + * URL will be resolved into an absolute URL in the context of the application document. + * Parsing means that the anchor node's host, hostname, protocol, port, pathname and related + * properties are all populated to reflect the normalized URL. This approach has wide + * compatibility - Safari 1+, Mozilla 1+ etc. See + * http://www.aptana.com/reference/html/api/HTMLAnchorElement.html + * + * Implementation Notes for IE + * --------------------------- + * IE <= 10 normalizes the URL when assigned to the anchor node similar to the other + * browsers. However, the parsed components will not be set if the URL assigned did not specify + * them. (e.g. if you assign a.href = "foo", then a.protocol, a.host, etc. will be empty.) We + * work around that by performing the parsing in a 2nd step by taking a previously normalized + * URL (e.g. by assigning to a.href) and assigning it a.href again. This correctly populates the + * properties such as protocol, hostname, port, etc. + * + * References: + * http://developer.mozilla.org/en-US/docs/Web/API/HTMLAnchorElement + * http://www.aptana.com/reference/html/api/HTMLAnchorElement.html + * http://url.spec.whatwg.org/#urlutils + * https://github.com/angular/angular.js/pull/2902 + * http://james.padolsey.com/javascript/parsing-urls-with-the-dom/ + * + * @kind function + * @param {string} url The URL to be parsed. + * @description Normalizes and parses a URL. + * @returns {object} Returns the normalized URL as a dictionary. + * + * | member name | Description | + * |---------------|----------------| + * | href | A normalized version of the provided URL if it was not an absolute URL | + * | protocol | The protocol including the trailing colon | + * | host | The host and port (if the port is non-default) of the normalizedUrl | + * | search | The search params, minus the question mark | + * | hash | The hash string, minus the hash symbol + * | hostname | The hostname + * | port | The port, without ":" + * | pathname | The pathname, beginning with "/" + * + */ + function urlResolve(url) { + var href = url; + + // Support: IE 9-11 only + if (msie) { + // Normalize before parse. Refer Implementation Notes on why this is + // done in two steps on IE. + urlParsingNode.setAttribute('href', href); + href = urlParsingNode.href; + } + + urlParsingNode.setAttribute('href', href); + + // urlParsingNode provides the UrlUtils interface - http://url.spec.whatwg.org/#urlutils + return { + href: urlParsingNode.href, + protocol: urlParsingNode.protocol ? urlParsingNode.protocol.replace(/:$/, '') : '', + host: urlParsingNode.host, + search: urlParsingNode.search ? urlParsingNode.search.replace(/^\?/, '') : '', + hash: urlParsingNode.hash ? urlParsingNode.hash.replace(/^#/, '') : '', + hostname: urlParsingNode.hostname, + port: urlParsingNode.port, + pathname: (urlParsingNode.pathname.charAt(0) === '/') + ? urlParsingNode.pathname + : '/' + urlParsingNode.pathname + }; + } + + /** + * Parse a request URL and determine whether this is a same-origin request as the application document. + * + * @param {string|object} requestUrl The url of the request as a string that will be resolved + * or a parsed URL object. + * @returns {boolean} Whether the request is for the same origin as the application document. + */ + function urlIsSameOrigin(requestUrl) { + var parsed = (isString(requestUrl)) ? urlResolve(requestUrl) : requestUrl; + return (parsed.protocol === originUrl.protocol && + parsed.host === originUrl.host); + } + + /** + * @ngdoc service + * @name $window + * @this + * + * @description + * A reference to the browser's `window` object. While `window` + * is globally available in JavaScript, it causes testability problems, because + * it is a global variable. In AngularJS we always refer to it through the + * `$window` service, so it may be overridden, removed or mocked for testing. + * + * Expressions, like the one defined for the `ngClick` directive in the example + * below, are evaluated with respect to the current scope. Therefore, there is + * no risk of inadvertently coding in a dependency on a global value in such an + * expression. + * + * @example + <example module="windowExample" name="window-service"> + <file name="index.html"> + <script> + angular.module('windowExample', []) + .controller('ExampleController', ['$scope', '$window', function($scope, $window) { + $scope.greeting = 'Hello, World!'; + $scope.doGreeting = function(greeting) { + $window.alert(greeting); + }; + }]); + </script> + <div ng-controller="ExampleController"> + <input type="text" ng-model="greeting" aria-label="greeting" /> + <button ng-click="doGreeting(greeting)">ALERT</button> + </div> + </file> + <file name="protractor.js" type="protractor"> + it('should display the greeting in the input box', function() { + element(by.model('greeting')).sendKeys('Hello, E2E Tests'); + // If we click the button it will block the test runner + // element(':button').click(); + }); + </file> + </example> + */ + function $WindowProvider() { + this.$get = valueFn(window); + } + + /** + * @name $$cookieReader + * @requires $document + * + * @description + * This is a private service for reading cookies used by $http and ngCookies + * + * @return {Object} a key/value map of the current cookies + */ + function $$CookieReader($document) { + var rawDocument = $document[0] || {}; + var lastCookies = {}; + var lastCookieString = ''; + + function safeGetCookie(rawDocument) { + try { + return rawDocument.cookie || ''; + } catch (e) { + return ''; + } + } + + function safeDecodeURIComponent(str) { + try { + return decodeURIComponent(str); + } catch (e) { + return str; + } + } + + return function() { + var cookieArray, cookie, i, index, name; + var currentCookieString = safeGetCookie(rawDocument); + + if (currentCookieString !== lastCookieString) { + lastCookieString = currentCookieString; + cookieArray = lastCookieString.split('; '); + lastCookies = {}; + + for (i = 0; i < cookieArray.length; i++) { + cookie = cookieArray[i]; + index = cookie.indexOf('='); + if (index > 0) { //ignore nameless cookies + name = safeDecodeURIComponent(cookie.substring(0, index)); + // the first value that is seen for a cookie is the most + // specific one. values for the same cookie name that + // follow are for less specific paths. + if (isUndefined(lastCookies[name])) { + lastCookies[name] = safeDecodeURIComponent(cookie.substring(index + 1)); + } + } + } + } + return lastCookies; + }; + } + + $$CookieReader.$inject = ['$document']; + + /** @this */ + function $$CookieReaderProvider() { + this.$get = $$CookieReader; + } + + /* global currencyFilter: true, + dateFilter: true, + filterFilter: true, + jsonFilter: true, + limitToFilter: true, + lowercaseFilter: true, + numberFilter: true, + orderByFilter: true, + uppercaseFilter: true, + */ + + /** + * @ngdoc provider + * @name $filterProvider + * @description + * + * Filters are just functions which transform input to an output. However filters need to be + * Dependency Injected. To achieve this a filter definition consists of a factory function which is + * annotated with dependencies and is responsible for creating a filter function. + * + * <div class="alert alert-warning"> + * **Note:** Filter names must be valid AngularJS {@link expression} identifiers, such as `uppercase` or `orderBy`. + * Names with special characters, such as hyphens and dots, are not allowed. If you wish to namespace + * your filters, then you can use capitalization (`myappSubsectionFilterx`) or underscores + * (`myapp_subsection_filterx`). + * </div> + * + * ```js + * // Filter registration + * function MyModule($provide, $filterProvider) { + * // create a service to demonstrate injection (not always needed) + * $provide.value('greet', function(name){ + * return 'Hello ' + name + '!'; + * }); + * + * // register a filter factory which uses the + * // greet service to demonstrate DI. + * $filterProvider.register('greet', function(greet){ + * // return the filter function which uses the greet service + * // to generate salutation + * return function(text) { + * // filters need to be forgiving so check input validity + * return text && greet(text) || text; + * }; + * }); + * } + * ``` + * + * The filter function is registered with the `$injector` under the filter name suffix with + * `Filter`. + * + * ```js + * it('should be the same instance', inject( + * function($filterProvider) { + * $filterProvider.register('reverse', function(){ + * return ...; + * }); + * }, + * function($filter, reverseFilter) { + * expect($filter('reverse')).toBe(reverseFilter); + * }); + * ``` + * + * + * For more information about how AngularJS filters work, and how to create your own filters, see + * {@link guide/filter Filters} in the AngularJS Developer Guide. + */ + + /** + * @ngdoc service + * @name $filter + * @kind function + * @description + * Filters are used for formatting data displayed to the user. + * + * They can be used in view templates, controllers or services. AngularJS comes + * with a collection of [built-in filters](api/ng/filter), but it is easy to + * define your own as well. + * + * The general syntax in templates is as follows: + * + * ```html + * {{ expression [| filter_name[:parameter_value] ... ] }} + * ``` + * + * @param {String} name Name of the filter function to retrieve + * @return {Function} the filter function + * @example + <example name="$filter" module="filterExample"> + <file name="index.html"> + <div ng-controller="MainCtrl"> + <h3>{{ originalText }}</h3> + <h3>{{ filteredText }}</h3> + </div> + </file> + + <file name="script.js"> + angular.module('filterExample', []) + .controller('MainCtrl', function($scope, $filter) { + $scope.originalText = 'hello'; + $scope.filteredText = $filter('uppercase')($scope.originalText); + }); + </file> + </example> + */ + $FilterProvider.$inject = ['$provide']; + /** @this */ + function $FilterProvider($provide) { + var suffix = 'Filter'; + + /** + * @ngdoc method + * @name $filterProvider#register + * @param {string|Object} name Name of the filter function, or an object map of filters where + * the keys are the filter names and the values are the filter factories. + * + * <div class="alert alert-warning"> + * **Note:** Filter names must be valid AngularJS {@link expression} identifiers, such as `uppercase` or `orderBy`. + * Names with special characters, such as hyphens and dots, are not allowed. If you wish to namespace + * your filters, then you can use capitalization (`myappSubsectionFilterx`) or underscores + * (`myapp_subsection_filterx`). + * </div> + * @param {Function} factory If the first argument was a string, a factory function for the filter to be registered. + * @returns {Object} Registered filter instance, or if a map of filters was provided then a map + * of the registered filter instances. + */ + function register(name, factory) { + if (isObject(name)) { + var filters = {}; + forEach(name, function(filter, key) { + filters[key] = register(key, filter); + }); + return filters; + } else { + return $provide.factory(name + suffix, factory); + } + } + this.register = register; + + this.$get = ['$injector', function($injector) { + return function(name) { + return $injector.get(name + suffix); + }; + }]; + + //////////////////////////////////////// + + /* global + currencyFilter: false, + dateFilter: false, + filterFilter: false, + jsonFilter: false, + limitToFilter: false, + lowercaseFilter: false, + numberFilter: false, + orderByFilter: false, + uppercaseFilter: false + */ + + register('currency', currencyFilter); + register('date', dateFilter); + register('filter', filterFilter); + register('json', jsonFilter); + register('limitTo', limitToFilter); + register('lowercase', lowercaseFilter); + register('number', numberFilter); + register('orderBy', orderByFilter); + register('uppercase', uppercaseFilter); + } + + /** + * @ngdoc filter + * @name filter + * @kind function + * + * @description + * Selects a subset of items from `array` and returns it as a new array. + * + * @param {Array} array The source array. + * <div class="alert alert-info"> + * **Note**: If the array contains objects that reference themselves, filtering is not possible. + * </div> + * @param {string|Object|function()} expression The predicate to be used for selecting items from + * `array`. + * + * Can be one of: + * + * - `string`: The string is used for matching against the contents of the `array`. All strings or + * objects with string properties in `array` that match this string will be returned. This also + * applies to nested object properties. + * The predicate can be negated by prefixing the string with `!`. + * + * - `Object`: A pattern object can be used to filter specific properties on objects contained + * by `array`. For example `{name:"M", phone:"1"}` predicate will return an array of items + * which have property `name` containing "M" and property `phone` containing "1". A special + * property name (`$` by default) can be used (e.g. as in `{$: "text"}`) to accept a match + * against any property of the object or its nested object properties. That's equivalent to the + * simple substring match with a `string` as described above. The special property name can be + * overwritten, using the `anyPropertyKey` parameter. + * The predicate can be negated by prefixing the string with `!`. + * For example `{name: "!M"}` predicate will return an array of items which have property `name` + * not containing "M". + * + * Note that a named property will match properties on the same level only, while the special + * `$` property will match properties on the same level or deeper. E.g. an array item like + * `{name: {first: 'John', last: 'Doe'}}` will **not** be matched by `{name: 'John'}`, but + * **will** be matched by `{$: 'John'}`. + * + * - `function(value, index, array)`: A predicate function can be used to write arbitrary filters. + * The function is called for each element of the array, with the element, its index, and + * the entire array itself as arguments. + * + * The final result is an array of those elements that the predicate returned true for. + * + * @param {function(actual, expected)|true|false} [comparator] Comparator which is used in + * determining if values retrieved using `expression` (when it is not a function) should be + * considered a match based on the expected value (from the filter expression) and actual + * value (from the object in the array). + * + * Can be one of: + * + * - `function(actual, expected)`: + * The function will be given the object value and the predicate value to compare and + * should return true if both values should be considered equal. + * + * - `true`: A shorthand for `function(actual, expected) { return angular.equals(actual, expected)}`. + * This is essentially strict comparison of expected and actual. + * + * - `false`: A short hand for a function which will look for a substring match in a case + * insensitive way. Primitive values are converted to strings. Objects are not compared against + * primitives, unless they have a custom `toString` method (e.g. `Date` objects). + * + * + * Defaults to `false`. + * + * @param {string} [anyPropertyKey] The special property name that matches against any property. + * By default `$`. + * + * @example + <example name="filter-filter"> + <file name="index.html"> + <div ng-init="friends = [{name:'John', phone:'555-1276'}, + {name:'Mary', phone:'800-BIG-MARY'}, + {name:'Mike', phone:'555-4321'}, + {name:'Adam', phone:'555-5678'}, + {name:'Julie', phone:'555-8765'}, + {name:'Juliette', phone:'555-5678'}]"></div> + + <label>Search: <input ng-model="searchText"></label> + <table id="searchTextResults"> + <tr><th>Name</th><th>Phone</th></tr> + <tr ng-repeat="friend in friends | filter:searchText"> + <td>{{friend.name}}</td> + <td>{{friend.phone}}</td> + </tr> + </table> + <hr> + <label>Any: <input ng-model="search.$"></label> <br> + <label>Name only <input ng-model="search.name"></label><br> + <label>Phone only <input ng-model="search.phone"></label><br> + <label>Equality <input type="checkbox" ng-model="strict"></label><br> + <table id="searchObjResults"> + <tr><th>Name</th><th>Phone</th></tr> + <tr ng-repeat="friendObj in friends | filter:search:strict"> + <td>{{friendObj.name}}</td> + <td>{{friendObj.phone}}</td> + </tr> + </table> + </file> + <file name="protractor.js" type="protractor"> + var expectFriendNames = function(expectedNames, key) { + element.all(by.repeater(key + ' in friends').column(key + '.name')).then(function(arr) { + arr.forEach(function(wd, i) { + expect(wd.getText()).toMatch(expectedNames[i]); + }); + }); + }; + + it('should search across all fields when filtering with a string', function() { + var searchText = element(by.model('searchText')); + searchText.clear(); + searchText.sendKeys('m'); + expectFriendNames(['Mary', 'Mike', 'Adam'], 'friend'); + + searchText.clear(); + searchText.sendKeys('76'); + expectFriendNames(['John', 'Julie'], 'friend'); + }); + + it('should search in specific fields when filtering with a predicate object', function() { + var searchAny = element(by.model('search.$')); + searchAny.clear(); + searchAny.sendKeys('i'); + expectFriendNames(['Mary', 'Mike', 'Julie', 'Juliette'], 'friendObj'); + }); + it('should use a equal comparison when comparator is true', function() { + var searchName = element(by.model('search.name')); + var strict = element(by.model('strict')); + searchName.clear(); + searchName.sendKeys('Julie'); + strict.click(); + expectFriendNames(['Julie'], 'friendObj'); + }); + </file> + </example> + */ + + function filterFilter() { + return function(array, expression, comparator, anyPropertyKey) { + if (!isArrayLike(array)) { + if (array == null) { + return array; + } else { + throw minErr('filter')('notarray', 'Expected array but received: {0}', array); + } + } + + anyPropertyKey = anyPropertyKey || '$'; + var expressionType = getTypeForFilter(expression); + var predicateFn; + var matchAgainstAnyProp; + + switch (expressionType) { + case 'function': + predicateFn = expression; + break; + case 'boolean': + case 'null': + case 'number': + case 'string': + matchAgainstAnyProp = true; + // falls through + case 'object': + predicateFn = createPredicateFn(expression, comparator, anyPropertyKey, matchAgainstAnyProp); + break; + default: + return array; + } + + return Array.prototype.filter.call(array, predicateFn); + }; + } + +// Helper functions for `filterFilter` + function createPredicateFn(expression, comparator, anyPropertyKey, matchAgainstAnyProp) { + var shouldMatchPrimitives = isObject(expression) && (anyPropertyKey in expression); + var predicateFn; + + if (comparator === true) { + comparator = equals; + } else if (!isFunction(comparator)) { + comparator = function(actual, expected) { + if (isUndefined(actual)) { + // No substring matching against `undefined` + return false; + } + if ((actual === null) || (expected === null)) { + // No substring matching against `null`; only match against `null` + return actual === expected; + } + if (isObject(expected) || (isObject(actual) && !hasCustomToString(actual))) { + // Should not compare primitives against objects, unless they have custom `toString` method + return false; + } + + actual = lowercase('' + actual); + expected = lowercase('' + expected); + return actual.indexOf(expected) !== -1; + }; + } + + predicateFn = function(item) { + if (shouldMatchPrimitives && !isObject(item)) { + return deepCompare(item, expression[anyPropertyKey], comparator, anyPropertyKey, false); + } + return deepCompare(item, expression, comparator, anyPropertyKey, matchAgainstAnyProp); + }; + + return predicateFn; + } + + function deepCompare(actual, expected, comparator, anyPropertyKey, matchAgainstAnyProp, dontMatchWholeObject) { + var actualType = getTypeForFilter(actual); + var expectedType = getTypeForFilter(expected); + + if ((expectedType === 'string') && (expected.charAt(0) === '!')) { + return !deepCompare(actual, expected.substring(1), comparator, anyPropertyKey, matchAgainstAnyProp); + } else if (isArray(actual)) { + // In case `actual` is an array, consider it a match + // if ANY of it's items matches `expected` + return actual.some(function(item) { + return deepCompare(item, expected, comparator, anyPropertyKey, matchAgainstAnyProp); + }); + } + + switch (actualType) { + case 'object': + var key; + if (matchAgainstAnyProp) { + for (key in actual) { + // Under certain, rare, circumstances, key may not be a string and `charAt` will be undefined + // See: https://github.com/angular/angular.js/issues/15644 + if (key.charAt && (key.charAt(0) !== '$') && + deepCompare(actual[key], expected, comparator, anyPropertyKey, true)) { + return true; + } + } + return dontMatchWholeObject ? false : deepCompare(actual, expected, comparator, anyPropertyKey, false); + } else if (expectedType === 'object') { + for (key in expected) { + var expectedVal = expected[key]; + if (isFunction(expectedVal) || isUndefined(expectedVal)) { + continue; + } + + var matchAnyProperty = key === anyPropertyKey; + var actualVal = matchAnyProperty ? actual : actual[key]; + if (!deepCompare(actualVal, expectedVal, comparator, anyPropertyKey, matchAnyProperty, matchAnyProperty)) { + return false; + } + } + return true; + } else { + return comparator(actual, expected); + } + case 'function': + return false; + default: + return comparator(actual, expected); + } + } + +// Used for easily differentiating between `null` and actual `object` + function getTypeForFilter(val) { + return (val === null) ? 'null' : typeof val; + } + + var MAX_DIGITS = 22; + var DECIMAL_SEP = '.'; + var ZERO_CHAR = '0'; + + /** + * @ngdoc filter + * @name currency + * @kind function + * + * @description + * Formats a number as a currency (ie $1,234.56). When no currency symbol is provided, default + * symbol for current locale is used. + * + * @param {number} amount Input to filter. + * @param {string=} symbol Currency symbol or identifier to be displayed. + * @param {number=} fractionSize Number of decimal places to round the amount to, defaults to default max fraction size for current locale + * @returns {string} Formatted number. + * + * + * @example + <example module="currencyExample" name="currency-filter"> + <file name="index.html"> + <script> + angular.module('currencyExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.amount = 1234.56; + }]); + </script> + <div ng-controller="ExampleController"> + <input type="number" ng-model="amount" aria-label="amount"> <br> + default currency symbol ($): <span id="currency-default">{{amount | currency}}</span><br> + custom currency identifier (USD$): <span id="currency-custom">{{amount | currency:"USD$"}}</span><br> + no fractions (0): <span id="currency-no-fractions">{{amount | currency:"USD$":0}}</span> + </div> + </file> + <file name="protractor.js" type="protractor"> + it('should init with 1234.56', function() { + expect(element(by.id('currency-default')).getText()).toBe('$1,234.56'); + expect(element(by.id('currency-custom')).getText()).toBe('USD$1,234.56'); + expect(element(by.id('currency-no-fractions')).getText()).toBe('USD$1,235'); + }); + it('should update', function() { + if (browser.params.browser === 'safari') { + // Safari does not understand the minus key. See + // https://github.com/angular/protractor/issues/481 + return; + } + element(by.model('amount')).clear(); + element(by.model('amount')).sendKeys('-1234'); + expect(element(by.id('currency-default')).getText()).toBe('-$1,234.00'); + expect(element(by.id('currency-custom')).getText()).toBe('-USD$1,234.00'); + expect(element(by.id('currency-no-fractions')).getText()).toBe('-USD$1,234'); + }); + </file> + </example> + */ + currencyFilter.$inject = ['$locale']; + function currencyFilter($locale) { + var formats = $locale.NUMBER_FORMATS; + return function(amount, currencySymbol, fractionSize) { + if (isUndefined(currencySymbol)) { + currencySymbol = formats.CURRENCY_SYM; + } + + if (isUndefined(fractionSize)) { + fractionSize = formats.PATTERNS[1].maxFrac; + } + + // If the currency symbol is empty, trim whitespace around the symbol + var currencySymbolRe = !currencySymbol ? /\s*\u00A4\s*/g : /\u00A4/g; + + // if null or undefined pass it through + return (amount == null) + ? amount + : formatNumber(amount, formats.PATTERNS[1], formats.GROUP_SEP, formats.DECIMAL_SEP, fractionSize). + replace(currencySymbolRe, currencySymbol); + }; + } + + /** + * @ngdoc filter + * @name number + * @kind function + * + * @description + * Formats a number as text. + * + * If the input is null or undefined, it will just be returned. + * If the input is infinite (Infinity or -Infinity), the Infinity symbol '∞' or '-∞' is returned, respectively. + * If the input is not a number an empty string is returned. + * + * + * @param {number|string} number Number to format. + * @param {(number|string)=} fractionSize Number of decimal places to round the number to. + * If this is not provided then the fraction size is computed from the current locale's number + * formatting pattern. In the case of the default locale, it will be 3. + * @returns {string} Number rounded to `fractionSize` appropriately formatted based on the current + * locale (e.g., in the en_US locale it will have "." as the decimal separator and + * include "," group separators after each third digit). + * + * @example + <example module="numberFilterExample" name="number-filter"> + <file name="index.html"> + <script> + angular.module('numberFilterExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.val = 1234.56789; + }]); + </script> + <div ng-controller="ExampleController"> + <label>Enter number: <input ng-model='val'></label><br> + Default formatting: <span id='number-default'>{{val | number}}</span><br> + No fractions: <span>{{val | number:0}}</span><br> + Negative number: <span>{{-val | number:4}}</span> + </div> + </file> + <file name="protractor.js" type="protractor"> + it('should format numbers', function() { + expect(element(by.id('number-default')).getText()).toBe('1,234.568'); + expect(element(by.binding('val | number:0')).getText()).toBe('1,235'); + expect(element(by.binding('-val | number:4')).getText()).toBe('-1,234.5679'); + }); + + it('should update', function() { + element(by.model('val')).clear(); + element(by.model('val')).sendKeys('3374.333'); + expect(element(by.id('number-default')).getText()).toBe('3,374.333'); + expect(element(by.binding('val | number:0')).getText()).toBe('3,374'); + expect(element(by.binding('-val | number:4')).getText()).toBe('-3,374.3330'); + }); + </file> + </example> + */ + numberFilter.$inject = ['$locale']; + function numberFilter($locale) { + var formats = $locale.NUMBER_FORMATS; + return function(number, fractionSize) { + + // if null or undefined pass it through + return (number == null) + ? number + : formatNumber(number, formats.PATTERNS[0], formats.GROUP_SEP, formats.DECIMAL_SEP, + fractionSize); + }; + } + + /** + * Parse a number (as a string) into three components that can be used + * for formatting the number. + * + * (Significant bits of this parse algorithm came from https://github.com/MikeMcl/big.js/) + * + * @param {string} numStr The number to parse + * @return {object} An object describing this number, containing the following keys: + * - d : an array of digits containing leading zeros as necessary + * - i : the number of the digits in `d` that are to the left of the decimal point + * - e : the exponent for numbers that would need more than `MAX_DIGITS` digits in `d` + * + */ + function parse(numStr) { + var exponent = 0, digits, numberOfIntegerDigits; + var i, j, zeros; + + // Decimal point? + if ((numberOfIntegerDigits = numStr.indexOf(DECIMAL_SEP)) > -1) { + numStr = numStr.replace(DECIMAL_SEP, ''); + } + + // Exponential form? + if ((i = numStr.search(/e/i)) > 0) { + // Work out the exponent. + if (numberOfIntegerDigits < 0) numberOfIntegerDigits = i; + numberOfIntegerDigits += +numStr.slice(i + 1); + numStr = numStr.substring(0, i); + } else if (numberOfIntegerDigits < 0) { + // There was no decimal point or exponent so it is an integer. + numberOfIntegerDigits = numStr.length; + } + + // Count the number of leading zeros. + for (i = 0; numStr.charAt(i) === ZERO_CHAR; i++) { /* empty */ } + + if (i === (zeros = numStr.length)) { + // The digits are all zero. + digits = [0]; + numberOfIntegerDigits = 1; + } else { + // Count the number of trailing zeros + zeros--; + while (numStr.charAt(zeros) === ZERO_CHAR) zeros--; + + // Trailing zeros are insignificant so ignore them + numberOfIntegerDigits -= i; + digits = []; + // Convert string to array of digits without leading/trailing zeros. + for (j = 0; i <= zeros; i++, j++) { + digits[j] = +numStr.charAt(i); + } + } + + // If the number overflows the maximum allowed digits then use an exponent. + if (numberOfIntegerDigits > MAX_DIGITS) { + digits = digits.splice(0, MAX_DIGITS - 1); + exponent = numberOfIntegerDigits - 1; + numberOfIntegerDigits = 1; + } + + return { d: digits, e: exponent, i: numberOfIntegerDigits }; + } + + /** + * Round the parsed number to the specified number of decimal places + * This function changed the parsedNumber in-place + */ + function roundNumber(parsedNumber, fractionSize, minFrac, maxFrac) { + var digits = parsedNumber.d; + var fractionLen = digits.length - parsedNumber.i; + + // determine fractionSize if it is not specified; `+fractionSize` converts it to a number + fractionSize = (isUndefined(fractionSize)) ? Math.min(Math.max(minFrac, fractionLen), maxFrac) : +fractionSize; + + // The index of the digit to where rounding is to occur + var roundAt = fractionSize + parsedNumber.i; + var digit = digits[roundAt]; + + if (roundAt > 0) { + // Drop fractional digits beyond `roundAt` + digits.splice(Math.max(parsedNumber.i, roundAt)); + + // Set non-fractional digits beyond `roundAt` to 0 + for (var j = roundAt; j < digits.length; j++) { + digits[j] = 0; + } + } else { + // We rounded to zero so reset the parsedNumber + fractionLen = Math.max(0, fractionLen); + parsedNumber.i = 1; + digits.length = Math.max(1, roundAt = fractionSize + 1); + digits[0] = 0; + for (var i = 1; i < roundAt; i++) digits[i] = 0; + } + + if (digit >= 5) { + if (roundAt - 1 < 0) { + for (var k = 0; k > roundAt; k--) { + digits.unshift(0); + parsedNumber.i++; + } + digits.unshift(1); + parsedNumber.i++; + } else { + digits[roundAt - 1]++; + } + } + + // Pad out with zeros to get the required fraction length + for (; fractionLen < Math.max(0, fractionSize); fractionLen++) digits.push(0); + + + // Do any carrying, e.g. a digit was rounded up to 10 + var carry = digits.reduceRight(function(carry, d, i, digits) { + d = d + carry; + digits[i] = d % 10; + return Math.floor(d / 10); + }, 0); + if (carry) { + digits.unshift(carry); + parsedNumber.i++; + } + } + + /** + * Format a number into a string + * @param {number} number The number to format + * @param {{ + * minFrac, // the minimum number of digits required in the fraction part of the number + * maxFrac, // the maximum number of digits required in the fraction part of the number + * gSize, // number of digits in each group of separated digits + * lgSize, // number of digits in the last group of digits before the decimal separator + * negPre, // the string to go in front of a negative number (e.g. `-` or `(`)) + * posPre, // the string to go in front of a positive number + * negSuf, // the string to go after a negative number (e.g. `)`) + * posSuf // the string to go after a positive number + * }} pattern + * @param {string} groupSep The string to separate groups of number (e.g. `,`) + * @param {string} decimalSep The string to act as the decimal separator (e.g. `.`) + * @param {[type]} fractionSize The size of the fractional part of the number + * @return {string} The number formatted as a string + */ + function formatNumber(number, pattern, groupSep, decimalSep, fractionSize) { + + if (!(isString(number) || isNumber(number)) || isNaN(number)) return ''; + + var isInfinity = !isFinite(number); + var isZero = false; + var numStr = Math.abs(number) + '', + formattedText = '', + parsedNumber; + + if (isInfinity) { + formattedText = '\u221e'; + } else { + parsedNumber = parse(numStr); + + roundNumber(parsedNumber, fractionSize, pattern.minFrac, pattern.maxFrac); + + var digits = parsedNumber.d; + var integerLen = parsedNumber.i; + var exponent = parsedNumber.e; + var decimals = []; + isZero = digits.reduce(function(isZero, d) { return isZero && !d; }, true); + + // pad zeros for small numbers + while (integerLen < 0) { + digits.unshift(0); + integerLen++; + } + + // extract decimals digits + if (integerLen > 0) { + decimals = digits.splice(integerLen, digits.length); + } else { + decimals = digits; + digits = [0]; + } + + // format the integer digits with grouping separators + var groups = []; + if (digits.length >= pattern.lgSize) { + groups.unshift(digits.splice(-pattern.lgSize, digits.length).join('')); + } + while (digits.length > pattern.gSize) { + groups.unshift(digits.splice(-pattern.gSize, digits.length).join('')); + } + if (digits.length) { + groups.unshift(digits.join('')); + } + formattedText = groups.join(groupSep); + + // append the decimal digits + if (decimals.length) { + formattedText += decimalSep + decimals.join(''); + } + + if (exponent) { + formattedText += 'e+' + exponent; + } + } + if (number < 0 && !isZero) { + return pattern.negPre + formattedText + pattern.negSuf; + } else { + return pattern.posPre + formattedText + pattern.posSuf; + } + } + + function padNumber(num, digits, trim, negWrap) { + var neg = ''; + if (num < 0 || (negWrap && num <= 0)) { + if (negWrap) { + num = -num + 1; + } else { + num = -num; + neg = '-'; + } + } + num = '' + num; + while (num.length < digits) num = ZERO_CHAR + num; + if (trim) { + num = num.substr(num.length - digits); + } + return neg + num; + } + + + function dateGetter(name, size, offset, trim, negWrap) { + offset = offset || 0; + return function(date) { + var value = date['get' + name](); + if (offset > 0 || value > -offset) { + value += offset; + } + if (value === 0 && offset === -12) value = 12; + return padNumber(value, size, trim, negWrap); + }; + } + + function dateStrGetter(name, shortForm, standAlone) { + return function(date, formats) { + var value = date['get' + name](); + var propPrefix = (standAlone ? 'STANDALONE' : '') + (shortForm ? 'SHORT' : ''); + var get = uppercase(propPrefix + name); + + return formats[get][value]; + }; + } + + function timeZoneGetter(date, formats, offset) { + var zone = -1 * offset; + var paddedZone = (zone >= 0) ? '+' : ''; + + paddedZone += padNumber(Math[zone > 0 ? 'floor' : 'ceil'](zone / 60), 2) + + padNumber(Math.abs(zone % 60), 2); + + return paddedZone; + } + + function getFirstThursdayOfYear(year) { + // 0 = index of January + var dayOfWeekOnFirst = (new Date(year, 0, 1)).getDay(); + // 4 = index of Thursday (+1 to account for 1st = 5) + // 11 = index of *next* Thursday (+1 account for 1st = 12) + return new Date(year, 0, ((dayOfWeekOnFirst <= 4) ? 5 : 12) - dayOfWeekOnFirst); + } + + function getThursdayThisWeek(datetime) { + return new Date(datetime.getFullYear(), datetime.getMonth(), + // 4 = index of Thursday + datetime.getDate() + (4 - datetime.getDay())); + } + + function weekGetter(size) { + return function(date) { + var firstThurs = getFirstThursdayOfYear(date.getFullYear()), + thisThurs = getThursdayThisWeek(date); + + var diff = +thisThurs - +firstThurs, + result = 1 + Math.round(diff / 6.048e8); // 6.048e8 ms per week + + return padNumber(result, size); + }; + } + + function ampmGetter(date, formats) { + return date.getHours() < 12 ? formats.AMPMS[0] : formats.AMPMS[1]; + } + + function eraGetter(date, formats) { + return date.getFullYear() <= 0 ? formats.ERAS[0] : formats.ERAS[1]; + } + + function longEraGetter(date, formats) { + return date.getFullYear() <= 0 ? formats.ERANAMES[0] : formats.ERANAMES[1]; + } + + var DATE_FORMATS = { + yyyy: dateGetter('FullYear', 4, 0, false, true), + yy: dateGetter('FullYear', 2, 0, true, true), + y: dateGetter('FullYear', 1, 0, false, true), + MMMM: dateStrGetter('Month'), + MMM: dateStrGetter('Month', true), + MM: dateGetter('Month', 2, 1), + M: dateGetter('Month', 1, 1), + LLLL: dateStrGetter('Month', false, true), + dd: dateGetter('Date', 2), + d: dateGetter('Date', 1), + HH: dateGetter('Hours', 2), + H: dateGetter('Hours', 1), + hh: dateGetter('Hours', 2, -12), + h: dateGetter('Hours', 1, -12), + mm: dateGetter('Minutes', 2), + m: dateGetter('Minutes', 1), + ss: dateGetter('Seconds', 2), + s: dateGetter('Seconds', 1), + // while ISO 8601 requires fractions to be prefixed with `.` or `,` + // we can be just safely rely on using `sss` since we currently don't support single or two digit fractions + sss: dateGetter('Milliseconds', 3), + EEEE: dateStrGetter('Day'), + EEE: dateStrGetter('Day', true), + a: ampmGetter, + Z: timeZoneGetter, + ww: weekGetter(2), + w: weekGetter(1), + G: eraGetter, + GG: eraGetter, + GGG: eraGetter, + GGGG: longEraGetter + }; + + var DATE_FORMATS_SPLIT = /((?:[^yMLdHhmsaZEwG']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|L+|d+|H+|h+|m+|s+|a|Z|G+|w+))([\s\S]*)/, + NUMBER_STRING = /^-?\d+$/; + + /** + * @ngdoc filter + * @name date + * @kind function + * + * @description + * Formats `date` to a string based on the requested `format`. + * + * `format` string can be composed of the following elements: + * + * * `'yyyy'`: 4 digit representation of year (e.g. AD 1 => 0001, AD 2010 => 2010) + * * `'yy'`: 2 digit representation of year, padded (00-99). (e.g. AD 2001 => 01, AD 2010 => 10) + * * `'y'`: 1 digit representation of year, e.g. (AD 1 => 1, AD 199 => 199) + * * `'MMMM'`: Month in year (January-December) + * * `'MMM'`: Month in year (Jan-Dec) + * * `'MM'`: Month in year, padded (01-12) + * * `'M'`: Month in year (1-12) + * * `'LLLL'`: Stand-alone month in year (January-December) + * * `'dd'`: Day in month, padded (01-31) + * * `'d'`: Day in month (1-31) + * * `'EEEE'`: Day in Week,(Sunday-Saturday) + * * `'EEE'`: Day in Week, (Sun-Sat) + * * `'HH'`: Hour in day, padded (00-23) + * * `'H'`: Hour in day (0-23) + * * `'hh'`: Hour in AM/PM, padded (01-12) + * * `'h'`: Hour in AM/PM, (1-12) + * * `'mm'`: Minute in hour, padded (00-59) + * * `'m'`: Minute in hour (0-59) + * * `'ss'`: Second in minute, padded (00-59) + * * `'s'`: Second in minute (0-59) + * * `'sss'`: Millisecond in second, padded (000-999) + * * `'a'`: AM/PM marker + * * `'Z'`: 4 digit (+sign) representation of the timezone offset (-1200-+1200) + * * `'ww'`: Week of year, padded (00-53). Week 01 is the week with the first Thursday of the year + * * `'w'`: Week of year (0-53). Week 1 is the week with the first Thursday of the year + * * `'G'`, `'GG'`, `'GGG'`: The abbreviated form of the era string (e.g. 'AD') + * * `'GGGG'`: The long form of the era string (e.g. 'Anno Domini') + * + * `format` string can also be one of the following predefined + * {@link guide/i18n localizable formats}: + * + * * `'medium'`: equivalent to `'MMM d, y h:mm:ss a'` for en_US locale + * (e.g. Sep 3, 2010 12:05:08 PM) + * * `'short'`: equivalent to `'M/d/yy h:mm a'` for en_US locale (e.g. 9/3/10 12:05 PM) + * * `'fullDate'`: equivalent to `'EEEE, MMMM d, y'` for en_US locale + * (e.g. Friday, September 3, 2010) + * * `'longDate'`: equivalent to `'MMMM d, y'` for en_US locale (e.g. September 3, 2010) + * * `'mediumDate'`: equivalent to `'MMM d, y'` for en_US locale (e.g. Sep 3, 2010) + * * `'shortDate'`: equivalent to `'M/d/yy'` for en_US locale (e.g. 9/3/10) + * * `'mediumTime'`: equivalent to `'h:mm:ss a'` for en_US locale (e.g. 12:05:08 PM) + * * `'shortTime'`: equivalent to `'h:mm a'` for en_US locale (e.g. 12:05 PM) + * + * `format` string can contain literal values. These need to be escaped by surrounding with single quotes (e.g. + * `"h 'in the morning'"`). In order to output a single quote, escape it - i.e., two single quotes in a sequence + * (e.g. `"h 'o''clock'"`). + * + * Any other characters in the `format` string will be output as-is. + * + * @param {(Date|number|string)} date Date to format either as Date object, milliseconds (string or + * number) or various ISO 8601 datetime string formats (e.g. yyyy-MM-ddTHH:mm:ss.sssZ and its + * shorter versions like yyyy-MM-ddTHH:mmZ, yyyy-MM-dd or yyyyMMddTHHmmssZ). If no timezone is + * specified in the string input, the time is considered to be in the local timezone. + * @param {string=} format Formatting rules (see Description). If not specified, + * `mediumDate` is used. + * @param {string=} timezone Timezone to be used for formatting. It understands UTC/GMT and the + * continental US time zone abbreviations, but for general use, use a time zone offset, for + * example, `'+0430'` (4 hours, 30 minutes east of the Greenwich meridian) + * If not specified, the timezone of the browser will be used. + * @returns {string} Formatted string or the input if input is not recognized as date/millis. + * + * @example + <example name="filter-date"> + <file name="index.html"> + <span ng-non-bindable>{{1288323623006 | date:'medium'}}</span>: + <span>{{1288323623006 | date:'medium'}}</span><br> + <span ng-non-bindable>{{1288323623006 | date:'yyyy-MM-dd HH:mm:ss Z'}}</span>: + <span>{{1288323623006 | date:'yyyy-MM-dd HH:mm:ss Z'}}</span><br> + <span ng-non-bindable>{{1288323623006 | date:'MM/dd/yyyy @ h:mma'}}</span>: + <span>{{'1288323623006' | date:'MM/dd/yyyy @ h:mma'}}</span><br> + <span ng-non-bindable>{{1288323623006 | date:"MM/dd/yyyy 'at' h:mma"}}</span>: + <span>{{'1288323623006' | date:"MM/dd/yyyy 'at' h:mma"}}</span><br> + </file> + <file name="protractor.js" type="protractor"> + it('should format date', function() { + expect(element(by.binding("1288323623006 | date:'medium'")).getText()). + toMatch(/Oct 2\d, 2010 \d{1,2}:\d{2}:\d{2} (AM|PM)/); + expect(element(by.binding("1288323623006 | date:'yyyy-MM-dd HH:mm:ss Z'")).getText()). + toMatch(/2010-10-2\d \d{2}:\d{2}:\d{2} (-|\+)?\d{4}/); + expect(element(by.binding("'1288323623006' | date:'MM/dd/yyyy @ h:mma'")).getText()). + toMatch(/10\/2\d\/2010 @ \d{1,2}:\d{2}(AM|PM)/); + expect(element(by.binding("'1288323623006' | date:\"MM/dd/yyyy 'at' h:mma\"")).getText()). + toMatch(/10\/2\d\/2010 at \d{1,2}:\d{2}(AM|PM)/); + }); + </file> + </example> + */ + dateFilter.$inject = ['$locale']; + function dateFilter($locale) { + + + var R_ISO8601_STR = /^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(Z|([+-])(\d\d):?(\d\d))?)?$/; + // 1 2 3 4 5 6 7 8 9 10 11 + function jsonStringToDate(string) { + var match; + if ((match = string.match(R_ISO8601_STR))) { + var date = new Date(0), + tzHour = 0, + tzMin = 0, + dateSetter = match[8] ? date.setUTCFullYear : date.setFullYear, + timeSetter = match[8] ? date.setUTCHours : date.setHours; + + if (match[9]) { + tzHour = toInt(match[9] + match[10]); + tzMin = toInt(match[9] + match[11]); + } + dateSetter.call(date, toInt(match[1]), toInt(match[2]) - 1, toInt(match[3])); + var h = toInt(match[4] || 0) - tzHour; + var m = toInt(match[5] || 0) - tzMin; + var s = toInt(match[6] || 0); + var ms = Math.round(parseFloat('0.' + (match[7] || 0)) * 1000); + timeSetter.call(date, h, m, s, ms); + return date; + } + return string; + } + + + return function(date, format, timezone) { + var text = '', + parts = [], + fn, match; + + format = format || 'mediumDate'; + format = $locale.DATETIME_FORMATS[format] || format; + if (isString(date)) { + date = NUMBER_STRING.test(date) ? toInt(date) : jsonStringToDate(date); + } + + if (isNumber(date)) { + date = new Date(date); + } + + if (!isDate(date) || !isFinite(date.getTime())) { + return date; + } + + while (format) { + match = DATE_FORMATS_SPLIT.exec(format); + if (match) { + parts = concat(parts, match, 1); + format = parts.pop(); + } else { + parts.push(format); + format = null; + } + } + + var dateTimezoneOffset = date.getTimezoneOffset(); + if (timezone) { + dateTimezoneOffset = timezoneToOffset(timezone, dateTimezoneOffset); + date = convertTimezoneToLocal(date, timezone, true); + } + forEach(parts, function(value) { + fn = DATE_FORMATS[value]; + text += fn ? fn(date, $locale.DATETIME_FORMATS, dateTimezoneOffset) + : value === '\'\'' ? '\'' : value.replace(/(^'|'$)/g, '').replace(/''/g, '\''); + }); + + return text; + }; + } + + + /** + * @ngdoc filter + * @name json + * @kind function + * + * @description + * Allows you to convert a JavaScript object into JSON string. + * + * This filter is mostly useful for debugging. When using the double curly {{value}} notation + * the binding is automatically converted to JSON. + * + * @param {*} object Any JavaScript object (including arrays and primitive types) to filter. + * @param {number=} spacing The number of spaces to use per indentation, defaults to 2. + * @returns {string} JSON string. + * + * + * @example + <example name="filter-json"> + <file name="index.html"> + <pre id="default-spacing">{{ {'name':'value'} | json }}</pre> + <pre id="custom-spacing">{{ {'name':'value'} | json:4 }}</pre> + </file> + <file name="protractor.js" type="protractor"> + it('should jsonify filtered objects', function() { + expect(element(by.id('default-spacing')).getText()).toMatch(/\{\n {2}"name": ?"value"\n}/); + expect(element(by.id('custom-spacing')).getText()).toMatch(/\{\n {4}"name": ?"value"\n}/); + }); + </file> + </example> + * + */ + function jsonFilter() { + return function(object, spacing) { + if (isUndefined(spacing)) { + spacing = 2; + } + return toJson(object, spacing); + }; + } + + + /** + * @ngdoc filter + * @name lowercase + * @kind function + * @description + * Converts string to lowercase. + * + * See the {@link ng.uppercase uppercase filter documentation} for a functionally identical example. + * + * @see angular.lowercase + */ + var lowercaseFilter = valueFn(lowercase); + + + /** + * @ngdoc filter + * @name uppercase + * @kind function + * @description + * Converts string to uppercase. + * @example + <example module="uppercaseFilterExample" name="filter-uppercase"> + <file name="index.html"> + <script> + angular.module('uppercaseFilterExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.title = 'This is a title'; + }]); + </script> + <div ng-controller="ExampleController"> + <!-- This title should be formatted normally --> + <h1>{{title}}</h1> + <!-- This title should be capitalized --> + <h1>{{title | uppercase}}</h1> + </div> + </file> + </example> + */ + var uppercaseFilter = valueFn(uppercase); + + /** + * @ngdoc filter + * @name limitTo + * @kind function + * + * @description + * Creates a new array or string containing only a specified number of elements. The elements are + * taken from either the beginning or the end of the source array, string or number, as specified by + * the value and sign (positive or negative) of `limit`. Other array-like objects are also supported + * (e.g. array subclasses, NodeLists, jqLite/jQuery collections etc). If a number is used as input, + * it is converted to a string. + * + * @param {Array|ArrayLike|string|number} input - Array/array-like, string or number to be limited. + * @param {string|number} limit - The length of the returned array or string. If the `limit` number + * is positive, `limit` number of items from the beginning of the source array/string are copied. + * If the number is negative, `limit` number of items from the end of the source array/string + * are copied. The `limit` will be trimmed if it exceeds `array.length`. If `limit` is undefined, + * the input will be returned unchanged. + * @param {(string|number)=} begin - Index at which to begin limitation. As a negative index, + * `begin` indicates an offset from the end of `input`. Defaults to `0`. + * @returns {Array|string} A new sub-array or substring of length `limit` or less if the input had + * less than `limit` elements. + * + * @example + <example module="limitToExample" name="limit-to-filter"> + <file name="index.html"> + <script> + angular.module('limitToExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.numbers = [1,2,3,4,5,6,7,8,9]; + $scope.letters = "abcdefghi"; + $scope.longNumber = 2345432342; + $scope.numLimit = 3; + $scope.letterLimit = 3; + $scope.longNumberLimit = 3; + }]); + </script> + <div ng-controller="ExampleController"> + <label> + Limit {{numbers}} to: + <input type="number" step="1" ng-model="numLimit"> + </label> + <p>Output numbers: {{ numbers | limitTo:numLimit }}</p> + <label> + Limit {{letters}} to: + <input type="number" step="1" ng-model="letterLimit"> + </label> + <p>Output letters: {{ letters | limitTo:letterLimit }}</p> + <label> + Limit {{longNumber}} to: + <input type="number" step="1" ng-model="longNumberLimit"> + </label> + <p>Output long number: {{ longNumber | limitTo:longNumberLimit }}</p> + </div> + </file> + <file name="protractor.js" type="protractor"> + var numLimitInput = element(by.model('numLimit')); + var letterLimitInput = element(by.model('letterLimit')); + var longNumberLimitInput = element(by.model('longNumberLimit')); + var limitedNumbers = element(by.binding('numbers | limitTo:numLimit')); + var limitedLetters = element(by.binding('letters | limitTo:letterLimit')); + var limitedLongNumber = element(by.binding('longNumber | limitTo:longNumberLimit')); + + it('should limit the number array to first three items', function() { + expect(numLimitInput.getAttribute('value')).toBe('3'); + expect(letterLimitInput.getAttribute('value')).toBe('3'); + expect(longNumberLimitInput.getAttribute('value')).toBe('3'); + expect(limitedNumbers.getText()).toEqual('Output numbers: [1,2,3]'); + expect(limitedLetters.getText()).toEqual('Output letters: abc'); + expect(limitedLongNumber.getText()).toEqual('Output long number: 234'); + }); + + // There is a bug in safari and protractor that doesn't like the minus key + // it('should update the output when -3 is entered', function() { + // numLimitInput.clear(); + // numLimitInput.sendKeys('-3'); + // letterLimitInput.clear(); + // letterLimitInput.sendKeys('-3'); + // longNumberLimitInput.clear(); + // longNumberLimitInput.sendKeys('-3'); + // expect(limitedNumbers.getText()).toEqual('Output numbers: [7,8,9]'); + // expect(limitedLetters.getText()).toEqual('Output letters: ghi'); + // expect(limitedLongNumber.getText()).toEqual('Output long number: 342'); + // }); + + it('should not exceed the maximum size of input array', function() { + numLimitInput.clear(); + numLimitInput.sendKeys('100'); + letterLimitInput.clear(); + letterLimitInput.sendKeys('100'); + longNumberLimitInput.clear(); + longNumberLimitInput.sendKeys('100'); + expect(limitedNumbers.getText()).toEqual('Output numbers: [1,2,3,4,5,6,7,8,9]'); + expect(limitedLetters.getText()).toEqual('Output letters: abcdefghi'); + expect(limitedLongNumber.getText()).toEqual('Output long number: 2345432342'); + }); + </file> + </example> + */ + function limitToFilter() { + return function(input, limit, begin) { + if (Math.abs(Number(limit)) === Infinity) { + limit = Number(limit); + } else { + limit = toInt(limit); + } + if (isNumberNaN(limit)) return input; + + if (isNumber(input)) input = input.toString(); + if (!isArrayLike(input)) return input; + + begin = (!begin || isNaN(begin)) ? 0 : toInt(begin); + begin = (begin < 0) ? Math.max(0, input.length + begin) : begin; + + if (limit >= 0) { + return sliceFn(input, begin, begin + limit); + } else { + if (begin === 0) { + return sliceFn(input, limit, input.length); + } else { + return sliceFn(input, Math.max(0, begin + limit), begin); + } + } + }; + } + + function sliceFn(input, begin, end) { + if (isString(input)) return input.slice(begin, end); + + return slice.call(input, begin, end); + } + + /** + * @ngdoc filter + * @name orderBy + * @kind function + * + * @description + * Returns an array containing the items from the specified `collection`, ordered by a `comparator` + * function based on the values computed using the `expression` predicate. + * + * For example, `[{id: 'foo'}, {id: 'bar'}] | orderBy:'id'` would result in + * `[{id: 'bar'}, {id: 'foo'}]`. + * + * The `collection` can be an Array or array-like object (e.g. NodeList, jQuery object, TypedArray, + * String, etc). + * + * The `expression` can be a single predicate, or a list of predicates each serving as a tie-breaker + * for the preceding one. The `expression` is evaluated against each item and the output is used + * for comparing with other items. + * + * You can change the sorting order by setting `reverse` to `true`. By default, items are sorted in + * ascending order. + * + * The comparison is done using the `comparator` function. If none is specified, a default, built-in + * comparator is used (see below for details - in a nutshell, it compares numbers numerically and + * strings alphabetically). + * + * ### Under the hood + * + * Ordering the specified `collection` happens in two phases: + * + * 1. All items are passed through the predicate (or predicates), and the returned values are saved + * along with their type (`string`, `number` etc). For example, an item `{label: 'foo'}`, passed + * through a predicate that extracts the value of the `label` property, would be transformed to: + * ``` + * { + * value: 'foo', + * type: 'string', + * index: ... + * } + * ``` + * 2. The comparator function is used to sort the items, based on the derived values, types and + * indices. + * + * If you use a custom comparator, it will be called with pairs of objects of the form + * `{value: ..., type: '...', index: ...}` and is expected to return `0` if the objects are equal + * (as far as the comparator is concerned), `-1` if the 1st one should be ranked higher than the + * second, or `1` otherwise. + * + * In order to ensure that the sorting will be deterministic across platforms, if none of the + * specified predicates can distinguish between two items, `orderBy` will automatically introduce a + * dummy predicate that returns the item's index as `value`. + * (If you are using a custom comparator, make sure it can handle this predicate as well.) + * + * If a custom comparator still can't distinguish between two items, then they will be sorted based + * on their index using the built-in comparator. + * + * Finally, in an attempt to simplify things, if a predicate returns an object as the extracted + * value for an item, `orderBy` will try to convert that object to a primitive value, before passing + * it to the comparator. The following rules govern the conversion: + * + * 1. If the object has a `valueOf()` method that returns a primitive, its return value will be + * used instead.<br /> + * (If the object has a `valueOf()` method that returns another object, then the returned object + * will be used in subsequent steps.) + * 2. If the object has a custom `toString()` method (i.e. not the one inherited from `Object`) that + * returns a primitive, its return value will be used instead.<br /> + * (If the object has a `toString()` method that returns another object, then the returned object + * will be used in subsequent steps.) + * 3. No conversion; the object itself is used. + * + * ### The default comparator + * + * The default, built-in comparator should be sufficient for most usecases. In short, it compares + * numbers numerically, strings alphabetically (and case-insensitively), for objects falls back to + * using their index in the original collection, and sorts values of different types by type. + * + * More specifically, it follows these steps to determine the relative order of items: + * + * 1. If the compared values are of different types, compare the types themselves alphabetically. + * 2. If both values are of type `string`, compare them alphabetically in a case- and + * locale-insensitive way. + * 3. If both values are objects, compare their indices instead. + * 4. Otherwise, return: + * - `0`, if the values are equal (by strict equality comparison, i.e. using `===`). + * - `-1`, if the 1st value is "less than" the 2nd value (compared using the `<` operator). + * - `1`, otherwise. + * + * **Note:** If you notice numbers not being sorted as expected, make sure they are actually being + * saved as numbers and not strings. + * **Note:** For the purpose of sorting, `null` values are treated as the string `'null'` (i.e. + * `type: 'string'`, `value: 'null'`). This may cause unexpected sort order relative to + * other values. + * + * @param {Array|ArrayLike} collection - The collection (array or array-like object) to sort. + * @param {(Function|string|Array.<Function|string>)=} expression - A predicate (or list of + * predicates) to be used by the comparator to determine the order of elements. + * + * Can be one of: + * + * - `Function`: A getter function. This function will be called with each item as argument and + * the return value will be used for sorting. + * - `string`: An AngularJS expression. This expression will be evaluated against each item and the + * result will be used for sorting. For example, use `'label'` to sort by a property called + * `label` or `'label.substring(0, 3)'` to sort by the first 3 characters of the `label` + * property.<br /> + * (The result of a constant expression is interpreted as a property name to be used for + * comparison. For example, use `'"special name"'` (note the extra pair of quotes) to sort by a + * property called `special name`.)<br /> + * An expression can be optionally prefixed with `+` or `-` to control the sorting direction, + * ascending or descending. For example, `'+label'` or `'-label'`. If no property is provided, + * (e.g. `'+'` or `'-'`), the collection element itself is used in comparisons. + * - `Array`: An array of function and/or string predicates. If a predicate cannot determine the + * relative order of two items, the next predicate is used as a tie-breaker. + * + * **Note:** If the predicate is missing or empty then it defaults to `'+'`. + * + * @param {boolean=} reverse - If `true`, reverse the sorting order. + * @param {(Function)=} comparator - The comparator function used to determine the relative order of + * value pairs. If omitted, the built-in comparator will be used. + * + * @returns {Array} - The sorted array. + * + * + * @example + * ### Ordering a table with `ngRepeat` + * + * The example below demonstrates a simple {@link ngRepeat ngRepeat}, where the data is sorted by + * age in descending order (expression is set to `'-age'`). The `comparator` is not set, which means + * it defaults to the built-in comparator. + * + <example name="orderBy-static" module="orderByExample1"> + <file name="index.html"> + <div ng-controller="ExampleController"> + <table class="friends"> + <tr> + <th>Name</th> + <th>Phone Number</th> + <th>Age</th> + </tr> + <tr ng-repeat="friend in friends | orderBy:'-age'"> + <td>{{friend.name}}</td> + <td>{{friend.phone}}</td> + <td>{{friend.age}}</td> + </tr> + </table> + </div> + </file> + <file name="script.js"> + angular.module('orderByExample1', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.friends = [ + {name: 'John', phone: '555-1212', age: 10}, + {name: 'Mary', phone: '555-9876', age: 19}, + {name: 'Mike', phone: '555-4321', age: 21}, + {name: 'Adam', phone: '555-5678', age: 35}, + {name: 'Julie', phone: '555-8765', age: 29} + ]; + }]); + </file> + <file name="style.css"> + .friends { + border-collapse: collapse; + } + + .friends th { + border-bottom: 1px solid; + } + .friends td, .friends th { + border-left: 1px solid; + padding: 5px 10px; + } + .friends td:first-child, .friends th:first-child { + border-left: none; + } + </file> + <file name="protractor.js" type="protractor"> + // Element locators + var names = element.all(by.repeater('friends').column('friend.name')); + + it('should sort friends by age in reverse order', function() { + expect(names.get(0).getText()).toBe('Adam'); + expect(names.get(1).getText()).toBe('Julie'); + expect(names.get(2).getText()).toBe('Mike'); + expect(names.get(3).getText()).toBe('Mary'); + expect(names.get(4).getText()).toBe('John'); + }); + </file> + </example> + * <hr /> * - * To be secure by default, you want to ensure that any such bindings are disallowed unless you can - * determine that something explicitly says it's safe to use a value for binding in that - * context. You can then audit your code (a simple grep would do) to ensure that this is only done - * for those values that you can easily tell are safe - because they were received from your server, - * sanitized by your library, etc. You can organize your codebase to help with this - perhaps - * allowing only the files in a specific directory to do this. Ensuring that the internal API - * exposed by that code doesn't markup arbitrary values as safe then becomes a more manageable task. + * @example + * ### Changing parameters dynamically * - * In the case of AngularJS' SCE service, one uses {@link ng.$sce#trustAs $sce.trustAs} - * (and shorthand methods such as {@link ng.$sce#trustAsHtml $sce.trustAsHtml}, etc.) to - * obtain values that will be accepted by SCE / privileged contexts. + * All parameters can be changed dynamically. The next example shows how you can make the columns of + * a table sortable, by binding the `expression` and `reverse` parameters to scope properties. + * + <example name="orderBy-dynamic" module="orderByExample2"> + <file name="index.html"> + <div ng-controller="ExampleController"> + <pre>Sort by = {{propertyName}}; reverse = {{reverse}}</pre> + <hr/> + <button ng-click="propertyName = null; reverse = false">Set to unsorted</button> + <hr/> + <table class="friends"> + <tr> + <th> + <button ng-click="sortBy('name')">Name</button> + <span class="sortorder" ng-show="propertyName === 'name'" ng-class="{reverse: reverse}"></span> + </th> + <th> + <button ng-click="sortBy('phone')">Phone Number</button> + <span class="sortorder" ng-show="propertyName === 'phone'" ng-class="{reverse: reverse}"></span> + </th> + <th> + <button ng-click="sortBy('age')">Age</button> + <span class="sortorder" ng-show="propertyName === 'age'" ng-class="{reverse: reverse}"></span> + </th> + </tr> + <tr ng-repeat="friend in friends | orderBy:propertyName:reverse"> + <td>{{friend.name}}</td> + <td>{{friend.phone}}</td> + <td>{{friend.age}}</td> + </tr> + </table> + </div> + </file> + <file name="script.js"> + angular.module('orderByExample2', []) + .controller('ExampleController', ['$scope', function($scope) { + var friends = [ + {name: 'John', phone: '555-1212', age: 10}, + {name: 'Mary', phone: '555-9876', age: 19}, + {name: 'Mike', phone: '555-4321', age: 21}, + {name: 'Adam', phone: '555-5678', age: 35}, + {name: 'Julie', phone: '555-8765', age: 29} + ]; + + $scope.propertyName = 'age'; + $scope.reverse = true; + $scope.friends = friends; + + $scope.sortBy = function(propertyName) { + $scope.reverse = ($scope.propertyName === propertyName) ? !$scope.reverse : false; + $scope.propertyName = propertyName; + }; + }]); + </file> + <file name="style.css"> + .friends { + border-collapse: collapse; + } + + .friends th { + border-bottom: 1px solid; + } + .friends td, .friends th { + border-left: 1px solid; + padding: 5px 10px; + } + .friends td:first-child, .friends th:first-child { + border-left: none; + } + + .sortorder:after { + content: '\25b2'; // BLACK UP-POINTING TRIANGLE + } + .sortorder.reverse:after { + content: '\25bc'; // BLACK DOWN-POINTING TRIANGLE + } + </file> + <file name="protractor.js" type="protractor"> + // Element locators + var unsortButton = element(by.partialButtonText('unsorted')); + var nameHeader = element(by.partialButtonText('Name')); + var phoneHeader = element(by.partialButtonText('Phone')); + var ageHeader = element(by.partialButtonText('Age')); + var firstName = element(by.repeater('friends').column('friend.name').row(0)); + var lastName = element(by.repeater('friends').column('friend.name').row(4)); + + it('should sort friends by some property, when clicking on the column header', function() { + expect(firstName.getText()).toBe('Adam'); + expect(lastName.getText()).toBe('John'); + + phoneHeader.click(); + expect(firstName.getText()).toBe('John'); + expect(lastName.getText()).toBe('Mary'); + + nameHeader.click(); + expect(firstName.getText()).toBe('Adam'); + expect(lastName.getText()).toBe('Mike'); + + ageHeader.click(); + expect(firstName.getText()).toBe('John'); + expect(lastName.getText()).toBe('Adam'); + }); + + it('should sort friends in reverse order, when clicking on the same column', function() { + expect(firstName.getText()).toBe('Adam'); + expect(lastName.getText()).toBe('John'); + + ageHeader.click(); + expect(firstName.getText()).toBe('John'); + expect(lastName.getText()).toBe('Adam'); + + ageHeader.click(); + expect(firstName.getText()).toBe('Adam'); + expect(lastName.getText()).toBe('John'); + }); + + it('should restore the original order, when clicking "Set to unsorted"', function() { + expect(firstName.getText()).toBe('Adam'); + expect(lastName.getText()).toBe('John'); + + unsortButton.click(); + expect(firstName.getText()).toBe('John'); + expect(lastName.getText()).toBe('Julie'); + }); + </file> + </example> + * <hr /> + * + * @example + * ### Using `orderBy` inside a controller + * + * It is also possible to call the `orderBy` filter manually, by injecting `orderByFilter`, and + * calling it with the desired parameters. (Alternatively, you could inject the `$filter` factory + * and retrieve the `orderBy` filter with `$filter('orderBy')`.) + * + <example name="orderBy-call-manually" module="orderByExample3"> + <file name="index.html"> + <div ng-controller="ExampleController"> + <pre>Sort by = {{propertyName}}; reverse = {{reverse}}</pre> + <hr/> + <button ng-click="sortBy(null)">Set to unsorted</button> + <hr/> + <table class="friends"> + <tr> + <th> + <button ng-click="sortBy('name')">Name</button> + <span class="sortorder" ng-show="propertyName === 'name'" ng-class="{reverse: reverse}"></span> + </th> + <th> + <button ng-click="sortBy('phone')">Phone Number</button> + <span class="sortorder" ng-show="propertyName === 'phone'" ng-class="{reverse: reverse}"></span> + </th> + <th> + <button ng-click="sortBy('age')">Age</button> + <span class="sortorder" ng-show="propertyName === 'age'" ng-class="{reverse: reverse}"></span> + </th> + </tr> + <tr ng-repeat="friend in friends"> + <td>{{friend.name}}</td> + <td>{{friend.phone}}</td> + <td>{{friend.age}}</td> + </tr> + </table> + </div> + </file> + <file name="script.js"> + angular.module('orderByExample3', []) + .controller('ExampleController', ['$scope', 'orderByFilter', function($scope, orderBy) { + var friends = [ + {name: 'John', phone: '555-1212', age: 10}, + {name: 'Mary', phone: '555-9876', age: 19}, + {name: 'Mike', phone: '555-4321', age: 21}, + {name: 'Adam', phone: '555-5678', age: 35}, + {name: 'Julie', phone: '555-8765', age: 29} + ]; + + $scope.propertyName = 'age'; + $scope.reverse = true; + $scope.friends = orderBy(friends, $scope.propertyName, $scope.reverse); + + $scope.sortBy = function(propertyName) { + $scope.reverse = (propertyName !== null && $scope.propertyName === propertyName) + ? !$scope.reverse : false; + $scope.propertyName = propertyName; + $scope.friends = orderBy(friends, $scope.propertyName, $scope.reverse); + }; + }]); + </file> + <file name="style.css"> + .friends { + border-collapse: collapse; + } + + .friends th { + border-bottom: 1px solid; + } + .friends td, .friends th { + border-left: 1px solid; + padding: 5px 10px; + } + .friends td:first-child, .friends th:first-child { + border-left: none; + } + + .sortorder:after { + content: '\25b2'; // BLACK UP-POINTING TRIANGLE + } + .sortorder.reverse:after { + content: '\25bc'; // BLACK DOWN-POINTING TRIANGLE + } + </file> + <file name="protractor.js" type="protractor"> + // Element locators + var unsortButton = element(by.partialButtonText('unsorted')); + var nameHeader = element(by.partialButtonText('Name')); + var phoneHeader = element(by.partialButtonText('Phone')); + var ageHeader = element(by.partialButtonText('Age')); + var firstName = element(by.repeater('friends').column('friend.name').row(0)); + var lastName = element(by.repeater('friends').column('friend.name').row(4)); + + it('should sort friends by some property, when clicking on the column header', function() { + expect(firstName.getText()).toBe('Adam'); + expect(lastName.getText()).toBe('John'); + + phoneHeader.click(); + expect(firstName.getText()).toBe('John'); + expect(lastName.getText()).toBe('Mary'); + + nameHeader.click(); + expect(firstName.getText()).toBe('Adam'); + expect(lastName.getText()).toBe('Mike'); + + ageHeader.click(); + expect(firstName.getText()).toBe('John'); + expect(lastName.getText()).toBe('Adam'); + }); + + it('should sort friends in reverse order, when clicking on the same column', function() { + expect(firstName.getText()).toBe('Adam'); + expect(lastName.getText()).toBe('John'); + + ageHeader.click(); + expect(firstName.getText()).toBe('John'); + expect(lastName.getText()).toBe('Adam'); + + ageHeader.click(); + expect(firstName.getText()).toBe('Adam'); + expect(lastName.getText()).toBe('John'); + }); + + it('should restore the original order, when clicking "Set to unsorted"', function() { + expect(firstName.getText()).toBe('Adam'); + expect(lastName.getText()).toBe('John'); + + unsortButton.click(); + expect(firstName.getText()).toBe('John'); + expect(lastName.getText()).toBe('Julie'); + }); + </file> + </example> + * <hr /> + * + * @example + * ### Using a custom comparator + * + * If you have very specific requirements about the way items are sorted, you can pass your own + * comparator function. For example, you might need to compare some strings in a locale-sensitive + * way. (When specifying a custom comparator, you also need to pass a value for the `reverse` + * argument - passing `false` retains the default sorting order, i.e. ascending.) + * + <example name="orderBy-custom-comparator" module="orderByExample4"> + <file name="index.html"> + <div ng-controller="ExampleController"> + <div class="friends-container custom-comparator"> + <h3>Locale-sensitive Comparator</h3> + <table class="friends"> + <tr> + <th>Name</th> + <th>Favorite Letter</th> + </tr> + <tr ng-repeat="friend in friends | orderBy:'favoriteLetter':false:localeSensitiveComparator"> + <td>{{friend.name}}</td> + <td>{{friend.favoriteLetter}}</td> + </tr> + </table> + </div> + <div class="friends-container default-comparator"> + <h3>Default Comparator</h3> + <table class="friends"> + <tr> + <th>Name</th> + <th>Favorite Letter</th> + </tr> + <tr ng-repeat="friend in friends | orderBy:'favoriteLetter'"> + <td>{{friend.name}}</td> + <td>{{friend.favoriteLetter}}</td> + </tr> + </table> + </div> + </div> + </file> + <file name="script.js"> + angular.module('orderByExample4', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.friends = [ + {name: 'John', favoriteLetter: 'Ä'}, + {name: 'Mary', favoriteLetter: 'Ü'}, + {name: 'Mike', favoriteLetter: 'Ö'}, + {name: 'Adam', favoriteLetter: 'H'}, + {name: 'Julie', favoriteLetter: 'Z'} + ]; + + $scope.localeSensitiveComparator = function(v1, v2) { + // If we don't get strings, just compare by index + if (v1.type !== 'string' || v2.type !== 'string') { + return (v1.index < v2.index) ? -1 : 1; + } + + // Compare strings alphabetically, taking locale into account + return v1.value.localeCompare(v2.value); + }; + }]); + </file> + <file name="style.css"> + .friends-container { + display: inline-block; + margin: 0 30px; + } + + .friends { + border-collapse: collapse; + } + + .friends th { + border-bottom: 1px solid; + } + .friends td, .friends th { + border-left: 1px solid; + padding: 5px 10px; + } + .friends td:first-child, .friends th:first-child { + border-left: none; + } + </file> + <file name="protractor.js" type="protractor"> + // Element locators + var container = element(by.css('.custom-comparator')); + var names = container.all(by.repeater('friends').column('friend.name')); + + it('should sort friends by favorite letter (in correct alphabetical order)', function() { + expect(names.get(0).getText()).toBe('John'); + expect(names.get(1).getText()).toBe('Adam'); + expect(names.get(2).getText()).toBe('Mike'); + expect(names.get(3).getText()).toBe('Mary'); + expect(names.get(4).getText()).toBe('Julie'); + }); + </file> + </example> * + */ + orderByFilter.$inject = ['$parse']; + function orderByFilter($parse) { + return function(array, sortPredicate, reverseOrder, compareFn) { + + if (array == null) return array; + if (!isArrayLike(array)) { + throw minErr('orderBy')('notarray', 'Expected array but received: {0}', array); + } + + if (!isArray(sortPredicate)) { sortPredicate = [sortPredicate]; } + if (sortPredicate.length === 0) { sortPredicate = ['+']; } + + var predicates = processPredicates(sortPredicate); + + var descending = reverseOrder ? -1 : 1; + + // Define the `compare()` function. Use a default comparator if none is specified. + var compare = isFunction(compareFn) ? compareFn : defaultCompare; + + // The next three lines are a version of a Swartzian Transform idiom from Perl + // (sometimes called the Decorate-Sort-Undecorate idiom) + // See https://en.wikipedia.org/wiki/Schwartzian_transform + var compareValues = Array.prototype.map.call(array, getComparisonObject); + compareValues.sort(doComparison); + array = compareValues.map(function(item) { return item.value; }); + + return array; + + function getComparisonObject(value, index) { + // NOTE: We are adding an extra `tieBreaker` value based on the element's index. + // This will be used to keep the sort stable when none of the input predicates can + // distinguish between two elements. + return { + value: value, + tieBreaker: {value: index, type: 'number', index: index}, + predicateValues: predicates.map(function(predicate) { + return getPredicateValue(predicate.get(value), index); + }) + }; + } + + function doComparison(v1, v2) { + for (var i = 0, ii = predicates.length; i < ii; i++) { + var result = compare(v1.predicateValues[i], v2.predicateValues[i]); + if (result) { + return result * predicates[i].descending * descending; + } + } + + return (compare(v1.tieBreaker, v2.tieBreaker) || defaultCompare(v1.tieBreaker, v2.tieBreaker)) * descending; + } + }; + + function processPredicates(sortPredicates) { + return sortPredicates.map(function(predicate) { + var descending = 1, get = identity; + + if (isFunction(predicate)) { + get = predicate; + } else if (isString(predicate)) { + if ((predicate.charAt(0) === '+' || predicate.charAt(0) === '-')) { + descending = predicate.charAt(0) === '-' ? -1 : 1; + predicate = predicate.substring(1); + } + if (predicate !== '') { + get = $parse(predicate); + if (get.constant) { + var key = get(); + get = function(value) { return value[key]; }; + } + } + } + return {get: get, descending: descending}; + }); + } + + function isPrimitive(value) { + switch (typeof value) { + case 'number': /* falls through */ + case 'boolean': /* falls through */ + case 'string': + return true; + default: + return false; + } + } + + function objectValue(value) { + // If `valueOf` is a valid function use that + if (isFunction(value.valueOf)) { + value = value.valueOf(); + if (isPrimitive(value)) return value; + } + // If `toString` is a valid function and not the one from `Object.prototype` use that + if (hasCustomToString(value)) { + value = value.toString(); + if (isPrimitive(value)) return value; + } + + return value; + } + + function getPredicateValue(value, index) { + var type = typeof value; + if (value === null) { + type = 'string'; + value = 'null'; + } else if (type === 'object') { + value = objectValue(value); + } + return {value: value, type: type, index: index}; + } + + function defaultCompare(v1, v2) { + var result = 0; + var type1 = v1.type; + var type2 = v2.type; + + if (type1 === type2) { + var value1 = v1.value; + var value2 = v2.value; + + if (type1 === 'string') { + // Compare strings case-insensitively + value1 = value1.toLowerCase(); + value2 = value2.toLowerCase(); + } else if (type1 === 'object') { + // For basic objects, use the position of the object + // in the collection instead of the value + if (isObject(value1)) value1 = v1.index; + if (isObject(value2)) value2 = v2.index; + } + + if (value1 !== value2) { + result = value1 < value2 ? -1 : 1; + } + } else { + result = type1 < type2 ? -1 : 1; + } + + return result; + } + } + + function ngDirective(directive) { + if (isFunction(directive)) { + directive = { + link: directive + }; + } + directive.restrict = directive.restrict || 'AC'; + return valueFn(directive); + } + + /** + * @ngdoc directive + * @name a + * @restrict E + * + * @description + * Modifies the default behavior of the html a tag so that the default action is prevented when + * the href attribute is empty. + * + * For dynamically creating `href` attributes for a tags, see the {@link ng.ngHref `ngHref`} directive. + */ + var htmlAnchorDirective = valueFn({ + restrict: 'E', + compile: function(element, attr) { + if (!attr.href && !attr.xlinkHref) { + return function(scope, element) { + // If the linked element is not an anchor tag anymore, do nothing + if (element[0].nodeName.toLowerCase() !== 'a') return; + + // SVGAElement does not use the href attribute, but rather the 'xlinkHref' attribute. + var href = toString.call(element.prop('href')) === '[object SVGAnimatedString]' ? + 'xlink:href' : 'href'; + element.on('click', function(event) { + // if we have no href url, then don't navigate anywhere. + if (!element.attr(href)) { + event.preventDefault(); + } + }); + }; + } + } + }); + + /** + * @ngdoc directive + * @name ngHref + * @restrict A + * @priority 99 + * + * @description + * Using AngularJS markup like `{{hash}}` in an href attribute will + * make the link go to the wrong URL if the user clicks it before + * AngularJS has a chance to replace the `{{hash}}` markup with its + * value. Until AngularJS replaces the markup the link will be broken + * and will most likely return a 404 error. The `ngHref` directive + * solves this problem. + * + * The wrong way to write it: + * ```html + * <a href="http://www.gravatar.com/avatar/{{hash}}">link1</a> + * ``` + * + * The correct way to write it: + * ```html + * <a ng-href="http://www.gravatar.com/avatar/{{hash}}">link1</a> + * ``` + * + * @element A + * @param {template} ngHref any string which can contain `{{}}` markup. + * + * @example + * This example shows various combinations of `href`, `ng-href` and `ng-click` attributes + * in links and their different behaviors: + <example name="ng-href"> + <file name="index.html"> + <input ng-model="value" /><br /> + <a id="link-1" href ng-click="value = 1">link 1</a> (link, don't reload)<br /> + <a id="link-2" href="" ng-click="value = 2">link 2</a> (link, don't reload)<br /> + <a id="link-3" ng-href="/{{'123'}}">link 3</a> (link, reload!)<br /> + <a id="link-4" href="" name="xx" ng-click="value = 4">anchor</a> (link, don't reload)<br /> + <a id="link-5" name="xxx" ng-click="value = 5">anchor</a> (no link)<br /> + <a id="link-6" ng-href="{{value}}">link</a> (link, change location) + </file> + <file name="protractor.js" type="protractor"> + it('should execute ng-click but not reload when href without value', function() { + element(by.id('link-1')).click(); + expect(element(by.model('value')).getAttribute('value')).toEqual('1'); + expect(element(by.id('link-1')).getAttribute('href')).toBe(''); + }); + + it('should execute ng-click but not reload when href empty string', function() { + element(by.id('link-2')).click(); + expect(element(by.model('value')).getAttribute('value')).toEqual('2'); + expect(element(by.id('link-2')).getAttribute('href')).toBe(''); + }); + + it('should execute ng-click and change url when ng-href specified', function() { + expect(element(by.id('link-3')).getAttribute('href')).toMatch(/\/123$/); + + element(by.id('link-3')).click(); + + // At this point, we navigate away from an AngularJS page, so we need + // to use browser.driver to get the base webdriver. + + browser.wait(function() { + return browser.driver.getCurrentUrl().then(function(url) { + return url.match(/\/123$/); + }); + }, 5000, 'page should navigate to /123'); + }); + + it('should execute ng-click but not reload when href empty string and name specified', function() { + element(by.id('link-4')).click(); + expect(element(by.model('value')).getAttribute('value')).toEqual('4'); + expect(element(by.id('link-4')).getAttribute('href')).toBe(''); + }); + + it('should execute ng-click but not reload when no href but name specified', function() { + element(by.id('link-5')).click(); + expect(element(by.model('value')).getAttribute('value')).toEqual('5'); + expect(element(by.id('link-5')).getAttribute('href')).toBe(null); + }); + + it('should only change url when only ng-href', function() { + element(by.model('value')).clear(); + element(by.model('value')).sendKeys('6'); + expect(element(by.id('link-6')).getAttribute('href')).toMatch(/\/6$/); + + element(by.id('link-6')).click(); + + // At this point, we navigate away from an AngularJS page, so we need + // to use browser.driver to get the base webdriver. + browser.wait(function() { + return browser.driver.getCurrentUrl().then(function(url) { + return url.match(/\/6$/); + }); + }, 5000, 'page should navigate to /6'); + }); + </file> + </example> + */ + + /** + * @ngdoc directive + * @name ngSrc + * @restrict A + * @priority 99 * - * ## How does it work? + * @description + * Using AngularJS markup like `{{hash}}` in a `src` attribute doesn't + * work right: The browser will fetch from the URL with the literal + * text `{{hash}}` until AngularJS replaces the expression inside + * `{{hash}}`. The `ngSrc` directive solves this problem. * - * In privileged contexts, directives and code will bind to the result of {@link ng.$sce#getTrusted - * $sce.getTrusted(context, value)} rather than to the value directly. Directives use {@link - * ng.$sce#parse $sce.parseAs} rather than `$parse` to watch attribute bindings, which performs the - * {@link ng.$sce#getTrusted $sce.getTrusted} behind the scenes on non-constant literals. + * The buggy way to write it: + * ```html + * <img src="http://www.gravatar.com/avatar/{{hash}}" alt="Description"/> + * ``` * - * As an example, {@link ng.directive:ngBindHtml ngBindHtml} uses {@link - * ng.$sce#parseAsHtml $sce.parseAsHtml(binding expression)}. Here's the actual code (slightly - * simplified): + * The correct way to write it: + * ```html + * <img ng-src="http://www.gravatar.com/avatar/{{hash}}" alt="Description" /> + * ``` * - * <pre class="prettyprint"> - * var ngBindHtmlDirective = ['$sce', function($sce) { - * return function(scope, element, attr) { - * scope.$watch($sce.parseAsHtml(attr.ngBindHtml), function(value) { - * element.html(value || ''); - * }); - * }; - * }]; - * </pre> + * @element IMG + * @param {template} ngSrc any string which can contain `{{}}` markup. + */ + + /** + * @ngdoc directive + * @name ngSrcset + * @restrict A + * @priority 99 * - * ## Impact on loading templates + * @description + * Using AngularJS markup like `{{hash}}` in a `srcset` attribute doesn't + * work right: The browser will fetch from the URL with the literal + * text `{{hash}}` until AngularJS replaces the expression inside + * `{{hash}}`. The `ngSrcset` directive solves this problem. * - * This applies both to the {@link ng.directive:ngInclude `ng-include`} directive as well as - * `templateUrl`'s specified by {@link guide/directive directives}. + * The buggy way to write it: + * ```html + * <img srcset="http://www.gravatar.com/avatar/{{hash}} 2x" alt="Description"/> + * ``` * - * By default, Angular only loads templates from the same domain and protocol as the application - * document. This is done by calling {@link ng.$sce#getTrustedResourceUrl - * $sce.getTrustedResourceUrl} on the template URL. To load templates from other domains and/or - * protocols, you may either either {@link ng.$sceDelegateProvider#resourceUrlWhitelist whitelist - * them} or {@link ng.$sce#trustAsResourceUrl wrap it} into a trusted value. + * The correct way to write it: + * ```html + * <img ng-srcset="http://www.gravatar.com/avatar/{{hash}} 2x" alt="Description" /> + * ``` * - * *Please note*: - * The browser's - * [Same Origin Policy](https://code.google.com/p/browsersec/wiki/Part2#Same-origin_policy_for_XMLHttpRequest) - * and [Cross-Origin Resource Sharing (CORS)](http://www.w3.org/TR/cors/) - * policy apply in addition to this and may further restrict whether the template is successfully - * loaded. This means that without the right CORS policy, loading templates from a different domain - * won't work on all browsers. Also, loading templates from `file://` URL does not work on some - * browsers. + * @element IMG + * @param {template} ngSrcset any string which can contain `{{}}` markup. + */ + + /** + * @ngdoc directive + * @name ngDisabled + * @restrict A + * @priority 100 * - * ## This feels like too much overhead for the developer? + * @description * - * It's important to remember that SCE only applies to interpolation expressions. + * This directive sets the `disabled` attribute on the element (typically a form control, + * e.g. `input`, `button`, `select` etc.) if the + * {@link guide/expression expression} inside `ngDisabled` evaluates to truthy. * - * If your expressions are constant literals, they're automatically trusted and you don't need to - * call `$sce.trustAs` on them (remember to include the `ngSanitize` module) (e.g. - * `<div ng-bind-html="'<b>implicitly trusted</b>'"></div>`) just works. + * A special directive is necessary because we cannot use interpolation inside the `disabled` + * attribute. See the {@link guide/interpolation interpolation guide} for more info. * - * Additionally, `a[href]` and `img[src]` automatically sanitize their URLs and do not pass them - * through {@link ng.$sce#getTrusted $sce.getTrusted}. SCE doesn't play a role here. + * @example + <example name="ng-disabled"> + <file name="index.html"> + <label>Click me to toggle: <input type="checkbox" ng-model="checked"></label><br/> + <button ng-model="button" ng-disabled="checked">Button</button> + </file> + <file name="protractor.js" type="protractor"> + it('should toggle button', function() { + expect(element(by.css('button')).getAttribute('disabled')).toBeFalsy(); + element(by.model('checked')).click(); + expect(element(by.css('button')).getAttribute('disabled')).toBeTruthy(); + }); + </file> + </example> * - * The included {@link ng.$sceDelegate $sceDelegate} comes with sane defaults to allow you to load - * templates in `ng-include` from your application's domain without having to even know about SCE. - * It blocks loading templates from other domains or loading templates over http from an https - * served document. You can change these by setting your own custom {@link - * ng.$sceDelegateProvider#resourceUrlWhitelist whitelists} and {@link - * ng.$sceDelegateProvider#resourceUrlBlacklist blacklists} for matching such URLs. + * @element INPUT + * @param {expression} ngDisabled If the {@link guide/expression expression} is truthy, + * then the `disabled` attribute will be set on the element + */ + + + /** + * @ngdoc directive + * @name ngChecked + * @restrict A + * @priority 100 * - * This significantly reduces the overhead. It is far easier to pay the small overhead and have an - * application that's secure and can be audited to verify that with much more ease than bolting - * security onto an application later. + * @description + * Sets the `checked` attribute on the element, if the expression inside `ngChecked` is truthy. * - * <a name="contexts"></a> - * ## What trusted context types are supported? + * Note that this directive should not be used together with {@link ngModel `ngModel`}, + * as this can lead to unexpected behavior. * - * | Context | Notes | - * |---------------------|----------------| - * | `$sce.HTML` | For HTML that's safe to source into the application. The {@link ng.directive:ngBindHtml ngBindHtml} directive uses this context for bindings. | - * | `$sce.CSS` | For CSS that's safe to source into the application. Currently unused. Feel free to use it in your own directives. | - * | `$sce.URL` | For URLs that are safe to follow as links. Currently unused (`<a href=` and `<img src=` sanitize their urls and don't constitute an SCE context. | - * | `$sce.RESOURCE_URL` | For URLs that are not only safe to follow as links, but whose contents are also safe to include in your application. Examples include `ng-include`, `src` / `ngSrc` bindings for tags other than `IMG` (e.g. `IFRAME`, `OBJECT`, etc.) <br><br>Note that `$sce.RESOURCE_URL` makes a stronger statement about the URL than `$sce.URL` does and therefore contexts requiring values trusted for `$sce.RESOURCE_URL` can be used anywhere that values trusted for `$sce.URL` are required. | - * | `$sce.JS` | For JavaScript that is safe to execute in your application's context. Currently unused. Feel free to use it in your own directives. | + * A special directive is necessary because we cannot use interpolation inside the `checked` + * attribute. See the {@link guide/interpolation interpolation guide} for more info. * - * ## Format of items in {@link ng.$sceDelegateProvider#resourceUrlWhitelist resourceUrlWhitelist}/{@link ng.$sceDelegateProvider#resourceUrlBlacklist Blacklist} <a name="resourceUrlPatternItem"></a> + * @example + <example name="ng-checked"> + <file name="index.html"> + <label>Check me to check both: <input type="checkbox" ng-model="leader"></label><br/> + <input id="checkFollower" type="checkbox" ng-checked="leader" aria-label="Follower input"> + </file> + <file name="protractor.js" type="protractor"> + it('should check both checkBoxes', function() { + expect(element(by.id('checkFollower')).getAttribute('checked')).toBeFalsy(); + element(by.model('leader')).click(); + expect(element(by.id('checkFollower')).getAttribute('checked')).toBeTruthy(); + }); + </file> + </example> * - * Each element in these arrays must be one of the following: + * @element INPUT + * @param {expression} ngChecked If the {@link guide/expression expression} is truthy, + * then the `checked` attribute will be set on the element + */ + + + /** + * @ngdoc directive + * @name ngReadonly + * @restrict A + * @priority 100 * - * - **'self'** - * - The special **string**, `'self'`, can be used to match against all URLs of the **same - * domain** as the application document using the **same protocol**. - * - **String** (except the special value `'self'`) - * - The string is matched against the full *normalized / absolute URL* of the resource - * being tested (substring matches are not good enough.) - * - There are exactly **two wildcard sequences** - `*` and `**`. All other characters - * match themselves. - * - `*`: matches zero or more occurrences of any character other than one of the following 6 - * characters: '`:`', '`/`', '`.`', '`?`', '`&`' and ';'. It's a useful wildcard for use - * in a whitelist. - * - `**`: matches zero or more occurrences of *any* character. As such, it's not - * not appropriate to use in for a scheme, domain, etc. as it would match too much. (e.g. - * http://**.example.com/ would match http://evil.com/?ignore=.example.com/ and that might - * not have been the intention.) It's usage at the very end of the path is ok. (e.g. - * http://foo.example.com/templates/**). - * - **RegExp** (*see caveat below*) - * - *Caveat*: While regular expressions are powerful and offer great flexibility, their syntax - * (and all the inevitable escaping) makes them *harder to maintain*. It's easy to - * accidentally introduce a bug when one updates a complex expression (imho, all regexes should - * have good test coverage.). For instance, the use of `.` in the regex is correct only in a - * small number of cases. A `.` character in the regex used when matching the scheme or a - * subdomain could be matched against a `:` or literal `.` that was likely not intended. It - * is highly recommended to use the string patterns and only fall back to regular expressions - * if they as a last resort. - * - The regular expression must be an instance of RegExp (i.e. not a string.) It is - * matched against the **entire** *normalized / absolute URL* of the resource being tested - * (even when the RegExp did not have the `^` and `$` codes.) In addition, any flags - * present on the RegExp (such as multiline, global, ignoreCase) are ignored. - * - If you are generating your JavaScript from some other templating engine (not - * recommended, e.g. in issue [#4006](https://github.com/angular/angular.js/issues/4006)), - * remember to escape your regular expression (and be aware that you might need more than - * one level of escaping depending on your templating engine and the way you interpolated - * the value.) Do make use of your platform's escaping mechanism as it might be good - * enough before coding your own. e.g. Ruby has - * [Regexp.escape(str)](http://www.ruby-doc.org/core-2.0.0/Regexp.html#method-c-escape) - * and Python has [re.escape](http://docs.python.org/library/re.html#re.escape). - * Javascript lacks a similar built in function for escaping. Take a look at Google - * Closure library's [goog.string.regExpEscape(s)]( - * http://docs.closure-library.googlecode.com/git/closure_goog_string_string.js.source.html#line962). + * @description * - * Refer {@link ng.$sceDelegateProvider $sceDelegateProvider} for an example. + * Sets the `readonly` attribute on the element, if the expression inside `ngReadonly` is truthy. + * Note that `readonly` applies only to `input` elements with specific types. [See the input docs on + * MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-readonly) for more information. * - * ## Show me an example using SCE. + * A special directive is necessary because we cannot use interpolation inside the `readonly` + * attribute. See the {@link guide/interpolation interpolation guide} for more info. * * @example - <example module="mySceApp" deps="angular-sanitize.js"> + <example name="ng-readonly"> <file name="index.html"> - <div ng-controller="myAppController as myCtrl"> - <i ng-bind-html="myCtrl.explicitlyTrustedHtml" id="explicitlyTrustedHtml"></i><br><br> - <b>User comments</b><br> - By default, HTML that isn't explicitly trusted (e.g. Alice's comment) is sanitized when - $sanitize is available. If $sanitize isn't available, this results in an error instead of an - exploit. - <div class="well"> - <div ng-repeat="userComment in myCtrl.userComments"> - <b>{{userComment.name}}</b>: - <span ng-bind-html="userComment.htmlComment" class="htmlComment"></span> - <br> - </div> - </div> - </div> + <label>Check me to make text readonly: <input type="checkbox" ng-model="checked"></label><br/> + <input type="text" ng-readonly="checked" value="I'm AngularJS" aria-label="Readonly field" /> </file> - - <file name="script.js"> - var mySceApp = angular.module('mySceApp', ['ngSanitize']); - - mySceApp.controller("myAppController", function myAppController($http, $templateCache, $sce) { - var self = this; - $http.get("test_data.json", {cache: $templateCache}).success(function(userComments) { - self.userComments = userComments; - }); - self.explicitlyTrustedHtml = $sce.trustAsHtml( - '<span onmouseover="this.textContent="Explicitly trusted HTML bypasses ' + - 'sanitization."">Hover over this text.</span>'); - }); + <file name="protractor.js" type="protractor"> + it('should toggle readonly attr', function() { + expect(element(by.css('[type="text"]')).getAttribute('readonly')).toBeFalsy(); + element(by.model('checked')).click(); + expect(element(by.css('[type="text"]')).getAttribute('readonly')).toBeTruthy(); + }); </file> + </example> + * + * @element INPUT + * @param {expression} ngReadonly If the {@link guide/expression expression} is truthy, + * then special attribute "readonly" will be set on the element + */ - <file name="test_data.json"> - [ - { "name": "Alice", - "htmlComment": - "<span onmouseover='this.textContent=\"PWN3D!\"'>Is <i>anyone</i> reading this?</span>" - }, - { "name": "Bob", - "htmlComment": "<i>Yes!</i> Am I the only other one?" - } - ] - </file> + /** + * @ngdoc directive + * @name ngSelected + * @restrict A + * @priority 100 + * + * @description + * + * Sets the `selected` attribute on the element, if the expression inside `ngSelected` is truthy. + * + * A special directive is necessary because we cannot use interpolation inside the `selected` + * attribute. See the {@link guide/interpolation interpolation guide} for more info. + * + * <div class="alert alert-warning"> + * **Note:** `ngSelected` does not interact with the `select` and `ngModel` directives, it only + * sets the `selected` attribute on the element. If you are using `ngModel` on the select, you + * should not use `ngSelected` on the options, as `ngModel` will set the select value and + * selected options. + * </div> + * + * @example + <example name="ng-selected"> + <file name="index.html"> + <label>Check me to select: <input type="checkbox" ng-model="selected"></label><br/> + <select aria-label="ngSelected demo"> + <option>Hello!</option> + <option id="greet" ng-selected="selected">Greetings!</option> + </select> + </file> <file name="protractor.js" type="protractor"> - describe('SCE doc demo', function() { - it('should sanitize untrusted values', function() { - expect(element(by.css('.htmlComment')).getInnerHtml()) - .toBe('<span>Is <i>anyone</i> reading this?</span>'); - }); - - it('should NOT sanitize explicitly trusted values', function() { - expect(element(by.id('explicitlyTrustedHtml')).getInnerHtml()).toBe( - '<span onmouseover="this.textContent="Explicitly trusted HTML bypasses ' + - 'sanitization."">Hover over this text.</span>'); - }); - }); + it('should select Greetings!', function() { + expect(element(by.id('greet')).getAttribute('selected')).toBeFalsy(); + element(by.model('selected')).click(); + expect(element(by.id('greet')).getAttribute('selected')).toBeTruthy(); + }); </file> </example> * + * @element OPTION + * @param {expression} ngSelected If the {@link guide/expression expression} is truthy, + * then special attribute "selected" will be set on the element + */ + + /** + * @ngdoc directive + * @name ngOpen + * @restrict A + * @priority 100 + * + * @description * + * Sets the `open` attribute on the element, if the expression inside `ngOpen` is truthy. * - * ## Can I disable SCE completely? + * A special directive is necessary because we cannot use interpolation inside the `open` + * attribute. See the {@link guide/interpolation interpolation guide} for more info. * - * Yes, you can. However, this is strongly discouraged. SCE gives you a lot of security benefits - * for little coding overhead. It will be much harder to take an SCE disabled application and - * either secure it on your own or enable SCE at a later stage. It might make sense to disable SCE - * for cases where you have a lot of existing code that was written before SCE was introduced and - * you're migrating them a module at a time. + * ## A note about browser compatibility * - * That said, here's how you can completely disable SCE: + * Internet Explorer and Edge do not support the `details` element, it is + * recommended to use {@link ng.ngShow} and {@link ng.ngHide} instead. * - * <pre class="prettyprint"> - * angular.module('myAppWithSceDisabledmyApp', []).config(function($sceProvider) { - * // Completely disable SCE. For demonstration purposes only! - * // Do not use in new projects. - * $sceProvider.enabled(false); - * }); - * </pre> + * @example + <example name="ng-open"> + <file name="index.html"> + <label>Toggle details: <input type="checkbox" ng-model="open"></label><br/> + <details id="details" ng-open="open"> + <summary>List</summary> + <ul> + <li>Apple</li> + <li>Orange</li> + <li>Durian</li> + </ul> + </details> + </file> + <file name="protractor.js" type="protractor"> + it('should toggle open', function() { + expect(element(by.id('details')).getAttribute('open')).toBeFalsy(); + element(by.model('open')).click(); + expect(element(by.id('details')).getAttribute('open')).toBeTruthy(); + }); + </file> + </example> * + * @element DETAILS + * @param {expression} ngOpen If the {@link guide/expression expression} is truthy, + * then special attribute "open" will be set on the element */ - /* jshint maxlen: 100 */ - - function $SceProvider() { - var enabled = true; - - /** - * @ngdoc method - * @name $sceProvider#enabled - * @function - * - * @param {boolean=} value If provided, then enables/disables SCE. - * @return {boolean} true if SCE is enabled, false otherwise. - * - * @description - * Enables/disables SCE and returns the current value. - */ - this.enabled = function (value) { - if (arguments.length) { - enabled = !!value; - } - return enabled; - }; - - - /* Design notes on the default implementation for SCE. - * - * The API contract for the SCE delegate - * ------------------------------------- - * The SCE delegate object must provide the following 3 methods: - * - * - trustAs(contextEnum, value) - * This method is used to tell the SCE service that the provided value is OK to use in the - * contexts specified by contextEnum. It must return an object that will be accepted by - * getTrusted() for a compatible contextEnum and return this value. - * - * - valueOf(value) - * For values that were not produced by trustAs(), return them as is. For values that were - * produced by trustAs(), return the corresponding input value to trustAs. Basically, if - * trustAs is wrapping the given values into some type, this operation unwraps it when given - * such a value. - * - * - getTrusted(contextEnum, value) - * This function should return the a value that is safe to use in the context specified by - * contextEnum or throw and exception otherwise. - * - * NOTE: This contract deliberately does NOT state that values returned by trustAs() must be - * opaque or wrapped in some holder object. That happens to be an implementation detail. For - * instance, an implementation could maintain a registry of all trusted objects by context. In - * such a case, trustAs() would return the same object that was passed in. getTrusted() would - * return the same object passed in if it was found in the registry under a compatible context or - * throw an exception otherwise. An implementation might only wrap values some of the time based - * on some criteria. getTrusted() might return a value and not throw an exception for special - * constants or objects even if not wrapped. All such implementations fulfill this contract. - * - * - * A note on the inheritance model for SCE contexts - * ------------------------------------------------ - * I've used inheritance and made RESOURCE_URL wrapped types a subtype of URL wrapped types. This - * is purely an implementation details. - * - * The contract is simply this: - * - * getTrusted($sce.RESOURCE_URL, value) succeeding implies that getTrusted($sce.URL, value) - * will also succeed. - * - * Inheritance happens to capture this in a natural way. In some future, we - * may not use inheritance anymore. That is OK because no code outside of - * sce.js and sceSpecs.js would need to be aware of this detail. - */ - this.$get = ['$parse', '$sniffer', '$sceDelegate', function( - $parse, $sniffer, $sceDelegate) { - // Prereq: Ensure that we're not running in IE8 quirks mode. In that mode, IE allows - // the "expression(javascript expression)" syntax which is insecure. - if (enabled && $sniffer.msie && $sniffer.msieDocumentMode < 8) { - throw $sceMinErr('iequirks', - 'Strict Contextual Escaping does not support Internet Explorer version < 9 in quirks ' + - 'mode. You can fix this by adding the text <!doctype html> to the top of your HTML ' + - 'document. See http://docs.angularjs.org/api/ng.$sce for more information.'); - } + var ngAttributeAliasDirectives = {}; - var sce = copy(SCE_CONTEXTS); +// boolean attrs are evaluated + forEach(BOOLEAN_ATTR, function(propName, attrName) { + // binding to multiple is not supported + if (propName === 'multiple') return; - /** - * @ngdoc method - * @name $sce#isEnabled - * @function - * - * @return {Boolean} true if SCE is enabled, false otherwise. If you want to set the value, you - * have to do it at module config time on {@link ng.$sceProvider $sceProvider}. - * - * @description - * Returns a boolean indicating if SCE is enabled. - */ - sce.isEnabled = function () { - return enabled; - }; - sce.trustAs = $sceDelegate.trustAs; - sce.getTrusted = $sceDelegate.getTrusted; - sce.valueOf = $sceDelegate.valueOf; + function defaultLinkFn(scope, element, attr) { + scope.$watch(attr[normalized], function ngBooleanAttrWatchAction(value) { + attr.$set(attrName, !!value); + }); + } - if (!enabled) { - sce.trustAs = sce.getTrusted = function(type, value) { return value; }; - sce.valueOf = identity; - } + var normalized = directiveNormalize('ng-' + attrName); + var linkFn = defaultLinkFn; - /** - * @ngdoc method - * @name $sce#parse - * - * @description - * Converts Angular {@link guide/expression expression} into a function. This is like {@link - * ng.$parse $parse} and is identical when the expression is a literal constant. Otherwise, it - * wraps the expression in a call to {@link ng.$sce#getTrusted $sce.getTrusted(*type*, - * *result*)} - * - * @param {string} type The kind of SCE context in which this result will be used. - * @param {string} expression String expression to compile. - * @returns {function(context, locals)} a function which represents the compiled expression: - * - * * `context` – `{object}` – an object against which any expressions embedded in the strings - * are evaluated against (typically a scope object). - * * `locals` – `{object=}` – local variables context object, useful for overriding values in - * `context`. - */ - sce.parseAs = function sceParseAs(type, expr) { - var parsed = $parse(expr); - if (parsed.literal && parsed.constant) { - return parsed; - } else { - return function sceParseAsTrusted(self, locals) { - return sce.getTrusted(type, parsed(self, locals)); - }; + if (propName === 'checked') { + linkFn = function(scope, element, attr) { + // ensuring ngChecked doesn't interfere with ngModel when both are set on the same input + if (attr.ngModel !== attr[normalized]) { + defaultLinkFn(scope, element, attr); } }; + } - /** - * @ngdoc method - * @name $sce#trustAs - * - * @description - * Delegates to {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs`}. As such, - * returns an object that is trusted by angular for use in specified strict contextual - * escaping contexts (such as ng-bind-html, ng-include, any src attribute - * interpolation, any dom event binding attribute interpolation such as for onclick, etc.) - * that uses the provided value. See * {@link ng.$sce $sce} for enabling strict contextual - * escaping. - * - * @param {string} type The kind of context in which this value is safe for use. e.g. url, - * resource_url, html, js and css. - * @param {*} value The value that that should be considered trusted/safe. - * @returns {*} A value that can be used to stand in for the provided `value` in places - * where Angular expects a $sce.trustAs() return value. - */ - - /** - * @ngdoc method - * @name $sce#trustAsHtml - * - * @description - * Shorthand method. `$sce.trustAsHtml(value)` → - * {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs($sce.HTML, value)`} - * - * @param {*} value The value to trustAs. - * @returns {*} An object that can be passed to {@link ng.$sce#getTrustedHtml - * $sce.getTrustedHtml(value)} to obtain the original value. (privileged directives - * only accept expressions that are either literal constants or are the - * return value of {@link ng.$sce#trustAs $sce.trustAs}.) - */ - - /** - * @ngdoc method - * @name $sce#trustAsUrl - * - * @description - * Shorthand method. `$sce.trustAsUrl(value)` → - * {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs($sce.URL, value)`} - * - * @param {*} value The value to trustAs. - * @returns {*} An object that can be passed to {@link ng.$sce#getTrustedUrl - * $sce.getTrustedUrl(value)} to obtain the original value. (privileged directives - * only accept expressions that are either literal constants or are the - * return value of {@link ng.$sce#trustAs $sce.trustAs}.) - */ - - /** - * @ngdoc method - * @name $sce#trustAsResourceUrl - * - * @description - * Shorthand method. `$sce.trustAsResourceUrl(value)` → - * {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs($sce.RESOURCE_URL, value)`} - * - * @param {*} value The value to trustAs. - * @returns {*} An object that can be passed to {@link ng.$sce#getTrustedResourceUrl - * $sce.getTrustedResourceUrl(value)} to obtain the original value. (privileged directives - * only accept expressions that are either literal constants or are the return - * value of {@link ng.$sce#trustAs $sce.trustAs}.) - */ - - /** - * @ngdoc method - * @name $sce#trustAsJs - * - * @description - * Shorthand method. `$sce.trustAsJs(value)` → - * {@link ng.$sceDelegate#trustAs `$sceDelegate.trustAs($sce.JS, value)`} - * - * @param {*} value The value to trustAs. - * @returns {*} An object that can be passed to {@link ng.$sce#getTrustedJs - * $sce.getTrustedJs(value)} to obtain the original value. (privileged directives - * only accept expressions that are either literal constants or are the - * return value of {@link ng.$sce#trustAs $sce.trustAs}.) - */ - - /** - * @ngdoc method - * @name $sce#getTrusted - * - * @description - * Delegates to {@link ng.$sceDelegate#getTrusted `$sceDelegate.getTrusted`}. As such, - * takes the result of a {@link ng.$sce#trustAs `$sce.trustAs`}() call and returns the - * originally supplied value if the queried context type is a supertype of the created type. - * If this condition isn't satisfied, throws an exception. - * - * @param {string} type The kind of context in which this value is to be used. - * @param {*} maybeTrusted The result of a prior {@link ng.$sce#trustAs `$sce.trustAs`} - * call. - * @returns {*} The value the was originally provided to - * {@link ng.$sce#trustAs `$sce.trustAs`} if valid in this context. - * Otherwise, throws an exception. - */ - - /** - * @ngdoc method - * @name $sce#getTrustedHtml - * - * @description - * Shorthand method. `$sce.getTrustedHtml(value)` → - * {@link ng.$sceDelegate#getTrusted `$sceDelegate.getTrusted($sce.HTML, value)`} - * - * @param {*} value The value to pass to `$sce.getTrusted`. - * @returns {*} The return value of `$sce.getTrusted($sce.HTML, value)` - */ - - /** - * @ngdoc method - * @name $sce#getTrustedCss - * - * @description - * Shorthand method. `$sce.getTrustedCss(value)` → - * {@link ng.$sceDelegate#getTrusted `$sceDelegate.getTrusted($sce.CSS, value)`} - * - * @param {*} value The value to pass to `$sce.getTrusted`. - * @returns {*} The return value of `$sce.getTrusted($sce.CSS, value)` - */ - - /** - * @ngdoc method - * @name $sce#getTrustedUrl - * - * @description - * Shorthand method. `$sce.getTrustedUrl(value)` → - * {@link ng.$sceDelegate#getTrusted `$sceDelegate.getTrusted($sce.URL, value)`} - * - * @param {*} value The value to pass to `$sce.getTrusted`. - * @returns {*} The return value of `$sce.getTrusted($sce.URL, value)` - */ - - /** - * @ngdoc method - * @name $sce#getTrustedResourceUrl - * - * @description - * Shorthand method. `$sce.getTrustedResourceUrl(value)` → - * {@link ng.$sceDelegate#getTrusted `$sceDelegate.getTrusted($sce.RESOURCE_URL, value)`} - * - * @param {*} value The value to pass to `$sceDelegate.getTrusted`. - * @returns {*} The return value of `$sce.getTrusted($sce.RESOURCE_URL, value)` - */ - - /** - * @ngdoc method - * @name $sce#getTrustedJs - * - * @description - * Shorthand method. `$sce.getTrustedJs(value)` → - * {@link ng.$sceDelegate#getTrusted `$sceDelegate.getTrusted($sce.JS, value)`} - * - * @param {*} value The value to pass to `$sce.getTrusted`. - * @returns {*} The return value of `$sce.getTrusted($sce.JS, value)` - */ + ngAttributeAliasDirectives[normalized] = function() { + return { + restrict: 'A', + priority: 100, + link: linkFn + }; + }; + }); - /** - * @ngdoc method - * @name $sce#parseAsHtml - * - * @description - * Shorthand method. `$sce.parseAsHtml(expression string)` → - * {@link ng.$sce#parse `$sce.parseAs($sce.HTML, value)`} - * - * @param {string} expression String expression to compile. - * @returns {function(context, locals)} a function which represents the compiled expression: - * - * * `context` – `{object}` – an object against which any expressions embedded in the strings - * are evaluated against (typically a scope object). - * * `locals` – `{object=}` – local variables context object, useful for overriding values in - * `context`. - */ +// aliased input attrs are evaluated + forEach(ALIASED_ATTR, function(htmlAttr, ngAttr) { + ngAttributeAliasDirectives[ngAttr] = function() { + return { + priority: 100, + link: function(scope, element, attr) { + //special case ngPattern when a literal regular expression value + //is used as the expression (this way we don't have to watch anything). + if (ngAttr === 'ngPattern' && attr.ngPattern.charAt(0) === '/') { + var match = attr.ngPattern.match(REGEX_STRING_REGEXP); + if (match) { + attr.$set('ngPattern', new RegExp(match[1], match[2])); + return; + } + } - /** - * @ngdoc method - * @name $sce#parseAsCss - * - * @description - * Shorthand method. `$sce.parseAsCss(value)` → - * {@link ng.$sce#parse `$sce.parseAs($sce.CSS, value)`} - * - * @param {string} expression String expression to compile. - * @returns {function(context, locals)} a function which represents the compiled expression: - * - * * `context` – `{object}` – an object against which any expressions embedded in the strings - * are evaluated against (typically a scope object). - * * `locals` – `{object=}` – local variables context object, useful for overriding values in - * `context`. - */ + scope.$watch(attr[ngAttr], function ngAttrAliasWatchAction(value) { + attr.$set(ngAttr, value); + }); + } + }; + }; + }); - /** - * @ngdoc method - * @name $sce#parseAsUrl - * - * @description - * Shorthand method. `$sce.parseAsUrl(value)` → - * {@link ng.$sce#parse `$sce.parseAs($sce.URL, value)`} - * - * @param {string} expression String expression to compile. - * @returns {function(context, locals)} a function which represents the compiled expression: - * - * * `context` – `{object}` – an object against which any expressions embedded in the strings - * are evaluated against (typically a scope object). - * * `locals` – `{object=}` – local variables context object, useful for overriding values in - * `context`. - */ +// ng-src, ng-srcset, ng-href are interpolated + forEach(['src', 'srcset', 'href'], function(attrName) { + var normalized = directiveNormalize('ng-' + attrName); + ngAttributeAliasDirectives[normalized] = function() { + return { + priority: 99, // it needs to run after the attributes are interpolated + link: function(scope, element, attr) { + var propName = attrName, + name = attrName; - /** - * @ngdoc method - * @name $sce#parseAsResourceUrl - * - * @description - * Shorthand method. `$sce.parseAsResourceUrl(value)` → - * {@link ng.$sce#parse `$sce.parseAs($sce.RESOURCE_URL, value)`} - * - * @param {string} expression String expression to compile. - * @returns {function(context, locals)} a function which represents the compiled expression: - * - * * `context` – `{object}` – an object against which any expressions embedded in the strings - * are evaluated against (typically a scope object). - * * `locals` – `{object=}` – local variables context object, useful for overriding values in - * `context`. - */ + if (attrName === 'href' && + toString.call(element.prop('href')) === '[object SVGAnimatedString]') { + name = 'xlinkHref'; + attr.$attr[name] = 'xlink:href'; + propName = null; + } - /** - * @ngdoc method - * @name $sce#parseAsJs - * - * @description - * Shorthand method. `$sce.parseAsJs(value)` → - * {@link ng.$sce#parse `$sce.parseAs($sce.JS, value)`} - * - * @param {string} expression String expression to compile. - * @returns {function(context, locals)} a function which represents the compiled expression: - * - * * `context` – `{object}` – an object against which any expressions embedded in the strings - * are evaluated against (typically a scope object). - * * `locals` – `{object=}` – local variables context object, useful for overriding values in - * `context`. - */ + attr.$observe(normalized, function(value) { + if (!value) { + if (attrName === 'href') { + attr.$set(name, null); + } + return; + } - // Shorthand delegations. - var parse = sce.parseAs, - getTrusted = sce.getTrusted, - trustAs = sce.trustAs; + attr.$set(name, value); - forEach(SCE_CONTEXTS, function (enumValue, name) { - var lName = lowercase(name); - sce[camelCase("parse_as_" + lName)] = function (expr) { - return parse(enumValue, expr); - }; - sce[camelCase("get_trusted_" + lName)] = function (value) { - return getTrusted(enumValue, value); - }; - sce[camelCase("trust_as_" + lName)] = function (value) { - return trustAs(enumValue, value); - }; - }); + // Support: IE 9-11 only + // On IE, if "ng:src" directive declaration is used and "src" attribute doesn't exist + // then calling element.setAttribute('src', 'foo') doesn't do anything, so we need + // to set the property as well to achieve the desired effect. + // We use attr[attrName] value since $set can sanitize the url. + if (msie && propName) element.prop(propName, attr[name]); + }); + } + }; + }; + }); - return sce; - }]; + /* global -nullFormCtrl, -PENDING_CLASS, -SUBMITTED_CLASS + */ + var nullFormCtrl = { + $addControl: noop, + $$renameControl: nullFormRenameControl, + $removeControl: noop, + $setValidity: noop, + $setDirty: noop, + $setPristine: noop, + $setSubmitted: noop + }, + PENDING_CLASS = 'ng-pending', + SUBMITTED_CLASS = 'ng-submitted'; + + function nullFormRenameControl(control, name) { + control.$name = name; } /** - * !!! This is an undocumented "private" service !!! + * @ngdoc type + * @name form.FormController * - * @name $sniffer - * @requires $window - * @requires $document + * @property {boolean} $pristine True if user has not interacted with the form yet. + * @property {boolean} $dirty True if user has already interacted with the form. + * @property {boolean} $valid True if all of the containing forms and controls are valid. + * @property {boolean} $invalid True if at least one containing control or form is invalid. + * @property {boolean} $submitted True if user has submitted the form even if its invalid. * - * @property {boolean} history Does the browser support html5 history api ? - * @property {boolean} hashchange Does the browser support hashchange event ? - * @property {boolean} transitions Does the browser support CSS transition events ? - * @property {boolean} animations Does the browser support CSS animation events ? + * @property {Object} $pending An object hash, containing references to controls or forms with + * pending validators, where: + * + * - keys are validations tokens (error names). + * - values are arrays of controls or forms that have a pending validator for the given error name. + * + * See {@link form.FormController#$error $error} for a list of built-in validation tokens. + * + * @property {Object} $error An object hash, containing references to controls or forms with failing + * validators, where: + * + * - keys are validation tokens (error names), + * - values are arrays of controls or forms that have a failing validator for the given error name. + * + * Built-in validation tokens: + * - `email` + * - `max` + * - `maxlength` + * - `min` + * - `minlength` + * - `number` + * - `pattern` + * - `required` + * - `url` + * - `date` + * - `datetimelocal` + * - `time` + * - `week` + * - `month` * * @description - * This is very simple implementation of testing browser's features. + * `FormController` keeps track of all its controls and nested forms as well as the state of them, + * such as being valid/invalid or dirty/pristine. + * + * Each {@link ng.directive:form form} directive creates an instance + * of `FormController`. + * */ - function $SnifferProvider() { - this.$get = ['$window', '$document', function($window, $document) { - var eventSupport = {}, - android = - int((/android (\d+)/.exec(lowercase(($window.navigator || {}).userAgent)) || [])[1]), - boxee = /Boxee/i.test(($window.navigator || {}).userAgent), - document = $document[0] || {}, - documentMode = document.documentMode, - vendorPrefix, - vendorRegex = /^(Moz|webkit|O|ms)(?=[A-Z])/, - bodyStyle = document.body && document.body.style, - transitions = false, - animations = false, - match; - - if (bodyStyle) { - for(var prop in bodyStyle) { - if(match = vendorRegex.exec(prop)) { - vendorPrefix = match[0]; - vendorPrefix = vendorPrefix.substr(0, 1).toUpperCase() + vendorPrefix.substr(1); - break; - } - } - - if(!vendorPrefix) { - vendorPrefix = ('WebkitOpacity' in bodyStyle) && 'webkit'; - } +//asks for $scope to fool the BC controller module + FormController.$inject = ['$element', '$attrs', '$scope', '$animate', '$interpolate']; + function FormController($element, $attrs, $scope, $animate, $interpolate) { + this.$$controls = []; - transitions = !!(('transition' in bodyStyle) || (vendorPrefix + 'Transition' in bodyStyle)); - animations = !!(('animation' in bodyStyle) || (vendorPrefix + 'Animation' in bodyStyle)); + // init state + this.$error = {}; + this.$$success = {}; + this.$pending = undefined; + this.$name = $interpolate($attrs.name || $attrs.ngForm || '')($scope); + this.$dirty = false; + this.$pristine = true; + this.$valid = true; + this.$invalid = false; + this.$submitted = false; + this.$$parentForm = nullFormCtrl; + + this.$$element = $element; + this.$$animate = $animate; + + setupValidity(this); + } - if (android && (!transitions||!animations)) { - transitions = isString(document.body.style.webkitTransition); - animations = isString(document.body.style.webkitAnimation); - } - } + FormController.prototype = { + /** + * @ngdoc method + * @name form.FormController#$rollbackViewValue + * + * @description + * Rollback all form controls pending updates to the `$modelValue`. + * + * Updates may be pending by a debounced event or because the input is waiting for a some future + * event defined in `ng-model-options`. This method is typically needed by the reset button of + * a form that uses `ng-model-options` to pend updates. + */ + $rollbackViewValue: function() { + forEach(this.$$controls, function(control) { + control.$rollbackViewValue(); + }); + }, + /** + * @ngdoc method + * @name form.FormController#$commitViewValue + * + * @description + * Commit all form controls pending updates to the `$modelValue`. + * + * Updates may be pending by a debounced event or because the input is waiting for a some future + * event defined in `ng-model-options`. This method is rarely needed as `NgModelController` + * usually handles calling this in response to input events. + */ + $commitViewValue: function() { + forEach(this.$$controls, function(control) { + control.$commitViewValue(); + }); + }, - return { - // Android has history.pushState, but it does not update location correctly - // so let's not use the history API at all. - // http://code.google.com/p/android/issues/detail?id=17471 - // https://github.com/angular/angular.js/issues/904 + /** + * @ngdoc method + * @name form.FormController#$addControl + * @param {object} control control object, either a {@link form.FormController} or an + * {@link ngModel.NgModelController} + * + * @description + * Register a control with the form. Input elements using ngModelController do this automatically + * when they are linked. + * + * Note that the current state of the control will not be reflected on the new parent form. This + * is not an issue with normal use, as freshly compiled and linked controls are in a `$pristine` + * state. + * + * However, if the method is used programmatically, for example by adding dynamically created controls, + * or controls that have been previously removed without destroying their corresponding DOM element, + * it's the developers responsibility to make sure the current state propagates to the parent form. + * + * For example, if an input control is added that is already `$dirty` and has `$error` properties, + * calling `$setDirty()` and `$validate()` afterwards will propagate the state to the parent form. + */ + $addControl: function(control) { + // Breaking change - before, inputs whose name was "hasOwnProperty" were quietly ignored + // and not added to the scope. Now we throw an error. + assertNotHasOwnProperty(control.$name, 'input'); + this.$$controls.push(control); - // older webkit browser (533.9) on Boxee box has exactly the same problem as Android has - // so let's not use the history API also - // We are purposefully using `!(android < 4)` to cover the case when `android` is undefined - // jshint -W018 - history: !!($window.history && $window.history.pushState && !(android < 4) && !boxee), - // jshint +W018 - hashchange: 'onhashchange' in $window && - // IE8 compatible mode lies - (!documentMode || documentMode > 7), - hasEvent: function(event) { - // IE9 implements 'input' event it's so fubared that we rather pretend that it doesn't have - // it. In particular the event is not fired when backspace or delete key are pressed or - // when cut operation is performed. - if (event == 'input' && msie == 9) return false; + if (control.$name) { + this[control.$name] = control; + } - if (isUndefined(eventSupport[event])) { - var divElm = document.createElement('div'); - eventSupport[event] = 'on' + event in divElm; - } + control.$$parentForm = this; + }, - return eventSupport[event]; - }, - csp: csp(), - vendorPrefix: vendorPrefix, - transitions : transitions, - animations : animations, - android: android, - msie : msie, - msieDocumentMode: documentMode - }; - }]; - } + // Private API: rename a form control + $$renameControl: function(control, newName) { + var oldName = control.$name; - function $TimeoutProvider() { - this.$get = ['$rootScope', '$browser', '$q', '$exceptionHandler', - function($rootScope, $browser, $q, $exceptionHandler) { - var deferreds = {}; + if (this[oldName] === control) { + delete this[oldName]; + } + this[newName] = control; + control.$name = newName; + }, + /** + * @ngdoc method + * @name form.FormController#$removeControl + * @param {object} control control object, either a {@link form.FormController} or an + * {@link ngModel.NgModelController} + * + * @description + * Deregister a control from the form. + * + * Input elements using ngModelController do this automatically when they are destroyed. + * + * Note that only the removed control's validation state (`$errors`etc.) will be removed from the + * form. `$dirty`, `$submitted` states will not be changed, because the expected behavior can be + * different from case to case. For example, removing the only `$dirty` control from a form may or + * may not mean that the form is still `$dirty`. + */ + $removeControl: function(control) { + if (control.$name && this[control.$name] === control) { + delete this[control.$name]; + } + forEach(this.$pending, function(value, name) { + // eslint-disable-next-line no-invalid-this + this.$setValidity(name, null, control); + }, this); + forEach(this.$error, function(value, name) { + // eslint-disable-next-line no-invalid-this + this.$setValidity(name, null, control); + }, this); + forEach(this.$$success, function(value, name) { + // eslint-disable-next-line no-invalid-this + this.$setValidity(name, null, control); + }, this); + + arrayRemove(this.$$controls, control); + control.$$parentForm = nullFormCtrl; + }, - /** - * @ngdoc service - * @name $timeout - * - * @description - * Angular's wrapper for `window.setTimeout`. The `fn` function is wrapped into a try/catch - * block and delegates any exceptions to - * {@link ng.$exceptionHandler $exceptionHandler} service. - * - * The return value of registering a timeout function is a promise, which will be resolved when - * the timeout is reached and the timeout function is executed. - * - * To cancel a timeout request, call `$timeout.cancel(promise)`. - * - * In tests you can use {@link ngMock.$timeout `$timeout.flush()`} to - * synchronously flush the queue of deferred functions. - * - * @param {function()} fn A function, whose execution should be delayed. - * @param {number=} [delay=0] Delay in milliseconds. - * @param {boolean=} [invokeApply=true] If set to `false` skips model dirty checking, otherwise - * will invoke `fn` within the {@link ng.$rootScope.Scope#$apply $apply} block. - * @returns {Promise} Promise that will be resolved when the timeout is reached. The value this - * promise will be resolved with is the return value of the `fn` function. - * - */ - function timeout(fn, delay, invokeApply) { - var deferred = $q.defer(), - promise = deferred.promise, - skipApply = (isDefined(invokeApply) && !invokeApply), - timeoutId; + /** + * @ngdoc method + * @name form.FormController#$setDirty + * + * @description + * Sets the form to a dirty state. + * + * This method can be called to add the 'ng-dirty' class and set the form to a dirty + * state (ng-dirty class). This method will also propagate to parent forms. + */ + $setDirty: function() { + this.$$animate.removeClass(this.$$element, PRISTINE_CLASS); + this.$$animate.addClass(this.$$element, DIRTY_CLASS); + this.$dirty = true; + this.$pristine = false; + this.$$parentForm.$setDirty(); + }, - timeoutId = $browser.defer(function() { - try { - deferred.resolve(fn()); - } catch(e) { - deferred.reject(e); - $exceptionHandler(e); - } - finally { - delete deferreds[promise.$$timeoutId]; - } + /** + * @ngdoc method + * @name form.FormController#$setPristine + * + * @description + * Sets the form to its pristine state. + * + * This method sets the form's `$pristine` state to true, the `$dirty` state to false, removes + * the `ng-dirty` class and adds the `ng-pristine` class. Additionally, it sets the `$submitted` + * state to false. + * + * This method will also propagate to all the controls contained in this form. + * + * Setting a form back to a pristine state is often useful when we want to 'reuse' a form after + * saving or resetting it. + */ + $setPristine: function() { + this.$$animate.setClass(this.$$element, PRISTINE_CLASS, DIRTY_CLASS + ' ' + SUBMITTED_CLASS); + this.$dirty = false; + this.$pristine = true; + this.$submitted = false; + forEach(this.$$controls, function(control) { + control.$setPristine(); + }); + }, - if (!skipApply) $rootScope.$apply(); - }, delay); + /** + * @ngdoc method + * @name form.FormController#$setUntouched + * + * @description + * Sets the form to its untouched state. + * + * This method can be called to remove the 'ng-touched' class and set the form controls to their + * untouched state (ng-untouched class). + * + * Setting a form controls back to their untouched state is often useful when setting the form + * back to its pristine state. + */ + $setUntouched: function() { + forEach(this.$$controls, function(control) { + control.$setUntouched(); + }); + }, - promise.$$timeoutId = timeoutId; - deferreds[timeoutId] = deferred; + /** + * @ngdoc method + * @name form.FormController#$setSubmitted + * + * @description + * Sets the form to its submitted state. + */ + $setSubmitted: function() { + this.$$animate.addClass(this.$$element, SUBMITTED_CLASS); + this.$submitted = true; + this.$$parentForm.$setSubmitted(); + } + }; - return promise; + /** + * @ngdoc method + * @name form.FormController#$setValidity + * + * @description + * Change the validity state of the form, and notify the parent form (if any). + * + * Application developers will rarely need to call this method directly. It is used internally, by + * {@link ngModel.NgModelController#$setValidity NgModelController.$setValidity()}, to propagate a + * control's validity state to the parent `FormController`. + * + * @param {string} validationErrorKey Name of the validator. The `validationErrorKey` will be + * assigned to either `$error[validationErrorKey]` or `$pending[validationErrorKey]` (for + * unfulfilled `$asyncValidators`), so that it is available for data-binding. The + * `validationErrorKey` should be in camelCase and will get converted into dash-case for + * class name. Example: `myError` will result in `ng-valid-my-error` and + * `ng-invalid-my-error` classes and can be bound to as `{{ someForm.$error.myError }}`. + * @param {boolean} isValid Whether the current state is valid (true), invalid (false), pending + * (undefined), or skipped (null). Pending is used for unfulfilled `$asyncValidators`. + * Skipped is used by AngularJS when validators do not run because of parse errors and when + * `$asyncValidators` do not run because any of the `$validators` failed. + * @param {NgModelController | FormController} controller - The controller whose validity state is + * triggering the change. + */ + addSetValidityMethod({ + clazz: FormController, + set: function(object, property, controller) { + var list = object[property]; + if (!list) { + object[property] = [controller]; + } else { + var index = list.indexOf(controller); + if (index === -1) { + list.push(controller); } + } + }, + unset: function(object, property, controller) { + var list = object[property]; + if (!list) { + return; + } + arrayRemove(list, controller); + if (list.length === 0) { + delete object[property]; + } + } + }); - - /** - * @ngdoc method - * @name $timeout#cancel - * - * @description - * Cancels a task associated with the `promise`. As a result of this, the promise will be - * resolved with a rejection. - * - * @param {Promise=} promise Promise returned by the `$timeout` function. - * @returns {boolean} Returns `true` if the task hasn't executed yet and was successfully - * canceled. - */ - timeout.cancel = function(promise) { - if (promise && promise.$$timeoutId in deferreds) { - deferreds[promise.$$timeoutId].reject('canceled'); - delete deferreds[promise.$$timeoutId]; - return $browser.defer.cancel(promise.$$timeoutId); - } - return false; - }; - - return timeout; - }]; - } - -// NOTE: The usage of window and document instead of $window and $document here is -// deliberate. This service depends on the specific behavior of anchor nodes created by the -// browser (resolving and parsing URLs) that is unlikely to be provided by mock objects and -// cause us to break tests. In addition, when the browser resolves a URL for XHR, it -// doesn't know about mocked locations and resolves URLs to the real document - which is -// exactly the behavior needed here. There is little value is mocking these out for this -// service. - var urlParsingNode = document.createElement("a"); - var originUrl = urlResolve(window.location.href, true); - + /** + * @ngdoc directive + * @name ngForm + * @restrict EAC + * + * @description + * Nestable alias of {@link ng.directive:form `form`} directive. HTML + * does not allow nesting of form elements. It is useful to nest forms, for example if the validity of a + * sub-group of controls needs to be determined. + * + * Note: the purpose of `ngForm` is to group controls, + * but not to be a replacement for the `<form>` tag with all of its capabilities + * (e.g. posting to the server, ...). + * + * @param {string=} ngForm|name Name of the form. If specified, the form controller will be published into + * related scope, under this name. + * + */ /** + * @ngdoc directive + * @name form + * @restrict E * - * Implementation Notes for non-IE browsers - * ---------------------------------------- - * Assigning a URL to the href property of an anchor DOM node, even one attached to the DOM, - * results both in the normalizing and parsing of the URL. Normalizing means that a relative - * URL will be resolved into an absolute URL in the context of the application document. - * Parsing means that the anchor node's host, hostname, protocol, port, pathname and related - * properties are all populated to reflect the normalized URL. This approach has wide - * compatibility - Safari 1+, Mozilla 1+, Opera 7+,e etc. See - * http://www.aptana.com/reference/html/api/HTMLAnchorElement.html + * @description + * Directive that instantiates + * {@link form.FormController FormController}. * - * Implementation Notes for IE - * --------------------------- - * IE >= 8 and <= 10 normalizes the URL when assigned to the anchor node similar to the other - * browsers. However, the parsed components will not be set if the URL assigned did not specify - * them. (e.g. if you assign a.href = "foo", then a.protocol, a.host, etc. will be empty.) We - * work around that by performing the parsing in a 2nd step by taking a previously normalized - * URL (e.g. by assigning to a.href) and assigning it a.href again. This correctly populates the - * properties such as protocol, hostname, port, etc. + * If the `name` attribute is specified, the form controller is published onto the current scope under + * this name. * - * IE7 does not normalize the URL when assigned to an anchor node. (Apparently, it does, if one - * uses the inner HTML approach to assign the URL as part of an HTML snippet - - * http://stackoverflow.com/a/472729) However, setting img[src] does normalize the URL. - * Unfortunately, setting img[src] to something like "javascript:foo" on IE throws an exception. - * Since the primary usage for normalizing URLs is to sanitize such URLs, we can't use that - * method and IE < 8 is unsupported. + * ## Alias: {@link ng.directive:ngForm `ngForm`} * - * References: - * http://developer.mozilla.org/en-US/docs/Web/API/HTMLAnchorElement - * http://www.aptana.com/reference/html/api/HTMLAnchorElement.html - * http://url.spec.whatwg.org/#urlutils - * https://github.com/angular/angular.js/pull/2902 - * http://james.padolsey.com/javascript/parsing-urls-with-the-dom/ + * In AngularJS, forms can be nested. This means that the outer form is valid when all of the child + * forms are valid as well. However, browsers do not allow nesting of `<form>` elements, so + * AngularJS provides the {@link ng.directive:ngForm `ngForm`} directive, which behaves identically to + * `form` but can be nested. Nested forms can be useful, for example, if the validity of a sub-group + * of controls needs to be determined. * - * @function - * @param {string} url The URL to be parsed. - * @description Normalizes and parses a URL. - * @returns {object} Returns the normalized URL as a dictionary. + * ## CSS classes + * - `ng-valid` is set if the form is valid. + * - `ng-invalid` is set if the form is invalid. + * - `ng-pending` is set if the form is pending. + * - `ng-pristine` is set if the form is pristine. + * - `ng-dirty` is set if the form is dirty. + * - `ng-submitted` is set if the form was submitted. * - * | member name | Description | - * |---------------|----------------| - * | href | A normalized version of the provided URL if it was not an absolute URL | - * | protocol | The protocol including the trailing colon | - * | host | The host and port (if the port is non-default) of the normalizedUrl | - * | search | The search params, minus the question mark | - * | hash | The hash string, minus the hash symbol - * | hostname | The hostname - * | port | The port, without ":" - * | pathname | The pathname, beginning with "/" + * Keep in mind that ngAnimate can detect each of these classes when added and removed. + * + * + * ## Submitting a form and preventing the default action + * + * Since the role of forms in client-side AngularJS applications is different than in classical + * roundtrip apps, it is desirable for the browser not to translate the form submission into a full + * page reload that sends the data to the server. Instead some javascript logic should be triggered + * to handle the form submission in an application-specific way. + * + * For this reason, AngularJS prevents the default action (form submission to the server) unless the + * `<form>` element has an `action` attribute specified. + * + * You can use one of the following two ways to specify what javascript method should be called when + * a form is submitted: + * + * - {@link ng.directive:ngSubmit ngSubmit} directive on the form element + * - {@link ng.directive:ngClick ngClick} directive on the first + * button or input field of type submit (input[type=submit]) + * + * To prevent double execution of the handler, use only one of the {@link ng.directive:ngSubmit ngSubmit} + * or {@link ng.directive:ngClick ngClick} directives. + * This is because of the following form submission rules in the HTML specification: + * + * - If a form has only one input field then hitting enter in this field triggers form submit + * (`ngSubmit`) + * - if a form has 2+ input fields and no buttons or input[type=submit] then hitting enter + * doesn't trigger submit + * - if a form has one or more input fields and one or more buttons or input[type=submit] then + * hitting enter in any of the input fields will trigger the click handler on the *first* button or + * input[type=submit] (`ngClick`) *and* a submit handler on the enclosing form (`ngSubmit`) + * + * Any pending `ngModelOptions` changes will take place immediately when an enclosing form is + * submitted. Note that `ngClick` events will occur before the model is updated. Use `ngSubmit` + * to have access to the updated model. + * + * @animations + * Animations in ngForm are triggered when any of the associated CSS classes are added and removed. + * These classes are: `.ng-pristine`, `.ng-dirty`, `.ng-invalid` and `.ng-valid` as well as any + * other validations that are performed within the form. Animations in ngForm are similar to how + * they work in ngClass and animations can be hooked into using CSS transitions, keyframes as well + * as JS animations. + * + * The following example shows a simple way to utilize CSS transitions to style a form element + * that has been rendered as invalid after it has been validated: + * + * <pre> + * //be sure to include ngAnimate as a module to hook into more + * //advanced animations + * .my-form { + * transition:0.5s linear all; + * background: white; + * } + * .my-form.ng-invalid { + * background: red; + * color:white; + * } + * </pre> + * + * @example + <example name="ng-form" deps="angular-animate.js" animations="true" fixBase="true" module="formExample"> + <file name="index.html"> + <script> + angular.module('formExample', []) + .controller('FormController', ['$scope', function($scope) { + $scope.userType = 'guest'; + }]); + </script> + <style> + .my-form { + transition:all linear 0.5s; + background: transparent; + } + .my-form.ng-invalid { + background: red; + } + </style> + <form name="myForm" ng-controller="FormController" class="my-form"> + userType: <input name="input" ng-model="userType" required> + <span class="error" ng-show="myForm.input.$error.required">Required!</span><br> + <code>userType = {{userType}}</code><br> + <code>myForm.input.$valid = {{myForm.input.$valid}}</code><br> + <code>myForm.input.$error = {{myForm.input.$error}}</code><br> + <code>myForm.$valid = {{myForm.$valid}}</code><br> + <code>myForm.$error.required = {{!!myForm.$error.required}}</code><br> + </form> + </file> + <file name="protractor.js" type="protractor"> + it('should initialize to model', function() { + var userType = element(by.binding('userType')); + var valid = element(by.binding('myForm.input.$valid')); + + expect(userType.getText()).toContain('guest'); + expect(valid.getText()).toContain('true'); + }); + + it('should be invalid if empty', function() { + var userType = element(by.binding('userType')); + var valid = element(by.binding('myForm.input.$valid')); + var userInput = element(by.model('userType')); + + userInput.clear(); + userInput.sendKeys(''); + + expect(userType.getText()).toEqual('userType ='); + expect(valid.getText()).toContain('false'); + }); + </file> + </example> * + * @param {string=} name Name of the form. If specified, the form controller will be published into + * related scope, under this name. */ - function urlResolve(url, base) { - var href = url; + var formDirectiveFactory = function(isNgForm) { + return ['$timeout', '$parse', function($timeout, $parse) { + var formDirective = { + name: 'form', + restrict: isNgForm ? 'EAC' : 'E', + require: ['form', '^^?form'], //first is the form's own ctrl, second is an optional parent form + controller: FormController, + compile: function ngFormCompile(formElement, attr) { + // Setup initial state of the control + formElement.addClass(PRISTINE_CLASS).addClass(VALID_CLASS); + + var nameAttr = attr.name ? 'name' : (isNgForm && attr.ngForm ? 'ngForm' : false); + + return { + pre: function ngFormPreLink(scope, formElement, attr, ctrls) { + var controller = ctrls[0]; + + // if `action` attr is not present on the form, prevent the default action (submission) + if (!('action' in attr)) { + // we can't use jq events because if a form is destroyed during submission the default + // action is not prevented. see #1238 + // + // IE 9 is not affected because it doesn't fire a submit event and try to do a full + // page reload if the form was destroyed by submission of the form via a click handler + // on a button in the form. Looks like an IE9 specific bug. + var handleFormSubmission = function(event) { + scope.$apply(function() { + controller.$commitViewValue(); + controller.$setSubmitted(); + }); + + event.preventDefault(); + }; + + formElement[0].addEventListener('submit', handleFormSubmission); + + // unregister the preventDefault listener so that we don't not leak memory but in a + // way that will achieve the prevention of the default action. + formElement.on('$destroy', function() { + $timeout(function() { + formElement[0].removeEventListener('submit', handleFormSubmission); + }, 0, false); + }); + } - if (msie) { - // Normalize before parse. Refer Implementation Notes on why this is - // done in two steps on IE. - urlParsingNode.setAttribute("href", href); - href = urlParsingNode.href; - } + var parentFormCtrl = ctrls[1] || controller.$$parentForm; + parentFormCtrl.$addControl(controller); - urlParsingNode.setAttribute('href', href); + var setter = nameAttr ? getSetter(controller.$name) : noop; - // urlParsingNode provides the UrlUtils interface - http://url.spec.whatwg.org/#urlutils - return { - href: urlParsingNode.href, - protocol: urlParsingNode.protocol ? urlParsingNode.protocol.replace(/:$/, '') : '', - host: urlParsingNode.host, - search: urlParsingNode.search ? urlParsingNode.search.replace(/^\?/, '') : '', - hash: urlParsingNode.hash ? urlParsingNode.hash.replace(/^#/, '') : '', - hostname: urlParsingNode.hostname, - port: urlParsingNode.port, - pathname: (urlParsingNode.pathname.charAt(0) === '/') - ? urlParsingNode.pathname - : '/' + urlParsingNode.pathname - }; - } + if (nameAttr) { + setter(scope, controller); + attr.$observe(nameAttr, function(newValue) { + if (controller.$name === newValue) return; + setter(scope, undefined); + controller.$$parentForm.$$renameControl(controller, newValue); + setter = getSetter(controller.$name); + setter(scope, controller); + }); + } + formElement.on('$destroy', function() { + controller.$$parentForm.$removeControl(controller); + setter(scope, undefined); + extend(controller, nullFormCtrl); //stop propagating child destruction handlers upwards + }); + } + }; + } + }; - /** - * Parse a request URL and determine whether this is a same-origin request as the application document. - * - * @param {string|object} requestUrl The url of the request as a string that will be resolved - * or a parsed URL object. - * @returns {boolean} Whether the request is for the same origin as the application document. - */ - function urlIsSameOrigin(requestUrl) { - var parsed = (isString(requestUrl)) ? urlResolve(requestUrl) : requestUrl; - return (parsed.protocol === originUrl.protocol && - parsed.host === originUrl.host); + return formDirective; + + function getSetter(expression) { + if (expression === '') { + //create an assignable expression, so forms with an empty name can be renamed later + return $parse('this[""]').assign; + } + return $parse(expression).assign || noop; + } + }]; + }; + + var formDirective = formDirectiveFactory(); + var ngFormDirective = formDirectiveFactory(true); + + + +// helper methods + function setupValidity(instance) { + instance.$$classCache = {}; + instance.$$classCache[INVALID_CLASS] = !(instance.$$classCache[VALID_CLASS] = instance.$$element.hasClass(VALID_CLASS)); } + function addSetValidityMethod(context) { + var clazz = context.clazz, + set = context.set, + unset = context.unset; + + clazz.prototype.$setValidity = function(validationErrorKey, state, controller) { + if (isUndefined(state)) { + createAndSet(this, '$pending', validationErrorKey, controller); + } else { + unsetAndCleanup(this, '$pending', validationErrorKey, controller); + } + if (!isBoolean(state)) { + unset(this.$error, validationErrorKey, controller); + unset(this.$$success, validationErrorKey, controller); + } else { + if (state) { + unset(this.$error, validationErrorKey, controller); + set(this.$$success, validationErrorKey, controller); + } else { + set(this.$error, validationErrorKey, controller); + unset(this.$$success, validationErrorKey, controller); + } + } + if (this.$pending) { + cachedToggleClass(this, PENDING_CLASS, true); + this.$valid = this.$invalid = undefined; + toggleValidationCss(this, '', null); + } else { + cachedToggleClass(this, PENDING_CLASS, false); + this.$valid = isObjectEmpty(this.$error); + this.$invalid = !this.$valid; + toggleValidationCss(this, '', this.$valid); + } - /** - * @ngdoc service - * @name $window - * - * @description - * A reference to the browser's `window` object. While `window` - * is globally available in JavaScript, it causes testability problems, because - * it is a global variable. In angular we always refer to it through the - * `$window` service, so it may be overridden, removed or mocked for testing. - * - * Expressions, like the one defined for the `ngClick` directive in the example - * below, are evaluated with respect to the current scope. Therefore, there is - * no risk of inadvertently coding in a dependency on a global value in such an - * expression. - * - * @example - <example> - <file name="index.html"> - <script> - function Ctrl($scope, $window) { - $scope.greeting = 'Hello, World!'; - $scope.doGreeting = function(greeting) { - $window.alert(greeting); - }; - } - </script> - <div ng-controller="Ctrl"> - <input type="text" ng-model="greeting" /> - <button ng-click="doGreeting(greeting)">ALERT</button> - </div> - </file> - <file name="protractor.js" type="protractor"> - it('should display the greeting in the input box', function() { - element(by.model('greeting')).sendKeys('Hello, E2E Tests'); - // If we click the button it will block the test runner - // element(':button').click(); - }); - </file> - </example> - */ - function $WindowProvider(){ - this.$get = valueFn(window); + // re-read the state as the set/unset methods could have + // combined state in this.$error[validationError] (used for forms), + // where setting/unsetting only increments/decrements the value, + // and does not replace it. + var combinedState; + if (this.$pending && this.$pending[validationErrorKey]) { + combinedState = undefined; + } else if (this.$error[validationErrorKey]) { + combinedState = false; + } else if (this.$$success[validationErrorKey]) { + combinedState = true; + } else { + combinedState = null; + } + + toggleValidationCss(this, validationErrorKey, combinedState); + this.$$parentForm.$setValidity(validationErrorKey, combinedState, this); + }; + + function createAndSet(ctrl, name, value, controller) { + if (!ctrl[name]) { + ctrl[name] = {}; + } + set(ctrl[name], value, controller); + } + + function unsetAndCleanup(ctrl, name, value, controller) { + if (ctrl[name]) { + unset(ctrl[name], value, controller); + } + if (isObjectEmpty(ctrl[name])) { + ctrl[name] = undefined; + } + } + + function cachedToggleClass(ctrl, className, switchValue) { + if (switchValue && !ctrl.$$classCache[className]) { + ctrl.$$animate.addClass(ctrl.$$element, className); + ctrl.$$classCache[className] = true; + } else if (!switchValue && ctrl.$$classCache[className]) { + ctrl.$$animate.removeClass(ctrl.$$element, className); + ctrl.$$classCache[className] = false; + } + } + + function toggleValidationCss(ctrl, validationErrorKey, isValid) { + validationErrorKey = validationErrorKey ? '-' + snake_case(validationErrorKey, '-') : ''; + + cachedToggleClass(ctrl, VALID_CLASS + validationErrorKey, isValid === true); + cachedToggleClass(ctrl, INVALID_CLASS + validationErrorKey, isValid === false); + } } - /** - * @ngdoc provider - * @name $filterProvider - * @description - * - * Filters are just functions which transform input to an output. However filters need to be - * Dependency Injected. To achieve this a filter definition consists of a factory function which is - * annotated with dependencies and is responsible for creating a filter function. - * - * ```js - * // Filter registration - * function MyModule($provide, $filterProvider) { - * // create a service to demonstrate injection (not always needed) - * $provide.value('greet', function(name){ - * return 'Hello ' + name + '!'; - * }); - * - * // register a filter factory which uses the - * // greet service to demonstrate DI. - * $filterProvider.register('greet', function(greet){ - * // return the filter function which uses the greet service - * // to generate salutation - * return function(text) { - * // filters need to be forgiving so check input validity - * return text && greet(text) || text; - * }; - * }); - * } - * ``` - * - * The filter function is registered with the `$injector` under the filter name suffix with - * `Filter`. - * - * ```js - * it('should be the same instance', inject( - * function($filterProvider) { - * $filterProvider.register('reverse', function(){ - * return ...; - * }); - * }, - * function($filter, reverseFilter) { - * expect($filter('reverse')).toBe(reverseFilter); - * }); - * ``` - * - * - * For more information about how angular filters work, and how to create your own filters, see - * {@link guide/filter Filters} in the Angular Developer Guide. - */ - /** - * @ngdoc method - * @name $filterProvider#register - * @description - * Register filter factory function. - * - * @param {String} name Name of the filter. - * @param {Function} fn The filter factory function which is injectable. - */ + function isObjectEmpty(obj) { + if (obj) { + for (var prop in obj) { + if (obj.hasOwnProperty(prop)) { + return false; + } + } + } + return true; + } + /* global + VALID_CLASS: false, + INVALID_CLASS: false, + PRISTINE_CLASS: false, + DIRTY_CLASS: false, + ngModelMinErr: false +*/ + +// Regex code was initially obtained from SO prior to modification: https://stackoverflow.com/questions/3143070/javascript-regex-iso-datetime#answer-3143231 + var ISO_DATE_REGEXP = /^\d{4,}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+(?:[+-][0-2]\d:[0-5]\d|Z)$/; +// See valid URLs in RFC3987 (http://tools.ietf.org/html/rfc3987) +// Note: We are being more lenient, because browsers are too. +// 1. Scheme +// 2. Slashes +// 3. Username +// 4. Password +// 5. Hostname +// 6. Port +// 7. Path +// 8. Query +// 9. Fragment +// 1111111111111111 222 333333 44444 55555555555555555555555 666 77777777 8888888 999 + var URL_REGEXP = /^[a-z][a-z\d.+-]*:\/*(?:[^:@]+(?::[^@]+)?@)?(?:[^\s:/?#]+|\[[a-f\d:]+])(?::\d+)?(?:\/[^?#]*)?(?:\?[^#]*)?(?:#.*)?$/i; +// eslint-disable-next-line max-len + var EMAIL_REGEXP = /^(?=.{1,254}$)(?=.{1,64}@)[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/; + var NUMBER_REGEXP = /^\s*(-|\+)?(\d+|(\d*(\.\d*)))([eE][+-]?\d+)?\s*$/; + var DATE_REGEXP = /^(\d{4,})-(\d{2})-(\d{2})$/; + var DATETIMELOCAL_REGEXP = /^(\d{4,})-(\d\d)-(\d\d)T(\d\d):(\d\d)(?::(\d\d)(\.\d{1,3})?)?$/; + var WEEK_REGEXP = /^(\d{4,})-W(\d\d)$/; + var MONTH_REGEXP = /^(\d{4,})-(\d\d)$/; + var TIME_REGEXP = /^(\d\d):(\d\d)(?::(\d\d)(\.\d{1,3})?)?$/; + + var PARTIAL_VALIDATION_EVENTS = 'keydown wheel mousedown'; + var PARTIAL_VALIDATION_TYPES = createMap(); + forEach('date,datetime-local,month,time,week'.split(','), function(type) { + PARTIAL_VALIDATION_TYPES[type] = true; + }); - /** - * @ngdoc service - * @name $filter - * @function - * @description - * Filters are used for formatting data displayed to the user. - * - * The general syntax in templates is as follows: - * - * {{ expression [| filter_name[:parameter_value] ... ] }} - * - * @param {String} name Name of the filter function to retrieve - * @return {Function} the filter function - */ - $FilterProvider.$inject = ['$provide']; - function $FilterProvider($provide) { - var suffix = 'Filter'; + var inputType = { /** - * @ngdoc method - * @name $controllerProvider#register - * @param {string|Object} name Name of the filter function, or an object map of filters where - * the keys are the filter names and the values are the filter factories. - * @returns {Object} Registered filter instance, or if a map of filters was provided then a map - * of the registered filter instances. + * @ngdoc input + * @name input[text] + * + * @description + * Standard HTML text input with AngularJS data binding, inherited by most of the `input` elements. + * + * + * @param {string} ngModel Assignable AngularJS expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} required Adds `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than + * minlength. + * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than + * maxlength. Setting the attribute to a negative or non-numeric value, allows view values of + * any length. + * @param {string=} pattern Similar to `ngPattern` except that the attribute value is the actual string + * that contains the regular expression body that will be converted to a regular expression + * as in the ngPattern directive. + * @param {string=} ngPattern Sets `pattern` validation error key if the ngModel {@link ngModel.NgModelController#$viewValue $viewValue} + * does not match a RegExp found by evaluating the AngularJS expression given in the attribute value. + * If the expression evaluates to a RegExp object, then this is used directly. + * If the expression evaluates to a string, then it will be converted to a RegExp + * after wrapping it in `^` and `$` characters. For instance, `"abc"` will be converted to + * `new RegExp('^abc$')`.<br /> + * **Note:** Avoid using the `g` flag on the RegExp, as it will cause each successive search to + * start at the index of the last search's match, thus not taking the whole input value into + * account. + * @param {string=} ngChange AngularJS expression to be executed when input changes due to user + * interaction with the input element. + * @param {boolean=} [ngTrim=true] If set to false AngularJS will not automatically trim the input. + * This parameter is ignored for input[type=password] controls, which will never trim the + * input. + * + * @example + <example name="text-input-directive" module="textInputExample"> + <file name="index.html"> + <script> + angular.module('textInputExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.example = { + text: 'guest', + word: /^\s*\w*\s*$/ + }; + }]); + </script> + <form name="myForm" ng-controller="ExampleController"> + <label>Single word: + <input type="text" name="input" ng-model="example.text" + ng-pattern="example.word" required ng-trim="false"> + </label> + <div role="alert"> + <span class="error" ng-show="myForm.input.$error.required"> + Required!</span> + <span class="error" ng-show="myForm.input.$error.pattern"> + Single word only!</span> + </div> + <code>text = {{example.text}}</code><br/> + <code>myForm.input.$valid = {{myForm.input.$valid}}</code><br/> + <code>myForm.input.$error = {{myForm.input.$error}}</code><br/> + <code>myForm.$valid = {{myForm.$valid}}</code><br/> + <code>myForm.$error.required = {{!!myForm.$error.required}}</code><br/> + </form> + </file> + <file name="protractor.js" type="protractor"> + var text = element(by.binding('example.text')); + var valid = element(by.binding('myForm.input.$valid')); + var input = element(by.model('example.text')); + + it('should initialize to model', function() { + expect(text.getText()).toContain('guest'); + expect(valid.getText()).toContain('true'); + }); + + it('should be invalid if empty', function() { + input.clear(); + input.sendKeys(''); + + expect(text.getText()).toEqual('text ='); + expect(valid.getText()).toContain('false'); + }); + + it('should be invalid if multi word', function() { + input.clear(); + input.sendKeys('hello world'); + + expect(valid.getText()).toContain('false'); + }); + </file> + </example> */ - function register(name, factory) { - if(isObject(name)) { - var filters = {}; - forEach(name, function(filter, key) { - filters[key] = register(key, filter); - }); - return filters; - } else { - return $provide.factory(name + suffix, factory); - } + 'text': textInputType, + + /** + * @ngdoc input + * @name input[date] + * + * @description + * Input with date validation and transformation. In browsers that do not yet support + * the HTML5 date input, a text element will be used. In that case, text must be entered in a valid ISO-8601 + * date format (yyyy-MM-dd), for example: `2009-01-06`. Since many + * modern browsers do not yet support this input type, it is important to provide cues to users on the + * expected input format via a placeholder or label. + * + * The model must always be a Date object, otherwise AngularJS will throw an error. + * Invalid `Date` objects (dates whose `getTime()` is `NaN`) will be rendered as an empty string. + * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * + * @param {string} ngModel Assignable AngularJS expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. This must be a + * valid ISO date string (yyyy-MM-dd). You can also use interpolation inside this attribute + * (e.g. `min="{{minDate | date:'yyyy-MM-dd'}}"`). Note that `min` will also add native HTML5 + * constraint validation. + * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. This must be + * a valid ISO date string (yyyy-MM-dd). You can also use interpolation inside this attribute + * (e.g. `max="{{maxDate | date:'yyyy-MM-dd'}}"`). Note that `max` will also add native HTML5 + * constraint validation. + * @param {(date|string)=} ngMin Sets the `min` validation constraint to the Date / ISO date string + * the `ngMin` expression evaluates to. Note that it does not set the `min` attribute. + * @param {(date|string)=} ngMax Sets the `max` validation constraint to the Date / ISO date string + * the `ngMax` expression evaluates to. Note that it does not set the `max` attribute. + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {string=} ngChange AngularJS expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + <example name="date-input-directive" module="dateInputExample"> + <file name="index.html"> + <script> + angular.module('dateInputExample', []) + .controller('DateController', ['$scope', function($scope) { + $scope.example = { + value: new Date(2013, 9, 22) + }; + }]); + </script> + <form name="myForm" ng-controller="DateController as dateCtrl"> + <label for="exampleInput">Pick a date in 2013:</label> + <input type="date" id="exampleInput" name="input" ng-model="example.value" + placeholder="yyyy-MM-dd" min="2013-01-01" max="2013-12-31" required /> + <div role="alert"> + <span class="error" ng-show="myForm.input.$error.required"> + Required!</span> + <span class="error" ng-show="myForm.input.$error.date"> + Not a valid date!</span> + </div> + <tt>value = {{example.value | date: "yyyy-MM-dd"}}</tt><br/> + <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> + <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> + <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> + <tt>myForm.$error.required = {{!!myForm.$error.required}}</tt><br/> + </form> + </file> + <file name="protractor.js" type="protractor"> + var value = element(by.binding('example.value | date: "yyyy-MM-dd"')); + var valid = element(by.binding('myForm.input.$valid')); + + // currently protractor/webdriver does not support + // sending keys to all known HTML5 input controls + // for various browsers (see https://github.com/angular/protractor/issues/562). + function setInput(val) { + // set the value of the element and force validation. + var scr = "var ipt = document.getElementById('exampleInput'); " + + "ipt.value = '" + val + "';" + + "angular.element(ipt).scope().$apply(function(s) { s.myForm[ipt.name].$setViewValue('" + val + "'); });"; + browser.executeScript(scr); } - this.register = register; - this.$get = ['$injector', function($injector) { - return function(name) { - return $injector.get(name + suffix); - }; - }]; + it('should initialize to model', function() { + expect(value.getText()).toContain('2013-10-22'); + expect(valid.getText()).toContain('myForm.input.$valid = true'); + }); - //////////////////////////////////////// + it('should be invalid if empty', function() { + setInput(''); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); - /* global - currencyFilter: false, - dateFilter: false, - filterFilter: false, - jsonFilter: false, - limitToFilter: false, - lowercaseFilter: false, - numberFilter: false, - orderByFilter: false, - uppercaseFilter: false, + it('should be invalid if over max', function() { + setInput('2015-01-01'); + expect(value.getText()).toContain(''); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + </file> + </example> */ + 'date': createDateInputType('date', DATE_REGEXP, + createDateParser(DATE_REGEXP, ['yyyy', 'MM', 'dd']), + 'yyyy-MM-dd'), - register('currency', currencyFilter); - register('date', dateFilter); - register('filter', filterFilter); - register('json', jsonFilter); - register('limitTo', limitToFilter); - register('lowercase', lowercaseFilter); - register('number', numberFilter); - register('orderBy', orderByFilter); - register('uppercase', uppercaseFilter); - } + /** + * @ngdoc input + * @name input[datetime-local] + * + * @description + * Input with datetime validation and transformation. In browsers that do not yet support + * the HTML5 date input, a text element will be used. In that case, the text must be entered in a valid ISO-8601 + * local datetime format (yyyy-MM-ddTHH:mm:ss), for example: `2010-12-28T14:57:00`. + * + * The model must always be a Date object, otherwise AngularJS will throw an error. + * Invalid `Date` objects (dates whose `getTime()` is `NaN`) will be rendered as an empty string. + * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * + * @param {string} ngModel Assignable AngularJS expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. + * This must be a valid ISO datetime format (yyyy-MM-ddTHH:mm:ss). You can also use interpolation + * inside this attribute (e.g. `min="{{minDatetimeLocal | date:'yyyy-MM-ddTHH:mm:ss'}}"`). + * Note that `min` will also add native HTML5 constraint validation. + * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. + * This must be a valid ISO datetime format (yyyy-MM-ddTHH:mm:ss). You can also use interpolation + * inside this attribute (e.g. `max="{{maxDatetimeLocal | date:'yyyy-MM-ddTHH:mm:ss'}}"`). + * Note that `max` will also add native HTML5 constraint validation. + * @param {(date|string)=} ngMin Sets the `min` validation error key to the Date / ISO datetime string + * the `ngMin` expression evaluates to. Note that it does not set the `min` attribute. + * @param {(date|string)=} ngMax Sets the `max` validation error key to the Date / ISO datetime string + * the `ngMax` expression evaluates to. Note that it does not set the `max` attribute. + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {string=} ngChange AngularJS expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + <example name="datetimelocal-input-directive" module="dateExample"> + <file name="index.html"> + <script> + angular.module('dateExample', []) + .controller('DateController', ['$scope', function($scope) { + $scope.example = { + value: new Date(2010, 11, 28, 14, 57) + }; + }]); + </script> + <form name="myForm" ng-controller="DateController as dateCtrl"> + <label for="exampleInput">Pick a date between in 2013:</label> + <input type="datetime-local" id="exampleInput" name="input" ng-model="example.value" + placeholder="yyyy-MM-ddTHH:mm:ss" min="2001-01-01T00:00:00" max="2013-12-31T00:00:00" required /> + <div role="alert"> + <span class="error" ng-show="myForm.input.$error.required"> + Required!</span> + <span class="error" ng-show="myForm.input.$error.datetimelocal"> + Not a valid date!</span> + </div> + <tt>value = {{example.value | date: "yyyy-MM-ddTHH:mm:ss"}}</tt><br/> + <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> + <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> + <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> + <tt>myForm.$error.required = {{!!myForm.$error.required}}</tt><br/> + </form> + </file> + <file name="protractor.js" type="protractor"> + var value = element(by.binding('example.value | date: "yyyy-MM-ddTHH:mm:ss"')); + var valid = element(by.binding('myForm.input.$valid')); - /** - * @ngdoc filter - * @name filter - * @function - * - * @description - * Selects a subset of items from `array` and returns it as a new array. - * - * @param {Array} array The source array. - * @param {string|Object|function()} expression The predicate to be used for selecting items from - * `array`. - * - * Can be one of: - * - * - `string`: The string is evaluated as an expression and the resulting value is used for substring match against - * the contents of the `array`. All strings or objects with string properties in `array` that contain this string - * will be returned. The predicate can be negated by prefixing the string with `!`. - * - * - `Object`: A pattern object can be used to filter specific properties on objects contained - * by `array`. For example `{name:"M", phone:"1"}` predicate will return an array of items - * which have property `name` containing "M" and property `phone` containing "1". A special - * property name `$` can be used (as in `{$:"text"}`) to accept a match against any - * property of the object. That's equivalent to the simple substring match with a `string` - * as described above. - * - * - `function(value)`: A predicate function can be used to write arbitrary filters. The function is - * called for each element of `array`. The final result is an array of those elements that - * the predicate returned true for. - * - * @param {function(actual, expected)|true|undefined} comparator Comparator which is used in - * determining if the expected value (from the filter expression) and actual value (from - * the object in the array) should be considered a match. - * - * Can be one of: - * - * - `function(actual, expected)`: - * The function will be given the object value and the predicate value to compare and - * should return true if the item should be included in filtered result. - * - * - `true`: A shorthand for `function(actual, expected) { return angular.equals(expected, actual)}`. - * this is essentially strict comparison of expected and actual. - * - * - `false|undefined`: A short hand for a function which will look for a substring match in case - * insensitive way. - * - * @example - <example> - <file name="index.html"> - <div ng-init="friends = [{name:'John', phone:'555-1276'}, - {name:'Mary', phone:'800-BIG-MARY'}, - {name:'Mike', phone:'555-4321'}, - {name:'Adam', phone:'555-5678'}, - {name:'Julie', phone:'555-8765'}, - {name:'Juliette', phone:'555-5678'}]"></div> + // currently protractor/webdriver does not support + // sending keys to all known HTML5 input controls + // for various browsers (https://github.com/angular/protractor/issues/562). + function setInput(val) { + // set the value of the element and force validation. + var scr = "var ipt = document.getElementById('exampleInput'); " + + "ipt.value = '" + val + "';" + + "angular.element(ipt).scope().$apply(function(s) { s.myForm[ipt.name].$setViewValue('" + val + "'); });"; + browser.executeScript(scr); + } - Search: <input ng-model="searchText"> - <table id="searchTextResults"> - <tr><th>Name</th><th>Phone</th></tr> - <tr ng-repeat="friend in friends | filter:searchText"> - <td>{{friend.name}}</td> - <td>{{friend.phone}}</td> - </tr> - </table> - <hr> - Any: <input ng-model="search.$"> <br> - Name only <input ng-model="search.name"><br> - Phone only <input ng-model="search.phone"><br> - Equality <input type="checkbox" ng-model="strict"><br> - <table id="searchObjResults"> - <tr><th>Name</th><th>Phone</th></tr> - <tr ng-repeat="friendObj in friends | filter:search:strict"> - <td>{{friendObj.name}}</td> - <td>{{friendObj.phone}}</td> - </tr> - </table> - </file> - <file name="protractor.js" type="protractor"> - var expectFriendNames = function(expectedNames, key) { - element.all(by.repeater(key + ' in friends').column(key + '.name')).then(function(arr) { - arr.forEach(function(wd, i) { - expect(wd.getText()).toMatch(expectedNames[i]); - }); - }); - }; + it('should initialize to model', function() { + expect(value.getText()).toContain('2010-12-28T14:57:00'); + expect(valid.getText()).toContain('myForm.input.$valid = true'); + }); - it('should search across all fields when filtering with a string', function() { - var searchText = element(by.model('searchText')); - searchText.clear(); - searchText.sendKeys('m'); - expectFriendNames(['Mary', 'Mike', 'Adam'], 'friend'); + it('should be invalid if empty', function() { + setInput(''); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); - searchText.clear(); - searchText.sendKeys('76'); - expectFriendNames(['John', 'Julie'], 'friend'); - }); + it('should be invalid if over max', function() { + setInput('2015-01-01T23:59:00'); + expect(value.getText()).toContain(''); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + </file> + </example> + */ + 'datetime-local': createDateInputType('datetimelocal', DATETIMELOCAL_REGEXP, + createDateParser(DATETIMELOCAL_REGEXP, ['yyyy', 'MM', 'dd', 'HH', 'mm', 'ss', 'sss']), + 'yyyy-MM-ddTHH:mm:ss.sss'), - it('should search in specific fields when filtering with a predicate object', function() { - var searchAny = element(by.model('search.$')); - searchAny.clear(); - searchAny.sendKeys('i'); - expectFriendNames(['Mary', 'Mike', 'Julie', 'Juliette'], 'friendObj'); - }); - it('should use a equal comparison when comparator is true', function() { - var searchName = element(by.model('search.name')); - var strict = element(by.model('strict')); - searchName.clear(); - searchName.sendKeys('Julie'); - strict.click(); - expectFriendNames(['Julie'], 'friendObj'); - }); - </file> - </example> - */ - function filterFilter() { - return function(array, expression, comparator) { - if (!isArray(array)) return array; + /** + * @ngdoc input + * @name input[time] + * + * @description + * Input with time validation and transformation. In browsers that do not yet support + * the HTML5 time input, a text element will be used. In that case, the text must be entered in a valid ISO-8601 + * local time format (HH:mm:ss), for example: `14:57:00`. Model must be a Date object. This binding will always output a + * Date object to the model of January 1, 1970, or local date `new Date(1970, 0, 1, HH, mm, ss)`. + * + * The model must always be a Date object, otherwise AngularJS will throw an error. + * Invalid `Date` objects (dates whose `getTime()` is `NaN`) will be rendered as an empty string. + * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * + * @param {string} ngModel Assignable AngularJS expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. + * This must be a valid ISO time format (HH:mm:ss). You can also use interpolation inside this + * attribute (e.g. `min="{{minTime | date:'HH:mm:ss'}}"`). Note that `min` will also add + * native HTML5 constraint validation. + * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. + * This must be a valid ISO time format (HH:mm:ss). You can also use interpolation inside this + * attribute (e.g. `max="{{maxTime | date:'HH:mm:ss'}}"`). Note that `max` will also add + * native HTML5 constraint validation. + * @param {(date|string)=} ngMin Sets the `min` validation constraint to the Date / ISO time string the + * `ngMin` expression evaluates to. Note that it does not set the `min` attribute. + * @param {(date|string)=} ngMax Sets the `max` validation constraint to the Date / ISO time string the + * `ngMax` expression evaluates to. Note that it does not set the `max` attribute. + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {string=} ngChange AngularJS expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + <example name="time-input-directive" module="timeExample"> + <file name="index.html"> + <script> + angular.module('timeExample', []) + .controller('DateController', ['$scope', function($scope) { + $scope.example = { + value: new Date(1970, 0, 1, 14, 57, 0) + }; + }]); + </script> + <form name="myForm" ng-controller="DateController as dateCtrl"> + <label for="exampleInput">Pick a time between 8am and 5pm:</label> + <input type="time" id="exampleInput" name="input" ng-model="example.value" + placeholder="HH:mm:ss" min="08:00:00" max="17:00:00" required /> + <div role="alert"> + <span class="error" ng-show="myForm.input.$error.required"> + Required!</span> + <span class="error" ng-show="myForm.input.$error.time"> + Not a valid date!</span> + </div> + <tt>value = {{example.value | date: "HH:mm:ss"}}</tt><br/> + <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> + <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> + <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> + <tt>myForm.$error.required = {{!!myForm.$error.required}}</tt><br/> + </form> + </file> + <file name="protractor.js" type="protractor"> + var value = element(by.binding('example.value | date: "HH:mm:ss"')); + var valid = element(by.binding('myForm.input.$valid')); - var comparatorType = typeof(comparator), - predicates = []; + // currently protractor/webdriver does not support + // sending keys to all known HTML5 input controls + // for various browsers (https://github.com/angular/protractor/issues/562). + function setInput(val) { + // set the value of the element and force validation. + var scr = "var ipt = document.getElementById('exampleInput'); " + + "ipt.value = '" + val + "';" + + "angular.element(ipt).scope().$apply(function(s) { s.myForm[ipt.name].$setViewValue('" + val + "'); });"; + browser.executeScript(scr); + } - predicates.check = function(value) { - for (var j = 0; j < predicates.length; j++) { - if(!predicates[j](value)) { - return false; - } - } - return true; - }; + it('should initialize to model', function() { + expect(value.getText()).toContain('14:57:00'); + expect(valid.getText()).toContain('myForm.input.$valid = true'); + }); - if (comparatorType !== 'function') { - if (comparatorType === 'boolean' && comparator) { - comparator = function(obj, text) { - return angular.equals(obj, text); - }; - } else { - comparator = function(obj, text) { - if (obj && text && typeof obj === 'object' && typeof text === 'object') { - for (var objKey in obj) { - if (objKey.charAt(0) !== '$' && hasOwnProperty.call(obj, objKey) && - comparator(obj[objKey], text[objKey])) { - return true; - } - } - return false; - } - text = (''+text).toLowerCase(); - return (''+obj).toLowerCase().indexOf(text) > -1; - }; - } - } + it('should be invalid if empty', function() { + setInput(''); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); - var search = function(obj, text){ - if (typeof text == 'string' && text.charAt(0) === '!') { - return !search(obj, text.substr(1)); - } - switch (typeof obj) { - case "boolean": - case "number": - case "string": - return comparator(obj, text); - case "object": - switch (typeof text) { - case "object": - return comparator(obj, text); - default: - for ( var objKey in obj) { - if (objKey.charAt(0) !== '$' && search(obj[objKey], text)) { - return true; - } - } - break; - } - return false; - case "array": - for ( var i = 0; i < obj.length; i++) { - if (search(obj[i], text)) { - return true; - } - } - return false; - default: - return false; - } - }; - switch (typeof expression) { - case "boolean": - case "number": - case "string": - // Set up expression object and fall through - expression = {$:expression}; - // jshint -W086 - case "object": - // jshint +W086 - for (var key in expression) { - (function(path) { - if (typeof expression[path] == 'undefined') return; - predicates.push(function(value) { - return search(path == '$' ? value : (value && value[path]), expression[path]); - }); - })(key); - } - break; - case 'function': - predicates.push(expression); - break; - default: - return array; - } - var filtered = []; - for ( var j = 0; j < array.length; j++) { - var value = array[j]; - if (predicates.check(value)) { - filtered.push(value); - } - } - return filtered; - }; - } + it('should be invalid if over max', function() { + setInput('23:59:00'); + expect(value.getText()).toContain(''); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + </file> + </example> + */ + 'time': createDateInputType('time', TIME_REGEXP, + createDateParser(TIME_REGEXP, ['HH', 'mm', 'ss', 'sss']), + 'HH:mm:ss.sss'), + + /** + * @ngdoc input + * @name input[week] + * + * @description + * Input with week-of-the-year validation and transformation to Date. In browsers that do not yet support + * the HTML5 week input, a text element will be used. In that case, the text must be entered in a valid ISO-8601 + * week format (yyyy-W##), for example: `2013-W02`. + * + * The model must always be a Date object, otherwise AngularJS will throw an error. + * Invalid `Date` objects (dates whose `getTime()` is `NaN`) will be rendered as an empty string. + * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * + * @param {string} ngModel Assignable AngularJS expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. + * This must be a valid ISO week format (yyyy-W##). You can also use interpolation inside this + * attribute (e.g. `min="{{minWeek | date:'yyyy-Www'}}"`). Note that `min` will also add + * native HTML5 constraint validation. + * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. + * This must be a valid ISO week format (yyyy-W##). You can also use interpolation inside this + * attribute (e.g. `max="{{maxWeek | date:'yyyy-Www'}}"`). Note that `max` will also add + * native HTML5 constraint validation. + * @param {(date|string)=} ngMin Sets the `min` validation constraint to the Date / ISO week string + * the `ngMin` expression evaluates to. Note that it does not set the `min` attribute. + * @param {(date|string)=} ngMax Sets the `max` validation constraint to the Date / ISO week string + * the `ngMax` expression evaluates to. Note that it does not set the `max` attribute. + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {string=} ngChange AngularJS expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + <example name="week-input-directive" module="weekExample"> + <file name="index.html"> + <script> + angular.module('weekExample', []) + .controller('DateController', ['$scope', function($scope) { + $scope.example = { + value: new Date(2013, 0, 3) + }; + }]); + </script> + <form name="myForm" ng-controller="DateController as dateCtrl"> + <label>Pick a date between in 2013: + <input id="exampleInput" type="week" name="input" ng-model="example.value" + placeholder="YYYY-W##" min="2012-W32" + max="2013-W52" required /> + </label> + <div role="alert"> + <span class="error" ng-show="myForm.input.$error.required"> + Required!</span> + <span class="error" ng-show="myForm.input.$error.week"> + Not a valid date!</span> + </div> + <tt>value = {{example.value | date: "yyyy-Www"}}</tt><br/> + <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> + <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> + <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> + <tt>myForm.$error.required = {{!!myForm.$error.required}}</tt><br/> + </form> + </file> + <file name="protractor.js" type="protractor"> + var value = element(by.binding('example.value | date: "yyyy-Www"')); + var valid = element(by.binding('myForm.input.$valid')); - /** - * @ngdoc filter - * @name currency - * @function - * - * @description - * Formats a number as a currency (ie $1,234.56). When no currency symbol is provided, default - * symbol for current locale is used. - * - * @param {number} amount Input to filter. - * @param {string=} symbol Currency symbol or identifier to be displayed. - * @returns {string} Formatted number. - * - * - * @example - <example> - <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.amount = 1234.56; - } - </script> - <div ng-controller="Ctrl"> - <input type="number" ng-model="amount"> <br> - default currency symbol ($): <span id="currency-default">{{amount | currency}}</span><br> - custom currency identifier (USD$): <span>{{amount | currency:"USD$"}}</span> - </div> - </file> - <file name="protractor.js" type="protractor"> - it('should init with 1234.56', function() { - expect(element(by.id('currency-default')).getText()).toBe('$1,234.56'); - expect(element(by.binding('amount | currency:"USD$"')).getText()).toBe('USD$1,234.56'); - }); - it('should update', function() { - if (browser.params.browser == 'safari') { - // Safari does not understand the minus key. See - // https://github.com/angular/protractor/issues/481 - return; - } - element(by.model('amount')).clear(); - element(by.model('amount')).sendKeys('-1234'); - expect(element(by.id('currency-default')).getText()).toBe('($1,234.00)'); - expect(element(by.binding('amount | currency:"USD$"')).getText()).toBe('(USD$1,234.00)'); - }); - </file> - </example> - */ - currencyFilter.$inject = ['$locale']; - function currencyFilter($locale) { - var formats = $locale.NUMBER_FORMATS; - return function(amount, currencySymbol){ - if (isUndefined(currencySymbol)) currencySymbol = formats.CURRENCY_SYM; - return formatNumber(amount, formats.PATTERNS[1], formats.GROUP_SEP, formats.DECIMAL_SEP, 2). - replace(/\u00A4/g, currencySymbol); - }; - } + // currently protractor/webdriver does not support + // sending keys to all known HTML5 input controls + // for various browsers (https://github.com/angular/protractor/issues/562). + function setInput(val) { + // set the value of the element and force validation. + var scr = "var ipt = document.getElementById('exampleInput'); " + + "ipt.value = '" + val + "';" + + "angular.element(ipt).scope().$apply(function(s) { s.myForm[ipt.name].$setViewValue('" + val + "'); });"; + browser.executeScript(scr); + } - /** - * @ngdoc filter - * @name number - * @function - * - * @description - * Formats a number as text. - * - * If the input is not a number an empty string is returned. - * - * @param {number|string} number Number to format. - * @param {(number|string)=} fractionSize Number of decimal places to round the number to. - * If this is not provided then the fraction size is computed from the current locale's number - * formatting pattern. In the case of the default locale, it will be 3. - * @returns {string} Number rounded to decimalPlaces and places a “,” after each third digit. - * - * @example - <example> - <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.val = 1234.56789; - } - </script> - <div ng-controller="Ctrl"> - Enter number: <input ng-model='val'><br> - Default formatting: <span id='number-default'>{{val | number}}</span><br> - No fractions: <span>{{val | number:0}}</span><br> - Negative number: <span>{{-val | number:4}}</span> - </div> - </file> - <file name="protractor.js" type="protractor"> - it('should format numbers', function() { - expect(element(by.id('number-default')).getText()).toBe('1,234.568'); - expect(element(by.binding('val | number:0')).getText()).toBe('1,235'); - expect(element(by.binding('-val | number:4')).getText()).toBe('-1,234.5679'); - }); + it('should initialize to model', function() { + expect(value.getText()).toContain('2013-W01'); + expect(valid.getText()).toContain('myForm.input.$valid = true'); + }); - it('should update', function() { - element(by.model('val')).clear(); - element(by.model('val')).sendKeys('3374.333'); - expect(element(by.id('number-default')).getText()).toBe('3,374.333'); - expect(element(by.binding('val | number:0')).getText()).toBe('3,374'); - expect(element(by.binding('-val | number:4')).getText()).toBe('-3,374.3330'); + it('should be invalid if empty', function() { + setInput(''); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('myForm.input.$valid = false'); }); - </file> - </example> - */ + it('should be invalid if over max', function() { + setInput('2015-W01'); + expect(value.getText()).toContain(''); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + </file> + </example> + */ + 'week': createDateInputType('week', WEEK_REGEXP, weekParser, 'yyyy-Www'), - numberFilter.$inject = ['$locale']; - function numberFilter($locale) { - var formats = $locale.NUMBER_FORMATS; - return function(number, fractionSize) { - return formatNumber(number, formats.PATTERNS[0], formats.GROUP_SEP, formats.DECIMAL_SEP, - fractionSize); - }; - } + /** + * @ngdoc input + * @name input[month] + * + * @description + * Input with month validation and transformation. In browsers that do not yet support + * the HTML5 month input, a text element will be used. In that case, the text must be entered in a valid ISO-8601 + * month format (yyyy-MM), for example: `2009-01`. + * + * The model must always be a Date object, otherwise AngularJS will throw an error. + * Invalid `Date` objects (dates whose `getTime()` is `NaN`) will be rendered as an empty string. + * If the model is not set to the first of the month, the next view to model update will set it + * to the first of the month. + * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * + * @param {string} ngModel Assignable AngularJS expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. + * This must be a valid ISO month format (yyyy-MM). You can also use interpolation inside this + * attribute (e.g. `min="{{minMonth | date:'yyyy-MM'}}"`). Note that `min` will also add + * native HTML5 constraint validation. + * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. + * This must be a valid ISO month format (yyyy-MM). You can also use interpolation inside this + * attribute (e.g. `max="{{maxMonth | date:'yyyy-MM'}}"`). Note that `max` will also add + * native HTML5 constraint validation. + * @param {(date|string)=} ngMin Sets the `min` validation constraint to the Date / ISO week string + * the `ngMin` expression evaluates to. Note that it does not set the `min` attribute. + * @param {(date|string)=} ngMax Sets the `max` validation constraint to the Date / ISO week string + * the `ngMax` expression evaluates to. Note that it does not set the `max` attribute. - var DECIMAL_SEP = '.'; - function formatNumber(number, pattern, groupSep, decimalSep, fractionSize) { - if (number == null || !isFinite(number) || isObject(number)) return ''; - - var isNegative = number < 0; - number = Math.abs(number); - var numStr = number + '', - formatedText = '', - parts = []; - - var hasExponent = false; - if (numStr.indexOf('e') !== -1) { - var match = numStr.match(/([\d\.]+)e(-?)(\d+)/); - if (match && match[2] == '-' && match[3] > fractionSize + 1) { - numStr = '0'; - } else { - formatedText = numStr; - hasExponent = true; - } - } + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {string=} ngChange AngularJS expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + <example name="month-input-directive" module="monthExample"> + <file name="index.html"> + <script> + angular.module('monthExample', []) + .controller('DateController', ['$scope', function($scope) { + $scope.example = { + value: new Date(2013, 9, 1) + }; + }]); + </script> + <form name="myForm" ng-controller="DateController as dateCtrl"> + <label for="exampleInput">Pick a month in 2013:</label> + <input id="exampleInput" type="month" name="input" ng-model="example.value" + placeholder="yyyy-MM" min="2013-01" max="2013-12" required /> + <div role="alert"> + <span class="error" ng-show="myForm.input.$error.required"> + Required!</span> + <span class="error" ng-show="myForm.input.$error.month"> + Not a valid month!</span> + </div> + <tt>value = {{example.value | date: "yyyy-MM"}}</tt><br/> + <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> + <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> + <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> + <tt>myForm.$error.required = {{!!myForm.$error.required}}</tt><br/> + </form> + </file> + <file name="protractor.js" type="protractor"> + var value = element(by.binding('example.value | date: "yyyy-MM"')); + var valid = element(by.binding('myForm.input.$valid')); - if (!hasExponent) { - var fractionLen = (numStr.split(DECIMAL_SEP)[1] || '').length; + // currently protractor/webdriver does not support + // sending keys to all known HTML5 input controls + // for various browsers (https://github.com/angular/protractor/issues/562). + function setInput(val) { + // set the value of the element and force validation. + var scr = "var ipt = document.getElementById('exampleInput'); " + + "ipt.value = '" + val + "';" + + "angular.element(ipt).scope().$apply(function(s) { s.myForm[ipt.name].$setViewValue('" + val + "'); });"; + browser.executeScript(scr); + } - // determine fractionSize if it is not specified - if (isUndefined(fractionSize)) { - fractionSize = Math.min(Math.max(pattern.minFrac, fractionLen), pattern.maxFrac); - } + it('should initialize to model', function() { + expect(value.getText()).toContain('2013-10'); + expect(valid.getText()).toContain('myForm.input.$valid = true'); + }); - var pow = Math.pow(10, fractionSize); - number = Math.round(number * pow) / pow; - var fraction = ('' + number).split(DECIMAL_SEP); - var whole = fraction[0]; - fraction = fraction[1] || ''; + it('should be invalid if empty', function() { + setInput(''); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); - var i, pos = 0, - lgroup = pattern.lgSize, - group = pattern.gSize; + it('should be invalid if over max', function() { + setInput('2015-01'); + expect(value.getText()).toContain(''); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + </file> + </example> + */ + 'month': createDateInputType('month', MONTH_REGEXP, + createDateParser(MONTH_REGEXP, ['yyyy', 'MM']), + 'yyyy-MM'), - if (whole.length >= (lgroup + group)) { - pos = whole.length - lgroup; - for (i = 0; i < pos; i++) { - if ((pos - i)%group === 0 && i !== 0) { - formatedText += groupSep; - } - formatedText += whole.charAt(i); - } - } + /** + * @ngdoc input + * @name input[number] + * + * @description + * Text input with number validation and transformation. Sets the `number` validation + * error if not a valid number. + * + * <div class="alert alert-warning"> + * The model must always be of type `number` otherwise AngularJS will throw an error. + * Be aware that a string containing a number is not enough. See the {@link ngModel:numfmt} + * error docs for more information and an example of how to convert your model if necessary. + * </div> + * + * ## Issues with HTML5 constraint validation + * + * In browsers that follow the + * [HTML5 specification](https://html.spec.whatwg.org/multipage/forms.html#number-state-%28type=number%29), + * `input[number]` does not work as expected with {@link ngModelOptions `ngModelOptions.allowInvalid`}. + * If a non-number is entered in the input, the browser will report the value as an empty string, + * which means the view / model values in `ngModel` and subsequently the scope value + * will also be an empty string. + * + * + * @param {string} ngModel Assignable AngularJS expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. + * Can be interpolated. + * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. + * Can be interpolated. + * @param {string=} ngMin Like `min`, sets the `min` validation error key if the value entered is less than `ngMin`, + * but does not trigger HTML5 native validation. Takes an expression. + * @param {string=} ngMax Like `max`, sets the `max` validation error key if the value entered is greater than `ngMax`, + * but does not trigger HTML5 native validation. Takes an expression. + * @param {string=} step Sets the `step` validation error key if the value entered does not fit the `step` constraint. + * Can be interpolated. + * @param {string=} ngStep Like `step`, sets the `step` validation error key if the value entered does not fit the `ngStep` constraint, + * but does not trigger HTML5 native validation. Takes an expression. + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than + * minlength. + * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than + * maxlength. Setting the attribute to a negative or non-numeric value, allows view values of + * any length. + * @param {string=} pattern Similar to `ngPattern` except that the attribute value is the actual string + * that contains the regular expression body that will be converted to a regular expression + * as in the ngPattern directive. + * @param {string=} ngPattern Sets `pattern` validation error key if the ngModel {@link ngModel.NgModelController#$viewValue $viewValue} + * does not match a RegExp found by evaluating the AngularJS expression given in the attribute value. + * If the expression evaluates to a RegExp object, then this is used directly. + * If the expression evaluates to a string, then it will be converted to a RegExp + * after wrapping it in `^` and `$` characters. For instance, `"abc"` will be converted to + * `new RegExp('^abc$')`.<br /> + * **Note:** Avoid using the `g` flag on the RegExp, as it will cause each successive search to + * start at the index of the last search's match, thus not taking the whole input value into + * account. + * @param {string=} ngChange AngularJS expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + <example name="number-input-directive" module="numberExample"> + <file name="index.html"> + <script> + angular.module('numberExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.example = { + value: 12 + }; + }]); + </script> + <form name="myForm" ng-controller="ExampleController"> + <label>Number: + <input type="number" name="input" ng-model="example.value" + min="0" max="99" required> + </label> + <div role="alert"> + <span class="error" ng-show="myForm.input.$error.required"> + Required!</span> + <span class="error" ng-show="myForm.input.$error.number"> + Not valid number!</span> + </div> + <tt>value = {{example.value}}</tt><br/> + <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> + <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> + <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> + <tt>myForm.$error.required = {{!!myForm.$error.required}}</tt><br/> + </form> + </file> + <file name="protractor.js" type="protractor"> + var value = element(by.binding('example.value')); + var valid = element(by.binding('myForm.input.$valid')); + var input = element(by.model('example.value')); - for (i = pos; i < whole.length; i++) { - if ((whole.length - i)%lgroup === 0 && i !== 0) { - formatedText += groupSep; - } - formatedText += whole.charAt(i); - } + it('should initialize to model', function() { + expect(value.getText()).toContain('12'); + expect(valid.getText()).toContain('true'); + }); - // format fraction part. - while(fraction.length < fractionSize) { - fraction += '0'; - } + it('should be invalid if empty', function() { + input.clear(); + input.sendKeys(''); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('false'); + }); - if (fractionSize && fractionSize !== "0") formatedText += decimalSep + fraction.substr(0, fractionSize); - } else { + it('should be invalid if over max', function() { + input.clear(); + input.sendKeys('123'); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('false'); + }); + </file> + </example> + */ + 'number': numberInputType, - if (fractionSize > 0 && number > -1 && number < 1) { - formatedText = number.toFixed(fractionSize); - } - } - parts.push(isNegative ? pattern.negPre : pattern.posPre); - parts.push(formatedText); - parts.push(isNegative ? pattern.negSuf : pattern.posSuf); - return parts.join(''); - } + /** + * @ngdoc input + * @name input[url] + * + * @description + * Text input with URL validation. Sets the `url` validation error key if the content is not a + * valid URL. + * + * <div class="alert alert-warning"> + * **Note:** `input[url]` uses a regex to validate urls that is derived from the regex + * used in Chromium. If you need stricter validation, you can use `ng-pattern` or modify + * the built-in validators (see the {@link guide/forms Forms guide}) + * </div> + * + * @param {string} ngModel Assignable AngularJS expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than + * minlength. + * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than + * maxlength. Setting the attribute to a negative or non-numeric value, allows view values of + * any length. + * @param {string=} pattern Similar to `ngPattern` except that the attribute value is the actual string + * that contains the regular expression body that will be converted to a regular expression + * as in the ngPattern directive. + * @param {string=} ngPattern Sets `pattern` validation error key if the ngModel {@link ngModel.NgModelController#$viewValue $viewValue} + * does not match a RegExp found by evaluating the AngularJS expression given in the attribute value. + * If the expression evaluates to a RegExp object, then this is used directly. + * If the expression evaluates to a string, then it will be converted to a RegExp + * after wrapping it in `^` and `$` characters. For instance, `"abc"` will be converted to + * `new RegExp('^abc$')`.<br /> + * **Note:** Avoid using the `g` flag on the RegExp, as it will cause each successive search to + * start at the index of the last search's match, thus not taking the whole input value into + * account. + * @param {string=} ngChange AngularJS expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + <example name="url-input-directive" module="urlExample"> + <file name="index.html"> + <script> + angular.module('urlExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.url = { + text: 'http://google.com' + }; + }]); + </script> + <form name="myForm" ng-controller="ExampleController"> + <label>URL: + <input type="url" name="input" ng-model="url.text" required> + <label> + <div role="alert"> + <span class="error" ng-show="myForm.input.$error.required"> + Required!</span> + <span class="error" ng-show="myForm.input.$error.url"> + Not valid url!</span> + </div> + <tt>text = {{url.text}}</tt><br/> + <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> + <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> + <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> + <tt>myForm.$error.required = {{!!myForm.$error.required}}</tt><br/> + <tt>myForm.$error.url = {{!!myForm.$error.url}}</tt><br/> + </form> + </file> + <file name="protractor.js" type="protractor"> + var text = element(by.binding('url.text')); + var valid = element(by.binding('myForm.input.$valid')); + var input = element(by.model('url.text')); + + it('should initialize to model', function() { + expect(text.getText()).toContain('http://google.com'); + expect(valid.getText()).toContain('true'); + }); - function padNumber(num, digits, trim) { - var neg = ''; - if (num < 0) { - neg = '-'; - num = -num; - } - num = '' + num; - while(num.length < digits) num = '0' + num; - if (trim) - num = num.substr(num.length - digits); - return neg + num; - } + it('should be invalid if empty', function() { + input.clear(); + input.sendKeys(''); + expect(text.getText()).toEqual('text ='); + expect(valid.getText()).toContain('false'); + }); - function dateGetter(name, size, offset, trim) { - offset = offset || 0; - return function(date) { - var value = date['get' + name](); - if (offset > 0 || value > -offset) - value += offset; - if (value === 0 && offset == -12 ) value = 12; - return padNumber(value, size, trim); - }; - } + it('should be invalid if not url', function() { + input.clear(); + input.sendKeys('box'); - function dateStrGetter(name, shortForm) { - return function(date, formats) { - var value = date['get' + name](); - var get = uppercase(shortForm ? ('SHORT' + name) : name); + expect(valid.getText()).toContain('false'); + }); + </file> + </example> + */ + 'url': urlInputType, - return formats[get][value]; - }; - } - function timeZoneGetter(date) { - var zone = -1 * date.getTimezoneOffset(); - var paddedZone = (zone >= 0) ? "+" : ""; + /** + * @ngdoc input + * @name input[email] + * + * @description + * Text input with email validation. Sets the `email` validation error key if not a valid email + * address. + * + * <div class="alert alert-warning"> + * **Note:** `input[email]` uses a regex to validate email addresses that is derived from the regex + * used in Chromium. If you need stricter validation (e.g. requiring a top-level domain), you can + * use `ng-pattern` or modify the built-in validators (see the {@link guide/forms Forms guide}) + * </div> + * + * @param {string} ngModel Assignable AngularJS expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than + * minlength. + * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than + * maxlength. Setting the attribute to a negative or non-numeric value, allows view values of + * any length. + * @param {string=} pattern Similar to `ngPattern` except that the attribute value is the actual string + * that contains the regular expression body that will be converted to a regular expression + * as in the ngPattern directive. + * @param {string=} ngPattern Sets `pattern` validation error key if the ngModel {@link ngModel.NgModelController#$viewValue $viewValue} + * does not match a RegExp found by evaluating the AngularJS expression given in the attribute value. + * If the expression evaluates to a RegExp object, then this is used directly. + * If the expression evaluates to a string, then it will be converted to a RegExp + * after wrapping it in `^` and `$` characters. For instance, `"abc"` will be converted to + * `new RegExp('^abc$')`.<br /> + * **Note:** Avoid using the `g` flag on the RegExp, as it will cause each successive search to + * start at the index of the last search's match, thus not taking the whole input value into + * account. + * @param {string=} ngChange AngularJS expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + <example name="email-input-directive" module="emailExample"> + <file name="index.html"> + <script> + angular.module('emailExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.email = { + text: 'me@example.com' + }; + }]); + </script> + <form name="myForm" ng-controller="ExampleController"> + <label>Email: + <input type="email" name="input" ng-model="email.text" required> + </label> + <div role="alert"> + <span class="error" ng-show="myForm.input.$error.required"> + Required!</span> + <span class="error" ng-show="myForm.input.$error.email"> + Not valid email!</span> + </div> + <tt>text = {{email.text}}</tt><br/> + <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> + <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> + <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> + <tt>myForm.$error.required = {{!!myForm.$error.required}}</tt><br/> + <tt>myForm.$error.email = {{!!myForm.$error.email}}</tt><br/> + </form> + </file> + <file name="protractor.js" type="protractor"> + var text = element(by.binding('email.text')); + var valid = element(by.binding('myForm.input.$valid')); + var input = element(by.model('email.text')); - paddedZone += padNumber(Math[zone > 0 ? 'floor' : 'ceil'](zone / 60), 2) + - padNumber(Math.abs(zone % 60), 2); + it('should initialize to model', function() { + expect(text.getText()).toContain('me@example.com'); + expect(valid.getText()).toContain('true'); + }); - return paddedZone; - } + it('should be invalid if empty', function() { + input.clear(); + input.sendKeys(''); + expect(text.getText()).toEqual('text ='); + expect(valid.getText()).toContain('false'); + }); - function ampmGetter(date, formats) { - return date.getHours() < 12 ? formats.AMPMS[0] : formats.AMPMS[1]; - } + it('should be invalid if not email', function() { + input.clear(); + input.sendKeys('xxx'); - var DATE_FORMATS = { - yyyy: dateGetter('FullYear', 4), - yy: dateGetter('FullYear', 2, 0, true), - y: dateGetter('FullYear', 1), - MMMM: dateStrGetter('Month'), - MMM: dateStrGetter('Month', true), - MM: dateGetter('Month', 2, 1), - M: dateGetter('Month', 1, 1), - dd: dateGetter('Date', 2), - d: dateGetter('Date', 1), - HH: dateGetter('Hours', 2), - H: dateGetter('Hours', 1), - hh: dateGetter('Hours', 2, -12), - h: dateGetter('Hours', 1, -12), - mm: dateGetter('Minutes', 2), - m: dateGetter('Minutes', 1), - ss: dateGetter('Seconds', 2), - s: dateGetter('Seconds', 1), - // while ISO 8601 requires fractions to be prefixed with `.` or `,` - // we can be just safely rely on using `sss` since we currently don't support single or two digit fractions - sss: dateGetter('Milliseconds', 3), - EEEE: dateStrGetter('Day'), - EEE: dateStrGetter('Day', true), - a: ampmGetter, - Z: timeZoneGetter - }; + expect(valid.getText()).toContain('false'); + }); + </file> + </example> + */ + 'email': emailInputType, - var DATE_FORMATS_SPLIT = /((?:[^yMdHhmsaZE']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d+|H+|h+|m+|s+|a|Z))(.*)/, - NUMBER_STRING = /^\-?\d+$/; - /** - * @ngdoc filter - * @name date - * @function - * - * @description - * Formats `date` to a string based on the requested `format`. - * - * `format` string can be composed of the following elements: - * - * * `'yyyy'`: 4 digit representation of year (e.g. AD 1 => 0001, AD 2010 => 2010) - * * `'yy'`: 2 digit representation of year, padded (00-99). (e.g. AD 2001 => 01, AD 2010 => 10) - * * `'y'`: 1 digit representation of year, e.g. (AD 1 => 1, AD 199 => 199) - * * `'MMMM'`: Month in year (January-December) - * * `'MMM'`: Month in year (Jan-Dec) - * * `'MM'`: Month in year, padded (01-12) - * * `'M'`: Month in year (1-12) - * * `'dd'`: Day in month, padded (01-31) - * * `'d'`: Day in month (1-31) - * * `'EEEE'`: Day in Week,(Sunday-Saturday) - * * `'EEE'`: Day in Week, (Sun-Sat) - * * `'HH'`: Hour in day, padded (00-23) - * * `'H'`: Hour in day (0-23) - * * `'hh'`: Hour in am/pm, padded (01-12) - * * `'h'`: Hour in am/pm, (1-12) - * * `'mm'`: Minute in hour, padded (00-59) - * * `'m'`: Minute in hour (0-59) - * * `'ss'`: Second in minute, padded (00-59) - * * `'s'`: Second in minute (0-59) - * * `'.sss' or ',sss'`: Millisecond in second, padded (000-999) - * * `'a'`: am/pm marker - * * `'Z'`: 4 digit (+sign) representation of the timezone offset (-1200-+1200) - * - * `format` string can also be one of the following predefined - * {@link guide/i18n localizable formats}: - * - * * `'medium'`: equivalent to `'MMM d, y h:mm:ss a'` for en_US locale - * (e.g. Sep 3, 2010 12:05:08 pm) - * * `'short'`: equivalent to `'M/d/yy h:mm a'` for en_US locale (e.g. 9/3/10 12:05 pm) - * * `'fullDate'`: equivalent to `'EEEE, MMMM d,y'` for en_US locale - * (e.g. Friday, September 3, 2010) - * * `'longDate'`: equivalent to `'MMMM d, y'` for en_US locale (e.g. September 3, 2010) - * * `'mediumDate'`: equivalent to `'MMM d, y'` for en_US locale (e.g. Sep 3, 2010) - * * `'shortDate'`: equivalent to `'M/d/yy'` for en_US locale (e.g. 9/3/10) - * * `'mediumTime'`: equivalent to `'h:mm:ss a'` for en_US locale (e.g. 12:05:08 pm) - * * `'shortTime'`: equivalent to `'h:mm a'` for en_US locale (e.g. 12:05 pm) - * - * `format` string can contain literal values. These need to be quoted with single quotes (e.g. - * `"h 'in the morning'"`). In order to output single quote, use two single quotes in a sequence - * (e.g. `"h 'o''clock'"`). - * - * @param {(Date|number|string)} date Date to format either as Date object, milliseconds (string or - * number) or various ISO 8601 datetime string formats (e.g. yyyy-MM-ddTHH:mm:ss.SSSZ and its - * shorter versions like yyyy-MM-ddTHH:mmZ, yyyy-MM-dd or yyyyMMddTHHmmssZ). If no timezone is - * specified in the string input, the time is considered to be in the local timezone. - * @param {string=} format Formatting rules (see Description). If not specified, - * `mediumDate` is used. - * @returns {string} Formatted string or the input if input is not recognized as date/millis. - * - * @example - <example> - <file name="index.html"> - <span ng-non-bindable>{{1288323623006 | date:'medium'}}</span>: - <span>{{1288323623006 | date:'medium'}}</span><br> - <span ng-non-bindable>{{1288323623006 | date:'yyyy-MM-dd HH:mm:ss Z'}}</span>: - <span>{{1288323623006 | date:'yyyy-MM-dd HH:mm:ss Z'}}</span><br> - <span ng-non-bindable>{{1288323623006 | date:'MM/dd/yyyy @ h:mma'}}</span>: - <span>{{'1288323623006' | date:'MM/dd/yyyy @ h:mma'}}</span><br> - </file> - <file name="protractor.js" type="protractor"> - it('should format date', function() { - expect(element(by.binding("1288323623006 | date:'medium'")).getText()). - toMatch(/Oct 2\d, 2010 \d{1,2}:\d{2}:\d{2} (AM|PM)/); - expect(element(by.binding("1288323623006 | date:'yyyy-MM-dd HH:mm:ss Z'")).getText()). - toMatch(/2010\-10\-2\d \d{2}:\d{2}:\d{2} (\-|\+)?\d{4}/); - expect(element(by.binding("'1288323623006' | date:'MM/dd/yyyy @ h:mma'")).getText()). - toMatch(/10\/2\d\/2010 @ \d{1,2}:\d{2}(AM|PM)/); - }); - </file> - </example> - */ - dateFilter.$inject = ['$locale']; - function dateFilter($locale) { + /** + * @ngdoc input + * @name input[radio] + * + * @description + * HTML radio button. + * + * @param {string} ngModel Assignable AngularJS expression to data-bind to. + * @param {string} value The value to which the `ngModel` expression should be set when selected. + * Note that `value` only supports `string` values, i.e. the scope model needs to be a string, + * too. Use `ngValue` if you need complex models (`number`, `object`, ...). + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} ngChange AngularJS expression to be executed when input changes due to user + * interaction with the input element. + * @param {string} ngValue AngularJS expression to which `ngModel` will be be set when the radio + * is selected. Should be used instead of the `value` attribute if you need + * a non-string `ngModel` (`boolean`, `array`, ...). + * + * @example + <example name="radio-input-directive" module="radioExample"> + <file name="index.html"> + <script> + angular.module('radioExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.color = { + name: 'blue' + }; + $scope.specialValue = { + "id": "12345", + "value": "green" + }; + }]); + </script> + <form name="myForm" ng-controller="ExampleController"> + <label> + <input type="radio" ng-model="color.name" value="red"> + Red + </label><br/> + <label> + <input type="radio" ng-model="color.name" ng-value="specialValue"> + Green + </label><br/> + <label> + <input type="radio" ng-model="color.name" value="blue"> + Blue + </label><br/> + <tt>color = {{color.name | json}}</tt><br/> + </form> + Note that `ng-value="specialValue"` sets radio item's value to be the value of `$scope.specialValue`. + </file> + <file name="protractor.js" type="protractor"> + it('should change state', function() { + var inputs = element.all(by.model('color.name')); + var color = element(by.binding('color.name')); + expect(color.getText()).toContain('blue'); - var R_ISO8601_STR = /^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(Z|([+-])(\d\d):?(\d\d))?)?$/; - // 1 2 3 4 5 6 7 8 9 10 11 - function jsonStringToDate(string) { - var match; - if (match = string.match(R_ISO8601_STR)) { - var date = new Date(0), - tzHour = 0, - tzMin = 0, - dateSetter = match[8] ? date.setUTCFullYear : date.setFullYear, - timeSetter = match[8] ? date.setUTCHours : date.setHours; + inputs.get(0).click(); + expect(color.getText()).toContain('red'); - if (match[9]) { - tzHour = int(match[9] + match[10]); - tzMin = int(match[9] + match[11]); - } - dateSetter.call(date, int(match[1]), int(match[2]) - 1, int(match[3])); - var h = int(match[4]||0) - tzHour; - var m = int(match[5]||0) - tzMin; - var s = int(match[6]||0); - var ms = Math.round(parseFloat('0.' + (match[7]||0)) * 1000); - timeSetter.call(date, h, m, s, ms); - return date; - } - return string; - } + inputs.get(1).click(); + expect(color.getText()).toContain('green'); + }); + </file> + </example> + */ + 'radio': radioInputType, + /** + * @ngdoc input + * @name input[range] + * + * @description + * Native range input with validation and transformation. + * + * The model for the range input must always be a `Number`. + * + * IE9 and other browsers that do not support the `range` type fall back + * to a text input without any default values for `min`, `max` and `step`. Model binding, + * validation and number parsing are nevertheless supported. + * + * Browsers that support range (latest Chrome, Safari, Firefox, Edge) treat `input[range]` + * in a way that never allows the input to hold an invalid value. That means: + * - any non-numerical value is set to `(max + min) / 2`. + * - any numerical value that is less than the current min val, or greater than the current max val + * is set to the min / max val respectively. + * - additionally, the current `step` is respected, so the nearest value that satisfies a step + * is used. + * + * See the [HTML Spec on input[type=range]](https://www.w3.org/TR/html5/forms.html#range-state-(type=range)) + * for more info. + * + * This has the following consequences for AngularJS: + * + * Since the element value should always reflect the current model value, a range input + * will set the bound ngModel expression to the value that the browser has set for the + * input element. For example, in the following input `<input type="range" ng-model="model.value">`, + * if the application sets `model.value = null`, the browser will set the input to `'50'`. + * AngularJS will then set the model to `50`, to prevent input and model value being out of sync. + * + * That means the model for range will immediately be set to `50` after `ngModel` has been + * initialized. It also means a range input can never have the required error. + * + * This does not only affect changes to the model value, but also to the values of the `min`, + * `max`, and `step` attributes. When these change in a way that will cause the browser to modify + * the input value, AngularJS will also update the model value. + * + * Automatic value adjustment also means that a range input element can never have the `required`, + * `min`, or `max` errors. + * + * However, `step` is currently only fully implemented by Firefox. Other browsers have problems + * when the step value changes dynamically - they do not adjust the element value correctly, but + * instead may set the `stepMismatch` error. If that's the case, the AngularJS will set the `step` + * error on the input, and set the model to `undefined`. + * + * Note that `input[range]` is not compatible with`ngMax`, `ngMin`, and `ngStep`, because they do + * not set the `min` and `max` attributes, which means that the browser won't automatically adjust + * the input value based on their values, and will always assume min = 0, max = 100, and step = 1. + * + * @param {string} ngModel Assignable AngularJS expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} min Sets the `min` validation to ensure that the value entered is greater + * than `min`. Can be interpolated. + * @param {string=} max Sets the `max` validation to ensure that the value entered is less than `max`. + * Can be interpolated. + * @param {string=} step Sets the `step` validation to ensure that the value entered matches the `step` + * Can be interpolated. + * @param {string=} ngChange AngularJS expression to be executed when the ngModel value changes due + * to user interaction with the input element. + * @param {expression=} ngChecked If the expression is truthy, then the `checked` attribute will be set on the + * element. **Note** : `ngChecked` should not be used alongside `ngModel`. + * Checkout {@link ng.directive:ngChecked ngChecked} for usage. + * + * @example + <example name="range-input-directive" module="rangeExample"> + <file name="index.html"> + <script> + angular.module('rangeExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.value = 75; + $scope.min = 10; + $scope.max = 90; + }]); + </script> + <form name="myForm" ng-controller="ExampleController"> + + Model as range: <input type="range" name="range" ng-model="value" min="{{min}}" max="{{max}}"> + <hr> + Model as number: <input type="number" ng-model="value"><br> + Min: <input type="number" ng-model="min"><br> + Max: <input type="number" ng-model="max"><br> + value = <code>{{value}}</code><br/> + myForm.range.$valid = <code>{{myForm.range.$valid}}</code><br/> + myForm.range.$error = <code>{{myForm.range.$error}}</code> + </form> + </file> + </example> - return function(date, format) { - var text = '', - parts = [], - fn, match; + * ## Range Input with ngMin & ngMax attributes - format = format || 'mediumDate'; - format = $locale.DATETIME_FORMATS[format] || format; - if (isString(date)) { - if (NUMBER_STRING.test(date)) { - date = int(date); - } else { - date = jsonStringToDate(date); - } - } + * @example + <example name="range-input-directive-ng" module="rangeExample"> + <file name="index.html"> + <script> + angular.module('rangeExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.value = 75; + $scope.min = 10; + $scope.max = 90; + }]); + </script> + <form name="myForm" ng-controller="ExampleController"> + Model as range: <input type="range" name="range" ng-model="value" ng-min="min" ng-max="max"> + <hr> + Model as number: <input type="number" ng-model="value"><br> + Min: <input type="number" ng-model="min"><br> + Max: <input type="number" ng-model="max"><br> + value = <code>{{value}}</code><br/> + myForm.range.$valid = <code>{{myForm.range.$valid}}</code><br/> + myForm.range.$error = <code>{{myForm.range.$error}}</code> + </form> + </file> + </example> - if (isNumber(date)) { - date = new Date(date); - } + */ + 'range': rangeInputType, - if (!isDate(date)) { - return date; - } + /** + * @ngdoc input + * @name input[checkbox] + * + * @description + * HTML checkbox. + * + * @param {string} ngModel Assignable AngularJS expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {expression=} ngTrueValue The value to which the expression should be set when selected. + * @param {expression=} ngFalseValue The value to which the expression should be set when not selected. + * @param {string=} ngChange AngularJS expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + <example name="checkbox-input-directive" module="checkboxExample"> + <file name="index.html"> + <script> + angular.module('checkboxExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.checkboxModel = { + value1 : true, + value2 : 'YES' + }; + }]); + </script> + <form name="myForm" ng-controller="ExampleController"> + <label>Value1: + <input type="checkbox" ng-model="checkboxModel.value1"> + </label><br/> + <label>Value2: + <input type="checkbox" ng-model="checkboxModel.value2" + ng-true-value="'YES'" ng-false-value="'NO'"> + </label><br/> + <tt>value1 = {{checkboxModel.value1}}</tt><br/> + <tt>value2 = {{checkboxModel.value2}}</tt><br/> + </form> + </file> + <file name="protractor.js" type="protractor"> + it('should change state', function() { + var value1 = element(by.binding('checkboxModel.value1')); + var value2 = element(by.binding('checkboxModel.value2')); - while(format) { - match = DATE_FORMATS_SPLIT.exec(format); - if (match) { - parts = concat(parts, match, 1); - format = parts.pop(); - } else { - parts.push(format); - format = null; - } - } + expect(value1.getText()).toContain('true'); + expect(value2.getText()).toContain('YES'); - forEach(parts, function(value){ - fn = DATE_FORMATS[value]; - text += fn ? fn(date, $locale.DATETIME_FORMATS) - : value.replace(/(^'|'$)/g, '').replace(/''/g, "'"); - }); + element(by.model('checkboxModel.value1')).click(); + element(by.model('checkboxModel.value2')).click(); - return text; - }; - } + expect(value1.getText()).toContain('false'); + expect(value2.getText()).toContain('NO'); + }); + </file> + </example> + */ + 'checkbox': checkboxInputType, + 'hidden': noop, + 'button': noop, + 'submit': noop, + 'reset': noop, + 'file': noop + }; - /** - * @ngdoc filter - * @name json - * @function - * - * @description - * Allows you to convert a JavaScript object into JSON string. - * - * This filter is mostly useful for debugging. When using the double curly {{value}} notation - * the binding is automatically converted to JSON. - * - * @param {*} object Any JavaScript object (including arrays and primitive types) to filter. - * @returns {string} JSON string. - * - * - * @example - <example> - <file name="index.html"> - <pre>{{ {'name':'value'} | json }}</pre> - </file> - <file name="protractor.js" type="protractor"> - it('should jsonify filtered objects', function() { - expect(element(by.binding("{'name':'value'}")).getText()).toMatch(/\{\n "name": ?"value"\n}/); - }); - </file> - </example> - * - */ - function jsonFilter() { - return function(object) { - return toJson(object, true); - }; + function stringBasedInputType(ctrl) { + ctrl.$formatters.push(function(value) { + return ctrl.$isEmpty(value) ? value : value.toString(); + }); } + function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + stringBasedInputType(ctrl); + } - /** - * @ngdoc filter - * @name lowercase - * @function - * @description - * Converts string to lowercase. - * @see angular.lowercase - */ - var lowercaseFilter = valueFn(lowercase); - - - /** - * @ngdoc filter - * @name uppercase - * @function - * @description - * Converts string to uppercase. - * @see angular.uppercase - */ - var uppercaseFilter = valueFn(uppercase); - - /** - * @ngdoc filter - * @name limitTo - * @function - * - * @description - * Creates a new array or string containing only a specified number of elements. The elements - * are taken from either the beginning or the end of the source array or string, as specified by - * the value and sign (positive or negative) of `limit`. - * - * @param {Array|string} input Source array or string to be limited. - * @param {string|number} limit The length of the returned array or string. If the `limit` number - * is positive, `limit` number of items from the beginning of the source array/string are copied. - * If the number is negative, `limit` number of items from the end of the source array/string - * are copied. The `limit` will be trimmed if it exceeds `array.length` - * @returns {Array|string} A new sub-array or substring of length `limit` or less if input array - * had less than `limit` elements. - * - * @example - <example> - <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.numbers = [1,2,3,4,5,6,7,8,9]; - $scope.letters = "abcdefghi"; - $scope.numLimit = 3; - $scope.letterLimit = 3; - } - </script> - <div ng-controller="Ctrl"> - Limit {{numbers}} to: <input type="integer" ng-model="numLimit"> - <p>Output numbers: {{ numbers | limitTo:numLimit }}</p> - Limit {{letters}} to: <input type="integer" ng-model="letterLimit"> - <p>Output letters: {{ letters | limitTo:letterLimit }}</p> - </div> - </file> - <file name="protractor.js" type="protractor"> - var numLimitInput = element(by.model('numLimit')); - var letterLimitInput = element(by.model('letterLimit')); - var limitedNumbers = element(by.binding('numbers | limitTo:numLimit')); - var limitedLetters = element(by.binding('letters | limitTo:letterLimit')); + function baseInputType(scope, element, attr, ctrl, $sniffer, $browser) { + var type = lowercase(element[0].type); - it('should limit the number array to first three items', function() { - expect(numLimitInput.getAttribute('value')).toBe('3'); - expect(letterLimitInput.getAttribute('value')).toBe('3'); - expect(limitedNumbers.getText()).toEqual('Output numbers: [1,2,3]'); - expect(limitedLetters.getText()).toEqual('Output letters: abc'); - }); + // In composition mode, users are still inputting intermediate text buffer, + // hold the listener until composition is done. + // More about composition events: https://developer.mozilla.org/en-US/docs/Web/API/CompositionEvent + if (!$sniffer.android) { + var composing = false; - it('should update the output when -3 is entered', function() { - numLimitInput.clear(); - numLimitInput.sendKeys('-3'); - letterLimitInput.clear(); - letterLimitInput.sendKeys('-3'); - expect(limitedNumbers.getText()).toEqual('Output numbers: [7,8,9]'); - expect(limitedLetters.getText()).toEqual('Output letters: ghi'); - }); + element.on('compositionstart', function() { + composing = true; + }); - it('should not exceed the maximum size of input array', function() { - numLimitInput.clear(); - numLimitInput.sendKeys('100'); - letterLimitInput.clear(); - letterLimitInput.sendKeys('100'); - expect(limitedNumbers.getText()).toEqual('Output numbers: [1,2,3,4,5,6,7,8,9]'); - expect(limitedLetters.getText()).toEqual('Output letters: abcdefghi'); - }); - </file> - </example> - */ - function limitToFilter(){ - return function(input, limit) { - if (!isArray(input) && !isString(input)) return input; + element.on('compositionend', function() { + composing = false; + listener(); + }); + } - limit = int(limit); + var timeout; - if (isString(input)) { - //NaN check on limit - if (limit) { - return limit >= 0 ? input.slice(0, limit) : input.slice(limit, input.length); - } else { - return ""; - } + var listener = function(ev) { + if (timeout) { + $browser.defer.cancel(timeout); + timeout = null; } + if (composing) return; + var value = element.val(), + event = ev && ev.type; - var out = [], - i, n; - - // if abs(limit) exceeds maximum length, trim it - if (limit > input.length) - limit = input.length; - else if (limit < -input.length) - limit = -input.length; - - if (limit > 0) { - i = 0; - n = limit; - } else { - i = input.length + limit; - n = input.length; + // By default we will trim the value + // If the attribute ng-trim exists we will avoid trimming + // If input type is 'password', the value is never trimmed + if (type !== 'password' && (!attr.ngTrim || attr.ngTrim !== 'false')) { + value = trim(value); } - for (; i<n; i++) { - out.push(input[i]); + // If a control is suffering from bad input (due to native validators), browsers discard its + // value, so it may be necessary to revalidate (by calling $setViewValue again) even if the + // control's value is the same empty value twice in a row. + if (ctrl.$viewValue !== value || (value === '' && ctrl.$$hasNativeValidators)) { + ctrl.$setViewValue(value, event); } - - return out; }; - } - /** - * @ngdoc filter - * @name orderBy - * @function - * - * @description - * Orders a specified `array` by the `expression` predicate. - * - * @param {Array} array The array to sort. - * @param {function(*)|string|Array.<(function(*)|string)>} expression A predicate to be - * used by the comparator to determine the order of elements. - * - * Can be one of: - * - * - `function`: Getter function. The result of this function will be sorted using the - * `<`, `=`, `>` operator. - * - `string`: An Angular expression which evaluates to an object to order by, such as 'name' - * to sort by a property called 'name'. Optionally prefixed with `+` or `-` to control - * ascending or descending sort order (for example, +name or -name). - * - `Array`: An array of function or string predicates. The first predicate in the array - * is used for sorting, but when two items are equivalent, the next predicate is used. - * - * @param {boolean=} reverse Reverse the order of the array. - * @returns {Array} Sorted copy of the source array. - * - * @example - <example> - <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.friends = - [{name:'John', phone:'555-1212', age:10}, - {name:'Mary', phone:'555-9876', age:19}, - {name:'Mike', phone:'555-4321', age:21}, - {name:'Adam', phone:'555-5678', age:35}, - {name:'Julie', phone:'555-8765', age:29}] - $scope.predicate = '-age'; - } - </script> - <div ng-controller="Ctrl"> - <pre>Sorting predicate = {{predicate}}; reverse = {{reverse}}</pre> - <hr/> - [ <a href="" ng-click="predicate=''">unsorted</a> ] - <table class="friend"> - <tr> - <th><a href="" ng-click="predicate = 'name'; reverse=false">Name</a> - (<a href="" ng-click="predicate = '-name'; reverse=false">^</a>)</th> - <th><a href="" ng-click="predicate = 'phone'; reverse=!reverse">Phone Number</a></th> - <th><a href="" ng-click="predicate = 'age'; reverse=!reverse">Age</a></th> - </tr> - <tr ng-repeat="friend in friends | orderBy:predicate:reverse"> - <td>{{friend.name}}</td> - <td>{{friend.phone}}</td> - <td>{{friend.age}}</td> - </tr> - </table> - </div> - </file> - </example> - */ - orderByFilter.$inject = ['$parse']; - function orderByFilter($parse){ - return function(array, sortPredicate, reverseOrder) { - if (!isArray(array)) return array; - if (!sortPredicate) return array; - sortPredicate = isArray(sortPredicate) ? sortPredicate: [sortPredicate]; - sortPredicate = map(sortPredicate, function(predicate){ - var descending = false, get = predicate || identity; - if (isString(predicate)) { - if ((predicate.charAt(0) == '+' || predicate.charAt(0) == '-')) { - descending = predicate.charAt(0) == '-'; - predicate = predicate.substring(1); - } - get = $parse(predicate); - if (get.constant) { - var key = get(); - return reverseComparator(function(a,b) { - return compare(a[key], b[key]); - }, descending); - } - } - return reverseComparator(function(a,b){ - return compare(get(a),get(b)); - }, descending); - }); - var arrayCopy = []; - for ( var i = 0; i < array.length; i++) { arrayCopy.push(array[i]); } - return arrayCopy.sort(reverseComparator(comparator, reverseOrder)); - - function comparator(o1, o2){ - for ( var i = 0; i < sortPredicate.length; i++) { - var comp = sortPredicate[i](o1, o2); - if (comp !== 0) return comp; - } - return 0; - } - function reverseComparator(comp, descending) { - return toBoolean(descending) - ? function(a,b){return comp(b,a);} - : comp; - } - function compare(v1, v2){ - var t1 = typeof v1; - var t2 = typeof v2; - if (t1 == t2) { - if (t1 == "string") { - v1 = v1.toLowerCase(); - v2 = v2.toLowerCase(); - } - if (v1 === v2) return 0; - return v1 < v2 ? -1 : 1; - } else { - return t1 < t2 ? -1 : 1; + // if the browser does support "input" event, we are fine - except on IE9 which doesn't fire the + // input event on backspace, delete or cut + if ($sniffer.hasEvent('input')) { + element.on('input', listener); + } else { + var deferListener = function(ev, input, origValue) { + if (!timeout) { + timeout = $browser.defer(function() { + timeout = null; + if (!input || input.value !== origValue) { + listener(ev); + } + }); } - } - }; - } - - function ngDirective(directive) { - if (isFunction(directive)) { - directive = { - link: directive }; - } - directive.restrict = directive.restrict || 'AC'; - return valueFn(directive); - } - /** - * @ngdoc directive - * @name a - * @restrict E - * - * @description - * Modifies the default behavior of the html A tag so that the default action is prevented when - * the href attribute is empty. - * - * This change permits the easy creation of action links with the `ngClick` directive - * without changing the location or causing page reloads, e.g.: - * `<a href="" ng-click="list.addItem()">Add Item</a>` - */ - var htmlAnchorDirective = valueFn({ - restrict: 'E', - compile: function(element, attr) { + element.on('keydown', /** @this */ function(event) { + var key = event.keyCode; - if (msie <= 8) { + // ignore + // command modifiers arrows + if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return; - // turn <a href ng-click="..">link</a> into a stylable link in IE - // but only if it doesn't have name attribute, in which case it's an anchor - if (!attr.href && !attr.name) { - attr.$set('href', ''); - } + deferListener(event, this, this.value); + }); - // add a comment node to anchors to workaround IE bug that causes element content to be reset - // to new attribute content if attribute is updated with value containing @ and element also - // contains value with @ - // see issue #1949 - element.append(document.createComment('IE fix')); + // if user modifies input value using context menu in IE, we need "paste", "cut" and "drop" events to catch it + if ($sniffer.hasEvent('paste')) { + element.on('paste cut drop', deferListener); } + } - if (!attr.href && !attr.xlinkHref && !attr.name) { - return function(scope, element) { - // SVGAElement does not use the href attribute, but rather the 'xlinkHref' attribute. - var href = toString.call(element.prop('href')) === '[object SVGAnimatedString]' ? - 'xlink:href' : 'href'; - element.on('click', function(event){ - // if we have no href url, then don't navigate anywhere. - if (!element.attr(href)) { - event.preventDefault(); + // if user paste into input using mouse on older browser + // or form autocomplete on newer browser, we need "change" event to catch it + element.on('change', listener); + + // Some native input types (date-family) have the ability to change validity without + // firing any input/change events. + // For these event types, when native validators are present and the browser supports the type, + // check for validity changes on various DOM events. + if (PARTIAL_VALIDATION_TYPES[type] && ctrl.$$hasNativeValidators && type === attr.type) { + element.on(PARTIAL_VALIDATION_EVENTS, /** @this */ function(ev) { + if (!timeout) { + var validity = this[VALIDITY_STATE_PROPERTY]; + var origBadInput = validity.badInput; + var origTypeMismatch = validity.typeMismatch; + timeout = $browser.defer(function() { + timeout = null; + if (validity.badInput !== origBadInput || validity.typeMismatch !== origTypeMismatch) { + listener(ev); } }); - }; - } + } + }); } - }); - /** - * @ngdoc directive - * @name ngHref - * @restrict A - * @priority 99 - * - * @description - * Using Angular markup like `{{hash}}` in an href attribute will - * make the link go to the wrong URL if the user clicks it before - * Angular has a chance to replace the `{{hash}}` markup with its - * value. Until Angular replaces the markup the link will be broken - * and will most likely return a 404 error. - * - * The `ngHref` directive solves this problem. - * - * The wrong way to write it: - * ```html - * <a href="http://www.gravatar.com/avatar/{{hash}}"/> - * ``` - * - * The correct way to write it: - * ```html - * <a ng-href="http://www.gravatar.com/avatar/{{hash}}"/> - * ``` - * - * @element A - * @param {template} ngHref any string which can contain `{{}}` markup. - * - * @example - * This example shows various combinations of `href`, `ng-href` and `ng-click` attributes - * in links and their different behaviors: - <example> - <file name="index.html"> - <input ng-model="value" /><br /> - <a id="link-1" href ng-click="value = 1">link 1</a> (link, don't reload)<br /> - <a id="link-2" href="" ng-click="value = 2">link 2</a> (link, don't reload)<br /> - <a id="link-3" ng-href="/{{'123'}}">link 3</a> (link, reload!)<br /> - <a id="link-4" href="" name="xx" ng-click="value = 4">anchor</a> (link, don't reload)<br /> - <a id="link-5" name="xxx" ng-click="value = 5">anchor</a> (no link)<br /> - <a id="link-6" ng-href="{{value}}">link</a> (link, change location) - </file> - <file name="protractor.js" type="protractor"> - it('should execute ng-click but not reload when href without value', function() { - element(by.id('link-1')).click(); - expect(element(by.model('value')).getAttribute('value')).toEqual('1'); - expect(element(by.id('link-1')).getAttribute('href')).toBe(''); - }); + ctrl.$render = function() { + // Workaround for Firefox validation #12102. + var value = ctrl.$isEmpty(ctrl.$viewValue) ? '' : ctrl.$viewValue; + if (element.val() !== value) { + element.val(value); + } + }; + } - it('should execute ng-click but not reload when href empty string', function() { - element(by.id('link-2')).click(); - expect(element(by.model('value')).getAttribute('value')).toEqual('2'); - expect(element(by.id('link-2')).getAttribute('href')).toBe(''); - }); + function weekParser(isoWeek, existingDate) { + if (isDate(isoWeek)) { + return isoWeek; + } - it('should execute ng-click and change url when ng-href specified', function() { - expect(element(by.id('link-3')).getAttribute('href')).toMatch(/\/123$/); + if (isString(isoWeek)) { + WEEK_REGEXP.lastIndex = 0; + var parts = WEEK_REGEXP.exec(isoWeek); + if (parts) { + var year = +parts[1], + week = +parts[2], + hours = 0, + minutes = 0, + seconds = 0, + milliseconds = 0, + firstThurs = getFirstThursdayOfYear(year), + addDays = (week - 1) * 7; + + if (existingDate) { + hours = existingDate.getHours(); + minutes = existingDate.getMinutes(); + seconds = existingDate.getSeconds(); + milliseconds = existingDate.getMilliseconds(); + } - element(by.id('link-3')).click(); + return new Date(year, 0, firstThurs.getDate() + addDays, hours, minutes, seconds, milliseconds); + } + } - // At this point, we navigate away from an Angular page, so we need - // to use browser.driver to get the base webdriver. + return NaN; + } - browser.wait(function() { - return browser.driver.getCurrentUrl().then(function(url) { - return url.match(/\/123$/); - }); - }, 1000, 'page should navigate to /123'); - }); + function createDateParser(regexp, mapping) { + return function(iso, date) { + var parts, map; - xit('should execute ng-click but not reload when href empty string and name specified', function() { - element(by.id('link-4')).click(); - expect(element(by.model('value')).getAttribute('value')).toEqual('4'); - expect(element(by.id('link-4')).getAttribute('href')).toBe(''); - }); + if (isDate(iso)) { + return iso; + } - it('should execute ng-click but not reload when no href but name specified', function() { - element(by.id('link-5')).click(); - expect(element(by.model('value')).getAttribute('value')).toEqual('5'); - expect(element(by.id('link-5')).getAttribute('href')).toBe(null); - }); + if (isString(iso)) { + // When a date is JSON'ified to wraps itself inside of an extra + // set of double quotes. This makes the date parsing code unable + // to match the date string and parse it as a date. + if (iso.charAt(0) === '"' && iso.charAt(iso.length - 1) === '"') { + iso = iso.substring(1, iso.length - 1); + } + if (ISO_DATE_REGEXP.test(iso)) { + return new Date(iso); + } + regexp.lastIndex = 0; + parts = regexp.exec(iso); + + if (parts) { + parts.shift(); + if (date) { + map = { + yyyy: date.getFullYear(), + MM: date.getMonth() + 1, + dd: date.getDate(), + HH: date.getHours(), + mm: date.getMinutes(), + ss: date.getSeconds(), + sss: date.getMilliseconds() / 1000 + }; + } else { + map = { yyyy: 1970, MM: 1, dd: 1, HH: 0, mm: 0, ss: 0, sss: 0 }; + } - it('should only change url when only ng-href', function() { - element(by.model('value')).clear(); - element(by.model('value')).sendKeys('6'); - expect(element(by.id('link-6')).getAttribute('href')).toMatch(/\/6$/); + forEach(parts, function(part, index) { + if (index < mapping.length) { + map[mapping[index]] = +part; + } + }); + return new Date(map.yyyy, map.MM - 1, map.dd, map.HH, map.mm, map.ss || 0, map.sss * 1000 || 0); + } + } - element(by.id('link-6')).click(); + return NaN; + }; + } - // At this point, we navigate away from an Angular page, so we need - // to use browser.driver to get the base webdriver. - browser.wait(function() { - return browser.driver.getCurrentUrl().then(function(url) { - return url.match(/\/6$/); + function createDateInputType(type, regexp, parseDate, format) { + return function dynamicDateInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter) { + badInputChecker(scope, element, attr, ctrl); + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + var timezone = ctrl && ctrl.$options.getOption('timezone'); + var previousDate; + + ctrl.$$parserName = type; + ctrl.$parsers.push(function(value) { + if (ctrl.$isEmpty(value)) return null; + if (regexp.test(value)) { + // Note: We cannot read ctrl.$modelValue, as there might be a different + // parser/formatter in the processing chain so that the model + // contains some different data format! + var parsedDate = parseDate(value, previousDate); + if (timezone) { + parsedDate = convertTimezoneToLocal(parsedDate, timezone); + } + return parsedDate; + } + return undefined; }); - }, 1000, 'page should navigate to /6'); - }); - </file> - </example> - */ - - /** - * @ngdoc directive - * @name ngSrc - * @restrict A - * @priority 99 - * - * @description - * Using Angular markup like `{{hash}}` in a `src` attribute doesn't - * work right: The browser will fetch from the URL with the literal - * text `{{hash}}` until Angular replaces the expression inside - * `{{hash}}`. The `ngSrc` directive solves this problem. - * - * The buggy way to write it: - * ```html - * <img src="http://www.gravatar.com/avatar/{{hash}}"/> - * ``` - * - * The correct way to write it: - * ```html - * <img ng-src="http://www.gravatar.com/avatar/{{hash}}"/> - * ``` - * - * @element IMG - * @param {template} ngSrc any string which can contain `{{}}` markup. - */ - - /** - * @ngdoc directive - * @name ngSrcset - * @restrict A - * @priority 99 - * - * @description - * Using Angular markup like `{{hash}}` in a `srcset` attribute doesn't - * work right: The browser will fetch from the URL with the literal - * text `{{hash}}` until Angular replaces the expression inside - * `{{hash}}`. The `ngSrcset` directive solves this problem. - * - * The buggy way to write it: - * ```html - * <img srcset="http://www.gravatar.com/avatar/{{hash}} 2x"/> - * ``` - * - * The correct way to write it: - * ```html - * <img ng-srcset="http://www.gravatar.com/avatar/{{hash}} 2x"/> - * ``` - * - * @element IMG - * @param {template} ngSrcset any string which can contain `{{}}` markup. - */ - /** - * @ngdoc directive - * @name ngDisabled - * @restrict A - * @priority 100 - * - * @description - * - * The following markup will make the button enabled on Chrome/Firefox but not on IE8 and older IEs: - * ```html - * <div ng-init="scope = { isDisabled: false }"> - * <button disabled="{{scope.isDisabled}}">Disabled</button> - * </div> - * ``` - * - * The HTML specification does not require browsers to preserve the values of boolean attributes - * such as disabled. (Their presence means true and their absence means false.) - * If we put an Angular interpolation expression into such an attribute then the - * binding information would be lost when the browser removes the attribute. - * The `ngDisabled` directive solves this problem for the `disabled` attribute. - * This complementary directive is not removed by the browser and so provides - * a permanent reliable place to store the binding information. - * - * @example - <example> - <file name="index.html"> - Click me to toggle: <input type="checkbox" ng-model="checked"><br/> - <button ng-model="button" ng-disabled="checked">Button</button> - </file> - <file name="protractor.js" type="protractor"> - it('should toggle button', function() { - expect(element(by.css('button')).getAttribute('disabled')).toBeFalsy(); - element(by.model('checked')).click(); - expect(element(by.css('button')).getAttribute('disabled')).toBeTruthy(); - }); - </file> - </example> - * - * @element INPUT - * @param {expression} ngDisabled If the {@link guide/expression expression} is truthy, - * then special attribute "disabled" will be set on the element - */ + ctrl.$formatters.push(function(value) { + if (value && !isDate(value)) { + throw ngModelMinErr('datefmt', 'Expected `{0}` to be a date', value); + } + if (isValidDate(value)) { + previousDate = value; + if (previousDate && timezone) { + previousDate = convertTimezoneToLocal(previousDate, timezone, true); + } + return $filter('date')(value, format, timezone); + } else { + previousDate = null; + return ''; + } + }); + if (isDefined(attr.min) || attr.ngMin) { + var minVal; + ctrl.$validators.min = function(value) { + return !isValidDate(value) || isUndefined(minVal) || parseDate(value) >= minVal; + }; + attr.$observe('min', function(val) { + minVal = parseObservedDateValue(val); + ctrl.$validate(); + }); + } - /** - * @ngdoc directive - * @name ngChecked - * @restrict A - * @priority 100 - * - * @description - * The HTML specification does not require browsers to preserve the values of boolean attributes - * such as checked. (Their presence means true and their absence means false.) - * If we put an Angular interpolation expression into such an attribute then the - * binding information would be lost when the browser removes the attribute. - * The `ngChecked` directive solves this problem for the `checked` attribute. - * This complementary directive is not removed by the browser and so provides - * a permanent reliable place to store the binding information. - * @example - <example> - <file name="index.html"> - Check me to check both: <input type="checkbox" ng-model="master"><br/> - <input id="checkSlave" type="checkbox" ng-checked="master"> - </file> - <file name="protractor.js" type="protractor"> - it('should check both checkBoxes', function() { - expect(element(by.id('checkSlave')).getAttribute('checked')).toBeFalsy(); - element(by.model('master')).click(); - expect(element(by.id('checkSlave')).getAttribute('checked')).toBeTruthy(); - }); - </file> - </example> - * - * @element INPUT - * @param {expression} ngChecked If the {@link guide/expression expression} is truthy, - * then special attribute "checked" will be set on the element - */ + if (isDefined(attr.max) || attr.ngMax) { + var maxVal; + ctrl.$validators.max = function(value) { + return !isValidDate(value) || isUndefined(maxVal) || parseDate(value) <= maxVal; + }; + attr.$observe('max', function(val) { + maxVal = parseObservedDateValue(val); + ctrl.$validate(); + }); + } + function isValidDate(value) { + // Invalid Date: getTime() returns NaN + return value && !(value.getTime && value.getTime() !== value.getTime()); + } - /** - * @ngdoc directive - * @name ngReadonly - * @restrict A - * @priority 100 - * - * @description - * The HTML specification does not require browsers to preserve the values of boolean attributes - * such as readonly. (Their presence means true and their absence means false.) - * If we put an Angular interpolation expression into such an attribute then the - * binding information would be lost when the browser removes the attribute. - * The `ngReadonly` directive solves this problem for the `readonly` attribute. - * This complementary directive is not removed by the browser and so provides - * a permanent reliable place to store the binding information. - * @example - <example> - <file name="index.html"> - Check me to make text readonly: <input type="checkbox" ng-model="checked"><br/> - <input type="text" ng-readonly="checked" value="I'm Angular"/> - </file> - <file name="protractor.js" type="protractor"> - it('should toggle readonly attr', function() { - expect(element(by.css('[type="text"]')).getAttribute('readonly')).toBeFalsy(); - element(by.model('checked')).click(); - expect(element(by.css('[type="text"]')).getAttribute('readonly')).toBeTruthy(); - }); - </file> - </example> - * - * @element INPUT - * @param {expression} ngReadonly If the {@link guide/expression expression} is truthy, - * then special attribute "readonly" will be set on the element - */ + function parseObservedDateValue(val) { + return isDefined(val) && !isDate(val) ? parseDate(val) || undefined : val; + } + }; + } + function badInputChecker(scope, element, attr, ctrl) { + var node = element[0]; + var nativeValidation = ctrl.$$hasNativeValidators = isObject(node.validity); + if (nativeValidation) { + ctrl.$parsers.push(function(value) { + var validity = element.prop(VALIDITY_STATE_PROPERTY) || {}; + return validity.badInput || validity.typeMismatch ? undefined : value; + }); + } + } - /** - * @ngdoc directive - * @name ngSelected - * @restrict A - * @priority 100 - * - * @description - * The HTML specification does not require browsers to preserve the values of boolean attributes - * such as selected. (Their presence means true and their absence means false.) - * If we put an Angular interpolation expression into such an attribute then the - * binding information would be lost when the browser removes the attribute. - * The `ngSelected` directive solves this problem for the `selected` attribute. - * This complementary directive is not removed by the browser and so provides - * a permanent reliable place to store the binding information. - * - * @example - <example> - <file name="index.html"> - Check me to select: <input type="checkbox" ng-model="selected"><br/> - <select> - <option>Hello!</option> - <option id="greet" ng-selected="selected">Greetings!</option> - </select> - </file> - <file name="protractor.js" type="protractor"> - it('should select Greetings!', function() { - expect(element(by.id('greet')).getAttribute('selected')).toBeFalsy(); - element(by.model('selected')).click(); - expect(element(by.id('greet')).getAttribute('selected')).toBeTruthy(); + function numberFormatterParser(ctrl) { + ctrl.$$parserName = 'number'; + ctrl.$parsers.push(function(value) { + if (ctrl.$isEmpty(value)) return null; + if (NUMBER_REGEXP.test(value)) return parseFloat(value); + return undefined; }); - </file> - </example> - * - * @element OPTION - * @param {expression} ngSelected If the {@link guide/expression expression} is truthy, - * then special attribute "selected" will be set on the element - */ - /** - * @ngdoc directive - * @name ngOpen - * @restrict A - * @priority 100 - * - * @description - * The HTML specification does not require browsers to preserve the values of boolean attributes - * such as open. (Their presence means true and their absence means false.) - * If we put an Angular interpolation expression into such an attribute then the - * binding information would be lost when the browser removes the attribute. - * The `ngOpen` directive solves this problem for the `open` attribute. - * This complementary directive is not removed by the browser and so provides - * a permanent reliable place to store the binding information. - * @example - <example> - <file name="index.html"> - Check me check multiple: <input type="checkbox" ng-model="open"><br/> - <details id="details" ng-open="open"> - <summary>Show/Hide me</summary> - </details> - </file> - <file name="protractor.js" type="protractor"> - it('should toggle open', function() { - expect(element(by.id('details')).getAttribute('open')).toBeFalsy(); - element(by.model('open')).click(); - expect(element(by.id('details')).getAttribute('open')).toBeTruthy(); - }); - </file> - </example> - * - * @element DETAILS - * @param {expression} ngOpen If the {@link guide/expression expression} is truthy, - * then special attribute "open" will be set on the element - */ + ctrl.$formatters.push(function(value) { + if (!ctrl.$isEmpty(value)) { + if (!isNumber(value)) { + throw ngModelMinErr('numfmt', 'Expected `{0}` to be a number', value); + } + value = value.toString(); + } + return value; + }); + } - var ngAttributeAliasDirectives = {}; + function parseNumberAttrVal(val) { + if (isDefined(val) && !isNumber(val)) { + val = parseFloat(val); + } + return !isNumberNaN(val) ? val : undefined; + } + function isNumberInteger(num) { + // See http://stackoverflow.com/questions/14636536/how-to-check-if-a-variable-is-an-integer-in-javascript#14794066 + // (minus the assumption that `num` is a number) -// boolean attrs are evaluated - forEach(BOOLEAN_ATTR, function(propName, attrName) { - // binding to multiple is not supported - if (propName == "multiple") return; + // eslint-disable-next-line no-bitwise + return (num | 0) === num; + } - var normalized = directiveNormalize('ng-' + attrName); - ngAttributeAliasDirectives[normalized] = function() { - return { - priority: 100, - link: function(scope, element, attr) { - scope.$watch(attr[normalized], function ngBooleanAttrWatchAction(value) { - attr.$set(attrName, !!value); - }); + function countDecimals(num) { + var numString = num.toString(); + var decimalSymbolIndex = numString.indexOf('.'); + + if (decimalSymbolIndex === -1) { + if (-1 < num && num < 1) { + // It may be in the exponential notation format (`1e-X`) + var match = /e-(\d+)$/.exec(numString); + + if (match) { + return Number(match[1]); } - }; - }; - }); + } + return 0; + } -// ng-src, ng-srcset, ng-href are interpolated - forEach(['src', 'srcset', 'href'], function(attrName) { - var normalized = directiveNormalize('ng-' + attrName); - ngAttributeAliasDirectives[normalized] = function() { - return { - priority: 99, // it needs to run after the attributes are interpolated - link: function(scope, element, attr) { - var propName = attrName, - name = attrName; + return numString.length - decimalSymbolIndex - 1; + } - if (attrName === 'href' && - toString.call(element.prop('href')) === '[object SVGAnimatedString]') { - name = 'xlinkHref'; - attr.$attr[name] = 'xlink:href'; - propName = null; - } + function isValidForStep(viewValue, stepBase, step) { + // At this point `stepBase` and `step` are expected to be non-NaN values + // and `viewValue` is expected to be a valid stringified number. + var value = Number(viewValue); - attr.$observe(normalized, function(value) { - if (!value) - return; + var isNonIntegerValue = !isNumberInteger(value); + var isNonIntegerStepBase = !isNumberInteger(stepBase); + var isNonIntegerStep = !isNumberInteger(step); - attr.$set(name, value); + // Due to limitations in Floating Point Arithmetic (e.g. `0.3 - 0.2 !== 0.1` or + // `0.5 % 0.1 !== 0`), we need to convert all numbers to integers. + if (isNonIntegerValue || isNonIntegerStepBase || isNonIntegerStep) { + var valueDecimals = isNonIntegerValue ? countDecimals(value) : 0; + var stepBaseDecimals = isNonIntegerStepBase ? countDecimals(stepBase) : 0; + var stepDecimals = isNonIntegerStep ? countDecimals(step) : 0; - // on IE, if "ng:src" directive declaration is used and "src" attribute doesn't exist - // then calling element.setAttribute('src', 'foo') doesn't do anything, so we need - // to set the property as well to achieve the desired effect. - // we use attr[attrName] value since $set can sanitize the url. - if (msie && propName) element.prop(propName, attr[name]); - }); - } + var decimalCount = Math.max(valueDecimals, stepBaseDecimals, stepDecimals); + var multiplier = Math.pow(10, decimalCount); + + value = value * multiplier; + stepBase = stepBase * multiplier; + step = step * multiplier; + + if (isNonIntegerValue) value = Math.round(value); + if (isNonIntegerStepBase) stepBase = Math.round(stepBase); + if (isNonIntegerStep) step = Math.round(step); + } + + return (value - stepBase) % step === 0; + } + + function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { + badInputChecker(scope, element, attr, ctrl); + numberFormatterParser(ctrl); + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + + var minVal; + var maxVal; + + if (isDefined(attr.min) || attr.ngMin) { + ctrl.$validators.min = function(value) { + return ctrl.$isEmpty(value) || isUndefined(minVal) || value >= minVal; }; - }; - }); - /* global -nullFormCtrl */ - var nullFormCtrl = { - $addControl: noop, - $removeControl: noop, - $setValidity: noop, - $setDirty: noop, - $setPristine: noop - }; + attr.$observe('min', function(val) { + minVal = parseNumberAttrVal(val); + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); + }); + } - /** - * @ngdoc type - * @name form.FormController - * - * @property {boolean} $pristine True if user has not interacted with the form yet. - * @property {boolean} $dirty True if user has already interacted with the form. - * @property {boolean} $valid True if all of the containing forms and controls are valid. - * @property {boolean} $invalid True if at least one containing control or form is invalid. - * - * @property {Object} $error Is an object hash, containing references to all invalid controls or - * forms, where: - * - * - keys are validation tokens (error names), - * - values are arrays of controls or forms that are invalid for given error name. - * - * - * Built-in validation tokens: - * - * - `email` - * - `max` - * - `maxlength` - * - `min` - * - `minlength` - * - `number` - * - `pattern` - * - `required` - * - `url` - * - * @description - * `FormController` keeps track of all its controls and nested forms as well as state of them, - * such as being valid/invalid or dirty/pristine. - * - * Each {@link ng.directive:form form} directive creates an instance - * of `FormController`. - * - */ -//asks for $scope to fool the BC controller module - FormController.$inject = ['$element', '$attrs', '$scope', '$animate']; - function FormController(element, attrs, $scope, $animate) { - var form = this, - parentForm = element.parent().controller('form') || nullFormCtrl, - invalidCount = 0, // used to easily determine if we are valid - errors = form.$error = {}, - controls = []; + if (isDefined(attr.max) || attr.ngMax) { + ctrl.$validators.max = function(value) { + return ctrl.$isEmpty(value) || isUndefined(maxVal) || value <= maxVal; + }; - // init state - form.$name = attrs.name || attrs.ngForm; - form.$dirty = false; - form.$pristine = true; - form.$valid = true; - form.$invalid = false; + attr.$observe('max', function(val) { + maxVal = parseNumberAttrVal(val); + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); + }); + } + + if (isDefined(attr.step) || attr.ngStep) { + var stepVal; + ctrl.$validators.step = function(modelValue, viewValue) { + return ctrl.$isEmpty(viewValue) || isUndefined(stepVal) || + isValidForStep(viewValue, minVal || 0, stepVal); + }; - parentForm.$addControl(form); + attr.$observe('step', function(val) { + stepVal = parseNumberAttrVal(val); + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); + }); + } + } - // Setup initial state of the control - element.addClass(PRISTINE_CLASS); - toggleValidCss(true); + function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) { + badInputChecker(scope, element, attr, ctrl); + numberFormatterParser(ctrl); + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + + var supportsRange = ctrl.$$hasNativeValidators && element[0].type === 'range', + minVal = supportsRange ? 0 : undefined, + maxVal = supportsRange ? 100 : undefined, + stepVal = supportsRange ? 1 : undefined, + validity = element[0].validity, + hasMinAttr = isDefined(attr.min), + hasMaxAttr = isDefined(attr.max), + hasStepAttr = isDefined(attr.step); + + var originalRender = ctrl.$render; + + ctrl.$render = supportsRange && isDefined(validity.rangeUnderflow) && isDefined(validity.rangeOverflow) ? + //Browsers that implement range will set these values automatically, but reading the adjusted values after + //$render would cause the min / max validators to be applied with the wrong value + function rangeRender() { + originalRender(); + ctrl.$setViewValue(element.val()); + } : + originalRender; + + if (hasMinAttr) { + ctrl.$validators.min = supportsRange ? + // Since all browsers set the input to a valid value, we don't need to check validity + function noopMinValidator() { return true; } : + // non-support browsers validate the min val + function minValidator(modelValue, viewValue) { + return ctrl.$isEmpty(viewValue) || isUndefined(minVal) || viewValue >= minVal; + }; - // convenience method for easy toggling of classes - function toggleValidCss(isValid, validationErrorKey) { - validationErrorKey = validationErrorKey ? '-' + snake_case(validationErrorKey, '-') : ''; - $animate.removeClass(element, (isValid ? INVALID_CLASS : VALID_CLASS) + validationErrorKey); - $animate.addClass(element, (isValid ? VALID_CLASS : INVALID_CLASS) + validationErrorKey); + setInitialValueAndObserver('min', minChange); } - /** - * @ngdoc method - * @name form.FormController#$addControl - * - * @description - * Register a control with the form. - * - * Input elements using ngModelController do this automatically when they are linked. - */ - form.$addControl = function(control) { - // Breaking change - before, inputs whose name was "hasOwnProperty" were quietly ignored - // and not added to the scope. Now we throw an error. - assertNotHasOwnProperty(control.$name, 'input'); - controls.push(control); + if (hasMaxAttr) { + ctrl.$validators.max = supportsRange ? + // Since all browsers set the input to a valid value, we don't need to check validity + function noopMaxValidator() { return true; } : + // non-support browsers validate the max val + function maxValidator(modelValue, viewValue) { + return ctrl.$isEmpty(viewValue) || isUndefined(maxVal) || viewValue <= maxVal; + }; - if (control.$name) { - form[control.$name] = control; + setInitialValueAndObserver('max', maxChange); + } + + if (hasStepAttr) { + ctrl.$validators.step = supportsRange ? + function nativeStepValidator() { + // Currently, only FF implements the spec on step change correctly (i.e. adjusting the + // input element value to a valid value). It's possible that other browsers set the stepMismatch + // validity error instead, so we can at least report an error in that case. + return !validity.stepMismatch; + } : + // ngStep doesn't set the setp attr, so the browser doesn't adjust the input value as setting step would + function stepValidator(modelValue, viewValue) { + return ctrl.$isEmpty(viewValue) || isUndefined(stepVal) || + isValidForStep(viewValue, minVal || 0, stepVal); + }; + + setInitialValueAndObserver('step', stepChange); + } + + function setInitialValueAndObserver(htmlAttrName, changeFn) { + // interpolated attributes set the attribute value only after a digest, but we need the + // attribute value when the input is first rendered, so that the browser can adjust the + // input value based on the min/max value + element.attr(htmlAttrName, attr[htmlAttrName]); + attr.$observe(htmlAttrName, changeFn); + } + + function minChange(val) { + minVal = parseNumberAttrVal(val); + // ignore changes before model is initialized + if (isNumberNaN(ctrl.$modelValue)) { + return; } - }; - /** - * @ngdoc method - * @name form.FormController#$removeControl - * - * @description - * Deregister a control from the form. - * - * Input elements using ngModelController do this automatically when they are destroyed. - */ - form.$removeControl = function(control) { - if (control.$name && form[control.$name] === control) { - delete form[control.$name]; + if (supportsRange) { + var elVal = element.val(); + // IE11 doesn't set the el val correctly if the minVal is greater than the element value + if (minVal > elVal) { + elVal = minVal; + element.val(elVal); + } + ctrl.$setViewValue(elVal); + } else { + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); } - forEach(errors, function(queue, validationToken) { - form.$setValidity(validationToken, true, control); - }); + } - arrayRemove(controls, control); - }; + function maxChange(val) { + maxVal = parseNumberAttrVal(val); + // ignore changes before model is initialized + if (isNumberNaN(ctrl.$modelValue)) { + return; + } - /** - * @ngdoc method - * @name form.FormController#$setValidity - * - * @description - * Sets the validity of a form control. - * - * This method will also propagate to parent forms. - */ - form.$setValidity = function(validationToken, isValid, control) { - var queue = errors[validationToken]; - - if (isValid) { - if (queue) { - arrayRemove(queue, control); - if (!queue.length) { - invalidCount--; - if (!invalidCount) { - toggleValidCss(isValid); - form.$valid = true; - form.$invalid = false; - } - errors[validationToken] = false; - toggleValidCss(true, validationToken); - parentForm.$setValidity(validationToken, true, form); - } + if (supportsRange) { + var elVal = element.val(); + // IE11 doesn't set the el val correctly if the maxVal is less than the element value + if (maxVal < elVal) { + element.val(maxVal); + // IE11 and Chrome don't set the value to the minVal when max < min + elVal = maxVal < minVal ? minVal : maxVal; } + ctrl.$setViewValue(elVal); + } else { + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); + } + } + + function stepChange(val) { + stepVal = parseNumberAttrVal(val); + // ignore changes before model is initialized + if (isNumberNaN(ctrl.$modelValue)) { + return; + } + // Some browsers don't adjust the input value correctly, but set the stepMismatch error + if (supportsRange && ctrl.$viewValue !== element.val()) { + ctrl.$setViewValue(element.val()); } else { - if (!invalidCount) { - toggleValidCss(isValid); - } - if (queue) { - if (includes(queue, control)) return; - } else { - errors[validationToken] = queue = []; - invalidCount++; - toggleValidCss(false, validationToken); - parentForm.$setValidity(validationToken, false, form); + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); + } + } + } + + function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) { + // Note: no badInputChecker here by purpose as `url` is only a validation + // in browsers, i.e. we can always read out input.value even if it is not valid! + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + stringBasedInputType(ctrl); + + ctrl.$$parserName = 'url'; + ctrl.$validators.url = function(modelValue, viewValue) { + var value = modelValue || viewValue; + return ctrl.$isEmpty(value) || URL_REGEXP.test(value); + }; + } + + function emailInputType(scope, element, attr, ctrl, $sniffer, $browser) { + // Note: no badInputChecker here by purpose as `url` is only a validation + // in browsers, i.e. we can always read out input.value even if it is not valid! + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + stringBasedInputType(ctrl); + + ctrl.$$parserName = 'email'; + ctrl.$validators.email = function(modelValue, viewValue) { + var value = modelValue || viewValue; + return ctrl.$isEmpty(value) || EMAIL_REGEXP.test(value); + }; + } + + function radioInputType(scope, element, attr, ctrl) { + var doTrim = !attr.ngTrim || trim(attr.ngTrim) !== 'false'; + // make the name unique, if not defined + if (isUndefined(attr.name)) { + element.attr('name', nextUid()); + } + + var listener = function(ev) { + var value; + if (element[0].checked) { + value = attr.value; + if (doTrim) { + value = trim(value); } - queue.push(control); + ctrl.$setViewValue(value, ev && ev.type); + } + }; + + element.on('click', listener); + + ctrl.$render = function() { + var value = attr.value; + if (doTrim) { + value = trim(value); + } + element[0].checked = (value === ctrl.$viewValue); + }; - form.$valid = false; - form.$invalid = true; + attr.$observe('value', ctrl.$render); + } + + function parseConstantExpr($parse, context, name, expression, fallback) { + var parseFn; + if (isDefined(expression)) { + parseFn = $parse(expression); + if (!parseFn.constant) { + throw ngModelMinErr('constexpr', 'Expected constant expression for `{0}`, but saw ' + + '`{1}`.', name, expression); } + return parseFn(context); + } + return fallback; + } + + function checkboxInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter, $parse) { + var trueValue = parseConstantExpr($parse, scope, 'ngTrueValue', attr.ngTrueValue, true); + var falseValue = parseConstantExpr($parse, scope, 'ngFalseValue', attr.ngFalseValue, false); + + var listener = function(ev) { + ctrl.$setViewValue(element[0].checked, ev && ev.type); }; - /** - * @ngdoc method - * @name form.FormController#$setDirty - * - * @description - * Sets the form to a dirty state. - * - * This method can be called to add the 'ng-dirty' class and set the form to a dirty - * state (ng-dirty class). This method will also propagate to parent forms. - */ - form.$setDirty = function() { - $animate.removeClass(element, PRISTINE_CLASS); - $animate.addClass(element, DIRTY_CLASS); - form.$dirty = true; - form.$pristine = false; - parentForm.$setDirty(); + element.on('click', listener); + + ctrl.$render = function() { + element[0].checked = ctrl.$viewValue; }; - /** - * @ngdoc method - * @name form.FormController#$setPristine - * - * @description - * Sets the form to its pristine state. - * - * This method can be called to remove the 'ng-dirty' class and set the form to its pristine - * state (ng-pristine class). This method will also propagate to all the controls contained - * in this form. - * - * Setting a form back to a pristine state is often useful when we want to 'reuse' a form after - * saving or resetting it. - */ - form.$setPristine = function () { - $animate.removeClass(element, DIRTY_CLASS); - $animate.addClass(element, PRISTINE_CLASS); - form.$dirty = false; - form.$pristine = true; - forEach(controls, function(control) { - control.$setPristine(); - }); + // Override the standard `$isEmpty` because the $viewValue of an empty checkbox is always set to `false` + // This is because of the parser below, which compares the `$modelValue` with `trueValue` to convert + // it to a boolean. + ctrl.$isEmpty = function(value) { + return value === false; }; + + ctrl.$formatters.push(function(value) { + return equals(value, trueValue); + }); + + ctrl.$parsers.push(function(value) { + return value ? trueValue : falseValue; + }); } /** * @ngdoc directive - * @name ngForm - * @restrict EAC + * @name textarea + * @restrict E * * @description - * Nestable alias of {@link ng.directive:form `form`} directive. HTML - * does not allow nesting of form elements. It is useful to nest forms, for example if the validity of a - * sub-group of controls needs to be determined. + * HTML textarea element control with AngularJS data-binding. The data-binding and validation + * properties of this element are exactly the same as those of the + * {@link ng.directive:input input element}. * - * Note: the purpose of `ngForm` is to group controls, - * but not to be a replacement for the `<form>` tag with all of its capabilities - * (e.g. posting to the server, ...). + * @param {string} ngModel Assignable AngularJS expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than + * minlength. + * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than + * maxlength. Setting the attribute to a negative or non-numeric value, allows view values of any + * length. + * @param {string=} ngPattern Sets `pattern` validation error key if the ngModel {@link ngModel.NgModelController#$viewValue $viewValue} + * does not match a RegExp found by evaluating the AngularJS expression given in the attribute value. + * If the expression evaluates to a RegExp object, then this is used directly. + * If the expression evaluates to a string, then it will be converted to a RegExp + * after wrapping it in `^` and `$` characters. For instance, `"abc"` will be converted to + * `new RegExp('^abc$')`.<br /> + * **Note:** Avoid using the `g` flag on the RegExp, as it will cause each successive search to + * start at the index of the last search's match, thus not taking the whole input value into + * account. + * @param {string=} ngChange AngularJS expression to be executed when input changes due to user + * interaction with the input element. + * @param {boolean=} [ngTrim=true] If set to false AngularJS will not automatically trim the input. * - * @param {string=} ngForm|name Name of the form. If specified, the form controller will be published into - * related scope, under this name. + * @knownIssue * + * When specifying the `placeholder` attribute of `<textarea>`, Internet Explorer will temporarily + * insert the placeholder value as the textarea's content. If the placeholder value contains + * interpolation (`{{ ... }}`), an error will be logged in the console when AngularJS tries to update + * the value of the by-then-removed text node. This doesn't affect the functionality of the + * textarea, but can be undesirable. + * + * You can work around this Internet Explorer issue by using `ng-attr-placeholder` instead of + * `placeholder` on textareas, whenever you need interpolation in the placeholder value. You can + * find more details on `ngAttr` in the + * [Interpolation](guide/interpolation#-ngattr-for-binding-to-arbitrary-attributes) section of the + * Developer Guide. */ + /** * @ngdoc directive - * @name form + * @name input * @restrict E * * @description - * Directive that instantiates - * {@link form.FormController FormController}. - * - * If the `name` attribute is specified, the form controller is published onto the current scope under - * this name. - * - * # Alias: {@link ng.directive:ngForm `ngForm`} - * - * In Angular forms can be nested. This means that the outer form is valid when all of the child - * forms are valid as well. However, browsers do not allow nesting of `<form>` elements, so - * Angular provides the {@link ng.directive:ngForm `ngForm`} directive which behaves identically to - * `<form>` but can be nested. This allows you to have nested forms, which is very useful when - * using Angular validation directives in forms that are dynamically generated using the - * {@link ng.directive:ngRepeat `ngRepeat`} directive. Since you cannot dynamically generate the `name` - * attribute of input elements using interpolation, you have to wrap each set of repeated inputs in an - * `ngForm` directive and nest these in an outer `form` element. - * - * - * # CSS classes - * - `ng-valid` is set if the form is valid. - * - `ng-invalid` is set if the form is invalid. - * - `ng-pristine` is set if the form is pristine. - * - `ng-dirty` is set if the form is dirty. + * HTML input element control. When used together with {@link ngModel `ngModel`}, it provides data-binding, + * input state control, and validation. + * Input control follows HTML5 input types and polyfills the HTML5 validation behavior for older browsers. * - * Keep in mind that ngAnimate can detect each of these classes when added and removed. + * <div class="alert alert-warning"> + * **Note:** Not every feature offered is available for all input types. + * Specifically, data binding and event handling via `ng-model` is unsupported for `input[file]`. + * </div> * + * @param {string} ngModel Assignable AngularJS expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {boolean=} ngRequired Sets `required` attribute if set to true + * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than + * minlength. + * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than + * maxlength. Setting the attribute to a negative or non-numeric value, allows view values of any + * length. + * @param {string=} ngPattern Sets `pattern` validation error key if the ngModel {@link ngModel.NgModelController#$viewValue $viewValue} + * value does not match a RegExp found by evaluating the AngularJS expression given in the attribute value. + * If the expression evaluates to a RegExp object, then this is used directly. + * If the expression evaluates to a string, then it will be converted to a RegExp + * after wrapping it in `^` and `$` characters. For instance, `"abc"` will be converted to + * `new RegExp('^abc$')`.<br /> + * **Note:** Avoid using the `g` flag on the RegExp, as it will cause each successive search to + * start at the index of the last search's match, thus not taking the whole input value into + * account. + * @param {string=} ngChange AngularJS expression to be executed when input changes due to user + * interaction with the input element. + * @param {boolean=} [ngTrim=true] If set to false AngularJS will not automatically trim the input. + * This parameter is ignored for input[type=password] controls, which will never trim the + * input. * - * # Submitting a form and preventing the default action + * @example + <example name="input-directive" module="inputExample"> + <file name="index.html"> + <script> + angular.module('inputExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.user = {name: 'guest', last: 'visitor'}; + }]); + </script> + <div ng-controller="ExampleController"> + <form name="myForm"> + <label> + User name: + <input type="text" name="userName" ng-model="user.name" required> + </label> + <div role="alert"> + <span class="error" ng-show="myForm.userName.$error.required"> + Required!</span> + </div> + <label> + Last name: + <input type="text" name="lastName" ng-model="user.last" + ng-minlength="3" ng-maxlength="10"> + </label> + <div role="alert"> + <span class="error" ng-show="myForm.lastName.$error.minlength"> + Too short!</span> + <span class="error" ng-show="myForm.lastName.$error.maxlength"> + Too long!</span> + </div> + </form> + <hr> + <tt>user = {{user}}</tt><br/> + <tt>myForm.userName.$valid = {{myForm.userName.$valid}}</tt><br/> + <tt>myForm.userName.$error = {{myForm.userName.$error}}</tt><br/> + <tt>myForm.lastName.$valid = {{myForm.lastName.$valid}}</tt><br/> + <tt>myForm.lastName.$error = {{myForm.lastName.$error}}</tt><br/> + <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> + <tt>myForm.$error.required = {{!!myForm.$error.required}}</tt><br/> + <tt>myForm.$error.minlength = {{!!myForm.$error.minlength}}</tt><br/> + <tt>myForm.$error.maxlength = {{!!myForm.$error.maxlength}}</tt><br/> + </div> + </file> + <file name="protractor.js" type="protractor"> + var user = element(by.exactBinding('user')); + var userNameValid = element(by.binding('myForm.userName.$valid')); + var lastNameValid = element(by.binding('myForm.lastName.$valid')); + var lastNameError = element(by.binding('myForm.lastName.$error')); + var formValid = element(by.binding('myForm.$valid')); + var userNameInput = element(by.model('user.name')); + var userLastInput = element(by.model('user.last')); + + it('should initialize to model', function() { + expect(user.getText()).toContain('{"name":"guest","last":"visitor"}'); + expect(userNameValid.getText()).toContain('true'); + expect(formValid.getText()).toContain('true'); + }); + + it('should be invalid if empty when required', function() { + userNameInput.clear(); + userNameInput.sendKeys(''); + + expect(user.getText()).toContain('{"last":"visitor"}'); + expect(userNameValid.getText()).toContain('false'); + expect(formValid.getText()).toContain('false'); + }); + + it('should be valid if empty when min length is set', function() { + userLastInput.clear(); + userLastInput.sendKeys(''); + + expect(user.getText()).toContain('{"name":"guest","last":""}'); + expect(lastNameValid.getText()).toContain('true'); + expect(formValid.getText()).toContain('true'); + }); + + it('should be invalid if less than required min length', function() { + userLastInput.clear(); + userLastInput.sendKeys('xx'); + + expect(user.getText()).toContain('{"name":"guest"}'); + expect(lastNameValid.getText()).toContain('false'); + expect(lastNameError.getText()).toContain('minlength'); + expect(formValid.getText()).toContain('false'); + }); + + it('should be invalid if longer than max length', function() { + userLastInput.clear(); + userLastInput.sendKeys('some ridiculously long name'); + + expect(user.getText()).toContain('{"name":"guest"}'); + expect(lastNameValid.getText()).toContain('false'); + expect(lastNameError.getText()).toContain('maxlength'); + expect(formValid.getText()).toContain('false'); + }); + </file> + </example> + */ + var inputDirective = ['$browser', '$sniffer', '$filter', '$parse', + function($browser, $sniffer, $filter, $parse) { + return { + restrict: 'E', + require: ['?ngModel'], + link: { + pre: function(scope, element, attr, ctrls) { + if (ctrls[0]) { + (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrls[0], $sniffer, + $browser, $filter, $parse); + } + } + } + }; + }]; + + + + var CONSTANT_VALUE_REGEXP = /^(true|false|\d+)$/; + /** + * @ngdoc directive + * @name ngValue + * @restrict A + * @priority 100 * - * Since the role of forms in client-side Angular applications is different than in classical - * roundtrip apps, it is desirable for the browser not to translate the form submission into a full - * page reload that sends the data to the server. Instead some javascript logic should be triggered - * to handle the form submission in an application-specific way. + * @description + * Binds the given expression to the value of the element. * - * For this reason, Angular prevents the default action (form submission to the server) unless the - * `<form>` element has an `action` attribute specified. + * It is mainly used on {@link input[radio] `input[radio]`} and option elements, + * so that when the element is selected, the {@link ngModel `ngModel`} of that element (or its + * {@link select `select`} parent element) is set to the bound value. It is especially useful + * for dynamically generated lists using {@link ngRepeat `ngRepeat`}, as shown below. * - * You can use one of the following two ways to specify what javascript method should be called when - * a form is submitted: + * It can also be used to achieve one-way binding of a given expression to an input element + * such as an `input[text]` or a `textarea`, when that element does not use ngModel. * - * - {@link ng.directive:ngSubmit ngSubmit} directive on the form element - * - {@link ng.directive:ngClick ngClick} directive on the first - * button or input field of type submit (input[type=submit]) + * @element ANY + * @param {string=} ngValue AngularJS expression, whose value will be bound to the `value` attribute + * and `value` property of the element. * - * To prevent double execution of the handler, use only one of the {@link ng.directive:ngSubmit ngSubmit} - * or {@link ng.directive:ngClick ngClick} directives. - * This is because of the following form submission rules in the HTML specification: + * @example + <example name="ngValue-directive" module="valueExample"> + <file name="index.html"> + <script> + angular.module('valueExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.names = ['pizza', 'unicorns', 'robots']; + $scope.my = { favorite: 'unicorns' }; + }]); + </script> + <form ng-controller="ExampleController"> + <h2>Which is your favorite?</h2> + <label ng-repeat="name in names" for="{{name}}"> + {{name}} + <input type="radio" + ng-model="my.favorite" + ng-value="name" + id="{{name}}" + name="favorite"> + </label> + <div>You chose {{my.favorite}}</div> + </form> + </file> + <file name="protractor.js" type="protractor"> + var favorite = element(by.binding('my.favorite')); + + it('should initialize to model', function() { + expect(favorite.getText()).toContain('unicorns'); + }); + it('should bind the values to the inputs', function() { + element.all(by.model('my.favorite')).get(0).click(); + expect(favorite.getText()).toContain('pizza'); + }); + </file> + </example> + */ + var ngValueDirective = function() { + /** + * inputs use the value attribute as their default value if the value property is not set. + * Once the value property has been set (by adding input), it will not react to changes to + * the value attribute anymore. Setting both attribute and property fixes this behavior, and + * makes it possible to use ngValue as a sort of one-way bind. + */ + function updateElementValue(element, attr, value) { + // Support: IE9 only + // In IE9 values are converted to string (e.g. `input.value = null` results in `input.value === 'null'`). + var propValue = isDefined(value) ? value : (msie === 9) ? '' : null; + element.prop('value', propValue); + attr.$set('value', value); + } + + return { + restrict: 'A', + priority: 100, + compile: function(tpl, tplAttr) { + if (CONSTANT_VALUE_REGEXP.test(tplAttr.ngValue)) { + return function ngValueConstantLink(scope, elm, attr) { + var value = scope.$eval(attr.ngValue); + updateElementValue(elm, attr, value); + }; + } else { + return function ngValueLink(scope, elm, attr) { + scope.$watch(attr.ngValue, function valueWatchAction(value) { + updateElementValue(elm, attr, value); + }); + }; + } + } + }; + }; + + /** + * @ngdoc directive + * @name ngBind + * @restrict AC * - * - If a form has only one input field then hitting enter in this field triggers form submit - * (`ngSubmit`) - * - if a form has 2+ input fields and no buttons or input[type=submit] then hitting enter - * doesn't trigger submit - * - if a form has one or more input fields and one or more buttons or input[type=submit] then - * hitting enter in any of the input fields will trigger the click handler on the *first* button or - * input[type=submit] (`ngClick`) *and* a submit handler on the enclosing form (`ngSubmit`) + * @description + * The `ngBind` attribute tells AngularJS to replace the text content of the specified HTML element + * with the value of a given expression, and to update the text content when the value of that + * expression changes. * - * @param {string=} name Name of the form. If specified, the form controller will be published into - * related scope, under this name. + * Typically, you don't use `ngBind` directly, but instead you use the double curly markup like + * `{{ expression }}` which is similar but less verbose. * - * ## Animation Hooks + * It is preferable to use `ngBind` instead of `{{ expression }}` if a template is momentarily + * displayed by the browser in its raw state before AngularJS compiles it. Since `ngBind` is an + * element attribute, it makes the bindings invisible to the user while the page is loading. * - * Animations in ngForm are triggered when any of the associated CSS classes are added and removed. - * These classes are: `.ng-pristine`, `.ng-dirty`, `.ng-invalid` and `.ng-valid` as well as any - * other validations that are performed within the form. Animations in ngForm are similar to how - * they work in ngClass and animations can be hooked into using CSS transitions, keyframes as well - * as JS animations. + * An alternative solution to this problem would be using the + * {@link ng.directive:ngCloak ngCloak} directive. * - * The following example shows a simple way to utilize CSS transitions to style a form element - * that has been rendered as invalid after it has been validated: * - * <pre> - * //be sure to include ngAnimate as a module to hook into more - * //advanced animations - * .my-form { - * transition:0.5s linear all; - * background: white; - * } - * .my-form.ng-invalid { - * background: red; - * color:white; - * } - * </pre> + * @element ANY + * @param {expression} ngBind {@link guide/expression Expression} to evaluate. * * @example - <example deps="angular-animate.js" animations="true" fixBase="true"> + * Enter a name in the Live Preview text box; the greeting below the text box changes instantly. + <example module="bindExample" name="ng-bind"> <file name="index.html"> <script> - function Ctrl($scope) { - $scope.userType = 'guest'; - } + angular.module('bindExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.name = 'Whirled'; + }]); </script> - <style> - .my-form { - -webkit-transition:all linear 0.5s; - transition:all linear 0.5s; - background: transparent; - } - .my-form.ng-invalid { - background: red; - } - </style> - <form name="myForm" ng-controller="Ctrl" class="my-form"> - userType: <input name="input" ng-model="userType" required> - <span class="error" ng-show="myForm.input.$error.required">Required!</span><br> - <tt>userType = {{userType}}</tt><br> - <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br> - <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br> - <tt>myForm.$valid = {{myForm.$valid}}</tt><br> - <tt>myForm.$error.required = {{!!myForm.$error.required}}</tt><br> - </form> + <div ng-controller="ExampleController"> + <label>Enter name: <input type="text" ng-model="name"></label><br> + Hello <span ng-bind="name"></span>! + </div> </file> <file name="protractor.js" type="protractor"> - it('should initialize to model', function() { - var userType = element(by.binding('userType')); - var valid = element(by.binding('myForm.input.$valid')); - - expect(userType.getText()).toContain('guest'); - expect(valid.getText()).toContain('true'); - }); - - it('should be invalid if empty', function() { - var userType = element(by.binding('userType')); - var valid = element(by.binding('myForm.input.$valid')); - var userInput = element(by.model('userType')); - - userInput.clear(); - userInput.sendKeys(''); + it('should check ng-bind', function() { + var nameInput = element(by.model('name')); - expect(userType.getText()).toEqual('userType ='); - expect(valid.getText()).toContain('false'); - }); + expect(element(by.binding('name')).getText()).toBe('Whirled'); + nameInput.clear(); + nameInput.sendKeys('world'); + expect(element(by.binding('name')).getText()).toBe('world'); + }); </file> </example> - * */ - var formDirectiveFactory = function(isNgForm) { - return ['$timeout', function($timeout) { - var formDirective = { - name: 'form', - restrict: isNgForm ? 'EAC' : 'E', - controller: FormController, - compile: function() { - return { - pre: function(scope, formElement, attr, controller) { - if (!attr.action) { - // we can't use jq events because if a form is destroyed during submission the default - // action is not prevented. see #1238 - // - // IE 9 is not affected because it doesn't fire a submit event and try to do a full - // page reload if the form was destroyed by submission of the form via a click handler - // on a button in the form. Looks like an IE9 specific bug. - var preventDefaultListener = function(event) { - event.preventDefault - ? event.preventDefault() - : event.returnValue = false; // IE - }; - - addEventListenerFn(formElement[0], 'submit', preventDefaultListener); - - // unregister the preventDefault listener so that we don't not leak memory but in a - // way that will achieve the prevention of the default action. - formElement.on('$destroy', function() { - $timeout(function() { - removeEventListenerFn(formElement[0], 'submit', preventDefaultListener); - }, 0, false); - }); - } - - var parentFormCtrl = formElement.parent().controller('form'), - alias = attr.name || attr.ngForm; + var ngBindDirective = ['$compile', function($compile) { + return { + restrict: 'AC', + compile: function ngBindCompile(templateElement) { + $compile.$$addBindingClass(templateElement); + return function ngBindLink(scope, element, attr) { + $compile.$$addBindingInfo(element, attr.ngBind); + element = element[0]; + scope.$watch(attr.ngBind, function ngBindWatchAction(value) { + element.textContent = stringify(value); + }); + }; + } + }; + }]; - if (alias) { - setter(scope, alias, controller, alias); - } - if (parentFormCtrl) { - formElement.on('$destroy', function() { - parentFormCtrl.$removeControl(controller); - if (alias) { - setter(scope, alias, undefined, alias); - } - extend(controller, nullFormCtrl); //stop propagating child destruction handlers upwards - }); - } - } - }; - } - }; - return formDirective; - }]; - }; + /** + * @ngdoc directive + * @name ngBindTemplate + * + * @description + * The `ngBindTemplate` directive specifies that the element + * text content should be replaced with the interpolation of the template + * in the `ngBindTemplate` attribute. + * Unlike `ngBind`, the `ngBindTemplate` can contain multiple `{{` `}}` + * expressions. This directive is needed since some HTML elements + * (such as TITLE and OPTION) cannot contain SPAN elements. + * + * @element ANY + * @param {string} ngBindTemplate template of form + * <tt>{{</tt> <tt>expression</tt> <tt>}}</tt> to eval. + * + * @example + * Try it here: enter text in text box and watch the greeting change. + <example module="bindExample" name="ng-bind-template"> + <file name="index.html"> + <script> + angular.module('bindExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.salutation = 'Hello'; + $scope.name = 'World'; + }]); + </script> + <div ng-controller="ExampleController"> + <label>Salutation: <input type="text" ng-model="salutation"></label><br> + <label>Name: <input type="text" ng-model="name"></label><br> + <pre ng-bind-template="{{salutation}} {{name}}!"></pre> + </div> + </file> + <file name="protractor.js" type="protractor"> + it('should check ng-bind', function() { + var salutationElem = element(by.binding('salutation')); + var salutationInput = element(by.model('salutation')); + var nameInput = element(by.model('name')); - var formDirective = formDirectiveFactory(); - var ngFormDirective = formDirectiveFactory(true); + expect(salutationElem.getText()).toBe('Hello World!'); - /* global + salutationInput.clear(); + salutationInput.sendKeys('Greetings'); + nameInput.clear(); + nameInput.sendKeys('user'); - -VALID_CLASS, - -INVALID_CLASS, - -PRISTINE_CLASS, - -DIRTY_CLASS + expect(salutationElem.getText()).toBe('Greetings user!'); + }); + </file> + </example> */ + var ngBindTemplateDirective = ['$interpolate', '$compile', function($interpolate, $compile) { + return { + compile: function ngBindTemplateCompile(templateElement) { + $compile.$$addBindingClass(templateElement); + return function ngBindTemplateLink(scope, element, attr) { + var interpolateFn = $interpolate(element.attr(attr.$attr.ngBindTemplate)); + $compile.$$addBindingInfo(element, interpolateFn.expressions); + element = element[0]; + attr.$observe('ngBindTemplate', function(value) { + element.textContent = isUndefined(value) ? '' : value; + }); + }; + } + }; + }]; - var URL_REGEXP = /^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/; - var EMAIL_REGEXP = /^[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(\.[a-z0-9-]+)*$/i; - var NUMBER_REGEXP = /^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/; - var inputType = { + /** + * @ngdoc directive + * @name ngBindHtml + * + * @description + * Evaluates the expression and inserts the resulting HTML into the element in a secure way. By default, + * the resulting HTML content will be sanitized using the {@link ngSanitize.$sanitize $sanitize} service. + * To utilize this functionality, ensure that `$sanitize` is available, for example, by including {@link + * ngSanitize} in your module's dependencies (not in core AngularJS). In order to use {@link ngSanitize} + * in your module's dependencies, you need to include "angular-sanitize.js" in your application. + * + * You may also bypass sanitization for values you know are safe. To do so, bind to + * an explicitly trusted value via {@link ng.$sce#trustAsHtml $sce.trustAsHtml}. See the example + * under {@link ng.$sce#show-me-an-example-using-sce- Strict Contextual Escaping (SCE)}. + * + * Note: If a `$sanitize` service is unavailable and the bound value isn't explicitly trusted, you + * will have an exception (instead of an exploit.) + * + * @element ANY + * @param {expression} ngBindHtml {@link guide/expression Expression} to evaluate. + * + * @example - /** - * @ngdoc input - * @name input[text] - * - * @description - * Standard HTML text input with angular data binding. - * - * @param {string} ngModel Assignable angular expression to data-bind to. - * @param {string=} name Property name of the form under which the control is published. - * @param {string=} required Adds `required` validation error key if the value is not entered. - * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to - * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of - * `required` when you want to data-bind to the `required` attribute. - * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than - * minlength. - * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than - * maxlength. - * @param {string=} ngPattern Sets `pattern` validation error key if the value does not match the - * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for - * patterns defined as scope expressions. - * @param {string=} ngChange Angular expression to be executed when input changes due to user - * interaction with the input element. - * @param {boolean=} [ngTrim=true] If set to false Angular will not automatically trim the input. - * - * @example - <example name="text-input-directive"> - <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.text = 'guest'; - $scope.word = /^\s*\w*\s*$/; - } - </script> - <form name="myForm" ng-controller="Ctrl"> - Single word: <input type="text" name="input" ng-model="text" - ng-pattern="word" required ng-trim="false"> - <span class="error" ng-show="myForm.input.$error.required"> - Required!</span> - <span class="error" ng-show="myForm.input.$error.pattern"> - Single word only!</span> + <example module="bindHtmlExample" deps="angular-sanitize.js" name="ng-bind-html"> + <file name="index.html"> + <div ng-controller="ExampleController"> + <p ng-bind-html="myHTML"></p> + </div> + </file> - <tt>text = {{text}}</tt><br/> - <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> - <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> - <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> - <tt>myForm.$error.required = {{!!myForm.$error.required}}</tt><br/> - </form> - </file> - <file name="protractor.js" type="protractor"> - var text = element(by.binding('text')); - var valid = element(by.binding('myForm.input.$valid')); - var input = element(by.model('text')); + <file name="script.js"> + angular.module('bindHtmlExample', ['ngSanitize']) + .controller('ExampleController', ['$scope', function($scope) { + $scope.myHTML = + 'I am an <code>HTML</code>string with ' + + '<a href="#">links!</a> and other <em>stuff</em>'; + }]); + </file> - it('should initialize to model', function() { - expect(text.getText()).toContain('guest'); - expect(valid.getText()).toContain('true'); - }); + <file name="protractor.js" type="protractor"> + it('should check ng-bind-html', function() { + expect(element(by.binding('myHTML')).getText()).toBe( + 'I am an HTMLstring with links! and other stuff'); + }); + </file> + </example> + */ + var ngBindHtmlDirective = ['$sce', '$parse', '$compile', function($sce, $parse, $compile) { + return { + restrict: 'A', + compile: function ngBindHtmlCompile(tElement, tAttrs) { + var ngBindHtmlGetter = $parse(tAttrs.ngBindHtml); + var ngBindHtmlWatch = $parse(tAttrs.ngBindHtml, function sceValueOf(val) { + // Unwrap the value to compare the actual inner safe value, not the wrapper object. + return $sce.valueOf(val); + }); + $compile.$$addBindingClass(tElement); - it('should be invalid if empty', function() { - input.clear(); - input.sendKeys(''); + return function ngBindHtmlLink(scope, element, attr) { + $compile.$$addBindingInfo(element, attr.ngBindHtml); - expect(text.getText()).toEqual('text ='); - expect(valid.getText()).toContain('false'); - }); + scope.$watch(ngBindHtmlWatch, function ngBindHtmlWatchAction() { + // The watched value is the unwrapped value. To avoid re-escaping, use the direct getter. + var value = ngBindHtmlGetter(scope); + element.html($sce.getTrustedHtml(value) || ''); + }); + }; + } + }; + }]; + + /** + * @ngdoc directive + * @name ngChange + * @restrict A + * + * @description + * Evaluate the given expression when the user changes the input. + * The expression is evaluated immediately, unlike the JavaScript onchange event + * which only triggers at the end of a change (usually, when the user leaves the + * form element or presses the return key). + * + * The `ngChange` expression is only evaluated when a change in the input value causes + * a new value to be committed to the model. + * + * It will not be evaluated: + * * if the value returned from the `$parsers` transformation pipeline has not changed + * * if the input has continued to be invalid since the model will stay `null` + * * if the model is changed programmatically and not by a change to the input value + * + * + * Note, this directive requires `ngModel` to be present. + * + * @element ANY + * @param {expression} ngChange {@link guide/expression Expression} to evaluate upon change + * in input value. + * + * @example + * <example name="ngChange-directive" module="changeExample"> + * <file name="index.html"> + * <script> + * angular.module('changeExample', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.counter = 0; + * $scope.change = function() { + * $scope.counter++; + * }; + * }]); + * </script> + * <div ng-controller="ExampleController"> + * <input type="checkbox" ng-model="confirmed" ng-change="change()" id="ng-change-example1" /> + * <input type="checkbox" ng-model="confirmed" id="ng-change-example2" /> + * <label for="ng-change-example2">Confirmed</label><br /> + * <tt>debug = {{confirmed}}</tt><br/> + * <tt>counter = {{counter}}</tt><br/> + * </div> + * </file> + * <file name="protractor.js" type="protractor"> + * var counter = element(by.binding('counter')); + * var debug = element(by.binding('confirmed')); + * + * it('should evaluate the expression if changing from view', function() { + * expect(counter.getText()).toContain('0'); + * + * element(by.id('ng-change-example1')).click(); + * + * expect(counter.getText()).toContain('1'); + * expect(debug.getText()).toContain('true'); + * }); + * + * it('should not evaluate the expression if changing from model', function() { + * element(by.id('ng-change-example2')).click(); - it('should be invalid if multi word', function() { - input.clear(); - input.sendKeys('hello world'); + * expect(counter.getText()).toContain('0'); + * expect(debug.getText()).toContain('true'); + * }); + * </file> + * </example> + */ + var ngChangeDirective = valueFn({ + restrict: 'A', + require: 'ngModel', + link: function(scope, element, attr, ctrl) { + ctrl.$viewChangeListeners.push(function() { + scope.$eval(attr.ngChange); + }); + } + }); - expect(valid.getText()).toContain('false'); - }); - </file> - </example> - */ - 'text': textInputType, + /* exported + ngClassDirective, + ngClassEvenDirective, + ngClassOddDirective +*/ + function classDirective(name, selector) { + name = 'ngClass' + name; + var indexWatchExpression; - /** - * @ngdoc input - * @name input[number] - * - * @description - * Text input with number validation and transformation. Sets the `number` validation - * error if not a valid number. - * - * @param {string} ngModel Assignable angular expression to data-bind to. - * @param {string=} name Property name of the form under which the control is published. - * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. - * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. - * @param {string=} required Sets `required` validation error key if the value is not entered. - * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to - * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of - * `required` when you want to data-bind to the `required` attribute. - * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than - * minlength. - * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than - * maxlength. - * @param {string=} ngPattern Sets `pattern` validation error key if the value does not match the - * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for - * patterns defined as scope expressions. - * @param {string=} ngChange Angular expression to be executed when input changes due to user - * interaction with the input element. - * - * @example - <example name="number-input-directive"> - <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.value = 12; - } - </script> - <form name="myForm" ng-controller="Ctrl"> - Number: <input type="number" name="input" ng-model="value" - min="0" max="99" required> - <span class="error" ng-show="myForm.input.$error.required"> - Required!</span> - <span class="error" ng-show="myForm.input.$error.number"> - Not valid number!</span> - <tt>value = {{value}}</tt><br/> - <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> - <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> - <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> - <tt>myForm.$error.required = {{!!myForm.$error.required}}</tt><br/> - </form> - </file> - <file name="protractor.js" type="protractor"> - var value = element(by.binding('value')); - var valid = element(by.binding('myForm.input.$valid')); - var input = element(by.model('value')); + return ['$parse', function($parse) { + return { + restrict: 'AC', + link: function(scope, element, attr) { + var expression = attr[name].trim(); + var isOneTime = (expression.charAt(0) === ':') && (expression.charAt(1) === ':'); - it('should initialize to model', function() { - expect(value.getText()).toContain('12'); - expect(valid.getText()).toContain('true'); - }); + var watchInterceptor = isOneTime ? toFlatValue : toClassString; + var watchExpression = $parse(expression, watchInterceptor); + var watchAction = isOneTime ? ngClassOneTimeWatchAction : ngClassWatchAction; - it('should be invalid if empty', function() { - input.clear(); - input.sendKeys(''); - expect(value.getText()).toEqual('value ='); - expect(valid.getText()).toContain('false'); - }); + var classCounts = element.data('$classCounts'); + var oldModulo = true; + var oldClassString; - it('should be invalid if over max', function() { - input.clear(); - input.sendKeys('123'); - expect(value.getText()).toEqual('value ='); - expect(valid.getText()).toContain('false'); - }); - </file> - </example> - */ - 'number': numberInputType, + if (!classCounts) { + // Use createMap() to prevent class assumptions involving property + // names in Object.prototype + classCounts = createMap(); + element.data('$classCounts', classCounts); + } + if (name !== 'ngClass') { + if (!indexWatchExpression) { + indexWatchExpression = $parse('$index', function moduloTwo($index) { + // eslint-disable-next-line no-bitwise + return $index & 1; + }); + } - /** - * @ngdoc input - * @name input[url] - * - * @description - * Text input with URL validation. Sets the `url` validation error key if the content is not a - * valid URL. - * - * @param {string} ngModel Assignable angular expression to data-bind to. - * @param {string=} name Property name of the form under which the control is published. - * @param {string=} required Sets `required` validation error key if the value is not entered. - * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to - * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of - * `required` when you want to data-bind to the `required` attribute. - * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than - * minlength. - * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than - * maxlength. - * @param {string=} ngPattern Sets `pattern` validation error key if the value does not match the - * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for - * patterns defined as scope expressions. - * @param {string=} ngChange Angular expression to be executed when input changes due to user - * interaction with the input element. - * - * @example - <example name="url-input-directive"> - <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.text = 'http://google.com'; - } - </script> - <form name="myForm" ng-controller="Ctrl"> - URL: <input type="url" name="input" ng-model="text" required> - <span class="error" ng-show="myForm.input.$error.required"> - Required!</span> - <span class="error" ng-show="myForm.input.$error.url"> - Not valid url!</span> - <tt>text = {{text}}</tt><br/> - <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> - <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> - <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> - <tt>myForm.$error.required = {{!!myForm.$error.required}}</tt><br/> - <tt>myForm.$error.url = {{!!myForm.$error.url}}</tt><br/> - </form> - </file> - <file name="protractor.js" type="protractor"> - var text = element(by.binding('text')); - var valid = element(by.binding('myForm.input.$valid')); - var input = element(by.model('text')); + scope.$watch(indexWatchExpression, ngClassIndexWatchAction); + } - it('should initialize to model', function() { - expect(text.getText()).toContain('http://google.com'); - expect(valid.getText()).toContain('true'); - }); + scope.$watch(watchExpression, watchAction, isOneTime); - it('should be invalid if empty', function() { - input.clear(); - input.sendKeys(''); + function addClasses(classString) { + classString = digestClassCounts(split(classString), 1); + attr.$addClass(classString); + } - expect(text.getText()).toEqual('text ='); - expect(valid.getText()).toContain('false'); - }); + function removeClasses(classString) { + classString = digestClassCounts(split(classString), -1); + attr.$removeClass(classString); + } - it('should be invalid if not url', function() { - input.clear(); - input.sendKeys('box'); + function updateClasses(oldClassString, newClassString) { + var oldClassArray = split(oldClassString); + var newClassArray = split(newClassString); - expect(valid.getText()).toContain('false'); - }); - </file> - </example> - */ - 'url': urlInputType, + var toRemoveArray = arrayDifference(oldClassArray, newClassArray); + var toAddArray = arrayDifference(newClassArray, oldClassArray); + var toRemoveString = digestClassCounts(toRemoveArray, -1); + var toAddString = digestClassCounts(toAddArray, 1); - /** - * @ngdoc input - * @name input[email] - * - * @description - * Text input with email validation. Sets the `email` validation error key if not a valid email - * address. - * - * @param {string} ngModel Assignable angular expression to data-bind to. - * @param {string=} name Property name of the form under which the control is published. - * @param {string=} required Sets `required` validation error key if the value is not entered. - * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to - * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of - * `required` when you want to data-bind to the `required` attribute. - * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than - * minlength. - * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than - * maxlength. - * @param {string=} ngPattern Sets `pattern` validation error key if the value does not match the - * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for - * patterns defined as scope expressions. - * @param {string=} ngChange Angular expression to be executed when input changes due to user - * interaction with the input element. - * - * @example - <example name="email-input-directive"> - <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.text = 'me@example.com'; - } - </script> - <form name="myForm" ng-controller="Ctrl"> - Email: <input type="email" name="input" ng-model="text" required> - <span class="error" ng-show="myForm.input.$error.required"> - Required!</span> - <span class="error" ng-show="myForm.input.$error.email"> - Not valid email!</span> - <tt>text = {{text}}</tt><br/> - <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> - <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> - <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> - <tt>myForm.$error.required = {{!!myForm.$error.required}}</tt><br/> - <tt>myForm.$error.email = {{!!myForm.$error.email}}</tt><br/> - </form> - </file> - <file name="protractor.js" type="protractor"> - var text = element(by.binding('text')); - var valid = element(by.binding('myForm.input.$valid')); - var input = element(by.model('text')); + attr.$addClass(toAddString); + attr.$removeClass(toRemoveString); + } - it('should initialize to model', function() { - expect(text.getText()).toContain('me@example.com'); - expect(valid.getText()).toContain('true'); - }); + function digestClassCounts(classArray, count) { + var classesToUpdate = []; - it('should be invalid if empty', function() { - input.clear(); - input.sendKeys(''); - expect(text.getText()).toEqual('text ='); - expect(valid.getText()).toContain('false'); - }); + forEach(classArray, function(className) { + if (count > 0 || classCounts[className]) { + classCounts[className] = (classCounts[className] || 0) + count; + if (classCounts[className] === +(count > 0)) { + classesToUpdate.push(className); + } + } + }); - it('should be invalid if not email', function() { - input.clear(); - input.sendKeys('xxx'); + return classesToUpdate.join(' '); + } - expect(valid.getText()).toContain('false'); - }); - </file> - </example> - */ - 'email': emailInputType, + function ngClassIndexWatchAction(newModulo) { + // This watch-action should run before the `ngClass[OneTime]WatchAction()`, thus it + // adds/removes `oldClassString`. If the `ngClass` expression has changed as well, the + // `ngClass[OneTime]WatchAction()` will update the classes. + if (newModulo === selector) { + addClasses(oldClassString); + } else { + removeClasses(oldClassString); + } + oldModulo = newModulo; + } - /** - * @ngdoc input - * @name input[radio] - * - * @description - * HTML radio button. - * - * @param {string} ngModel Assignable angular expression to data-bind to. - * @param {string} value The value to which the expression should be set when selected. - * @param {string=} name Property name of the form under which the control is published. - * @param {string=} ngChange Angular expression to be executed when input changes due to user - * interaction with the input element. - * @param {string} ngValue Angular expression which sets the value to which the expression should - * be set when selected. - * - * @example - <example name="radio-input-directive"> - <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.color = 'blue'; - $scope.specialValue = { - "id": "12345", - "value": "green" - }; - } - </script> - <form name="myForm" ng-controller="Ctrl"> - <input type="radio" ng-model="color" value="red"> Red <br/> - <input type="radio" ng-model="color" ng-value="specialValue"> Green <br/> - <input type="radio" ng-model="color" value="blue"> Blue <br/> - <tt>color = {{color | json}}</tt><br/> - </form> - Note that `ng-value="specialValue"` sets radio item's value to be the value of `$scope.specialValue`. - </file> - <file name="protractor.js" type="protractor"> - it('should change state', function() { - var color = element(by.binding('color')); + function ngClassOneTimeWatchAction(newClassValue) { + var newClassString = toClassString(newClassValue); - expect(color.getText()).toContain('blue'); + if (newClassString !== oldClassString) { + ngClassWatchAction(newClassString); + } + } - element.all(by.model('color')).get(0).click(); + function ngClassWatchAction(newClassString) { + if (oldModulo === selector) { + updateClasses(oldClassString, newClassString); + } - expect(color.getText()).toContain('red'); - }); - </file> - </example> - */ - 'radio': radioInputType, + oldClassString = newClassString; + } + } + }; + }]; + // Helpers + function arrayDifference(tokens1, tokens2) { + if (!tokens1 || !tokens1.length) return []; + if (!tokens2 || !tokens2.length) return tokens1; - /** - * @ngdoc input - * @name input[checkbox] - * - * @description - * HTML checkbox. - * - * @param {string} ngModel Assignable angular expression to data-bind to. - * @param {string=} name Property name of the form under which the control is published. - * @param {string=} ngTrueValue The value to which the expression should be set when selected. - * @param {string=} ngFalseValue The value to which the expression should be set when not selected. - * @param {string=} ngChange Angular expression to be executed when input changes due to user - * interaction with the input element. - * - * @example - <example name="checkbox-input-directive"> - <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.value1 = true; - $scope.value2 = 'YES' - } - </script> - <form name="myForm" ng-controller="Ctrl"> - Value1: <input type="checkbox" ng-model="value1"> <br/> - Value2: <input type="checkbox" ng-model="value2" - ng-true-value="YES" ng-false-value="NO"> <br/> - <tt>value1 = {{value1}}</tt><br/> - <tt>value2 = {{value2}}</tt><br/> - </form> - </file> - <file name="protractor.js" type="protractor"> - it('should change state', function() { - var value1 = element(by.binding('value1')); - var value2 = element(by.binding('value2')); + var values = []; - expect(value1.getText()).toContain('true'); - expect(value2.getText()).toContain('YES'); + outer: + for (var i = 0; i < tokens1.length; i++) { + var token = tokens1[i]; + for (var j = 0; j < tokens2.length; j++) { + if (token === tokens2[j]) continue outer; + } + values.push(token); + } - element(by.model('value1')).click(); - element(by.model('value2')).click(); + return values; + } - expect(value1.getText()).toContain('false'); - expect(value2.getText()).toContain('NO'); - }); - </file> - </example> - */ - 'checkbox': checkboxInputType, + function split(classString) { + return classString && classString.split(' '); + } - 'hidden': noop, - 'button': noop, - 'submit': noop, - 'reset': noop, - 'file': noop - }; + function toClassString(classValue) { + var classString = classValue; -// A helper function to call $setValidity and return the value / undefined, -// a pattern that is repeated a lot in the input validation logic. - function validate(ctrl, validatorName, validity, value){ - ctrl.$setValidity(validatorName, validity); - return validity ? value : undefined; - } + if (isArray(classValue)) { + classString = classValue.map(toClassString).join(' '); + } else if (isObject(classValue)) { + classString = Object.keys(classValue). + filter(function(key) { return classValue[key]; }). + join(' '); + } + return classString; + } - function addNativeHtml5Validators(ctrl, validatorName, element) { - var validity = element.prop('validity'); - if (isObject(validity)) { - var validator = function(value) { - // Don't overwrite previous validation, don't consider valueMissing to apply (ng-required can - // perform the required validation) - if (!ctrl.$error[validatorName] && (validity.badInput || validity.customError || - validity.typeMismatch) && !validity.valueMissing) { - ctrl.$setValidity(validatorName, false); - return; + function toFlatValue(classValue) { + var flatValue = classValue; + + if (isArray(classValue)) { + flatValue = classValue.map(toFlatValue); + } else if (isObject(classValue)) { + var hasUndefined = false; + + flatValue = Object.keys(classValue).filter(function(key) { + var value = classValue[key]; + + if (!hasUndefined && isUndefined(value)) { + hasUndefined = true; + } + + return value; + }); + + if (hasUndefined) { + // Prevent the `oneTimeLiteralWatchInterceptor` from unregistering + // the watcher, by including at least one `undefined` value. + flatValue.push(undefined); } - return value; - }; - ctrl.$parsers.push(validator); + } + + return flatValue; } } - function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { - var validity = element.prop('validity'); - // In composition mode, users are still inputing intermediate text buffer, - // hold the listener until composition is done. - // More about composition events: https://developer.mozilla.org/en-US/docs/Web/API/CompositionEvent - if (!$sniffer.android) { - var composing = false; + /** + * @ngdoc directive + * @name ngClass + * @restrict AC + * @element ANY + * + * @description + * The `ngClass` directive allows you to dynamically set CSS classes on an HTML element by databinding + * an expression that represents all classes to be added. + * + * The directive operates in three different ways, depending on which of three types the expression + * evaluates to: + * + * 1. If the expression evaluates to a string, the string should be one or more space-delimited class + * names. + * + * 2. If the expression evaluates to an object, then for each key-value pair of the + * object with a truthy value the corresponding key is used as a class name. + * + * 3. If the expression evaluates to an array, each element of the array should either be a string as in + * type 1 or an object as in type 2. This means that you can mix strings and objects together in an array + * to give you more control over what CSS classes appear. See the code below for an example of this. + * + * + * The directive won't add duplicate classes if a particular class was already set. + * + * When the expression changes, the previously added classes are removed and only then are the + * new classes added. + * + * @knownIssue + * You should not use {@link guide/interpolation interpolation} in the value of the `class` + * attribute, when using the `ngClass` directive on the same element. + * See {@link guide/interpolation#known-issues here} for more info. + * + * @animations + * | Animation | Occurs | + * |----------------------------------|-------------------------------------| + * | {@link ng.$animate#addClass addClass} | just before the class is applied to the element | + * | {@link ng.$animate#removeClass removeClass} | just before the class is removed from the element | + * + * ### ngClass and pre-existing CSS3 Transitions/Animations + The ngClass directive still supports CSS3 Transitions/Animations even if they do not follow the ngAnimate CSS naming structure. + Upon animation ngAnimate will apply supplementary CSS classes to track the start and end of an animation, but this will not hinder + any pre-existing CSS transitions already on the element. To get an idea of what happens during a class-based animation, be sure + to view the step by step details of {@link $animate#addClass $animate.addClass} and + {@link $animate#removeClass $animate.removeClass}. + * + * @param {expression} ngClass {@link guide/expression Expression} to eval. The result + * of the evaluation can be a string representing space delimited class + * names, an array, or a map of class names to boolean values. In the case of a map, the + * names of the properties whose values are truthy will be added as css classes to the + * element. + * + * @example + * ### Basic + <example name="ng-class"> + <file name="index.html"> + <p ng-class="{strike: deleted, bold: important, 'has-error': error}">Map Syntax Example</p> + <label> + <input type="checkbox" ng-model="deleted"> + deleted (apply "strike" class) + </label><br> + <label> + <input type="checkbox" ng-model="important"> + important (apply "bold" class) + </label><br> + <label> + <input type="checkbox" ng-model="error"> + error (apply "has-error" class) + </label> + <hr> + <p ng-class="style">Using String Syntax</p> + <input type="text" ng-model="style" + placeholder="Type: bold strike red" aria-label="Type: bold strike red"> + <hr> + <p ng-class="[style1, style2, style3]">Using Array Syntax</p> + <input ng-model="style1" + placeholder="Type: bold, strike or red" aria-label="Type: bold, strike or red"><br> + <input ng-model="style2" + placeholder="Type: bold, strike or red" aria-label="Type: bold, strike or red 2"><br> + <input ng-model="style3" + placeholder="Type: bold, strike or red" aria-label="Type: bold, strike or red 3"><br> + <hr> + <p ng-class="[style4, {orange: warning}]">Using Array and Map Syntax</p> + <input ng-model="style4" placeholder="Type: bold, strike" aria-label="Type: bold, strike"><br> + <label><input type="checkbox" ng-model="warning"> warning (apply "orange" class)</label> + </file> + <file name="style.css"> + .strike { + text-decoration: line-through; + } + .bold { + font-weight: bold; + } + .red { + color: red; + } + .has-error { + color: red; + background-color: yellow; + } + .orange { + color: orange; + } + </file> + <file name="protractor.js" type="protractor"> + var ps = element.all(by.css('p')); - element.on('compositionstart', function(data) { - composing = true; - }); + it('should let you toggle the class', function() { - element.on('compositionend', function() { - composing = false; - listener(); - }); - } + expect(ps.first().getAttribute('class')).not.toMatch(/bold/); + expect(ps.first().getAttribute('class')).not.toMatch(/has-error/); - var listener = function() { - if (composing) return; - var value = element.val(); + element(by.model('important')).click(); + expect(ps.first().getAttribute('class')).toMatch(/bold/); - // By default we will trim the value - // If the attribute ng-trim exists we will avoid trimming - // e.g. <input ng-model="foo" ng-trim="false"> - if (toBoolean(attr.ngTrim || 'T')) { - value = trim(value); - } + element(by.model('error')).click(); + expect(ps.first().getAttribute('class')).toMatch(/has-error/); + }); - if (ctrl.$viewValue !== value || - // If the value is still empty/falsy, and there is no `required` error, run validators - // again. This enables HTML5 constraint validation errors to affect Angular validation - // even when the first character entered causes an error. - (validity && value === '' && !validity.valueMissing)) { - if (scope.$$phase) { - ctrl.$setViewValue(value); - } else { - scope.$apply(function() { - ctrl.$setViewValue(value); - }); - } - } - }; + it('should let you toggle string example', function() { + expect(ps.get(1).getAttribute('class')).toBe(''); + element(by.model('style')).clear(); + element(by.model('style')).sendKeys('red'); + expect(ps.get(1).getAttribute('class')).toBe('red'); + }); - // if the browser does support "input" event, we are fine - except on IE9 which doesn't fire the - // input event on backspace, delete or cut - if ($sniffer.hasEvent('input')) { - element.on('input', listener); - } else { - var timeout; + it('array example should have 3 classes', function() { + expect(ps.get(2).getAttribute('class')).toBe(''); + element(by.model('style1')).sendKeys('bold'); + element(by.model('style2')).sendKeys('strike'); + element(by.model('style3')).sendKeys('red'); + expect(ps.get(2).getAttribute('class')).toBe('bold strike red'); + }); - var deferListener = function() { - if (!timeout) { - timeout = $browser.defer(function() { - listener(); - timeout = null; - }); - } - }; + it('array with map example should have 2 classes', function() { + expect(ps.last().getAttribute('class')).toBe(''); + element(by.model('style4')).sendKeys('bold'); + element(by.model('warning')).click(); + expect(ps.last().getAttribute('class')).toBe('bold orange'); + }); + </file> + </example> - element.on('keydown', function(event) { - var key = event.keyCode; + @example + ### Animations - // ignore - // command modifiers arrows - if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return; + The example below demonstrates how to perform animations using ngClass. + + <example module="ngAnimate" deps="angular-animate.js" animations="true" name="ng-class"> + <file name="index.html"> + <input id="setbtn" type="button" value="set" ng-click="myVar='my-class'"> + <input id="clearbtn" type="button" value="clear" ng-click="myVar=''"> + <br> + <span class="base-class" ng-class="myVar">Sample Text</span> + </file> + <file name="style.css"> + .base-class { + transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; + } + + .base-class.my-class { + color: red; + font-size:3em; + } + </file> + <file name="protractor.js" type="protractor"> + it('should check ng-class', function() { + expect(element(by.css('.base-class')).getAttribute('class')).not. + toMatch(/my-class/); + + element(by.id('setbtn')).click(); + + expect(element(by.css('.base-class')).getAttribute('class')). + toMatch(/my-class/); + + element(by.id('clearbtn')).click(); + + expect(element(by.css('.base-class')).getAttribute('class')).not. + toMatch(/my-class/); + }); + </file> + </example> + */ + var ngClassDirective = classDirective('', true); + + /** + * @ngdoc directive + * @name ngClassOdd + * @restrict AC + * + * @description + * The `ngClassOdd` and `ngClassEven` directives work exactly as + * {@link ng.directive:ngClass ngClass}, except they work in + * conjunction with `ngRepeat` and take effect only on odd (even) rows. + * + * This directive can be applied only within the scope of an + * {@link ng.directive:ngRepeat ngRepeat}. + * + * @element ANY + * @param {expression} ngClassOdd {@link guide/expression Expression} to eval. The result + * of the evaluation can be a string representing space delimited class names or an array. + * + * @example + <example name="ng-class-odd"> + <file name="index.html"> + <ol ng-init="names=['John', 'Mary', 'Cate', 'Suz']"> + <li ng-repeat="name in names"> + <span ng-class-odd="'odd'" ng-class-even="'even'"> + {{name}} + </span> + </li> + </ol> + </file> + <file name="style.css"> + .odd { + color: red; + } + .even { + color: blue; + } + </file> + <file name="protractor.js" type="protractor"> + it('should check ng-class-odd and ng-class-even', function() { + expect(element(by.repeater('name in names').row(0).column('name')).getAttribute('class')). + toMatch(/odd/); + expect(element(by.repeater('name in names').row(1).column('name')).getAttribute('class')). + toMatch(/even/); + }); + </file> + </example> + */ + var ngClassOddDirective = classDirective('Odd', 0); - deferListener(); - }); + /** + * @ngdoc directive + * @name ngClassEven + * @restrict AC + * + * @description + * The `ngClassOdd` and `ngClassEven` directives work exactly as + * {@link ng.directive:ngClass ngClass}, except they work in + * conjunction with `ngRepeat` and take effect only on odd (even) rows. + * + * This directive can be applied only within the scope of an + * {@link ng.directive:ngRepeat ngRepeat}. + * + * @element ANY + * @param {expression} ngClassEven {@link guide/expression Expression} to eval. The + * result of the evaluation can be a string representing space delimited class names or an array. + * + * @example + <example name="ng-class-even"> + <file name="index.html"> + <ol ng-init="names=['John', 'Mary', 'Cate', 'Suz']"> + <li ng-repeat="name in names"> + <span ng-class-odd="'odd'" ng-class-even="'even'"> + {{name}}       + </span> + </li> + </ol> + </file> + <file name="style.css"> + .odd { + color: red; + } + .even { + color: blue; + } + </file> + <file name="protractor.js" type="protractor"> + it('should check ng-class-odd and ng-class-even', function() { + expect(element(by.repeater('name in names').row(0).column('name')).getAttribute('class')). + toMatch(/odd/); + expect(element(by.repeater('name in names').row(1).column('name')).getAttribute('class')). + toMatch(/even/); + }); + </file> + </example> + */ + var ngClassEvenDirective = classDirective('Even', 1); - // if user modifies input value using context menu in IE, we need "paste" and "cut" events to catch it - if ($sniffer.hasEvent('paste')) { - element.on('paste cut', deferListener); - } + /** + * @ngdoc directive + * @name ngCloak + * @restrict AC + * + * @description + * The `ngCloak` directive is used to prevent the AngularJS html template from being briefly + * displayed by the browser in its raw (uncompiled) form while your application is loading. Use this + * directive to avoid the undesirable flicker effect caused by the html template display. + * + * The directive can be applied to the `<body>` element, but the preferred usage is to apply + * multiple `ngCloak` directives to small portions of the page to permit progressive rendering + * of the browser view. + * + * `ngCloak` works in cooperation with the following css rule embedded within `angular.js` and + * `angular.min.js`. + * For CSP mode please add `angular-csp.css` to your html file (see {@link ng.directive:ngCsp ngCsp}). + * + * ```css + * [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak { + * display: none !important; + * } + * ``` + * + * When this css rule is loaded by the browser, all html elements (including their children) that + * are tagged with the `ngCloak` directive are hidden. When AngularJS encounters this directive + * during the compilation of the template it deletes the `ngCloak` element attribute, making + * the compiled element visible. + * + * For the best result, the `angular.js` script must be loaded in the head section of the html + * document; alternatively, the css rule above must be included in the external stylesheet of the + * application. + * + * @element ANY + * + * @example + <example name="ng-cloak"> + <file name="index.html"> + <div id="template1" ng-cloak>{{ 'hello' }}</div> + <div id="template2" class="ng-cloak">{{ 'world' }}</div> + </file> + <file name="protractor.js" type="protractor"> + it('should remove the template directive and css class', function() { + expect($('#template1').getAttribute('ng-cloak')). + toBeNull(); + expect($('#template2').getAttribute('ng-cloak')). + toBeNull(); + }); + </file> + </example> + * + */ + var ngCloakDirective = ngDirective({ + compile: function(element, attr) { + attr.$set('ngCloak', undefined); + element.removeClass('ng-cloak'); } + }); - // if user paste into input using mouse on older browser - // or form autocomplete on newer browser, we need "change" event to catch it - element.on('change', listener); + /** + * @ngdoc directive + * @name ngController + * + * @description + * The `ngController` directive attaches a controller class to the view. This is a key aspect of how angular + * supports the principles behind the Model-View-Controller design pattern. + * + * MVC components in angular: + * + * * Model — Models are the properties of a scope; scopes are attached to the DOM where scope properties + * are accessed through bindings. + * * View — The template (HTML with data bindings) that is rendered into the View. + * * Controller — The `ngController` directive specifies a Controller class; the class contains business + * logic behind the application to decorate the scope with functions and values + * + * Note that you can also attach controllers to the DOM by declaring it in a route definition + * via the {@link ngRoute.$route $route} service. A common mistake is to declare the controller + * again using `ng-controller` in the template itself. This will cause the controller to be attached + * and executed twice. + * + * @element ANY + * @scope + * @priority 500 + * @param {expression} ngController Name of a constructor function registered with the current + * {@link ng.$controllerProvider $controllerProvider} or an {@link guide/expression expression} + * that on the current scope evaluates to a constructor function. + * + * The controller instance can be published into a scope property by specifying + * `ng-controller="as propertyName"`. + * + * If the current `$controllerProvider` is configured to use globals (via + * {@link ng.$controllerProvider#allowGlobals `$controllerProvider.allowGlobals()` }), this may + * also be the name of a globally accessible constructor function (deprecated, not recommended). + * + * @example + * Here is a simple form for editing user contact information. Adding, removing, clearing, and + * greeting are methods declared on the controller (see source tab). These methods can + * easily be called from the AngularJS markup. Any changes to the data are automatically reflected + * in the View without the need for a manual update. + * + * Two different declaration styles are included below: + * + * * one binds methods and properties directly onto the controller using `this`: + * `ng-controller="SettingsController1 as settings"` + * * one injects `$scope` into the controller: + * `ng-controller="SettingsController2"` + * + * The second option is more common in the AngularJS community, and is generally used in boilerplates + * and in this guide. However, there are advantages to binding properties directly to the controller + * and avoiding scope. + * + * * Using `controller as` makes it obvious which controller you are accessing in the template when + * multiple controllers apply to an element. + * * If you are writing your controllers as classes you have easier access to the properties and + * methods, which will appear on the scope, from inside the controller code. + * * Since there is always a `.` in the bindings, you don't have to worry about prototypal + * inheritance masking primitives. + * + * This example demonstrates the `controller as` syntax. + * + * <example name="ngControllerAs" module="controllerAsExample"> + * <file name="index.html"> + * <div id="ctrl-as-exmpl" ng-controller="SettingsController1 as settings"> + * <label>Name: <input type="text" ng-model="settings.name"/></label> + * <button ng-click="settings.greet()">greet</button><br/> + * Contact: + * <ul> + * <li ng-repeat="contact in settings.contacts"> + * <select ng-model="contact.type" aria-label="Contact method" id="select_{{$index}}"> + * <option>phone</option> + * <option>email</option> + * </select> + * <input type="text" ng-model="contact.value" aria-labelledby="select_{{$index}}" /> + * <button ng-click="settings.clearContact(contact)">clear</button> + * <button ng-click="settings.removeContact(contact)" aria-label="Remove">X</button> + * </li> + * <li><button ng-click="settings.addContact()">add</button></li> + * </ul> + * </div> + * </file> + * <file name="app.js"> + * angular.module('controllerAsExample', []) + * .controller('SettingsController1', SettingsController1); + * + * function SettingsController1() { + * this.name = 'John Smith'; + * this.contacts = [ + * {type: 'phone', value: '408 555 1212'}, + * {type: 'email', value: 'john.smith@example.org'} + * ]; + * } + * + * SettingsController1.prototype.greet = function() { + * alert(this.name); + * }; + * + * SettingsController1.prototype.addContact = function() { + * this.contacts.push({type: 'email', value: 'yourname@example.org'}); + * }; + * + * SettingsController1.prototype.removeContact = function(contactToRemove) { + * var index = this.contacts.indexOf(contactToRemove); + * this.contacts.splice(index, 1); + * }; + * + * SettingsController1.prototype.clearContact = function(contact) { + * contact.type = 'phone'; + * contact.value = ''; + * }; + * </file> + * <file name="protractor.js" type="protractor"> + * it('should check controller as', function() { + * var container = element(by.id('ctrl-as-exmpl')); + * expect(container.element(by.model('settings.name')) + * .getAttribute('value')).toBe('John Smith'); + * + * var firstRepeat = + * container.element(by.repeater('contact in settings.contacts').row(0)); + * var secondRepeat = + * container.element(by.repeater('contact in settings.contacts').row(1)); + * + * expect(firstRepeat.element(by.model('contact.value')).getAttribute('value')) + * .toBe('408 555 1212'); + * + * expect(secondRepeat.element(by.model('contact.value')).getAttribute('value')) + * .toBe('john.smith@example.org'); + * + * firstRepeat.element(by.buttonText('clear')).click(); + * + * expect(firstRepeat.element(by.model('contact.value')).getAttribute('value')) + * .toBe(''); + * + * container.element(by.buttonText('add')).click(); + * + * expect(container.element(by.repeater('contact in settings.contacts').row(2)) + * .element(by.model('contact.value')) + * .getAttribute('value')) + * .toBe('yourname@example.org'); + * }); + * </file> + * </example> + * + * This example demonstrates the "attach to `$scope`" style of controller. + * + * <example name="ngController" module="controllerExample"> + * <file name="index.html"> + * <div id="ctrl-exmpl" ng-controller="SettingsController2"> + * <label>Name: <input type="text" ng-model="name"/></label> + * <button ng-click="greet()">greet</button><br/> + * Contact: + * <ul> + * <li ng-repeat="contact in contacts"> + * <select ng-model="contact.type" id="select_{{$index}}"> + * <option>phone</option> + * <option>email</option> + * </select> + * <input type="text" ng-model="contact.value" aria-labelledby="select_{{$index}}" /> + * <button ng-click="clearContact(contact)">clear</button> + * <button ng-click="removeContact(contact)">X</button> + * </li> + * <li>[ <button ng-click="addContact()">add</button> ]</li> + * </ul> + * </div> + * </file> + * <file name="app.js"> + * angular.module('controllerExample', []) + * .controller('SettingsController2', ['$scope', SettingsController2]); + * + * function SettingsController2($scope) { + * $scope.name = 'John Smith'; + * $scope.contacts = [ + * {type:'phone', value:'408 555 1212'}, + * {type:'email', value:'john.smith@example.org'} + * ]; + * + * $scope.greet = function() { + * alert($scope.name); + * }; + * + * $scope.addContact = function() { + * $scope.contacts.push({type:'email', value:'yourname@example.org'}); + * }; + * + * $scope.removeContact = function(contactToRemove) { + * var index = $scope.contacts.indexOf(contactToRemove); + * $scope.contacts.splice(index, 1); + * }; + * + * $scope.clearContact = function(contact) { + * contact.type = 'phone'; + * contact.value = ''; + * }; + * } + * </file> + * <file name="protractor.js" type="protractor"> + * it('should check controller', function() { + * var container = element(by.id('ctrl-exmpl')); + * + * expect(container.element(by.model('name')) + * .getAttribute('value')).toBe('John Smith'); + * + * var firstRepeat = + * container.element(by.repeater('contact in contacts').row(0)); + * var secondRepeat = + * container.element(by.repeater('contact in contacts').row(1)); + * + * expect(firstRepeat.element(by.model('contact.value')).getAttribute('value')) + * .toBe('408 555 1212'); + * expect(secondRepeat.element(by.model('contact.value')).getAttribute('value')) + * .toBe('john.smith@example.org'); + * + * firstRepeat.element(by.buttonText('clear')).click(); + * + * expect(firstRepeat.element(by.model('contact.value')).getAttribute('value')) + * .toBe(''); + * + * container.element(by.buttonText('add')).click(); + * + * expect(container.element(by.repeater('contact in contacts').row(2)) + * .element(by.model('contact.value')) + * .getAttribute('value')) + * .toBe('yourname@example.org'); + * }); + * </file> + *</example> - ctrl.$render = function() { - element.val(ctrl.$isEmpty(ctrl.$viewValue) ? '' : ctrl.$viewValue); + */ + var ngControllerDirective = [function() { + return { + restrict: 'A', + scope: true, + controller: '@', + priority: 500 }; + }]; - // pattern validator - var pattern = attr.ngPattern, - patternValidator, - match; - - if (pattern) { - var validateRegex = function(regexp, value) { - return validate(ctrl, 'pattern', ctrl.$isEmpty(value) || regexp.test(value), value); - }; - match = pattern.match(/^\/(.*)\/([gim]*)$/); - if (match) { - pattern = new RegExp(match[1], match[2]); - patternValidator = function(value) { - return validateRegex(pattern, value); - }; - } else { - patternValidator = function(value) { - var patternObj = scope.$eval(pattern); - - if (!patternObj || !patternObj.test) { - throw minErr('ngPattern')('noregexp', - 'Expected {0} to be a RegExp but was {1}. Element: {2}', pattern, - patternObj, startingTag(element)); - } - return validateRegex(patternObj, value); - }; - } - - ctrl.$formatters.push(patternValidator); - ctrl.$parsers.push(patternValidator); - } - - // min length validator - if (attr.ngMinlength) { - var minlength = int(attr.ngMinlength); - var minLengthValidator = function(value) { - return validate(ctrl, 'minlength', ctrl.$isEmpty(value) || value.length >= minlength, value); - }; - - ctrl.$parsers.push(minLengthValidator); - ctrl.$formatters.push(minLengthValidator); - } - - // max length validator - if (attr.ngMaxlength) { - var maxlength = int(attr.ngMaxlength); - var maxLengthValidator = function(value) { - return validate(ctrl, 'maxlength', ctrl.$isEmpty(value) || value.length <= maxlength, value); - }; - - ctrl.$parsers.push(maxLengthValidator); - ctrl.$formatters.push(maxLengthValidator); - } - } - - function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); - - ctrl.$parsers.push(function(value) { - var empty = ctrl.$isEmpty(value); - if (empty || NUMBER_REGEXP.test(value)) { - ctrl.$setValidity('number', true); - return value === '' ? null : (empty ? value : parseFloat(value)); - } else { - ctrl.$setValidity('number', false); - return undefined; - } - }); - - addNativeHtml5Validators(ctrl, 'number', element); + /** + * @ngdoc directive + * @name ngCsp + * + * @restrict A + * @element ANY + * @description + * + * AngularJS has some features that can conflict with certain restrictions that are applied when using + * [CSP (Content Security Policy)](https://developer.mozilla.org/en/Security/CSP) rules. + * + * If you intend to implement CSP with these rules then you must tell AngularJS not to use these + * features. + * + * This is necessary when developing things like Google Chrome Extensions or Universal Windows Apps. + * + * + * The following default rules in CSP affect AngularJS: + * + * * The use of `eval()`, `Function(string)` and similar functions to dynamically create and execute + * code from strings is forbidden. AngularJS makes use of this in the {@link $parse} service to + * provide a 30% increase in the speed of evaluating AngularJS expressions. (This CSP rule can be + * disabled with the CSP keyword `unsafe-eval`, but it is generally not recommended as it would + * weaken the protections offered by CSP.) + * + * * The use of inline resources, such as inline `<script>` and `<style>` elements, are forbidden. + * This prevents apps from injecting custom styles directly into the document. AngularJS makes use of + * this to include some CSS rules (e.g. {@link ngCloak} and {@link ngHide}). To make these + * directives work when a CSP rule is blocking inline styles, you must link to the `angular-csp.css` + * in your HTML manually. (This CSP rule can be disabled with the CSP keyword `unsafe-inline`, but + * it is generally not recommended as it would weaken the protections offered by CSP.) + * + * If you do not provide `ngCsp` then AngularJS tries to autodetect if CSP is blocking dynamic code + * creation from strings (e.g., `unsafe-eval` not specified in CSP header) and automatically + * deactivates this feature in the {@link $parse} service. This autodetection, however, triggers a + * CSP error to be logged in the console: + * + * ``` + * Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of + * script in the following Content Security Policy directive: "default-src 'self'". Note that + * 'script-src' was not explicitly set, so 'default-src' is used as a fallback. + * ``` + * + * This error is harmless but annoying. To prevent the error from showing up, put the `ngCsp` + * directive on an element of the HTML document that appears before the `<script>` tag that loads + * the `angular.js` file. + * + * *Note: This directive is only available in the `ng-csp` and `data-ng-csp` attribute form.* + * + * You can specify which of the CSP related AngularJS features should be deactivated by providing + * a value for the `ng-csp` attribute. The options are as follows: + * + * * no-inline-style: this stops AngularJS from injecting CSS styles into the DOM + * + * * no-unsafe-eval: this stops AngularJS from optimizing $parse with unsafe eval of strings + * + * You can use these values in the following combinations: + * + * + * * No declaration means that AngularJS will assume that you can do inline styles, but it will do + * a runtime check for unsafe-eval. E.g. `<body>`. This is backwardly compatible with previous + * versions of AngularJS. + * + * * A simple `ng-csp` (or `data-ng-csp`) attribute will tell AngularJS to deactivate both inline + * styles and unsafe eval. E.g. `<body ng-csp>`. This is backwardly compatible with previous + * versions of AngularJS. + * + * * Specifying only `no-unsafe-eval` tells AngularJS that we must not use eval, but that we can + * inject inline styles. E.g. `<body ng-csp="no-unsafe-eval">`. + * + * * Specifying only `no-inline-style` tells AngularJS that we must not inject styles, but that we can + * run eval - no automatic check for unsafe eval will occur. E.g. `<body ng-csp="no-inline-style">` + * + * * Specifying both `no-unsafe-eval` and `no-inline-style` tells AngularJS that we must not inject + * styles nor use eval, which is the same as an empty: ng-csp. + * E.g.`<body ng-csp="no-inline-style;no-unsafe-eval">` + * + * @example + * + * This example shows how to apply the `ngCsp` directive to the `html` tag. + ```html + <!doctype html> + <html ng-app ng-csp> + ... + ... + </html> + ``` - ctrl.$formatters.push(function(value) { - return ctrl.$isEmpty(value) ? '' : '' + value; - }); + <!-- Note: the `.csp` suffix in the example name triggers CSP mode in our http server! --> + <example name="example.csp" module="cspExample" ng-csp="true"> + <file name="index.html"> + <div ng-controller="MainController as ctrl"> + <div> + <button ng-click="ctrl.inc()" id="inc">Increment</button> + <span id="counter"> + {{ctrl.counter}} + </span> + </div> - if (attr.min) { - var minValidator = function(value) { - var min = parseFloat(attr.min); - return validate(ctrl, 'min', ctrl.$isEmpty(value) || value >= min, value); + <div> + <button ng-click="ctrl.evil()" id="evil">Evil</button> + <span id="evilError"> + {{ctrl.evilError}} + </span> + </div> + </div> + </file> + <file name="script.js"> + angular.module('cspExample', []) + .controller('MainController', function MainController() { + this.counter = 0; + this.inc = function() { + this.counter++; }; - - ctrl.$parsers.push(minValidator); - ctrl.$formatters.push(minValidator); - } - - if (attr.max) { - var maxValidator = function(value) { - var max = parseFloat(attr.max); - return validate(ctrl, 'max', ctrl.$isEmpty(value) || value <= max, value); + this.evil = function() { + try { + eval('1+2'); // eslint-disable-line no-eval + } catch (e) { + this.evilError = e.message; + } }; + }); + </file> + <file name="protractor.js" type="protractor"> + var util, webdriver; - ctrl.$parsers.push(maxValidator); - ctrl.$formatters.push(maxValidator); - } + var incBtn = element(by.id('inc')); + var counter = element(by.id('counter')); + var evilBtn = element(by.id('evil')); + var evilError = element(by.id('evilError')); - ctrl.$formatters.push(function(value) { - return validate(ctrl, 'number', ctrl.$isEmpty(value) || isNumber(value), value); + function getAndClearSevereErrors() { + return browser.manage().logs().get('browser').then(function(browserLog) { + return browserLog.filter(function(logEntry) { + return logEntry.level.value > webdriver.logging.Level.WARNING.value; + }); }); - } - - function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); - - var urlValidator = function(value) { - return validate(ctrl, 'url', ctrl.$isEmpty(value) || URL_REGEXP.test(value), value); - }; - - ctrl.$formatters.push(urlValidator); - ctrl.$parsers.push(urlValidator); - } - - function emailInputType(scope, element, attr, ctrl, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); - - var emailValidator = function(value) { - return validate(ctrl, 'email', ctrl.$isEmpty(value) || EMAIL_REGEXP.test(value), value); - }; + } - ctrl.$formatters.push(emailValidator); - ctrl.$parsers.push(emailValidator); - } + function clearErrors() { + getAndClearSevereErrors(); + } - function radioInputType(scope, element, attr, ctrl) { - // make the name unique, if not defined - if (isUndefined(attr.name)) { - element.attr('name', nextUid()); - } + function expectNoErrors() { + getAndClearSevereErrors().then(function(filteredLog) { + expect(filteredLog.length).toEqual(0); + if (filteredLog.length) { + console.log('browser console errors: ' + util.inspect(filteredLog)); + } + }); + } - element.on('click', function() { - if (element[0].checked) { - scope.$apply(function() { - ctrl.$setViewValue(attr.value); - }); + function expectError(regex) { + getAndClearSevereErrors().then(function(filteredLog) { + var found = false; + filteredLog.forEach(function(log) { + if (log.message.match(regex)) { + found = true; } + }); + if (!found) { + throw new Error('expected an error that matches ' + regex); + } }); + } - ctrl.$render = function() { - var value = attr.value; - element[0].checked = (value == ctrl.$viewValue); - }; - - attr.$observe('value', ctrl.$render); - } - - function checkboxInputType(scope, element, attr, ctrl) { - var trueValue = attr.ngTrueValue, - falseValue = attr.ngFalseValue; + beforeEach(function() { + util = require('util'); + webdriver = require('selenium-webdriver'); + }); - if (!isString(trueValue)) trueValue = true; - if (!isString(falseValue)) falseValue = false; + // For now, we only test on Chrome, + // as Safari does not load the page with Protractor's injected scripts, + // and Firefox webdriver always disables content security policy (#6358) + if (browser.params.browser !== 'chrome') { + return; + } - element.on('click', function() { - scope.$apply(function() { - ctrl.$setViewValue(element[0].checked); - }); + it('should not report errors when the page is loaded', function() { + // clear errors so we are not dependent on previous tests + clearErrors(); + // Need to reload the page as the page is already loaded when + // we come here + browser.driver.getCurrentUrl().then(function(url) { + browser.get(url); }); + expectNoErrors(); + }); - ctrl.$render = function() { - element[0].checked = ctrl.$viewValue; - }; - - // Override the standard `$isEmpty` because a value of `false` means empty in a checkbox. - ctrl.$isEmpty = function(value) { - return value !== trueValue; - }; - - ctrl.$formatters.push(function(value) { - return value === trueValue; - }); + it('should evaluate expressions', function() { + expect(counter.getText()).toEqual('0'); + incBtn.click(); + expect(counter.getText()).toEqual('1'); + expectNoErrors(); + }); - ctrl.$parsers.push(function(value) { - return value ? trueValue : falseValue; - }); - } + it('should throw and report an error when using "eval"', function() { + evilBtn.click(); + expect(evilError.getText()).toMatch(/Content Security Policy/); + expectError(/Content Security Policy/); + }); + </file> + </example> + */ +// `ngCsp` is not implemented as a proper directive any more, because we need it be processed while +// we bootstrap the app (before `$parse` is instantiated). For this reason, we just have the `csp()` +// fn that looks for the `ng-csp` attribute anywhere in the current doc. /** * @ngdoc directive - * @name textarea - * @restrict E + * @name ngClick + * @restrict A + * @element ANY + * @priority 0 * * @description - * HTML textarea element control with angular data-binding. The data-binding and validation - * properties of this element are exactly the same as those of the - * {@link ng.directive:input input element}. + * The ngClick directive allows you to specify custom behavior when + * an element is clicked. * - * @param {string} ngModel Assignable angular expression to data-bind to. - * @param {string=} name Property name of the form under which the control is published. - * @param {string=} required Sets `required` validation error key if the value is not entered. - * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to - * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of - * `required` when you want to data-bind to the `required` attribute. - * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than - * minlength. - * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than - * maxlength. - * @param {string=} ngPattern Sets `pattern` validation error key if the value does not match the - * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for - * patterns defined as scope expressions. - * @param {string=} ngChange Angular expression to be executed when input changes due to user - * interaction with the input element. + * @param {expression} ngClick {@link guide/expression Expression} to evaluate upon + * click. ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + <example name="ng-click"> + <file name="index.html"> + <button ng-click="count = count + 1" ng-init="count=0"> + Increment + </button> + <span> + count: {{count}} + </span> + </file> + <file name="protractor.js" type="protractor"> + it('should check ng-click', function() { + expect(element(by.binding('count')).getText()).toMatch('0'); + element(by.css('button')).click(); + expect(element(by.binding('count')).getText()).toMatch('1'); + }); + </file> + </example> */ + /* + * A collection of directives that allows creation of custom event handlers that are defined as + * AngularJS expressions and are compiled and executed within the current scope. + */ + var ngEventDirectives = {}; +// For events that might fire synchronously during DOM manipulation +// we need to execute their event handlers asynchronously using $evalAsync, +// so that they are not executed in an inconsistent state. + var forceAsyncEvents = { + 'blur': true, + 'focus': true + }; + forEach( + 'click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste'.split(' '), + function(eventName) { + var directiveName = directiveNormalize('ng-' + eventName); + ngEventDirectives[directiveName] = ['$parse', '$rootScope', function($parse, $rootScope) { + return { + restrict: 'A', + compile: function($element, attr) { + // NOTE: + // We expose the powerful `$event` object on the scope that provides access to the Window, + // etc. This is OK, because expressions are not sandboxed any more (and the expression + // sandbox was never meant to be a security feature anyway). + var fn = $parse(attr[directiveName]); + return function ngEventHandler(scope, element) { + element.on(eventName, function(event) { + var callback = function() { + fn(scope, {$event: event}); + }; + if (forceAsyncEvents[eventName] && $rootScope.$$phase) { + scope.$evalAsync(callback); + } else { + scope.$apply(callback); + } + }); + }; + } + }; + }]; + } + ); /** * @ngdoc directive - * @name input - * @restrict E + * @name ngDblclick + * @restrict A + * @element ANY + * @priority 0 * * @description - * HTML input element control with angular data-binding. Input control follows HTML5 input types - * and polyfills the HTML5 validation behavior for older browsers. + * The `ngDblclick` directive allows you to specify custom behavior on a dblclick event. * - * @param {string} ngModel Assignable angular expression to data-bind to. - * @param {string=} name Property name of the form under which the control is published. - * @param {string=} required Sets `required` validation error key if the value is not entered. - * @param {boolean=} ngRequired Sets `required` attribute if set to true - * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than - * minlength. - * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than - * maxlength. - * @param {string=} ngPattern Sets `pattern` validation error key if the value does not match the - * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for - * patterns defined as scope expressions. - * @param {string=} ngChange Angular expression to be executed when input changes due to user - * interaction with the input element. + * @param {expression} ngDblclick {@link guide/expression Expression} to evaluate upon + * a dblclick. (The Event object is available as `$event`) * * @example - <example name="input-directive"> + <example name="ng-dblclick"> <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.user = {name: 'guest', last: 'visitor'}; - } - </script> - <div ng-controller="Ctrl"> - <form name="myForm"> - User name: <input type="text" name="userName" ng-model="user.name" required> - <span class="error" ng-show="myForm.userName.$error.required"> - Required!</span><br> - Last name: <input type="text" name="lastName" ng-model="user.last" - ng-minlength="3" ng-maxlength="10"> - <span class="error" ng-show="myForm.lastName.$error.minlength"> - Too short!</span> - <span class="error" ng-show="myForm.lastName.$error.maxlength"> - Too long!</span><br> - </form> - <hr> - <tt>user = {{user}}</tt><br/> - <tt>myForm.userName.$valid = {{myForm.userName.$valid}}</tt><br> - <tt>myForm.userName.$error = {{myForm.userName.$error}}</tt><br> - <tt>myForm.lastName.$valid = {{myForm.lastName.$valid}}</tt><br> - <tt>myForm.lastName.$error = {{myForm.lastName.$error}}</tt><br> - <tt>myForm.$valid = {{myForm.$valid}}</tt><br> - <tt>myForm.$error.required = {{!!myForm.$error.required}}</tt><br> - <tt>myForm.$error.minlength = {{!!myForm.$error.minlength}}</tt><br> - <tt>myForm.$error.maxlength = {{!!myForm.$error.maxlength}}</tt><br> - </div> - </file> - <file name="protractor.js" type="protractor"> - var user = element(by.binding('{{user}}')); - var userNameValid = element(by.binding('myForm.userName.$valid')); - var lastNameValid = element(by.binding('myForm.lastName.$valid')); - var lastNameError = element(by.binding('myForm.lastName.$error')); - var formValid = element(by.binding('myForm.$valid')); - var userNameInput = element(by.model('user.name')); - var userLastInput = element(by.model('user.last')); - - it('should initialize to model', function() { - expect(user.getText()).toContain('{"name":"guest","last":"visitor"}'); - expect(userNameValid.getText()).toContain('true'); - expect(formValid.getText()).toContain('true'); - }); - - it('should be invalid if empty when required', function() { - userNameInput.clear(); - userNameInput.sendKeys(''); - - expect(user.getText()).toContain('{"last":"visitor"}'); - expect(userNameValid.getText()).toContain('false'); - expect(formValid.getText()).toContain('false'); - }); - - it('should be valid if empty when min length is set', function() { - userLastInput.clear(); - userLastInput.sendKeys(''); - - expect(user.getText()).toContain('{"name":"guest","last":""}'); - expect(lastNameValid.getText()).toContain('true'); - expect(formValid.getText()).toContain('true'); - }); - - it('should be invalid if less than required min length', function() { - userLastInput.clear(); - userLastInput.sendKeys('xx'); - - expect(user.getText()).toContain('{"name":"guest"}'); - expect(lastNameValid.getText()).toContain('false'); - expect(lastNameError.getText()).toContain('minlength'); - expect(formValid.getText()).toContain('false'); - }); - - it('should be invalid if longer than max length', function() { - userLastInput.clear(); - userLastInput.sendKeys('some ridiculously long name'); - - expect(user.getText()).toContain('{"name":"guest"}'); - expect(lastNameValid.getText()).toContain('false'); - expect(lastNameError.getText()).toContain('maxlength'); - expect(formValid.getText()).toContain('false'); - }); + <button ng-dblclick="count = count + 1" ng-init="count=0"> + Increment (on double click) + </button> + count: {{count}} + </file> + </example> + */ + + + /** + * @ngdoc directive + * @name ngMousedown + * @restrict A + * @element ANY + * @priority 0 + * + * @description + * The ngMousedown directive allows you to specify custom behavior on mousedown event. + * + * @param {expression} ngMousedown {@link guide/expression Expression} to evaluate upon + * mousedown. ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + <example name="ng-mousedown"> + <file name="index.html"> + <button ng-mousedown="count = count + 1" ng-init="count=0"> + Increment (on mouse down) + </button> + count: {{count}} </file> </example> */ - var inputDirective = ['$browser', '$sniffer', function($browser, $sniffer) { - return { - restrict: 'E', - require: '?ngModel', - link: function(scope, element, attr, ctrl) { - if (ctrl) { - (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrl, $sniffer, - $browser); - } - } - }; - }]; - var VALID_CLASS = 'ng-valid', - INVALID_CLASS = 'ng-invalid', - PRISTINE_CLASS = 'ng-pristine', - DIRTY_CLASS = 'ng-dirty'; /** - * @ngdoc type - * @name ngModel.NgModelController - * - * @property {string} $viewValue Actual string value in the view. - * @property {*} $modelValue The value in the model, that the control is bound to. - * @property {Array.<Function>} $parsers Array of functions to execute, as a pipeline, whenever - the control reads value from the DOM. Each function is called, in turn, passing the value - through to the next. The last return value is used to populate the model. - Used to sanitize / convert the value as well as validation. For validation, - the parsers should update the validity state using - {@link ngModel.NgModelController#$setValidity $setValidity()}, - and return `undefined` for invalid values. - + * @ngdoc directive + * @name ngMouseup + * @restrict A + * @element ANY + * @priority 0 * - * @property {Array.<Function>} $formatters Array of functions to execute, as a pipeline, whenever - the model value changes. Each function is called, in turn, passing the value through to the - next. Used to format / convert values for display in the control and validation. - * ```js - * function formatter(value) { - * if (value) { - * return value.toUpperCase(); - * } - * } - * ngModel.$formatters.push(formatter); - * ``` - * - * @property {Array.<Function>} $viewChangeListeners Array of functions to execute whenever the - * view value has changed. It is called with no arguments, and its return value is ignored. - * This can be used in place of additional $watches against the model value. + * @description + * Specify custom behavior on mouseup event. * - * @property {Object} $error An object hash with all errors as keys. + * @param {expression} ngMouseup {@link guide/expression Expression} to evaluate upon + * mouseup. ({@link guide/expression#-event- Event object is available as `$event`}) * - * @property {boolean} $pristine True if user has not interacted with the control yet. - * @property {boolean} $dirty True if user has already interacted with the control. - * @property {boolean} $valid True if there is no error. - * @property {boolean} $invalid True if at least one error on the control. + * @example + <example name="ng-mouseup"> + <file name="index.html"> + <button ng-mouseup="count = count + 1" ng-init="count=0"> + Increment (on mouse up) + </button> + count: {{count}} + </file> + </example> + */ + + /** + * @ngdoc directive + * @name ngMouseover + * @restrict A + * @element ANY + * @priority 0 * * @description + * Specify custom behavior on mouseover event. * - * `NgModelController` provides API for the `ng-model` directive. The controller contains - * services for data-binding, validation, CSS updates, and value formatting and parsing. It - * purposefully does not contain any logic which deals with DOM rendering or listening to - * DOM events. Such DOM related logic should be provided by other directives which make use of - * `NgModelController` for data-binding. - * - * ## Custom Control Example - * This example shows how to use `NgModelController` with a custom control to achieve - * data-binding. Notice how different directives (`contenteditable`, `ng-model`, and `required`) - * collaborate together to achieve the desired result. - * - * Note that `contenteditable` is an HTML5 attribute, which tells the browser to let the element - * contents be edited in place by the user. This will not work on older browsers. + * @param {expression} ngMouseover {@link guide/expression Expression} to evaluate upon + * mouseover. ({@link guide/expression#-event- Event object is available as `$event`}) * - * <example name="NgModelController" module="customControl"> - <file name="style.css"> - [contenteditable] { - border: 1px solid black; - background-color: white; - min-height: 20px; - } - - .ng-invalid { - border: 1px solid red; - } - + * @example + <example name="ng-mouseover"> + <file name="index.html"> + <button ng-mouseover="count = count + 1" ng-init="count=0"> + Increment (when mouse is over) + </button> + count: {{count}} </file> - <file name="script.js"> - angular.module('customControl', []). - directive('contenteditable', function() { - return { - restrict: 'A', // only activate on element attribute - require: '?ngModel', // get a hold of NgModelController - link: function(scope, element, attrs, ngModel) { - if(!ngModel) return; // do nothing if no ng-model - - // Specify how UI should be updated - ngModel.$render = function() { - element.html(ngModel.$viewValue || ''); - }; + </example> + */ - // Listen for change events to enable binding - element.on('blur keyup change', function() { - scope.$apply(read); - }); - read(); // initialize - // Write data to the model - function read() { - var html = element.html(); - // When we clear the content editable the browser leaves a <br> behind - // If strip-br attribute is provided then we strip this out - if( attrs.stripBr && html == '<br>' ) { - html = ''; - } - ngModel.$setViewValue(html); - } - } - }; - }); - </file> + /** + * @ngdoc directive + * @name ngMouseenter + * @restrict A + * @element ANY + * @priority 0 + * + * @description + * Specify custom behavior on mouseenter event. + * + * @param {expression} ngMouseenter {@link guide/expression Expression} to evaluate upon + * mouseenter. ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + <example name="ng-mouseenter"> <file name="index.html"> - <form name="myForm"> - <div contenteditable - name="myWidget" ng-model="userContent" - strip-br="true" - required>Change me!</div> - <span ng-show="myForm.myWidget.$error.required">Required!</span> - <hr> - <textarea ng-model="userContent"></textarea> - </form> + <button ng-mouseenter="count = count + 1" ng-init="count=0"> + Increment (when mouse enters) + </button> + count: {{count}} </file> - <file name="protractor.js" type="protractor"> - it('should data-bind and become invalid', function() { - if (browser.params.browser == 'safari' || browser.params.browser == 'firefox') { - // SafariDriver can't handle contenteditable - // and Firefox driver can't clear contenteditables very well - return; - } - var contentEditable = element(by.css('[contenteditable]')); - var content = 'Change me!'; + </example> + */ - expect(contentEditable.getText()).toEqual(content); - contentEditable.clear(); - contentEditable.sendKeys(protractor.Key.BACK_SPACE); - expect(contentEditable.getText()).toEqual(''); - expect(contentEditable.getAttribute('class')).toMatch(/ng-invalid-required/); - }); - </file> - * </example> + /** + * @ngdoc directive + * @name ngMouseleave + * @restrict A + * @element ANY + * @priority 0 + * + * @description + * Specify custom behavior on mouseleave event. * + * @param {expression} ngMouseleave {@link guide/expression Expression} to evaluate upon + * mouseleave. ({@link guide/expression#-event- Event object is available as `$event`}) * + * @example + <example name="ng-mouseleave"> + <file name="index.html"> + <button ng-mouseleave="count = count + 1" ng-init="count=0"> + Increment (when mouse leaves) + </button> + count: {{count}} + </file> + </example> */ - var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', '$animate', - function($scope, $exceptionHandler, $attr, $element, $parse, $animate) { - this.$viewValue = Number.NaN; - this.$modelValue = Number.NaN; - this.$parsers = []; - this.$formatters = []; - this.$viewChangeListeners = []; - this.$pristine = true; - this.$dirty = false; - this.$valid = true; - this.$invalid = false; - this.$name = $attr.name; - - var ngModelGet = $parse($attr.ngModel), - ngModelSet = ngModelGet.assign; - - if (!ngModelSet) { - throw minErr('ngModel')('nonassign', "Expression '{0}' is non-assignable. Element: {1}", - $attr.ngModel, startingTag($element)); - } - - /** - * @ngdoc method - * @name ngModel.NgModelController#$render - * - * @description - * Called when the view needs to be updated. It is expected that the user of the ng-model - * directive will implement this method. - */ - this.$render = noop; - - /** - * @ngdoc method - * @name ngModel.NgModelController#$isEmpty - * - * @description - * This is called when we need to determine if the value of the input is empty. - * - * For instance, the required directive does this to work out if the input has data or not. - * The default `$isEmpty` function checks whether the value is `undefined`, `''`, `null` or `NaN`. - * - * You can override this for input directives whose concept of being empty is different to the - * default. The `checkboxInputType` directive does this because in its case a value of `false` - * implies empty. - * - * @param {*} value Reference to check. - * @returns {boolean} True if `value` is empty. - */ - this.$isEmpty = function(value) { - return isUndefined(value) || value === '' || value === null || value !== value; - }; - - var parentForm = $element.inheritedData('$formController') || nullFormCtrl, - invalidCount = 0, // used to easily determine if we are valid - $error = this.$error = {}; // keep invalid keys here - - - // Setup initial state of the control - $element.addClass(PRISTINE_CLASS); - toggleValidCss(true); - - // convenience method for easy toggling of classes - function toggleValidCss(isValid, validationErrorKey) { - validationErrorKey = validationErrorKey ? '-' + snake_case(validationErrorKey, '-') : ''; - $animate.removeClass($element, (isValid ? INVALID_CLASS : VALID_CLASS) + validationErrorKey); - $animate.addClass($element, (isValid ? VALID_CLASS : INVALID_CLASS) + validationErrorKey); - } - - /** - * @ngdoc method - * @name ngModel.NgModelController#$setValidity - * - * @description - * Change the validity state, and notifies the form when the control changes validity. (i.e. it - * does not notify form if given validator is already marked as invalid). - * - * This method should be called by validators - i.e. the parser or formatter functions. - * - * @param {string} validationErrorKey Name of the validator. the `validationErrorKey` will assign - * to `$error[validationErrorKey]=isValid` so that it is available for data-binding. - * The `validationErrorKey` should be in camelCase and will get converted into dash-case - * for class name. Example: `myError` will result in `ng-valid-my-error` and `ng-invalid-my-error` - * class and can be bound to as `{{someForm.someControl.$error.myError}}` . - * @param {boolean} isValid Whether the current state is valid (true) or invalid (false). - */ - this.$setValidity = function(validationErrorKey, isValid) { - // Purposeful use of ! here to cast isValid to boolean in case it is undefined - // jshint -W018 - if ($error[validationErrorKey] === !isValid) return; - // jshint +W018 - - if (isValid) { - if ($error[validationErrorKey]) invalidCount--; - if (!invalidCount) { - toggleValidCss(true); - this.$valid = true; - this.$invalid = false; - } - } else { - toggleValidCss(false); - this.$invalid = true; - this.$valid = false; - invalidCount++; - } - - $error[validationErrorKey] = !isValid; - toggleValidCss(isValid, validationErrorKey); - - parentForm.$setValidity(validationErrorKey, isValid, this); - }; - - /** - * @ngdoc method - * @name ngModel.NgModelController#$setPristine - * - * @description - * Sets the control to its pristine state. - * - * This method can be called to remove the 'ng-dirty' class and set the control to its pristine - * state (ng-pristine class). - */ - this.$setPristine = function () { - this.$dirty = false; - this.$pristine = true; - $animate.removeClass($element, DIRTY_CLASS); - $animate.addClass($element, PRISTINE_CLASS); - }; - /** - * @ngdoc method - * @name ngModel.NgModelController#$setViewValue - * - * @description - * Update the view value. - * - * This method should be called when the view value changes, typically from within a DOM event handler. - * For example {@link ng.directive:input input} and - * {@link ng.directive:select select} directives call it. - * - * It will update the $viewValue, then pass this value through each of the functions in `$parsers`, - * which includes any validators. The value that comes out of this `$parsers` pipeline, be applied to - * `$modelValue` and the **expression** specified in the `ng-model` attribute. - * - * Lastly, all the registered change listeners, in the `$viewChangeListeners` list, are called. - * - * Note that calling this function does not trigger a `$digest`. - * - * @param {string} value Value from the view. - */ - this.$setViewValue = function(value) { - this.$viewValue = value; - - // change to dirty - if (this.$pristine) { - this.$dirty = true; - this.$pristine = false; - $animate.removeClass($element, PRISTINE_CLASS); - $animate.addClass($element, DIRTY_CLASS); - parentForm.$setDirty(); - } - forEach(this.$parsers, function(fn) { - value = fn(value); - }); - - if (this.$modelValue !== value) { - this.$modelValue = value; - ngModelSet($scope, value); - forEach(this.$viewChangeListeners, function(listener) { - try { - listener(); - } catch(e) { - $exceptionHandler(e); - } - }); - } - }; + /** + * @ngdoc directive + * @name ngMousemove + * @restrict A + * @element ANY + * @priority 0 + * + * @description + * Specify custom behavior on mousemove event. + * + * @param {expression} ngMousemove {@link guide/expression Expression} to evaluate upon + * mousemove. ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + <example name="ng-mousemove"> + <file name="index.html"> + <button ng-mousemove="count = count + 1" ng-init="count=0"> + Increment (when mouse moves) + </button> + count: {{count}} + </file> + </example> + */ - // model -> value - var ctrl = this; - $scope.$watch(function ngModelWatch() { - var value = ngModelGet($scope); + /** + * @ngdoc directive + * @name ngKeydown + * @restrict A + * @element ANY + * @priority 0 + * + * @description + * Specify custom behavior on keydown event. + * + * @param {expression} ngKeydown {@link guide/expression Expression} to evaluate upon + * keydown. (Event object is available as `$event` and can be interrogated for keyCode, altKey, etc.) + * + * @example + <example name="ng-keydown"> + <file name="index.html"> + <input ng-keydown="count = count + 1" ng-init="count=0"> + key down count: {{count}} + </file> + </example> + */ - // if scope model value and ngModel value are out of sync - if (ctrl.$modelValue !== value) { - var formatters = ctrl.$formatters, - idx = formatters.length; + /** + * @ngdoc directive + * @name ngKeyup + * @restrict A + * @element ANY + * @priority 0 + * + * @description + * Specify custom behavior on keyup event. + * + * @param {expression} ngKeyup {@link guide/expression Expression} to evaluate upon + * keyup. (Event object is available as `$event` and can be interrogated for keyCode, altKey, etc.) + * + * @example + <example name="ng-keyup"> + <file name="index.html"> + <p>Typing in the input box below updates the key count</p> + <input ng-keyup="count = count + 1" ng-init="count=0"> key up count: {{count}} - ctrl.$modelValue = value; - while(idx--) { - value = formatters[idx](value); - } + <p>Typing in the input box below updates the keycode</p> + <input ng-keyup="event=$event"> + <p>event keyCode: {{ event.keyCode }}</p> + <p>event altKey: {{ event.altKey }}</p> + </file> + </example> + */ - if (ctrl.$viewValue !== value) { - ctrl.$viewValue = value; - ctrl.$render(); - } - } - return value; - }); - }]; + /** + * @ngdoc directive + * @name ngKeypress + * @restrict A + * @element ANY + * + * @description + * Specify custom behavior on keypress event. + * + * @param {expression} ngKeypress {@link guide/expression Expression} to evaluate upon + * keypress. ({@link guide/expression#-event- Event object is available as `$event`} + * and can be interrogated for keyCode, altKey, etc.) + * + * @example + <example name="ng-keypress"> + <file name="index.html"> + <input ng-keypress="count = count + 1" ng-init="count=0"> + key press count: {{count}} + </file> + </example> + */ /** * @ngdoc directive - * @name ngModel - * - * @element input + * @name ngSubmit + * @restrict A + * @element form + * @priority 0 * * @description - * The `ngModel` directive binds an `input`,`select`, `textarea` (or custom form control) to a - * property on the scope using {@link ngModel.NgModelController NgModelController}, - * which is created and exposed by this directive. + * Enables binding AngularJS expressions to onsubmit events. * - * `ngModel` is responsible for: + * Additionally it prevents the default action (which for form means sending the request to the + * server and reloading the current page), but only if the form does not contain `action`, + * `data-action`, or `x-action` attributes. * - * - Binding the view into the model, which other directives such as `input`, `textarea` or `select` - * require. - * - Providing validation behavior (i.e. required, number, email, url). - * - Keeping the state of the control (valid/invalid, dirty/pristine, validation errors). - * - Setting related css classes on the element (`ng-valid`, `ng-invalid`, `ng-dirty`, `ng-pristine`) including animations. - * - Registering the control with its parent {@link ng.directive:form form}. + * <div class="alert alert-warning"> + * **Warning:** Be careful not to cause "double-submission" by using both the `ngClick` and + * `ngSubmit` handlers together. See the + * {@link form#submitting-a-form-and-preventing-the-default-action `form` directive documentation} + * for a detailed discussion of when `ngSubmit` may be triggered. + * </div> * - * Note: `ngModel` will try to bind to the property given by evaluating the expression on the - * current scope. If the property doesn't already exist on this scope, it will be created - * implicitly and added to the scope. + * @param {expression} ngSubmit {@link guide/expression Expression} to eval. + * ({@link guide/expression#-event- Event object is available as `$event`}) * - * For best practices on using `ngModel`, see: + * @example + <example module="submitExample" name="ng-submit"> + <file name="index.html"> + <script> + angular.module('submitExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.list = []; + $scope.text = 'hello'; + $scope.submit = function() { + if ($scope.text) { + $scope.list.push(this.text); + $scope.text = ''; + } + }; + }]); + </script> + <form ng-submit="submit()" ng-controller="ExampleController"> + Enter text and hit enter: + <input type="text" ng-model="text" name="text" /> + <input type="submit" id="submit" value="Submit" /> + <pre>list={{list}}</pre> + </form> + </file> + <file name="protractor.js" type="protractor"> + it('should check ng-submit', function() { + expect(element(by.binding('list')).getText()).toBe('list=[]'); + element(by.css('#submit')).click(); + expect(element(by.binding('list')).getText()).toContain('hello'); + expect(element(by.model('text')).getAttribute('value')).toBe(''); + }); + it('should ignore empty strings', function() { + expect(element(by.binding('list')).getText()).toBe('list=[]'); + element(by.css('#submit')).click(); + element(by.css('#submit')).click(); + expect(element(by.binding('list')).getText()).toContain('hello'); + }); + </file> + </example> + */ + + /** + * @ngdoc directive + * @name ngFocus + * @restrict A + * @element window, input, select, textarea, a + * @priority 0 * - * - [https://github.com/angular/angular.js/wiki/Understanding-Scopes] + * @description + * Specify custom behavior on focus event. * - * For basic examples, how to use `ngModel`, see: + * Note: As the `focus` event is executed synchronously when calling `input.focus()` + * AngularJS executes the expression using `scope.$evalAsync` if the event is fired + * during an `$apply` to ensure a consistent state. * - * - {@link ng.directive:input input} - * - {@link input[text] text} - * - {@link input[checkbox] checkbox} - * - {@link input[radio] radio} - * - {@link input[number] number} - * - {@link input[email] email} - * - {@link input[url] url} - * - {@link ng.directive:select select} - * - {@link ng.directive:textarea textarea} + * @param {expression} ngFocus {@link guide/expression Expression} to evaluate upon + * focus. ({@link guide/expression#-event- Event object is available as `$event`}) * - * # CSS classes - * The following CSS classes are added and removed on the associated input/select/textarea element - * depending on the validity of the model. + * @example + * See {@link ng.directive:ngClick ngClick} + */ + + /** + * @ngdoc directive + * @name ngBlur + * @restrict A + * @element window, input, select, textarea, a + * @priority 0 * - * - `ng-valid` is set if the model is valid. - * - `ng-invalid` is set if the model is invalid. - * - `ng-pristine` is set if the model is pristine. - * - `ng-dirty` is set if the model is dirty. + * @description + * Specify custom behavior on blur event. * - * Keep in mind that ngAnimate can detect each of these classes when added and removed. + * A [blur event](https://developer.mozilla.org/en-US/docs/Web/Events/blur) fires when + * an element has lost focus. * - * ## Animation Hooks + * Note: As the `blur` event is executed synchronously also during DOM manipulations + * (e.g. removing a focussed input), + * AngularJS executes the expression using `scope.$evalAsync` if the event is fired + * during an `$apply` to ensure a consistent state. * - * Animations within models are triggered when any of the associated CSS classes are added and removed - * on the input element which is attached to the model. These classes are: `.ng-pristine`, `.ng-dirty`, - * `.ng-invalid` and `.ng-valid` as well as any other validations that are performed on the model itself. - * The animations that are triggered within ngModel are similar to how they work in ngClass and - * animations can be hooked into using CSS transitions, keyframes as well as JS animations. + * @param {expression} ngBlur {@link guide/expression Expression} to evaluate upon + * blur. ({@link guide/expression#-event- Event object is available as `$event`}) * - * The following example shows a simple way to utilize CSS transitions to style an input element - * that has been rendered as invalid after it has been validated: + * @example + * See {@link ng.directive:ngClick ngClick} + */ + + /** + * @ngdoc directive + * @name ngCopy + * @restrict A + * @element window, input, select, textarea, a + * @priority 0 * - * <pre> - * //be sure to include ngAnimate as a module to hook into more - * //advanced animations - * .my-input { - * transition:0.5s linear all; - * background: white; - * } - * .my-input.ng-invalid { - * background: red; - * color:white; - * } - * </pre> + * @description + * Specify custom behavior on copy event. + * + * @param {expression} ngCopy {@link guide/expression Expression} to evaluate upon + * copy. ({@link guide/expression#-event- Event object is available as `$event`}) * * @example - * <example deps="angular-animate.js" animations="true" fixBase="true"> + <example name="ng-copy"> <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.val = '1'; - } - </script> - <style> - .my-input { - -webkit-transition:all linear 0.5s; - transition:all linear 0.5s; - background: transparent; - } - .my-input.ng-invalid { - color:white; - background: red; - } - </style> - Update input to see transitions when valid/invalid. - Integer is a valid value. - <form name="testForm" ng-controller="Ctrl"> - <input ng-model="val" ng-pattern="/^\d+$/" name="anim" class="my-input" /> - </form> + <input ng-copy="copied=true" ng-init="copied=false; value='copy me'" ng-model="value"> + copied: {{copied}} </file> - * </example> + </example> */ - var ngModelDirective = function() { - return { - require: ['ngModel', '^?form'], - controller: NgModelController, - link: function(scope, element, attr, ctrls) { - // notify others, especially parent forms - - var modelCtrl = ctrls[0], - formCtrl = ctrls[1] || nullFormCtrl; - formCtrl.$addControl(modelCtrl); - - scope.$on('$destroy', function() { - formCtrl.$removeControl(modelCtrl); - }); - } - }; - }; + /** + * @ngdoc directive + * @name ngCut + * @restrict A + * @element window, input, select, textarea, a + * @priority 0 + * + * @description + * Specify custom behavior on cut event. + * + * @param {expression} ngCut {@link guide/expression Expression} to evaluate upon + * cut. ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + <example name="ng-cut"> + <file name="index.html"> + <input ng-cut="cut=true" ng-init="cut=false; value='cut me'" ng-model="value"> + cut: {{cut}} + </file> + </example> + */ + /** + * @ngdoc directive + * @name ngPaste + * @restrict A + * @element window, input, select, textarea, a + * @priority 0 + * + * @description + * Specify custom behavior on paste event. + * + * @param {expression} ngPaste {@link guide/expression Expression} to evaluate upon + * paste. ({@link guide/expression#-event- Event object is available as `$event`}) + * + * @example + <example name="ng-paste"> + <file name="index.html"> + <input ng-paste="paste=true" ng-init="paste=false" placeholder='paste here'> + pasted: {{paste}} + </file> + </example> + */ /** * @ngdoc directive - * @name ngChange + * @name ngIf + * @restrict A + * @multiElement * * @description - * Evaluate the given expression when the user changes the input. - * The expression is evaluated immediately, unlike the JavaScript onchange event - * which only triggers at the end of a change (usually, when the user leaves the - * form element or presses the return key). - * The expression is not evaluated when the value change is coming from the model. + * The `ngIf` directive removes or recreates a portion of the DOM tree based on an + * {expression}. If the expression assigned to `ngIf` evaluates to a false + * value then the element is removed from the DOM, otherwise a clone of the + * element is reinserted into the DOM. + * + * `ngIf` differs from `ngShow` and `ngHide` in that `ngIf` completely removes and recreates the + * element in the DOM rather than changing its visibility via the `display` css property. A common + * case when this difference is significant is when using css selectors that rely on an element's + * position within the DOM, such as the `:first-child` or `:last-child` pseudo-classes. * - * Note, this directive requires `ngModel` to be present. + * Note that when an element is removed using `ngIf` its scope is destroyed and a new scope + * is created when the element is restored. The scope created within `ngIf` inherits from + * its parent scope using + * [prototypal inheritance](https://github.com/angular/angular.js/wiki/Understanding-Scopes#javascript-prototypal-inheritance). + * An important implication of this is if `ngModel` is used within `ngIf` to bind to + * a javascript primitive defined in the parent scope. In this case any modifications made to the + * variable within the child scope will override (hide) the value in the parent scope. * - * @element input - * @param {expression} ngChange {@link guide/expression Expression} to evaluate upon change - * in input value. + * Also, `ngIf` recreates elements using their compiled state. An example of this behavior + * is if an element's class attribute is directly modified after it's compiled, using something like + * jQuery's `.addClass()` method, and the element is later removed. When `ngIf` recreates the element + * the added class will be lost because the original compiled state is used to regenerate the element. * - * @example - * <example name="ngChange-directive"> - * <file name="index.html"> - * <script> - * function Controller($scope) { - * $scope.counter = 0; - * $scope.change = function() { - * $scope.counter++; - * }; - * } - * </script> - * <div ng-controller="Controller"> - * <input type="checkbox" ng-model="confirmed" ng-change="change()" id="ng-change-example1" /> - * <input type="checkbox" ng-model="confirmed" id="ng-change-example2" /> - * <label for="ng-change-example2">Confirmed</label><br /> - * <tt>debug = {{confirmed}}</tt><br/> - * <tt>counter = {{counter}}</tt><br/> - * </div> - * </file> - * <file name="protractor.js" type="protractor"> - * var counter = element(by.binding('counter')); - * var debug = element(by.binding('confirmed')); + * Additionally, you can provide animations via the `ngAnimate` module to animate the `enter` + * and `leave` effects. * - * it('should evaluate the expression if changing from view', function() { - * expect(counter.getText()).toContain('0'); - * - * element(by.id('ng-change-example1')).click(); - * - * expect(counter.getText()).toContain('1'); - * expect(debug.getText()).toContain('true'); - * }); + * @animations + * | Animation | Occurs | + * |----------------------------------|-------------------------------------| + * | {@link ng.$animate#enter enter} | just after the `ngIf` contents change and a new DOM element is created and injected into the `ngIf` container | + * | {@link ng.$animate#leave leave} | just before the `ngIf` contents are removed from the DOM | * - * it('should not evaluate the expression if changing from model', function() { - * element(by.id('ng-change-example2')).click(); + * @element ANY + * @scope + * @priority 600 + * @param {expression} ngIf If the {@link guide/expression expression} is falsy then + * the element is removed from the DOM tree. If it is truthy a copy of the compiled + * element is added to the DOM tree. + * + * @example + <example module="ngAnimate" deps="angular-animate.js" animations="true" name="ng-if"> + <file name="index.html"> + <label>Click me: <input type="checkbox" ng-model="checked" ng-init="checked=true" /></label><br/> + Show when checked: + <span ng-if="checked" class="animate-if"> + This is removed when the checkbox is unchecked. + </span> + </file> + <file name="animations.css"> + .animate-if { + background:white; + border:1px solid black; + padding:10px; + } - * expect(counter.getText()).toContain('0'); - * expect(debug.getText()).toContain('true'); - * }); - * </file> - * </example> - */ - var ngChangeDirective = valueFn({ - require: 'ngModel', - link: function(scope, element, attr, ctrl) { - ctrl.$viewChangeListeners.push(function() { - scope.$eval(attr.ngChange); - }); - } - }); + .animate-if.ng-enter, .animate-if.ng-leave { + transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; + } + .animate-if.ng-enter, + .animate-if.ng-leave.ng-leave-active { + opacity:0; + } - var requiredDirective = function() { + .animate-if.ng-leave, + .animate-if.ng-enter.ng-enter-active { + opacity:1; + } + </file> + </example> + */ + var ngIfDirective = ['$animate', '$compile', function($animate, $compile) { return { - require: '?ngModel', - link: function(scope, elm, attr, ctrl) { - if (!ctrl) return; - attr.required = true; // force truthy in case we are on non input element + multiElement: true, + transclude: 'element', + priority: 600, + terminal: true, + restrict: 'A', + $$tlb: true, + link: function($scope, $element, $attr, ctrl, $transclude) { + var block, childScope, previousElements; + $scope.$watch($attr.ngIf, function ngIfWatchAction(value) { - var validator = function(value) { - if (attr.required && ctrl.$isEmpty(value)) { - ctrl.$setValidity('required', false); - return; + if (value) { + if (!childScope) { + $transclude(function(clone, newScope) { + childScope = newScope; + clone[clone.length++] = $compile.$$createComment('end ngIf', $attr.ngIf); + // Note: We only need the first/last node of the cloned nodes. + // However, we need to keep the reference to the jqlite wrapper as it might be changed later + // by a directive with templateUrl when its template arrives. + block = { + clone: clone + }; + $animate.enter(clone, $element.parent(), $element); + }); + } } else { - ctrl.$setValidity('required', true); - return value; + if (previousElements) { + previousElements.remove(); + previousElements = null; + } + if (childScope) { + childScope.$destroy(); + childScope = null; + } + if (block) { + previousElements = getBlockNodes(block.clone); + $animate.leave(previousElements).done(function(response) { + if (response !== false) previousElements = null; + }); + block = null; + } } - }; - - ctrl.$formatters.push(validator); - ctrl.$parsers.unshift(validator); - - attr.$observe('required', function() { - validator(ctrl.$viewValue); }); } }; - }; - + }]; /** * @ngdoc directive - * @name ngList + * @name ngInclude + * @restrict ECA + * @scope + * @priority -400 * * @description - * Text input that converts between a delimited string and an array of strings. The delimiter - * can be a fixed string (by default a comma) or a regular expression. + * Fetches, compiles and includes an external HTML fragment. + * + * By default, the template URL is restricted to the same domain and protocol as the + * application document. This is done by calling {@link $sce#getTrustedResourceUrl + * $sce.getTrustedResourceUrl} on it. To load templates from other domains or protocols + * you may either {@link ng.$sceDelegateProvider#resourceUrlWhitelist whitelist them} or + * {@link $sce#trustAsResourceUrl wrap them} as trusted values. Refer to AngularJS's {@link + * ng.$sce Strict Contextual Escaping}. + * + * In addition, the browser's + * [Same Origin Policy](https://code.google.com/p/browsersec/wiki/Part2#Same-origin_policy_for_XMLHttpRequest) + * and [Cross-Origin Resource Sharing (CORS)](http://www.w3.org/TR/cors/) + * policy may further restrict whether the template is successfully loaded. + * For example, `ngInclude` won't work for cross-domain requests on all browsers and for `file://` + * access on some browsers. + * + * @animations + * | Animation | Occurs | + * |----------------------------------|-------------------------------------| + * | {@link ng.$animate#enter enter} | when the expression changes, on the new include | + * | {@link ng.$animate#leave leave} | when the expression changes, on the old include | + * + * The enter and leave animation occur concurrently. + * + * @param {string} ngInclude|src AngularJS expression evaluating to URL. If the source is a string constant, + * make sure you wrap it in **single** quotes, e.g. `src="'myPartialTemplate.html'"`. + * @param {string=} onload Expression to evaluate when a new partial is loaded. + * <div class="alert alert-warning"> + * **Note:** When using onload on SVG elements in IE11, the browser will try to call + * a function with the name on the window element, which will usually throw a + * "function is undefined" error. To fix this, you can instead use `data-onload` or a + * different form that {@link guide/directive#normalization matches} `onload`. + * </div> * - * @element input - * @param {string=} ngList optional delimiter that should be used to split the value. If - * specified in form `/something/` then the value will be converted into a regular expression. + * @param {string=} autoscroll Whether `ngInclude` should call {@link ng.$anchorScroll + * $anchorScroll} to scroll the viewport after the content is loaded. + * + * - If the attribute is not set, disable scrolling. + * - If the attribute is set without value, enable scrolling. + * - Otherwise enable scrolling only if the expression evaluates to truthy value. * * @example - <example name="ngList-directive"> + <example module="includeExample" deps="angular-animate.js" animations="true" name="ng-include"> <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.names = ['igor', 'misko', 'vojta']; - } - </script> - <form name="myForm" ng-controller="Ctrl"> - List: <input name="namesInput" ng-model="names" ng-list required> - <span class="error" ng-show="myForm.namesInput.$error.required"> - Required!</span> - <br> - <tt>names = {{names}}</tt><br/> - <tt>myForm.namesInput.$valid = {{myForm.namesInput.$valid}}</tt><br/> - <tt>myForm.namesInput.$error = {{myForm.namesInput.$error}}</tt><br/> - <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> - <tt>myForm.$error.required = {{!!myForm.$error.required}}</tt><br/> - </form> + <div ng-controller="ExampleController"> + <select ng-model="template" ng-options="t.name for t in templates"> + <option value="">(blank)</option> + </select> + url of the template: <code>{{template.url}}</code> + <hr/> + <div class="slide-animate-container"> + <div class="slide-animate" ng-include="template.url"></div> + </div> + </div> + </file> + <file name="script.js"> + angular.module('includeExample', ['ngAnimate']) + .controller('ExampleController', ['$scope', function($scope) { + $scope.templates = + [{ name: 'template1.html', url: 'template1.html'}, + { name: 'template2.html', url: 'template2.html'}]; + $scope.template = $scope.templates[0]; + }]); + </file> + <file name="template1.html"> + Content of template1.html + </file> + <file name="template2.html"> + Content of template2.html + </file> + <file name="animations.css"> + .slide-animate-container { + position:relative; + background:white; + border:1px solid black; + height:40px; + overflow:hidden; + } + + .slide-animate { + padding:10px; + } + + .slide-animate.ng-enter, .slide-animate.ng-leave { + transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; + + position:absolute; + top:0; + left:0; + right:0; + bottom:0; + display:block; + padding:10px; + } + + .slide-animate.ng-enter { + top:-50px; + } + .slide-animate.ng-enter.ng-enter-active { + top:0; + } + + .slide-animate.ng-leave { + top:0; + } + .slide-animate.ng-leave.ng-leave-active { + top:50px; + } </file> <file name="protractor.js" type="protractor"> - var listInput = element(by.model('names')); - var names = element(by.binding('{{names}}')); - var valid = element(by.binding('myForm.namesInput.$valid')); - var error = element(by.css('span.error')); + var templateSelect = element(by.model('template')); + var includeElem = element(by.css('[ng-include]')); - it('should initialize to model', function() { - expect(names.getText()).toContain('["igor","misko","vojta"]'); - expect(valid.getText()).toContain('true'); - expect(error.getCssValue('display')).toBe('none'); - }); + it('should load template1.html', function() { + expect(includeElem.getText()).toMatch(/Content of template1.html/); + }); - it('should be invalid if empty', function() { - listInput.clear(); - listInput.sendKeys(''); + it('should load template2.html', function() { + if (browser.params.browser === 'firefox') { + // Firefox can't handle using selects + // See https://github.com/angular/protractor/issues/480 + return; + } + templateSelect.click(); + templateSelect.all(by.css('option')).get(2).click(); + expect(includeElem.getText()).toMatch(/Content of template2.html/); + }); - expect(names.getText()).toContain(''); - expect(valid.getText()).toContain('false'); - expect(error.getCssValue('display')).not.toBe('none'); }); + it('should change to blank', function() { + if (browser.params.browser === 'firefox') { + // Firefox can't handle using selects + return; + } + templateSelect.click(); + templateSelect.all(by.css('option')).get(0).click(); + expect(includeElem.isPresent()).toBe(false); + }); </file> </example> */ - var ngListDirective = function() { - return { - require: 'ngModel', - link: function(scope, element, attr, ctrl) { - var match = /\/(.*)\//.exec(attr.ngList), - separator = match && new RegExp(match[1]) || attr.ngList || ','; - var parse = function(viewValue) { - // If the viewValue is invalid (say required but empty) it will be `undefined` - if (isUndefined(viewValue)) return; - var list = []; + /** + * @ngdoc event + * @name ngInclude#$includeContentRequested + * @eventType emit on the scope ngInclude was declared in + * @description + * Emitted every time the ngInclude content is requested. + * + * @param {Object} angularEvent Synthetic event object. + * @param {String} src URL of content to load. + */ + + + /** + * @ngdoc event + * @name ngInclude#$includeContentLoaded + * @eventType emit on the current ngInclude scope + * @description + * Emitted every time the ngInclude content is reloaded. + * + * @param {Object} angularEvent Synthetic event object. + * @param {String} src URL of content to load. + */ + + + /** + * @ngdoc event + * @name ngInclude#$includeContentError + * @eventType emit on the scope ngInclude was declared in + * @description + * Emitted when a template HTTP request yields an erroneous response (status < 200 || status > 299) + * + * @param {Object} angularEvent Synthetic event object. + * @param {String} src URL of content to load. + */ + var ngIncludeDirective = ['$templateRequest', '$anchorScroll', '$animate', + function($templateRequest, $anchorScroll, $animate) { + return { + restrict: 'ECA', + priority: 400, + terminal: true, + transclude: 'element', + controller: angular.noop, + compile: function(element, attr) { + var srcExp = attr.ngInclude || attr.src, + onloadExp = attr.onload || '', + autoScrollExp = attr.autoscroll; - if (viewValue) { - forEach(viewValue.split(separator), function(value) { - if (value) list.push(trim(value)); - }); - } + return function(scope, $element, $attr, ctrl, $transclude) { + var changeCounter = 0, + currentScope, + previousElement, + currentElement; - return list; - }; + var cleanupLastIncludeContent = function() { + if (previousElement) { + previousElement.remove(); + previousElement = null; + } + if (currentScope) { + currentScope.$destroy(); + currentScope = null; + } + if (currentElement) { + $animate.leave(currentElement).done(function(response) { + if (response !== false) previousElement = null; + }); + previousElement = currentElement; + currentElement = null; + } + }; - ctrl.$parsers.push(parse); - ctrl.$formatters.push(function(value) { - if (isArray(value)) { - return value.join(', '); - } + scope.$watch(srcExp, function ngIncludeWatchAction(src) { + var afterAnimation = function(response) { + if (response !== false && isDefined(autoScrollExp) && + (!autoScrollExp || scope.$eval(autoScrollExp))) { + $anchorScroll(); + } + }; + var thisChangeId = ++changeCounter; - return undefined; - }); + if (src) { + //set the 2nd param to true to ignore the template request error so that the inner + //contents and scope can be cleaned up. + $templateRequest(src, true).then(function(response) { + if (scope.$$destroyed) return; - // Override the standard $isEmpty because an empty array means the input is empty. - ctrl.$isEmpty = function(value) { - return !value || !value.length; - }; - } - }; - }; + if (thisChangeId !== changeCounter) return; + var newScope = scope.$new(); + ctrl.template = response; + // Note: This will also link all children of ng-include that were contained in the original + // html. If that content contains controllers, ... they could pollute/change the scope. + // However, using ng-include on an element with additional content does not make sense... + // Note: We can't remove them in the cloneAttchFn of $transclude as that + // function is called before linking the content, which would apply child + // directives to non existing elements. + var clone = $transclude(newScope, function(clone) { + cleanupLastIncludeContent(); + $animate.enter(clone, null, $element).done(afterAnimation); + }); - var CONSTANT_VALUE_REGEXP = /^(true|false|\d+)$/; - /** - * @ngdoc directive - * @name ngValue - * - * @description - * Binds the given expression to the value of `input[select]` or `input[radio]`, so - * that when the element is selected, the `ngModel` of that element is set to the - * bound value. - * - * `ngValue` is useful when dynamically generating lists of radio buttons using `ng-repeat`, as - * shown below. - * - * @element input - * @param {string=} ngValue angular expression, whose value will be bound to the `value` attribute - * of the `input` element - * - * @example - <example name="ngValue-directive"> - <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.names = ['pizza', 'unicorns', 'robots']; - $scope.my = { favorite: 'unicorns' }; - } - </script> - <form ng-controller="Ctrl"> - <h2>Which is your favorite?</h2> - <label ng-repeat="name in names" for="{{name}}"> - {{name}} - <input type="radio" - ng-model="my.favorite" - ng-value="name" - id="{{name}}" - name="favorite"> - </label> - <div>You chose {{my.favorite}}</div> - </form> - </file> - <file name="protractor.js" type="protractor"> - var favorite = element(by.binding('my.favorite')); + currentScope = newScope; + currentElement = clone; - it('should initialize to model', function() { - expect(favorite.getText()).toContain('unicorns'); - }); - it('should bind the values to the inputs', function() { - element.all(by.model('my.favorite')).get(0).click(); - expect(favorite.getText()).toContain('pizza'); - }); - </file> - </example> - */ - var ngValueDirective = function() { - return { - priority: 100, - compile: function(tpl, tplAttr) { - if (CONSTANT_VALUE_REGEXP.test(tplAttr.ngValue)) { - return function ngValueConstantLink(scope, elm, attr) { - attr.$set('value', scope.$eval(attr.ngValue)); - }; - } else { - return function ngValueLink(scope, elm, attr) { - scope.$watch(attr.ngValue, function valueWatchAction(value) { - attr.$set('value', value); + currentScope.$emit('$includeContentLoaded', src); + scope.$eval(onloadExp); + }, function() { + if (scope.$$destroyed) return; + + if (thisChangeId === changeCounter) { + cleanupLastIncludeContent(); + scope.$emit('$includeContentError', src); + } + }); + scope.$emit('$includeContentRequested', src); + } else { + cleanupLastIncludeContent(); + ctrl.template = null; + } }); }; } - } - }; - }; + }; + }]; + +// This directive is called during the $transclude call of the first `ngInclude` directive. +// It will replace and compile the content of the element with the loaded template. +// We need this directive so that the element content is already filled when +// the link function of another directive on the same element as ngInclude +// is called. + var ngIncludeFillContentDirective = ['$compile', + function($compile) { + return { + restrict: 'ECA', + priority: -400, + require: 'ngInclude', + link: function(scope, $element, $attr, ctrl) { + if (toString.call($element[0]).match(/SVG/)) { + // WebKit: https://bugs.webkit.org/show_bug.cgi?id=135698 --- SVG elements do not + // support innerHTML, so detect this here and try to generate the contents + // specially. + $element.empty(); + $compile(jqLiteBuildFragment(ctrl.template, window.document).childNodes)(scope, + function namespaceAdaptedClone(clone) { + $element.append(clone); + }, {futureParentElement: $element}); + return; + } + + $element.html(ctrl.template); + $compile($element.contents())(scope); + } + }; + }]; /** * @ngdoc directive - * @name ngBind + * @name ngInit * @restrict AC - * - * @description - * The `ngBind` attribute tells Angular to replace the text content of the specified HTML element - * with the value of a given expression, and to update the text content when the value of that - * expression changes. - * - * Typically, you don't use `ngBind` directly, but instead you use the double curly markup like - * `{{ expression }}` which is similar but less verbose. - * - * It is preferable to use `ngBind` instead of `{{ expression }}` when a template is momentarily - * displayed by the browser in its raw state before Angular compiles it. Since `ngBind` is an - * element attribute, it makes the bindings invisible to the user while the page is loading. - * - * An alternative solution to this problem would be using the - * {@link ng.directive:ngCloak ngCloak} directive. - * - * + * @priority 450 * @element ANY - * @param {expression} ngBind {@link guide/expression Expression} to evaluate. * - * @example - * Enter a name in the Live Preview text box; the greeting below the text box changes instantly. - <example> - <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.name = 'Whirled'; - } - </script> - <div ng-controller="Ctrl"> - Enter name: <input type="text" ng-model="name"><br> - Hello <span ng-bind="name"></span>! - </div> - </file> - <file name="protractor.js" type="protractor"> - it('should check ng-bind', function() { - var nameInput = element(by.model('name')); - - expect(element(by.binding('name')).getText()).toBe('Whirled'); - nameInput.clear(); - nameInput.sendKeys('world'); - expect(element(by.binding('name')).getText()).toBe('world'); - }); - </file> - </example> - */ - var ngBindDirective = ngDirective(function(scope, element, attr) { - element.addClass('ng-binding').data('$binding', attr.ngBind); - scope.$watch(attr.ngBind, function ngBindWatchAction(value) { - // We are purposefully using == here rather than === because we want to - // catch when value is "null or undefined" - // jshint -W041 - element.text(value == undefined ? '' : value); - }); - }); - - - /** - * @ngdoc directive - * @name ngBindTemplate + * @param {expression} ngInit {@link guide/expression Expression} to eval. * * @description - * The `ngBindTemplate` directive specifies that the element - * text content should be replaced with the interpolation of the template - * in the `ngBindTemplate` attribute. - * Unlike `ngBind`, the `ngBindTemplate` can contain multiple `{{` `}}` - * expressions. This directive is needed since some HTML elements - * (such as TITLE and OPTION) cannot contain SPAN elements. + * The `ngInit` directive allows you to evaluate an expression in the + * current scope. * - * @element ANY - * @param {string} ngBindTemplate template of form - * <tt>{{</tt> <tt>expression</tt> <tt>}}</tt> to eval. + * <div class="alert alert-danger"> + * This directive can be abused to add unnecessary amounts of logic into your templates. + * There are only a few appropriate uses of `ngInit`: + * <ul> + * <li>aliasing special properties of {@link ng.directive:ngRepeat `ngRepeat`}, + * as seen in the demo below.</li> + * <li>initializing data during development, or for examples, as seen throughout these docs.</li> + * <li>injecting data via server side scripting.</li> + * </ul> + * + * Besides these few cases, you should use {@link guide/component Components} or + * {@link guide/controller Controllers} rather than `ngInit` to initialize values on a scope. + * </div> + * + * <div class="alert alert-warning"> + * **Note**: If you have assignment in `ngInit` along with a {@link ng.$filter `filter`}, make + * sure you have parentheses to ensure correct operator precedence: + * <pre class="prettyprint"> + * `<div ng-init="test1 = ($index | toString)"></div>` + * </pre> + * </div> * * @example - * Try it here: enter text in text box and watch the greeting change. - <example> + <example module="initExample" name="ng-init"> <file name="index.html"> <script> - function Ctrl($scope) { - $scope.salutation = 'Hello'; - $scope.name = 'World'; - } + angular.module('initExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.list = [['a', 'b'], ['c', 'd']]; + }]); </script> - <div ng-controller="Ctrl"> - Salutation: <input type="text" ng-model="salutation"><br> - Name: <input type="text" ng-model="name"><br> - <pre ng-bind-template="{{salutation}} {{name}}!"></pre> + <div ng-controller="ExampleController"> + <div ng-repeat="innerList in list" ng-init="outerIndex = $index"> + <div ng-repeat="value in innerList" ng-init="innerIndex = $index"> + <span class="example-init">list[ {{outerIndex}} ][ {{innerIndex}} ] = {{value}};</span> + </div> + </div> </div> </file> <file name="protractor.js" type="protractor"> - it('should check ng-bind', function() { - var salutationElem = element(by.binding('salutation')); - var salutationInput = element(by.model('salutation')); - var nameInput = element(by.model('name')); - - expect(salutationElem.getText()).toBe('Hello World!'); - - salutationInput.clear(); - salutationInput.sendKeys('Greetings'); - nameInput.clear(); - nameInput.sendKeys('user'); - - expect(salutationElem.getText()).toBe('Greetings user!'); + it('should alias index positions', function() { + var elements = element.all(by.css('.example-init')); + expect(elements.get(0).getText()).toBe('list[ 0 ][ 0 ] = a;'); + expect(elements.get(1).getText()).toBe('list[ 0 ][ 1 ] = b;'); + expect(elements.get(2).getText()).toBe('list[ 1 ][ 0 ] = c;'); + expect(elements.get(3).getText()).toBe('list[ 1 ][ 1 ] = d;'); }); </file> </example> */ - var ngBindTemplateDirective = ['$interpolate', function($interpolate) { - return function(scope, element, attr) { - // TODO: move this to scenario runner - var interpolateFn = $interpolate(element.attr(attr.$attr.ngBindTemplate)); - element.addClass('ng-binding').data('$binding', interpolateFn); - attr.$observe('ngBindTemplate', function(value) { - element.text(value); - }); - }; - }]; - + var ngInitDirective = ngDirective({ + priority: 450, + compile: function() { + return { + pre: function(scope, element, attrs) { + scope.$eval(attrs.ngInit); + } + }; + } + }); /** * @ngdoc directive - * @name ngBindHtml + * @name ngList + * @restrict A + * @priority 100 + * + * @param {string=} ngList optional delimiter that should be used to split the value. * * @description - * Creates a binding that will innerHTML the result of evaluating the `expression` into the current - * element in a secure way. By default, the innerHTML-ed content will be sanitized using the {@link - * ngSanitize.$sanitize $sanitize} service. To utilize this functionality, ensure that `$sanitize` - * is available, for example, by including {@link ngSanitize} in your module's dependencies (not in - * core Angular.) You may also bypass sanitization for values you know are safe. To do so, bind to - * an explicitly trusted value via {@link ng.$sce#trustAsHtml $sce.trustAsHtml}. See the example - * under {@link ng.$sce#Example Strict Contextual Escaping (SCE)}. + * Text input that converts between a delimited string and an array of strings. The default + * delimiter is a comma followed by a space - equivalent to `ng-list=", "`. You can specify a custom + * delimiter as the value of the `ngList` attribute - for example, `ng-list=" | "`. + * + * The behaviour of the directive is affected by the use of the `ngTrim` attribute. + * * If `ngTrim` is set to `"false"` then whitespace around both the separator and each + * list item is respected. This implies that the user of the directive is responsible for + * dealing with whitespace but also allows you to use whitespace as a delimiter, such as a + * tab or newline character. + * * Otherwise whitespace around the delimiter is ignored when splitting (although it is respected + * when joining the list items back together) and whitespace around each list item is stripped + * before it is added to the model. * - * Note: If a `$sanitize` service is unavailable and the bound value isn't explicitly trusted, you - * will have an exception (instead of an exploit.) + * @example + * ### Validation + * + * <example name="ngList-directive" module="listExample"> + * <file name="app.js"> + * angular.module('listExample', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.names = ['morpheus', 'neo', 'trinity']; + * }]); + * </file> + * <file name="index.html"> + * <form name="myForm" ng-controller="ExampleController"> + * <label>List: <input name="namesInput" ng-model="names" ng-list required></label> + * <span role="alert"> + * <span class="error" ng-show="myForm.namesInput.$error.required"> + * Required!</span> + * </span> + * <br> + * <tt>names = {{names}}</tt><br/> + * <tt>myForm.namesInput.$valid = {{myForm.namesInput.$valid}}</tt><br/> + * <tt>myForm.namesInput.$error = {{myForm.namesInput.$error}}</tt><br/> + * <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> + * <tt>myForm.$error.required = {{!!myForm.$error.required}}</tt><br/> + * </form> + * </file> + * <file name="protractor.js" type="protractor"> + * var listInput = element(by.model('names')); + * var names = element(by.exactBinding('names')); + * var valid = element(by.binding('myForm.namesInput.$valid')); + * var error = element(by.css('span.error')); + * + * it('should initialize to model', function() { + * expect(names.getText()).toContain('["morpheus","neo","trinity"]'); + * expect(valid.getText()).toContain('true'); + * expect(error.getCssValue('display')).toBe('none'); + * }); * - * @element ANY - * @param {expression} ngBindHtml {@link guide/expression Expression} to evaluate. + * it('should be invalid if empty', function() { + * listInput.clear(); + * listInput.sendKeys(''); + * + * expect(names.getText()).toContain(''); + * expect(valid.getText()).toContain('false'); + * expect(error.getCssValue('display')).not.toBe('none'); + * }); + * </file> + * </example> * * @example - Try it here: enter text in text box and watch the greeting change. + * ### Splitting on newline + * + * <example name="ngList-directive-newlines"> + * <file name="index.html"> + * <textarea ng-model="list" ng-list=" " ng-trim="false"></textarea> + * <pre>{{ list | json }}</pre> + * </file> + * <file name="protractor.js" type="protractor"> + * it("should split the text by newlines", function() { + * var listInput = element(by.model('list')); + * var output = element(by.binding('list | json')); + * listInput.sendKeys('abc\ndef\nghi'); + * expect(output.getText()).toContain('[\n "abc",\n "def",\n "ghi"\n]'); + * }); + * </file> + * </example> + * + */ + var ngListDirective = function() { + return { + restrict: 'A', + priority: 100, + require: 'ngModel', + link: function(scope, element, attr, ctrl) { + var ngList = attr.ngList || ', '; + var trimValues = attr.ngTrim !== 'false'; + var separator = trimValues ? trim(ngList) : ngList; - <example module="ngBindHtmlExample" deps="angular-sanitize.js"> - <file name="index.html"> - <div ng-controller="ngBindHtmlCtrl"> - <p ng-bind-html="myHTML"></p> - </div> - </file> + var parse = function(viewValue) { + // If the viewValue is invalid (say required but empty) it will be `undefined` + if (isUndefined(viewValue)) return; - <file name="script.js"> - angular.module('ngBindHtmlExample', ['ngSanitize']) + var list = []; - .controller('ngBindHtmlCtrl', ['$scope', function ngBindHtmlCtrl($scope) { - $scope.myHTML = - 'I am an <code>HTML</code>string with <a href="#">links!</a> and other <em>stuff</em>'; - }]); - </file> + if (viewValue) { + forEach(viewValue.split(separator), function(value) { + if (value) list.push(trimValues ? trim(value) : value); + }); + } - <file name="protractor.js" type="protractor"> - it('should check ng-bind-html', function() { - expect(element(by.binding('myHTML')).getText()).toBe( - 'I am an HTMLstring with links! and other stuff'); - }); - </file> - </example> - */ - var ngBindHtmlDirective = ['$sce', '$parse', function($sce, $parse) { - return function(scope, element, attr) { - element.addClass('ng-binding').data('$binding', attr.ngBindHtml); + return list; + }; - var parsed = $parse(attr.ngBindHtml); - function getStringValue() { return (parsed(scope) || '').toString(); } + ctrl.$parsers.push(parse); + ctrl.$formatters.push(function(value) { + if (isArray(value)) { + return value.join(ngList); + } - scope.$watch(getStringValue, function ngBindHtmlWatchAction(value) { - element.html($sce.getTrustedHtml(parsed(scope)) || ''); - }); - }; - }]; + return undefined; + }); - function classDirective(name, selector) { - name = 'ngClass' + name; - return ['$animate', function($animate) { - return { - restrict: 'AC', - link: function(scope, element, attr) { - var oldVal; + // Override the standard $isEmpty because an empty array means the input is empty. + ctrl.$isEmpty = function(value) { + return !value || !value.length; + }; + } + }; + }; - scope.$watch(attr[name], ngClassWatchAction, true); + /* global VALID_CLASS: true, + INVALID_CLASS: true, + PRISTINE_CLASS: true, + DIRTY_CLASS: true, + UNTOUCHED_CLASS: true, + TOUCHED_CLASS: true, + PENDING_CLASS: true, + addSetValidityMethod: true, + setupValidity: true, + defaultModelOptions: false +*/ - attr.$observe('class', function(value) { - ngClassWatchAction(scope.$eval(attr[name])); - }); + var VALID_CLASS = 'ng-valid', + INVALID_CLASS = 'ng-invalid', + PRISTINE_CLASS = 'ng-pristine', + DIRTY_CLASS = 'ng-dirty', + UNTOUCHED_CLASS = 'ng-untouched', + TOUCHED_CLASS = 'ng-touched', + EMPTY_CLASS = 'ng-empty', + NOT_EMPTY_CLASS = 'ng-not-empty'; - if (name !== 'ngClass') { - scope.$watch('$index', function($index, old$index) { - // jshint bitwise: false - var mod = $index & 1; - if (mod !== old$index & 1) { - var classes = arrayClasses(scope.$eval(attr[name])); - mod === selector ? - addClasses(classes) : - removeClasses(classes); - } - }); - } + var ngModelMinErr = minErr('ngModel'); - function addClasses(classes) { - var newClasses = digestClassCounts(classes, 1); - attr.$addClass(newClasses); - } + /** + * @ngdoc type + * @name ngModel.NgModelController + * @property {*} $viewValue The actual value from the control's view. For `input` elements, this is a + * String. See {@link ngModel.NgModelController#$setViewValue} for information about when the $viewValue + * is set. + * + * @property {*} $modelValue The value in the model that the control is bound to. + * + * @property {Array.<Function>} $parsers Array of functions to execute, as a pipeline, whenever + * the control updates the ngModelController with a new {@link ngModel.NgModelController#$viewValue + `$viewValue`} from the DOM, usually via user input. + See {@link ngModel.NgModelController#$setViewValue `$setViewValue()`} for a detailed lifecycle explanation. + Note that the `$parsers` are not called when the bound ngModel expression changes programmatically. - function removeClasses(classes) { - var newClasses = digestClassCounts(classes, -1); - attr.$removeClass(newClasses); - } + The functions are called in array order, each passing + its return value through to the next. The last return value is forwarded to the + {@link ngModel.NgModelController#$validators `$validators`} collection. - function digestClassCounts (classes, count) { - var classCounts = element.data('$classCounts') || {}; - var classesToUpdate = []; - forEach(classes, function (className) { - if (count > 0 || classCounts[className]) { - classCounts[className] = (classCounts[className] || 0) + count; - if (classCounts[className] === +(count > 0)) { - classesToUpdate.push(className); - } - } - }); - element.data('$classCounts', classCounts); - return classesToUpdate.join(' '); - } + Parsers are used to sanitize / convert the {@link ngModel.NgModelController#$viewValue + `$viewValue`}. - function updateClasses (oldClasses, newClasses) { - var toAdd = arrayDifference(newClasses, oldClasses); - var toRemove = arrayDifference(oldClasses, newClasses); - toRemove = digestClassCounts(toRemove, -1); - toAdd = digestClassCounts(toAdd, 1); + Returning `undefined` from a parser means a parse error occurred. In that case, + no {@link ngModel.NgModelController#$validators `$validators`} will run and the `ngModel` + will be set to `undefined` unless {@link ngModelOptions `ngModelOptions.allowInvalid`} + is set to `true`. The parse error is stored in `ngModel.$error.parse`. - if (toAdd.length === 0) { - $animate.removeClass(element, toRemove); - } else if (toRemove.length === 0) { - $animate.addClass(element, toAdd); - } else { - $animate.setClass(element, toAdd, toRemove); - } - } + This simple example shows a parser that would convert text input value to lowercase: + * ```js + * function parse(value) { + * if (value) { + * return value.toLowerCase(); + * } + * } + * ngModelController.$parsers.push(parse); + * ``` - function ngClassWatchAction(newVal) { - if (selector === true || scope.$index % 2 === selector) { - var newClasses = arrayClasses(newVal || []); - if (!oldVal) { - addClasses(newClasses); - } else if (!equals(newVal,oldVal)) { - var oldClasses = arrayClasses(oldVal); - updateClasses(oldClasses, newClasses); - } - } - oldVal = copy(newVal); - } - } - }; + * + * @property {Array.<Function>} $formatters Array of functions to execute, as a pipeline, whenever + the bound ngModel expression changes programmatically. The `$formatters` are not called when the + value of the control is changed by user interaction. - function arrayDifference(tokens1, tokens2) { - var values = []; + Formatters are used to format / convert the {@link ngModel.NgModelController#$modelValue + `$modelValue`} for display in the control. - outer: - for(var i = 0; i < tokens1.length; i++) { - var token = tokens1[i]; - for(var j = 0; j < tokens2.length; j++) { - if(token == tokens2[j]) continue outer; - } - values.push(token); - } - return values; - } + The functions are called in reverse array order, each passing the value through to the + next. The last return value is used as the actual DOM value. - function arrayClasses (classVal) { - if (isArray(classVal)) { - return classVal; - } else if (isString(classVal)) { - return classVal.split(' '); - } else if (isObject(classVal)) { - var classes = [], i = 0; - forEach(classVal, function(v, k) { - if (v) { - classes.push(k); - } - }); - return classes; - } - return classVal; - } - }]; - } + This simple example shows a formatter that would convert the model value to uppercase: - /** - * @ngdoc directive - * @name ngClass - * @restrict AC + * ```js + * function format(value) { + * if (value) { + * return value.toUpperCase(); + * } + * } + * ngModel.$formatters.push(format); + * ``` * - * @description - * The `ngClass` directive allows you to dynamically set CSS classes on an HTML element by databinding - * an expression that represents all classes to be added. + * @property {Object.<string, function>} $validators A collection of validators that are applied + * whenever the model value changes. The key value within the object refers to the name of the + * validator while the function refers to the validation operation. The validation operation is + * provided with the model value as an argument and must return a true or false value depending + * on the response of that validation. * - * The directive operates in three different ways, depending on which of three types the expression - * evaluates to: + * ```js + * ngModel.$validators.validCharacters = function(modelValue, viewValue) { + * var value = modelValue || viewValue; + * return /[0-9]+/.test(value) && + * /[a-z]+/.test(value) && + * /[A-Z]+/.test(value) && + * /\W+/.test(value); + * }; + * ``` * - * 1. If the expression evaluates to a string, the string should be one or more space-delimited class - * names. + * @property {Object.<string, function>} $asyncValidators A collection of validations that are expected to + * perform an asynchronous validation (e.g. a HTTP request). The validation function that is provided + * is expected to return a promise when it is run during the model validation process. Once the promise + * is delivered then the validation status will be set to true when fulfilled and false when rejected. + * When the asynchronous validators are triggered, each of the validators will run in parallel and the model + * value will only be updated once all validators have been fulfilled. As long as an asynchronous validator + * is unfulfilled, its key will be added to the controllers `$pending` property. Also, all asynchronous validators + * will only run once all synchronous validators have passed. * - * 2. If the expression evaluates to an array, each element of the array should be a string that is - * one or more space-delimited class names. + * Please note that if $http is used then it is important that the server returns a success HTTP response code + * in order to fulfill the validation and a status level of `4xx` in order to reject the validation. * - * 3. If the expression evaluates to an object, then for each key-value pair of the - * object with a truthy value the corresponding key is used as a class name. + * ```js + * ngModel.$asyncValidators.uniqueUsername = function(modelValue, viewValue) { + * var value = modelValue || viewValue; + * + * // Lookup user by username + * return $http.get('/api/users/' + value). + * then(function resolved() { + * //username exists, this means validation fails + * return $q.reject('exists'); + * }, function rejected() { + * //username does not exist, therefore this validation passes + * return true; + * }); + * }; + * ``` * - * The directive won't add duplicate classes if a particular class was already set. + * @property {Array.<Function>} $viewChangeListeners Array of functions to execute whenever + * a change to {@link ngModel.NgModelController#$viewValue `$viewValue`} has caused a change + * to {@link ngModel.NgModelController#$modelValue `$modelValue`}. + * It is called with no arguments, and its return value is ignored. + * This can be used in place of additional $watches against the model value. * - * When the expression changes, the previously added classes are removed and only then the - * new classes are added. + * @property {Object} $error An object hash with all failing validator ids as keys. + * @property {Object} $pending An object hash with all pending validator ids as keys. * - * @animations - * add - happens just before the class is applied to the element - * remove - happens just before the class is removed from the element + * @property {boolean} $untouched True if control has not lost focus yet. + * @property {boolean} $touched True if control has lost focus. + * @property {boolean} $pristine True if user has not interacted with the control yet. + * @property {boolean} $dirty True if user has already interacted with the control. + * @property {boolean} $valid True if there is no error. + * @property {boolean} $invalid True if at least one error on the control. + * @property {string} $name The name attribute of the control. * - * @element ANY - * @param {expression} ngClass {@link guide/expression Expression} to eval. The result - * of the evaluation can be a string representing space delimited class - * names, an array, or a map of class names to boolean values. In the case of a map, the - * names of the properties whose values are truthy will be added as css classes to the - * element. + * @description + * + * `NgModelController` provides API for the {@link ngModel `ngModel`} directive. + * The controller contains services for data-binding, validation, CSS updates, and value formatting + * and parsing. It purposefully does not contain any logic which deals with DOM rendering or + * listening to DOM events. + * Such DOM related logic should be provided by other directives which make use of + * `NgModelController` for data-binding to control elements. + * AngularJS provides this DOM logic for most {@link input `input`} elements. + * At the end of this page you can find a {@link ngModel.NgModelController#custom-control-example + * custom control example} that uses `ngModelController` to bind to `contenteditable` elements. + * + * @example + * ### Custom Control Example + * This example shows how to use `NgModelController` with a custom control to achieve + * data-binding. Notice how different directives (`contenteditable`, `ng-model`, and `required`) + * collaborate together to achieve the desired result. + * + * `contenteditable` is an HTML5 attribute, which tells the browser to let the element + * contents be edited in place by the user. * - * @example Example that demonstrates basic bindings via ngClass directive. - <example> + * We are using the {@link ng.service:$sce $sce} service here and include the {@link ngSanitize $sanitize} + * module to automatically remove "bad" content like inline event listener (e.g. `<span onclick="...">`). + * However, as we are using `$sce` the model can still decide to provide unsafe content if it marks + * that content using the `$sce` service. + * + * <example name="NgModelController" module="customControl" deps="angular-sanitize.js"> + <file name="style.css"> + [contenteditable] { + border: 1px solid black; + background-color: white; + min-height: 20px; + } + + .ng-invalid { + border: 1px solid red; + } + + </file> + <file name="script.js"> + angular.module('customControl', ['ngSanitize']). + directive('contenteditable', ['$sce', function($sce) { + return { + restrict: 'A', // only activate on element attribute + require: '?ngModel', // get a hold of NgModelController + link: function(scope, element, attrs, ngModel) { + if (!ngModel) return; // do nothing if no ng-model + + // Specify how UI should be updated + ngModel.$render = function() { + element.html($sce.getTrustedHtml(ngModel.$viewValue || '')); + }; + + // Listen for change events to enable binding + element.on('blur keyup change', function() { + scope.$evalAsync(read); + }); + read(); // initialize + + // Write data to the model + function read() { + var html = element.html(); + // When we clear the content editable the browser leaves a <br> behind + // If strip-br attribute is provided then we strip this out + if (attrs.stripBr && html === '<br>') { + html = ''; + } + ngModel.$setViewValue(html); + } + } + }; + }]); + </file> <file name="index.html"> - <p ng-class="{strike: deleted, bold: important, red: error}">Map Syntax Example</p> - <input type="checkbox" ng-model="deleted"> deleted (apply "strike" class)<br> - <input type="checkbox" ng-model="important"> important (apply "bold" class)<br> - <input type="checkbox" ng-model="error"> error (apply "red" class) - <hr> - <p ng-class="style">Using String Syntax</p> - <input type="text" ng-model="style" placeholder="Type: bold strike red"> + <form name="myForm"> + <div contenteditable + name="myWidget" ng-model="userContent" + strip-br="true" + required>Change me!</div> + <span ng-show="myForm.myWidget.$error.required">Required!</span> <hr> - <p ng-class="[style1, style2, style3]">Using Array Syntax</p> - <input ng-model="style1" placeholder="Type: bold, strike or red"><br> - <input ng-model="style2" placeholder="Type: bold, strike or red"><br> - <input ng-model="style3" placeholder="Type: bold, strike or red"><br> + <textarea ng-model="userContent" aria-label="Dynamic textarea"></textarea> + </form> </file> - <file name="style.css"> - .strike { - text-decoration: line-through; - } - .bold { - font-weight: bold; - } - .red { - color: red; - } + <file name="protractor.js" type="protractor"> + it('should data-bind and become invalid', function() { + if (browser.params.browser === 'safari' || browser.params.browser === 'firefox') { + // SafariDriver can't handle contenteditable + // and Firefox driver can't clear contenteditables very well + return; + } + var contentEditable = element(by.css('[contenteditable]')); + var content = 'Change me!'; + + expect(contentEditable.getText()).toEqual(content); + + contentEditable.clear(); + contentEditable.sendKeys(protractor.Key.BACK_SPACE); + expect(contentEditable.getText()).toEqual(''); + expect(contentEditable.getAttribute('class')).toMatch(/ng-invalid-required/); + }); </file> - <file name="protractor.js" type="protractor"> - var ps = element.all(by.css('p')); + * </example> + * + * + */ + NgModelController.$inject = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', '$animate', '$timeout', '$q', '$interpolate']; + function NgModelController($scope, $exceptionHandler, $attr, $element, $parse, $animate, $timeout, $q, $interpolate) { + this.$viewValue = Number.NaN; + this.$modelValue = Number.NaN; + this.$$rawModelValue = undefined; // stores the parsed modelValue / model set from scope regardless of validity. + this.$validators = {}; + this.$asyncValidators = {}; + this.$parsers = []; + this.$formatters = []; + this.$viewChangeListeners = []; + this.$untouched = true; + this.$touched = false; + this.$pristine = true; + this.$dirty = false; + this.$valid = true; + this.$invalid = false; + this.$error = {}; // keep invalid keys here + this.$$success = {}; // keep valid keys here + this.$pending = undefined; // keep pending keys here + this.$name = $interpolate($attr.name || '', false)($scope); + this.$$parentForm = nullFormCtrl; + this.$options = defaultModelOptions; + this.$$updateEvents = ''; + // Attach the correct context to the event handler function for updateOn + this.$$updateEventHandler = this.$$updateEventHandler.bind(this); + + this.$$parsedNgModel = $parse($attr.ngModel); + this.$$parsedNgModelAssign = this.$$parsedNgModel.assign; + this.$$ngModelGet = this.$$parsedNgModel; + this.$$ngModelSet = this.$$parsedNgModelAssign; + this.$$pendingDebounce = null; + this.$$parserValid = undefined; + + this.$$currentValidationRunId = 0; + + // https://github.com/angular/angular.js/issues/15833 + // Prevent `$$scope` from being iterated over by `copy` when NgModelController is deep watched + Object.defineProperty(this, '$$scope', {value: $scope}); + this.$$attr = $attr; + this.$$element = $element; + this.$$animate = $animate; + this.$$timeout = $timeout; + this.$$parse = $parse; + this.$$q = $q; + this.$$exceptionHandler = $exceptionHandler; + + setupValidity(this); + setupModelWatcher(this); + } - it('should let you toggle the class', function() { + NgModelController.prototype = { + $$initGetterSetters: function() { + if (this.$options.getOption('getterSetter')) { + var invokeModelGetter = this.$$parse(this.$$attr.ngModel + '()'), + invokeModelSetter = this.$$parse(this.$$attr.ngModel + '($$$p)'); - expect(ps.first().getAttribute('class')).not.toMatch(/bold/); - expect(ps.first().getAttribute('class')).not.toMatch(/red/); + this.$$ngModelGet = function($scope) { + var modelValue = this.$$parsedNgModel($scope); + if (isFunction(modelValue)) { + modelValue = invokeModelGetter($scope); + } + return modelValue; + }; + this.$$ngModelSet = function($scope, newValue) { + if (isFunction(this.$$parsedNgModel($scope))) { + invokeModelSetter($scope, {$$$p: newValue}); + } else { + this.$$parsedNgModelAssign($scope, newValue); + } + }; + } else if (!this.$$parsedNgModel.assign) { + throw ngModelMinErr('nonassign', 'Expression \'{0}\' is non-assignable. Element: {1}', + this.$$attr.ngModel, startingTag(this.$$element)); + } + }, - element(by.model('important')).click(); - expect(ps.first().getAttribute('class')).toMatch(/bold/); - element(by.model('error')).click(); - expect(ps.first().getAttribute('class')).toMatch(/red/); - }); + /** + * @ngdoc method + * @name ngModel.NgModelController#$render + * + * @description + * Called when the view needs to be updated. It is expected that the user of the ng-model + * directive will implement this method. + * + * The `$render()` method is invoked in the following situations: + * + * * `$rollbackViewValue()` is called. If we are rolling back the view value to the last + * committed value then `$render()` is called to update the input control. + * * The value referenced by `ng-model` is changed programmatically and both the `$modelValue` and + * the `$viewValue` are different from last time. + * + * Since `ng-model` does not do a deep watch, `$render()` is only invoked if the values of + * `$modelValue` and `$viewValue` are actually different from their previous values. If `$modelValue` + * or `$viewValue` are objects (rather than a string or number) then `$render()` will not be + * invoked if you only change a property on the objects. + */ + $render: noop, - it('should let you toggle string example', function() { - expect(ps.get(1).getAttribute('class')).toBe(''); - element(by.model('style')).clear(); - element(by.model('style')).sendKeys('red'); - expect(ps.get(1).getAttribute('class')).toBe('red'); - }); + /** + * @ngdoc method + * @name ngModel.NgModelController#$isEmpty + * + * @description + * This is called when we need to determine if the value of an input is empty. + * + * For instance, the required directive does this to work out if the input has data or not. + * + * The default `$isEmpty` function checks whether the value is `undefined`, `''`, `null` or `NaN`. + * + * You can override this for input directives whose concept of being empty is different from the + * default. The `checkboxInputType` directive does this because in its case a value of `false` + * implies empty. + * + * @param {*} value The value of the input to check for emptiness. + * @returns {boolean} True if `value` is "empty". + */ + $isEmpty: function(value) { + // eslint-disable-next-line no-self-compare + return isUndefined(value) || value === '' || value === null || value !== value; + }, - it('array example should have 3 classes', function() { - expect(ps.last().getAttribute('class')).toBe(''); - element(by.model('style1')).sendKeys('bold'); - element(by.model('style2')).sendKeys('strike'); - element(by.model('style3')).sendKeys('red'); - expect(ps.last().getAttribute('class')).toBe('bold strike red'); - }); - </file> - </example> + $$updateEmptyClasses: function(value) { + if (this.$isEmpty(value)) { + this.$$animate.removeClass(this.$$element, NOT_EMPTY_CLASS); + this.$$animate.addClass(this.$$element, EMPTY_CLASS); + } else { + this.$$animate.removeClass(this.$$element, EMPTY_CLASS); + this.$$animate.addClass(this.$$element, NOT_EMPTY_CLASS); + } + }, - ## Animations + /** + * @ngdoc method + * @name ngModel.NgModelController#$setPristine + * + * @description + * Sets the control to its pristine state. + * + * This method can be called to remove the `ng-dirty` class and set the control to its pristine + * state (`ng-pristine` class). A model is considered to be pristine when the control + * has not been changed from when first compiled. + */ + $setPristine: function() { + this.$dirty = false; + this.$pristine = true; + this.$$animate.removeClass(this.$$element, DIRTY_CLASS); + this.$$animate.addClass(this.$$element, PRISTINE_CLASS); + }, - The example below demonstrates how to perform animations using ngClass. + /** + * @ngdoc method + * @name ngModel.NgModelController#$setDirty + * + * @description + * Sets the control to its dirty state. + * + * This method can be called to remove the `ng-pristine` class and set the control to its dirty + * state (`ng-dirty` class). A model is considered to be dirty when the control has been changed + * from when first compiled. + */ + $setDirty: function() { + this.$dirty = true; + this.$pristine = false; + this.$$animate.removeClass(this.$$element, PRISTINE_CLASS); + this.$$animate.addClass(this.$$element, DIRTY_CLASS); + this.$$parentForm.$setDirty(); + }, - <example module="ngAnimate" deps="angular-animate.js" animations="true"> - <file name="index.html"> - <input id="setbtn" type="button" value="set" ng-click="myVar='my-class'"> - <input id="clearbtn" type="button" value="clear" ng-click="myVar=''"> - <br> - <span class="base-class" ng-class="myVar">Sample Text</span> - </file> - <file name="style.css"> - .base-class { - -webkit-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; - transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; - } + /** + * @ngdoc method + * @name ngModel.NgModelController#$setUntouched + * + * @description + * Sets the control to its untouched state. + * + * This method can be called to remove the `ng-touched` class and set the control to its + * untouched state (`ng-untouched` class). Upon compilation, a model is set as untouched + * by default, however this function can be used to restore that state if the model has + * already been touched by the user. + */ + $setUntouched: function() { + this.$touched = false; + this.$untouched = true; + this.$$animate.setClass(this.$$element, UNTOUCHED_CLASS, TOUCHED_CLASS); + }, - .base-class.my-class { - color: red; - font-size:3em; - } - </file> - <file name="protractor.js" type="protractor"> - it('should check ng-class', function() { - expect(element(by.css('.base-class')).getAttribute('class')).not. - toMatch(/my-class/); + /** + * @ngdoc method + * @name ngModel.NgModelController#$setTouched + * + * @description + * Sets the control to its touched state. + * + * This method can be called to remove the `ng-untouched` class and set the control to its + * touched state (`ng-touched` class). A model is considered to be touched when the user has + * first focused the control element and then shifted focus away from the control (blur event). + */ + $setTouched: function() { + this.$touched = true; + this.$untouched = false; + this.$$animate.setClass(this.$$element, TOUCHED_CLASS, UNTOUCHED_CLASS); + }, - element(by.id('setbtn')).click(); + /** + * @ngdoc method + * @name ngModel.NgModelController#$rollbackViewValue + * + * @description + * Cancel an update and reset the input element's value to prevent an update to the `$modelValue`, + * which may be caused by a pending debounced event or because the input is waiting for some + * future event. + * + * If you have an input that uses `ng-model-options` to set up debounced updates or updates that + * depend on special events such as `blur`, there can be a period when the `$viewValue` is out of + * sync with the ngModel's `$modelValue`. + * + * In this case, you can use `$rollbackViewValue()` to manually cancel the debounced / future update + * and reset the input to the last committed view value. + * + * It is also possible that you run into difficulties if you try to update the ngModel's `$modelValue` + * programmatically before these debounced/future events have resolved/occurred, because AngularJS's + * dirty checking mechanism is not able to tell whether the model has actually changed or not. + * + * The `$rollbackViewValue()` method should be called before programmatically changing the model of an + * input which may have such events pending. This is important in order to make sure that the + * input field will be updated with the new model value and any pending operations are cancelled. + * + * @example + * <example name="ng-model-cancel-update" module="cancel-update-example"> + * <file name="app.js"> + * angular.module('cancel-update-example', []) + * + * .controller('CancelUpdateController', ['$scope', function($scope) { + * $scope.model = {value1: '', value2: ''}; + * + * $scope.setEmpty = function(e, value, rollback) { + * if (e.keyCode === 27) { + * e.preventDefault(); + * if (rollback) { + * $scope.myForm[value].$rollbackViewValue(); + * } + * $scope.model[value] = ''; + * } + * }; + * }]); + * </file> + * <file name="index.html"> + * <div ng-controller="CancelUpdateController"> + * <p>Both of these inputs are only updated if they are blurred. Hitting escape should + * empty them. Follow these steps and observe the difference:</p> + * <ol> + * <li>Type something in the input. You will see that the model is not yet updated</li> + * <li>Press the Escape key. + * <ol> + * <li> In the first example, nothing happens, because the model is already '', and no + * update is detected. If you blur the input, the model will be set to the current view. + * </li> + * <li> In the second example, the pending update is cancelled, and the input is set back + * to the last committed view value (''). Blurring the input does nothing. + * </li> + * </ol> + * </li> + * </ol> + * + * <form name="myForm" ng-model-options="{ updateOn: 'blur' }"> + * <div> + * <p id="inputDescription1">Without $rollbackViewValue():</p> + * <input name="value1" aria-describedby="inputDescription1" ng-model="model.value1" + * ng-keydown="setEmpty($event, 'value1')"> + * value1: "{{ model.value1 }}" + * </div> + * + * <div> + * <p id="inputDescription2">With $rollbackViewValue():</p> + * <input name="value2" aria-describedby="inputDescription2" ng-model="model.value2" + * ng-keydown="setEmpty($event, 'value2', true)"> + * value2: "{{ model.value2 }}" + * </div> + * </form> + * </div> + * </file> + <file name="style.css"> + div { + display: table-cell; + } + div:nth-child(1) { + padding-right: 30px; + } - expect(element(by.css('.base-class')).getAttribute('class')). - toMatch(/my-class/); + </file> + * </example> + */ + $rollbackViewValue: function() { + this.$$timeout.cancel(this.$$pendingDebounce); + this.$viewValue = this.$$lastCommittedViewValue; + this.$render(); + }, - element(by.id('clearbtn')).click(); + /** + * @ngdoc method + * @name ngModel.NgModelController#$validate + * + * @description + * Runs each of the registered validators (first synchronous validators and then + * asynchronous validators). + * If the validity changes to invalid, the model will be set to `undefined`, + * unless {@link ngModelOptions `ngModelOptions.allowInvalid`} is `true`. + * If the validity changes to valid, it will set the model to the last available valid + * `$modelValue`, i.e. either the last parsed value or the last value set from the scope. + */ + $validate: function() { + // ignore $validate before model is initialized + if (isNumberNaN(this.$modelValue)) { + return; + } - expect(element(by.css('.base-class')).getAttribute('class')).not. - toMatch(/my-class/); - }); - </file> - </example> + var viewValue = this.$$lastCommittedViewValue; + // Note: we use the $$rawModelValue as $modelValue might have been + // set to undefined during a view -> model update that found validation + // errors. We can't parse the view here, since that could change + // the model although neither viewValue nor the model on the scope changed + var modelValue = this.$$rawModelValue; + + var prevValid = this.$valid; + var prevModelValue = this.$modelValue; + + var allowInvalid = this.$options.getOption('allowInvalid'); + + var that = this; + this.$$runValidators(modelValue, viewValue, function(allValid) { + // If there was no change in validity, don't update the model + // This prevents changing an invalid modelValue to undefined + if (!allowInvalid && prevValid !== allValid) { + // Note: Don't check this.$valid here, as we could have + // external validators (e.g. calculated on the server), + // that just call $setValidity and need the model value + // to calculate their validity. + that.$modelValue = allValid ? modelValue : undefined; + + if (that.$modelValue !== prevModelValue) { + that.$$writeModelToScope(); + } + } + }); + }, + $$runValidators: function(modelValue, viewValue, doneCallback) { + this.$$currentValidationRunId++; + var localValidationRunId = this.$$currentValidationRunId; + var that = this; - ## ngClass and pre-existing CSS3 Transitions/Animations - The ngClass directive still supports CSS3 Transitions/Animations even if they do not follow the ngAnimate CSS naming structure. - Upon animation ngAnimate will apply supplementary CSS classes to track the start and end of an animation, but this will not hinder - any pre-existing CSS transitions already on the element. To get an idea of what happens during a class-based animation, be sure - to view the step by step details of {@link ngAnimate.$animate#addclass $animate.addClass} and - {@link ngAnimate.$animate#removeclass $animate.removeClass}. - */ - var ngClassDirective = classDirective('', true); + // check parser error + if (!processParseErrors()) { + validationDone(false); + return; + } + if (!processSyncValidators()) { + validationDone(false); + return; + } + processAsyncValidators(); - /** - * @ngdoc directive - * @name ngClassOdd - * @restrict AC - * - * @description - * The `ngClassOdd` and `ngClassEven` directives work exactly as - * {@link ng.directive:ngClass ngClass}, except they work in - * conjunction with `ngRepeat` and take effect only on odd (even) rows. - * - * This directive can be applied only within the scope of an - * {@link ng.directive:ngRepeat ngRepeat}. - * - * @element ANY - * @param {expression} ngClassOdd {@link guide/expression Expression} to eval. The result - * of the evaluation can be a string representing space delimited class names or an array. - * - * @example - <example> - <file name="index.html"> - <ol ng-init="names=['John', 'Mary', 'Cate', 'Suz']"> - <li ng-repeat="name in names"> - <span ng-class-odd="'odd'" ng-class-even="'even'"> - {{name}} - </span> - </li> - </ol> - </file> - <file name="style.css"> - .odd { - color: red; - } - .even { - color: blue; - } - </file> - <file name="protractor.js" type="protractor"> - it('should check ng-class-odd and ng-class-even', function() { - expect(element(by.repeater('name in names').row(0).column('name')).getAttribute('class')). - toMatch(/odd/); - expect(element(by.repeater('name in names').row(1).column('name')).getAttribute('class')). - toMatch(/even/); - }); - </file> - </example> - */ - var ngClassOddDirective = classDirective('Odd', 0); + function processParseErrors() { + var errorKey = that.$$parserName || 'parse'; + if (isUndefined(that.$$parserValid)) { + setValidity(errorKey, null); + } else { + if (!that.$$parserValid) { + forEach(that.$validators, function(v, name) { + setValidity(name, null); + }); + forEach(that.$asyncValidators, function(v, name) { + setValidity(name, null); + }); + } + // Set the parse error last, to prevent unsetting it, should a $validators key == parserName + setValidity(errorKey, that.$$parserValid); + return that.$$parserValid; + } + return true; + } - /** - * @ngdoc directive - * @name ngClassEven - * @restrict AC - * - * @description - * The `ngClassOdd` and `ngClassEven` directives work exactly as - * {@link ng.directive:ngClass ngClass}, except they work in - * conjunction with `ngRepeat` and take effect only on odd (even) rows. - * - * This directive can be applied only within the scope of an - * {@link ng.directive:ngRepeat ngRepeat}. - * - * @element ANY - * @param {expression} ngClassEven {@link guide/expression Expression} to eval. The - * result of the evaluation can be a string representing space delimited class names or an array. - * - * @example - <example> - <file name="index.html"> - <ol ng-init="names=['John', 'Mary', 'Cate', 'Suz']"> - <li ng-repeat="name in names"> - <span ng-class-odd="'odd'" ng-class-even="'even'"> - {{name}}       - </span> - </li> - </ol> - </file> - <file name="style.css"> - .odd { - color: red; - } - .even { - color: blue; - } - </file> - <file name="protractor.js" type="protractor"> - it('should check ng-class-odd and ng-class-even', function() { - expect(element(by.repeater('name in names').row(0).column('name')).getAttribute('class')). - toMatch(/odd/); - expect(element(by.repeater('name in names').row(1).column('name')).getAttribute('class')). - toMatch(/even/); - }); - </file> - </example> - */ - var ngClassEvenDirective = classDirective('Even', 1); + function processSyncValidators() { + var syncValidatorsValid = true; + forEach(that.$validators, function(validator, name) { + var result = Boolean(validator(modelValue, viewValue)); + syncValidatorsValid = syncValidatorsValid && result; + setValidity(name, result); + }); + if (!syncValidatorsValid) { + forEach(that.$asyncValidators, function(v, name) { + setValidity(name, null); + }); + return false; + } + return true; + } - /** - * @ngdoc directive - * @name ngCloak - * @restrict AC - * - * @description - * The `ngCloak` directive is used to prevent the Angular html template from being briefly - * displayed by the browser in its raw (uncompiled) form while your application is loading. Use this - * directive to avoid the undesirable flicker effect caused by the html template display. - * - * The directive can be applied to the `<body>` element, but the preferred usage is to apply - * multiple `ngCloak` directives to small portions of the page to permit progressive rendering - * of the browser view. - * - * `ngCloak` works in cooperation with the following css rule embedded within `angular.js` and - * `angular.min.js`. - * For CSP mode please add `angular-csp.css` to your html file (see {@link ng.directive:ngCsp ngCsp}). - * - * ```css - * [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak { - * display: none !important; - * } - * ``` - * - * When this css rule is loaded by the browser, all html elements (including their children) that - * are tagged with the `ngCloak` directive are hidden. When Angular encounters this directive - * during the compilation of the template it deletes the `ngCloak` element attribute, making - * the compiled element visible. - * - * For the best result, the `angular.js` script must be loaded in the head section of the html - * document; alternatively, the css rule above must be included in the external stylesheet of the - * application. - * - * Legacy browsers, like IE7, do not provide attribute selector support (added in CSS 2.1) so they - * cannot match the `[ng\:cloak]` selector. To work around this limitation, you must add the css - * class `ng-cloak` in addition to the `ngCloak` directive as shown in the example below. - * - * @element ANY - * - * @example - <example> - <file name="index.html"> - <div id="template1" ng-cloak>{{ 'hello' }}</div> - <div id="template2" ng-cloak class="ng-cloak">{{ 'hello IE7' }}</div> - </file> - <file name="protractor.js" type="protractor"> - it('should remove the template directive and css class', function() { - expect($('#template1').getAttribute('ng-cloak')). - toBeNull(); - expect($('#template2').getAttribute('ng-cloak')). - toBeNull(); - }); - </file> - </example> - * - */ - var ngCloakDirective = ngDirective({ - compile: function(element, attr) { - attr.$set('ngCloak', undefined); - element.removeClass('ng-cloak'); - } - }); + function processAsyncValidators() { + var validatorPromises = []; + var allValid = true; + forEach(that.$asyncValidators, function(validator, name) { + var promise = validator(modelValue, viewValue); + if (!isPromiseLike(promise)) { + throw ngModelMinErr('nopromise', + 'Expected asynchronous validator to return a promise but got \'{0}\' instead.', promise); + } + setValidity(name, undefined); + validatorPromises.push(promise.then(function() { + setValidity(name, true); + }, function() { + allValid = false; + setValidity(name, false); + })); + }); + if (!validatorPromises.length) { + validationDone(true); + } else { + that.$$q.all(validatorPromises).then(function() { + validationDone(allValid); + }, noop); + } + } - /** - * @ngdoc directive - * @name ngController - * - * @description - * The `ngController` directive attaches a controller class to the view. This is a key aspect of how angular - * supports the principles behind the Model-View-Controller design pattern. - * - * MVC components in angular: - * - * * Model — The Model is scope properties; scopes are attached to the DOM where scope properties - * are accessed through bindings. - * * View — The template (HTML with data bindings) that is rendered into the View. - * * Controller — The `ngController` directive specifies a Controller class; the class contains business - * logic behind the application to decorate the scope with functions and values - * - * Note that you can also attach controllers to the DOM by declaring it in a route definition - * via the {@link ngRoute.$route $route} service. A common mistake is to declare the controller - * again using `ng-controller` in the template itself. This will cause the controller to be attached - * and executed twice. - * - * @element ANY - * @scope - * @param {expression} ngController Name of a globally accessible constructor function or an - * {@link guide/expression expression} that on the current scope evaluates to a - * constructor function. The controller instance can be published into a scope property - * by specifying `as propertyName`. - * - * @example - * Here is a simple form for editing user contact information. Adding, removing, clearing, and - * greeting are methods declared on the controller (see source tab). These methods can - * easily be called from the angular markup. Notice that the scope becomes the `this` for the - * controller's instance. This allows for easy access to the view data from the controller. Also - * notice that any changes to the data are automatically reflected in the View without the need - * for a manual update. The example is shown in two different declaration styles you may use - * according to preference. - <example> - <file name="index.html"> - <script> - function SettingsController1() { - this.name = "John Smith"; - this.contacts = [ - {type: 'phone', value: '408 555 1212'}, - {type: 'email', value: 'john.smith@example.org'} ]; - }; - - SettingsController1.prototype.greet = function() { - alert(this.name); - }; + function setValidity(name, isValid) { + if (localValidationRunId === that.$$currentValidationRunId) { + that.$setValidity(name, isValid); + } + } - SettingsController1.prototype.addContact = function() { - this.contacts.push({type: 'email', value: 'yourname@example.org'}); - }; + function validationDone(allValid) { + if (localValidationRunId === that.$$currentValidationRunId) { - SettingsController1.prototype.removeContact = function(contactToRemove) { - var index = this.contacts.indexOf(contactToRemove); - this.contacts.splice(index, 1); - }; + doneCallback(allValid); + } + } + }, - SettingsController1.prototype.clearContact = function(contact) { - contact.type = 'phone'; - contact.value = ''; - }; - </script> - <div id="ctrl-as-exmpl" ng-controller="SettingsController1 as settings"> - Name: <input type="text" ng-model="settings.name"/> - [ <a href="" ng-click="settings.greet()">greet</a> ]<br/> - Contact: - <ul> - <li ng-repeat="contact in settings.contacts"> - <select ng-model="contact.type"> - <option>phone</option> - <option>email</option> - </select> - <input type="text" ng-model="contact.value"/> - [ <a href="" ng-click="settings.clearContact(contact)">clear</a> - | <a href="" ng-click="settings.removeContact(contact)">X</a> ] - </li> - <li>[ <a href="" ng-click="settings.addContact()">add</a> ]</li> - </ul> - </div> - </file> - <file name="protractor.js" type="protractor"> - it('should check controller as', function() { - var container = element(by.id('ctrl-as-exmpl')); + /** + * @ngdoc method + * @name ngModel.NgModelController#$commitViewValue + * + * @description + * Commit a pending update to the `$modelValue`. + * + * Updates may be pending by a debounced event or because the input is waiting for a some future + * event defined in `ng-model-options`. this method is rarely needed as `NgModelController` + * usually handles calling this in response to input events. + */ + $commitViewValue: function() { + var viewValue = this.$viewValue; - expect(container.findElement(by.model('settings.name')) - .getAttribute('value')).toBe('John Smith'); + this.$$timeout.cancel(this.$$pendingDebounce); - var firstRepeat = - container.findElement(by.repeater('contact in settings.contacts').row(0)); - var secondRepeat = - container.findElement(by.repeater('contact in settings.contacts').row(1)); + // If the view value has not changed then we should just exit, except in the case where there is + // a native validator on the element. In this case the validation state may have changed even though + // the viewValue has stayed empty. + if (this.$$lastCommittedViewValue === viewValue && (viewValue !== '' || !this.$$hasNativeValidators)) { + return; + } + this.$$updateEmptyClasses(viewValue); + this.$$lastCommittedViewValue = viewValue; - expect(firstRepeat.findElement(by.model('contact.value')).getAttribute('value')) - .toBe('408 555 1212'); - expect(secondRepeat.findElement(by.model('contact.value')).getAttribute('value')) - .toBe('john.smith@example.org'); + // change to dirty + if (this.$pristine) { + this.$setDirty(); + } + this.$$parseAndValidate(); + }, - firstRepeat.findElement(by.linkText('clear')).click(); + $$parseAndValidate: function() { + var viewValue = this.$$lastCommittedViewValue; + var modelValue = viewValue; + var that = this; - expect(firstRepeat.findElement(by.model('contact.value')).getAttribute('value')) - .toBe(''); + this.$$parserValid = isUndefined(modelValue) ? undefined : true; - container.findElement(by.linkText('add')).click(); + if (this.$$parserValid) { + for (var i = 0; i < this.$parsers.length; i++) { + modelValue = this.$parsers[i](modelValue); + if (isUndefined(modelValue)) { + this.$$parserValid = false; + break; + } + } + } + if (isNumberNaN(this.$modelValue)) { + // this.$modelValue has not been touched yet... + this.$modelValue = this.$$ngModelGet(this.$$scope); + } + var prevModelValue = this.$modelValue; + var allowInvalid = this.$options.getOption('allowInvalid'); + this.$$rawModelValue = modelValue; - expect(container.findElement(by.repeater('contact in settings.contacts').row(2)) - .findElement(by.model('contact.value')) - .getAttribute('value')) - .toBe('yourname@example.org'); - }); - </file> - </example> - <example> - <file name="index.html"> - <script> - function SettingsController2($scope) { - $scope.name = "John Smith"; - $scope.contacts = [ - {type:'phone', value:'408 555 1212'}, - {type:'email', value:'john.smith@example.org'} ]; - - $scope.greet = function() { - alert(this.name); - }; + if (allowInvalid) { + this.$modelValue = modelValue; + writeToModelIfNeeded(); + } - $scope.addContact = function() { - this.contacts.push({type:'email', value:'yourname@example.org'}); - }; + // Pass the $$lastCommittedViewValue here, because the cached viewValue might be out of date. + // This can happen if e.g. $setViewValue is called from inside a parser + this.$$runValidators(modelValue, this.$$lastCommittedViewValue, function(allValid) { + if (!allowInvalid) { + // Note: Don't check this.$valid here, as we could have + // external validators (e.g. calculated on the server), + // that just call $setValidity and need the model value + // to calculate their validity. + that.$modelValue = allValid ? modelValue : undefined; + writeToModelIfNeeded(); + } + }); - $scope.removeContact = function(contactToRemove) { - var index = this.contacts.indexOf(contactToRemove); - this.contacts.splice(index, 1); - }; + function writeToModelIfNeeded() { + if (that.$modelValue !== prevModelValue) { + that.$$writeModelToScope(); + } + } + }, - $scope.clearContact = function(contact) { - contact.type = 'phone'; - contact.value = ''; - }; - } - </script> - <div id="ctrl-exmpl" ng-controller="SettingsController2"> - Name: <input type="text" ng-model="name"/> - [ <a href="" ng-click="greet()">greet</a> ]<br/> - Contact: - <ul> - <li ng-repeat="contact in contacts"> - <select ng-model="contact.type"> - <option>phone</option> - <option>email</option> - </select> - <input type="text" ng-model="contact.value"/> - [ <a href="" ng-click="clearContact(contact)">clear</a> - | <a href="" ng-click="removeContact(contact)">X</a> ] - </li> - <li>[ <a href="" ng-click="addContact()">add</a> ]</li> - </ul> - </div> - </file> - <file name="protractor.js" type="protractor"> - it('should check controller', function() { - var container = element(by.id('ctrl-exmpl')); + $$writeModelToScope: function() { + this.$$ngModelSet(this.$$scope, this.$modelValue); + forEach(this.$viewChangeListeners, function(listener) { + try { + listener(); + } catch (e) { + // eslint-disable-next-line no-invalid-this + this.$$exceptionHandler(e); + } + }, this); + }, - expect(container.findElement(by.model('name')) - .getAttribute('value')).toBe('John Smith'); + /** + * @ngdoc method + * @name ngModel.NgModelController#$setViewValue + * + * @description + * Update the view value. + * + * This method should be called when a control wants to change the view value; typically, + * this is done from within a DOM event handler. For example, the {@link ng.directive:input input} + * directive calls it when the value of the input changes and {@link ng.directive:select select} + * calls it when an option is selected. + * + * When `$setViewValue` is called, the new `value` will be staged for committing through the `$parsers` + * and `$validators` pipelines. If there are no special {@link ngModelOptions} specified then the staged + * value is sent directly for processing through the `$parsers` pipeline. After this, the `$validators` and + * `$asyncValidators` are called and the value is applied to `$modelValue`. + * Finally, the value is set to the **expression** specified in the `ng-model` attribute and + * all the registered change listeners, in the `$viewChangeListeners` list are called. + * + * In case the {@link ng.directive:ngModelOptions ngModelOptions} directive is used with `updateOn` + * and the `default` trigger is not listed, all those actions will remain pending until one of the + * `updateOn` events is triggered on the DOM element. + * All these actions will be debounced if the {@link ng.directive:ngModelOptions ngModelOptions} + * directive is used with a custom debounce for this particular event. + * Note that a `$digest` is only triggered once the `updateOn` events are fired, or if `debounce` + * is specified, once the timer runs out. + * + * When used with standard inputs, the view value will always be a string (which is in some cases + * parsed into another type, such as a `Date` object for `input[date]`.) + * However, custom controls might also pass objects to this method. In this case, we should make + * a copy of the object before passing it to `$setViewValue`. This is because `ngModel` does not + * perform a deep watch of objects, it only looks for a change of identity. If you only change + * the property of the object then ngModel will not realize that the object has changed and + * will not invoke the `$parsers` and `$validators` pipelines. For this reason, you should + * not change properties of the copy once it has been passed to `$setViewValue`. + * Otherwise you may cause the model value on the scope to change incorrectly. + * + * <div class="alert alert-info"> + * In any case, the value passed to the method should always reflect the current value + * of the control. For example, if you are calling `$setViewValue` for an input element, + * you should pass the input DOM value. Otherwise, the control and the scope model become + * out of sync. It's also important to note that `$setViewValue` does not call `$render` or change + * the control's DOM value in any way. If we want to change the control's DOM value + * programmatically, we should update the `ngModel` scope expression. Its new value will be + * picked up by the model controller, which will run it through the `$formatters`, `$render` it + * to update the DOM, and finally call `$validate` on it. + * </div> + * + * @param {*} value value from the view. + * @param {string} trigger Event that triggered the update. + */ + $setViewValue: function(value, trigger) { + this.$viewValue = value; + if (this.$options.getOption('updateOnDefault')) { + this.$$debounceViewValueCommit(trigger); + } + }, - var firstRepeat = - container.findElement(by.repeater('contact in contacts').row(0)); - var secondRepeat = - container.findElement(by.repeater('contact in contacts').row(1)); + $$debounceViewValueCommit: function(trigger) { + var debounceDelay = this.$options.getOption('debounce'); - expect(firstRepeat.findElement(by.model('contact.value')).getAttribute('value')) - .toBe('408 555 1212'); - expect(secondRepeat.findElement(by.model('contact.value')).getAttribute('value')) - .toBe('john.smith@example.org'); + if (isNumber(debounceDelay[trigger])) { + debounceDelay = debounceDelay[trigger]; + } else if (isNumber(debounceDelay['default'])) { + debounceDelay = debounceDelay['default']; + } - firstRepeat.findElement(by.linkText('clear')).click(); + this.$$timeout.cancel(this.$$pendingDebounce); + var that = this; + if (debounceDelay > 0) { // this fails if debounceDelay is an object + this.$$pendingDebounce = this.$$timeout(function() { + that.$commitViewValue(); + }, debounceDelay); + } else if (this.$$scope.$root.$$phase) { + this.$commitViewValue(); + } else { + this.$$scope.$apply(function() { + that.$commitViewValue(); + }); + } + }, - expect(firstRepeat.findElement(by.model('contact.value')).getAttribute('value')) - .toBe(''); + /** + * @ngdoc method + * + * @name ngModel.NgModelController#$overrideModelOptions + * + * @description + * + * Override the current model options settings programmatically. + * + * The previous `ModelOptions` value will not be modified. Instead, a + * new `ModelOptions` object will inherit from the previous one overriding + * or inheriting settings that are defined in the given parameter. + * + * See {@link ngModelOptions} for information about what options can be specified + * and how model option inheritance works. + * + * <div class="alert alert-warning"> + * **Note:** this function only affects the options set on the `ngModelController`, + * and not the options on the {@link ngModelOptions} directive from which they might have been + * obtained initially. + * </div> + * + * <div class="alert alert-danger"> + * **Note:** it is not possible to override the `getterSetter` option. + * </div> + * + * @param {Object} options a hash of settings to override the previous options + * + */ + $overrideModelOptions: function(options) { + this.$options = this.$options.createChild(options); + this.$$setUpdateOnEvents(); + }, - container.findElement(by.linkText('add')).click(); + /** + * @ngdoc method + * + * @name ngModel.NgModelController#$processModelValue - expect(container.findElement(by.repeater('contact in contacts').row(2)) - .findElement(by.model('contact.value')) - .getAttribute('value')) - .toBe('yourname@example.org'); - }); - </file> - </example> + * @description + * + * Runs the model -> view pipeline on the current + * {@link ngModel.NgModelController#$modelValue $modelValue}. + * + * The following actions are performed by this method: + * + * - the `$modelValue` is run through the {@link ngModel.NgModelController#$formatters $formatters} + * and the result is set to the {@link ngModel.NgModelController#$viewValue $viewValue} + * - the `ng-empty` or `ng-not-empty` class is set on the element + * - if the `$viewValue` has changed: + * - {@link ngModel.NgModelController#$render $render} is called on the control + * - the {@link ngModel.NgModelController#$validators $validators} are run and + * the validation status is set. + * + * This method is called by ngModel internally when the bound scope value changes. + * Application developers usually do not have to call this function themselves. + * + * This function can be used when the `$viewValue` or the rendered DOM value are not correctly + * formatted and the `$modelValue` must be run through the `$formatters` again. + * + * @example + * Consider a text input with an autocomplete list (for fruit), where the items are + * objects with a name and an id. + * A user enters `ap` and then selects `Apricot` from the list. + * Based on this, the autocomplete widget will call `$setViewValue({name: 'Apricot', id: 443})`, + * but the rendered value will still be `ap`. + * The widget can then call `ctrl.$processModelValue()` to run the model -> view + * pipeline again, which formats the object to the string `Apricot`, + * then updates the `$viewValue`, and finally renders it in the DOM. + * + * <example module="inputExample" name="ng-model-process"> + <file name="index.html"> + <div ng-controller="inputController" style="display: flex;"> + <div style="margin-right: 30px;"> + Search Fruit: + <basic-autocomplete items="items" on-select="selectedFruit = item"></basic-autocomplete> + </div> + <div> + Model:<br> + <pre>{{selectedFruit | json}}</pre> + </div> + </div> + </file> + <file name="app.js"> + angular.module('inputExample', []) + .controller('inputController', function($scope) { + $scope.items = [ + {name: 'Apricot', id: 443}, + {name: 'Clementine', id: 972}, + {name: 'Durian', id: 169}, + {name: 'Jackfruit', id: 982}, + {name: 'Strawberry', id: 863} + ]; + }) + .component('basicAutocomplete', { + bindings: { + items: '<', + onSelect: '&' + }, + templateUrl: 'autocomplete.html', + controller: function($element, $scope) { + var that = this; + var ngModel; + + that.$postLink = function() { + ngModel = $element.find('input').controller('ngModel'); + + ngModel.$formatters.push(function(value) { + return (value && value.name) || value; + }); + + ngModel.$parsers.push(function(value) { + var match = value; + for (var i = 0; i < that.items.length; i++) { + if (that.items[i].name === value) { + match = that.items[i]; + break; + } + } + + return match; + }); + }; + + that.selectItem = function(item) { + ngModel.$setViewValue(item); + ngModel.$processModelValue(); + that.onSelect({item: item}); + }; + } + }); + </file> + <file name="autocomplete.html"> + <div> + <input type="search" ng-model="$ctrl.searchTerm" /> + <ul> + <li ng-repeat="item in $ctrl.items | filter:$ctrl.searchTerm"> + <button ng-click="$ctrl.selectItem(item)">{{ item.name }}</button> + </li> + </ul> + </div> + </file> + * </example> + * + */ + $processModelValue: function() { + var viewValue = this.$$format(); + + if (this.$viewValue !== viewValue) { + this.$$updateEmptyClasses(viewValue); + this.$viewValue = this.$$lastCommittedViewValue = viewValue; + this.$render(); + // It is possible that model and view value have been updated during render + this.$$runValidators(this.$modelValue, this.$viewValue, noop); + } + }, + + /** + * This method is called internally to run the $formatters on the $modelValue + */ + $$format: function() { + var formatters = this.$formatters, + idx = formatters.length; + + var viewValue = this.$modelValue; + while (idx--) { + viewValue = formatters[idx](viewValue); + } + + return viewValue; + }, + + /** + * This method is called internally when the bound scope value changes. + */ + $$setModelValue: function(modelValue) { + this.$modelValue = this.$$rawModelValue = modelValue; + this.$$parserValid = undefined; + this.$processModelValue(); + }, + + $$setUpdateOnEvents: function() { + if (this.$$updateEvents) { + this.$$element.off(this.$$updateEvents, this.$$updateEventHandler); + } + + this.$$updateEvents = this.$options.getOption('updateOn'); + if (this.$$updateEvents) { + this.$$element.on(this.$$updateEvents, this.$$updateEventHandler); + } + }, + + $$updateEventHandler: function(ev) { + this.$$debounceViewValueCommit(ev && ev.type); + } + }; + + function setupModelWatcher(ctrl) { + // model -> value + // Note: we cannot use a normal scope.$watch as we want to detect the following: + // 1. scope value is 'a' + // 2. user enters 'b' + // 3. ng-change kicks in and reverts scope value to 'a' + // -> scope value did not change since the last digest as + // ng-change executes in apply phase + // 4. view should be changed back to 'a' + ctrl.$$scope.$watch(function ngModelWatch(scope) { + var modelValue = ctrl.$$ngModelGet(scope); + + // if scope model value and ngModel value are out of sync + // This cannot be moved to the action function, because it would not catch the + // case where the model is changed in the ngChange function or the model setter + if (modelValue !== ctrl.$modelValue && + // checks for NaN is needed to allow setting the model to NaN when there's an asyncValidator + // eslint-disable-next-line no-self-compare + (ctrl.$modelValue === ctrl.$modelValue || modelValue === modelValue) + ) { + ctrl.$$setModelValue(modelValue); + } + + return modelValue; + }); + } + /** + * @ngdoc method + * @name ngModel.NgModelController#$setValidity + * + * @description + * Change the validity state, and notify the form. + * + * This method can be called within $parsers/$formatters or a custom validation implementation. + * However, in most cases it should be sufficient to use the `ngModel.$validators` and + * `ngModel.$asyncValidators` collections which will call `$setValidity` automatically. + * + * @param {string} validationErrorKey Name of the validator. The `validationErrorKey` will be assigned + * to either `$error[validationErrorKey]` or `$pending[validationErrorKey]` + * (for unfulfilled `$asyncValidators`), so that it is available for data-binding. + * The `validationErrorKey` should be in camelCase and will get converted into dash-case + * for class name. Example: `myError` will result in `ng-valid-my-error` and `ng-invalid-my-error` + * classes and can be bound to as `{{ someForm.someControl.$error.myError }}`. + * @param {boolean} isValid Whether the current state is valid (true), invalid (false), pending (undefined), + * or skipped (null). Pending is used for unfulfilled `$asyncValidators`. + * Skipped is used by AngularJS when validators do not run because of parse errors and + * when `$asyncValidators` do not run because any of the `$validators` failed. */ - var ngControllerDirective = [function() { - return { - scope: true, - controller: '@', - priority: 500 - }; - }]; + addSetValidityMethod({ + clazz: NgModelController, + set: function(object, property) { + object[property] = true; + }, + unset: function(object, property) { + delete object[property]; + } + }); + /** * @ngdoc directive - * @name ngCsp + * @name ngModel + * @restrict A + * @priority 1 + * @param {expression} ngModel assignable {@link guide/expression Expression} to bind to. * - * @element html * @description - * Enables [CSP (Content Security Policy)](https://developer.mozilla.org/en/Security/CSP) support. + * The `ngModel` directive binds an `input`,`select`, `textarea` (or custom form control) to a + * property on the scope using {@link ngModel.NgModelController NgModelController}, + * which is created and exposed by this directive. * - * This is necessary when developing things like Google Chrome Extensions. + * `ngModel` is responsible for: * - * CSP forbids apps to use `eval` or `Function(string)` generated functions (among other things). - * For us to be compatible, we just need to implement the "getterFn" in $parse without violating - * any of these restrictions. + * - Binding the view into the model, which other directives such as `input`, `textarea` or `select` + * require. + * - Providing validation behavior (i.e. required, number, email, url). + * - Keeping the state of the control (valid/invalid, dirty/pristine, touched/untouched, validation errors). + * - Setting related css classes on the element (`ng-valid`, `ng-invalid`, `ng-dirty`, `ng-pristine`, `ng-touched`, + * `ng-untouched`, `ng-empty`, `ng-not-empty`) including animations. + * - Registering the control with its parent {@link ng.directive:form form}. * - * AngularJS uses `Function(string)` generated functions as a speed optimization. Applying the `ngCsp` - * directive will cause Angular to use CSP compatibility mode. When this mode is on AngularJS will - * evaluate all expressions up to 30% slower than in non-CSP mode, but no security violations will - * be raised. + * Note: `ngModel` will try to bind to the property given by evaluating the expression on the + * current scope. If the property doesn't already exist on this scope, it will be created + * implicitly and added to the scope. * - * CSP forbids JavaScript to inline stylesheet rules. In non CSP mode Angular automatically - * includes some CSS rules (e.g. {@link ng.directive:ngCloak ngCloak}). - * To make those directives work in CSP mode, include the `angular-csp.css` manually. + * For best practices on using `ngModel`, see: * - * In order to use this feature put the `ngCsp` directive on the root element of the application. + * - [Understanding Scopes](https://github.com/angular/angular.js/wiki/Understanding-Scopes) * - * *Note: This directive is only available in the `ng-csp` and `data-ng-csp` attribute form.* + * For basic examples, how to use `ngModel`, see: * - * @example - * This example shows how to apply the `ngCsp` directive to the `html` tag. - ```html - <!doctype html> - <html ng-app ng-csp> - ... - ... - </html> - ``` - */ - -// ngCsp is not implemented as a proper directive any more, because we need it be processed while we bootstrap -// the system (before $parse is instantiated), for this reason we just have a csp() fn that looks for ng-csp attribute -// anywhere in the current doc - - /** - * @ngdoc directive - * @name ngClick + * - {@link ng.directive:input input} + * - {@link input[text] text} + * - {@link input[checkbox] checkbox} + * - {@link input[radio] radio} + * - {@link input[number] number} + * - {@link input[email] email} + * - {@link input[url] url} + * - {@link input[date] date} + * - {@link input[datetime-local] datetime-local} + * - {@link input[time] time} + * - {@link input[month] month} + * - {@link input[week] week} + * - {@link ng.directive:select select} + * - {@link ng.directive:textarea textarea} * - * @description - * The ngClick directive allows you to specify custom behavior when - * an element is clicked. + * ## Complex Models (objects or collections) * - * @element ANY - * @priority 0 - * @param {expression} ngClick {@link guide/expression Expression} to evaluate upon - * click. ({@link guide/expression#-event- Event object is available as `$event`}) + * By default, `ngModel` watches the model by reference, not value. This is important to know when + * binding inputs to models that are objects (e.g. `Date`) or collections (e.g. arrays). If only properties of the + * object or collection change, `ngModel` will not be notified and so the input will not be re-rendered. * - * @example - <example> - <file name="index.html"> - <button ng-click="count = count + 1" ng-init="count=0"> - Increment - </button> - count: {{count}} - </file> - <file name="protractor.js" type="protractor"> - it('should check ng-click', function() { - expect(element(by.binding('count')).getText()).toMatch('0'); - element(by.css('button')).click(); - expect(element(by.binding('count')).getText()).toMatch('1'); - }); - </file> - </example> - */ - /* - * A directive that allows creation of custom onclick handlers that are defined as angular - * expressions and are compiled and executed within the current scope. + * The model must be assigned an entirely new object or collection before a re-rendering will occur. * - * Events that are handled via these handler are always configured not to propagate further. - */ - var ngEventDirectives = {}; - forEach( - 'click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste'.split(' '), - function(name) { - var directiveName = directiveNormalize('ng-' + name); - ngEventDirectives[directiveName] = ['$parse', function($parse) { - return { - compile: function($element, attr) { - var fn = $parse(attr[directiveName]); - return function(scope, element, attr) { - element.on(lowercase(name), function(event) { - scope.$apply(function() { - fn(scope, {$event:event}); - }); - }); - }; - } - }; - }]; - } - ); - - /** - * @ngdoc directive - * @name ngDblclick + * Some directives have options that will cause them to use a custom `$watchCollection` on the model expression + * - for example, `ngOptions` will do so when a `track by` clause is included in the comprehension expression or + * if the select is given the `multiple` attribute. * - * @description - * The `ngDblclick` directive allows you to specify custom behavior on a dblclick event. + * The `$watchCollection()` method only does a shallow comparison, meaning that changing properties deeper than the + * first level of the object (or only changing the properties of an item in the collection if it's an array) will still + * not trigger a re-rendering of the model. * - * @element ANY - * @priority 0 - * @param {expression} ngDblclick {@link guide/expression Expression} to evaluate upon - * a dblclick. (The Event object is available as `$event`) + * ## CSS classes + * The following CSS classes are added and removed on the associated input/select/textarea element + * depending on the validity of the model. * - * @example - <example> - <file name="index.html"> - <button ng-dblclick="count = count + 1" ng-init="count=0"> - Increment (on double click) - </button> - count: {{count}} - </file> - </example> - */ - - - /** - * @ngdoc directive - * @name ngMousedown + * - `ng-valid`: the model is valid + * - `ng-invalid`: the model is invalid + * - `ng-valid-[key]`: for each valid key added by `$setValidity` + * - `ng-invalid-[key]`: for each invalid key added by `$setValidity` + * - `ng-pristine`: the control hasn't been interacted with yet + * - `ng-dirty`: the control has been interacted with + * - `ng-touched`: the control has been blurred + * - `ng-untouched`: the control hasn't been blurred + * - `ng-pending`: any `$asyncValidators` are unfulfilled + * - `ng-empty`: the view does not contain a value or the value is deemed "empty", as defined + * by the {@link ngModel.NgModelController#$isEmpty} method + * - `ng-not-empty`: the view contains a non-empty value * - * @description - * The ngMousedown directive allows you to specify custom behavior on mousedown event. + * Keep in mind that ngAnimate can detect each of these classes when added and removed. * - * @element ANY - * @priority 0 - * @param {expression} ngMousedown {@link guide/expression Expression} to evaluate upon - * mousedown. ({@link guide/expression#-event- Event object is available as `$event`}) + * @animations + * Animations within models are triggered when any of the associated CSS classes are added and removed + * on the input element which is attached to the model. These classes include: `.ng-pristine`, `.ng-dirty`, + * `.ng-invalid` and `.ng-valid` as well as any other validations that are performed on the model itself. + * The animations that are triggered within ngModel are similar to how they work in ngClass and + * animations can be hooked into using CSS transitions, keyframes as well as JS animations. + * + * The following example shows a simple way to utilize CSS transitions to style an input element + * that has been rendered as invalid after it has been validated: + * + * <pre> + * //be sure to include ngAnimate as a module to hook into more + * //advanced animations + * .my-input { + * transition:0.5s linear all; + * background: white; + * } + * .my-input.ng-invalid { + * background: red; + * color:white; + * } + * </pre> * * @example - <example> + * ### Basic Usage + * <example deps="angular-animate.js" animations="true" fixBase="true" module="inputExample" name="ng-model"> <file name="index.html"> - <button ng-mousedown="count = count + 1" ng-init="count=0"> - Increment (on mouse down) - </button> - count: {{count}} + <script> + angular.module('inputExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.val = '1'; + }]); + </script> + <style> + .my-input { + transition:all linear 0.5s; + background: transparent; + } + .my-input.ng-invalid { + color:white; + background: red; + } + </style> + <p id="inputDescription"> + Update input to see transitions when valid/invalid. + Integer is a valid value. + </p> + <form name="testForm" ng-controller="ExampleController"> + <input ng-model="val" ng-pattern="/^\d+$/" name="anim" class="my-input" + aria-describedby="inputDescription" /> + </form> </file> - </example> - */ - - - /** - * @ngdoc directive - * @name ngMouseup + * </example> * - * @description - * Specify custom behavior on mouseup event. + * @example + * ### Binding to a getter/setter * - * @element ANY - * @priority 0 - * @param {expression} ngMouseup {@link guide/expression Expression} to evaluate upon - * mouseup. ({@link guide/expression#-event- Event object is available as `$event`}) + * Sometimes it's helpful to bind `ngModel` to a getter/setter function. A getter/setter is a + * function that returns a representation of the model when called with zero arguments, and sets + * the internal state of a model when called with an argument. It's sometimes useful to use this + * for models that have an internal representation that's different from what the model exposes + * to the view. + * + * <div class="alert alert-success"> + * **Best Practice:** It's best to keep getters fast because AngularJS is likely to call them more + * frequently than other parts of your code. + * </div> + * + * You use this behavior by adding `ng-model-options="{ getterSetter: true }"` to an element that + * has `ng-model` attached to it. You can also add `ng-model-options="{ getterSetter: true }"` to + * a `<form>`, which will enable this behavior for all `<input>`s within it. See + * {@link ng.directive:ngModelOptions `ngModelOptions`} for more. + * + * The following example shows how to use `ngModel` with a getter/setter: * * @example - <example> + * <example name="ngModel-getter-setter" module="getterSetterExample"> <file name="index.html"> - <button ng-mouseup="count = count + 1" ng-init="count=0"> - Increment (on mouse up) - </button> - count: {{count}} + <div ng-controller="ExampleController"> + <form name="userForm"> + <label>Name: + <input type="text" name="userName" + ng-model="user.name" + ng-model-options="{ getterSetter: true }" /> + </label> + </form> + <pre>user.name = <span ng-bind="user.name()"></span></pre> + </div> </file> - </example> + <file name="app.js"> + angular.module('getterSetterExample', []) + .controller('ExampleController', ['$scope', function($scope) { + var _name = 'Brian'; + $scope.user = { + name: function(newName) { + // Note that newName can be undefined for two reasons: + // 1. Because it is called as a getter and thus called with no arguments + // 2. Because the property should actually be set to undefined. This happens e.g. if the + // input is invalid + return arguments.length ? (_name = newName) : _name; + } + }; + }]); + </file> + * </example> */ + var ngModelDirective = ['$rootScope', function($rootScope) { + return { + restrict: 'A', + require: ['ngModel', '^?form', '^?ngModelOptions'], + controller: NgModelController, + // Prelink needs to run before any input directive + // so that we can set the NgModelOptions in NgModelController + // before anyone else uses it. + priority: 1, + compile: function ngModelCompile(element) { + // Setup initial state of the control + element.addClass(PRISTINE_CLASS).addClass(UNTOUCHED_CLASS).addClass(VALID_CLASS); + + return { + pre: function ngModelPreLink(scope, element, attr, ctrls) { + var modelCtrl = ctrls[0], + formCtrl = ctrls[1] || modelCtrl.$$parentForm, + optionsCtrl = ctrls[2]; + + if (optionsCtrl) { + modelCtrl.$options = optionsCtrl.$options; + } + + modelCtrl.$$initGetterSetters(); + + // notify others, especially parent forms + formCtrl.$addControl(modelCtrl); + + attr.$observe('name', function(newValue) { + if (modelCtrl.$name !== newValue) { + modelCtrl.$$parentForm.$$renameControl(modelCtrl, newValue); + } + }); + + scope.$on('$destroy', function() { + modelCtrl.$$parentForm.$removeControl(modelCtrl); + }); + }, + post: function ngModelPostLink(scope, element, attr, ctrls) { + var modelCtrl = ctrls[0]; + modelCtrl.$$setUpdateOnEvents(); + + function setTouched() { + modelCtrl.$setTouched(); + } + + element.on('blur', function() { + if (modelCtrl.$touched) return; + + if ($rootScope.$$phase) { + scope.$evalAsync(setTouched); + } else { + scope.$apply(setTouched); + } + }); + } + }; + } + }; + }]; + + /* exported defaultModelOptions */ + var defaultModelOptions; + var DEFAULT_REGEXP = /(\s+|^)default(\s+|$)/; /** - * @ngdoc directive - * @name ngMouseover - * + * @ngdoc type + * @name ModelOptions * @description - * Specify custom behavior on mouseover event. - * - * @element ANY - * @priority 0 - * @param {expression} ngMouseover {@link guide/expression Expression} to evaluate upon - * mouseover. ({@link guide/expression#-event- Event object is available as `$event`}) - * - * @example - <example> - <file name="index.html"> - <button ng-mouseover="count = count + 1" ng-init="count=0"> - Increment (when mouse is over) - </button> - count: {{count}} - </file> - </example> + * A container for the options set by the {@link ngModelOptions} directive */ + function ModelOptions(options) { + this.$$options = options; + } + + ModelOptions.prototype = { + + /** + * @ngdoc method + * @name ModelOptions#getOption + * @param {string} name the name of the option to retrieve + * @returns {*} the value of the option + * @description + * Returns the value of the given option + */ + getOption: function(name) { + return this.$$options[name]; + }, + + /** + * @ngdoc method + * @name ModelOptions#createChild + * @param {Object} options a hash of options for the new child that will override the parent's options + * @return {ModelOptions} a new `ModelOptions` object initialized with the given options. + */ + createChild: function(options) { + var inheritAll = false; + + // make a shallow copy + options = extend({}, options); + + // Inherit options from the parent if specified by the value `"$inherit"` + forEach(options, /* @this */ function(option, key) { + if (option === '$inherit') { + if (key === '*') { + inheritAll = true; + } else { + options[key] = this.$$options[key]; + // `updateOn` is special so we must also inherit the `updateOnDefault` option + if (key === 'updateOn') { + options.updateOnDefault = this.$$options.updateOnDefault; + } + } + } else { + if (key === 'updateOn') { + // If the `updateOn` property contains the `default` event then we have to remove + // it from the event list and set the `updateOnDefault` flag. + options.updateOnDefault = false; + options[key] = trim(option.replace(DEFAULT_REGEXP, function() { + options.updateOnDefault = true; + return ' '; + })); + } + } + }, this); + + if (inheritAll) { + // We have a property of the form: `"*": "$inherit"` + delete options['*']; + defaults(options, this.$$options); + } + + // Finally add in any missing defaults + defaults(options, defaultModelOptions.$$options); + + return new ModelOptions(options); + } + }; + + + defaultModelOptions = new ModelOptions({ + updateOn: '', + updateOnDefault: true, + debounce: 0, + getterSetter: false, + allowInvalid: false, + timezone: null + }); /** * @ngdoc directive - * @name ngMouseenter + * @name ngModelOptions + * @restrict A + * @priority 10 * * @description - * Specify custom behavior on mouseenter event. + * This directive allows you to modify the behaviour of {@link ngModel} directives within your + * application. You can specify an `ngModelOptions` directive on any element. All {@link ngModel} + * directives will use the options of their nearest `ngModelOptions` ancestor. * - * @element ANY - * @priority 0 - * @param {expression} ngMouseenter {@link guide/expression Expression} to evaluate upon - * mouseenter. ({@link guide/expression#-event- Event object is available as `$event`}) + * The `ngModelOptions` settings are found by evaluating the value of the attribute directive as + * an AngularJS expression. This expression should evaluate to an object, whose properties contain + * the settings. For example: `<div ng-model-options="{ debounce: 100 }"`. * - * @example - <example> - <file name="index.html"> - <button ng-mouseenter="count = count + 1" ng-init="count=0"> - Increment (when mouse enters) - </button> - count: {{count}} - </file> - </example> - */ - - - /** - * @ngdoc directive - * @name ngMouseleave + * ## Inheriting Options * - * @description - * Specify custom behavior on mouseleave event. + * You can specify that an `ngModelOptions` setting should be inherited from a parent `ngModelOptions` + * directive by giving it the value of `"$inherit"`. + * Then it will inherit that setting from the first `ngModelOptions` directive found by traversing up the + * DOM tree. If there is no ancestor element containing an `ngModelOptions` directive then default settings + * will be used. * - * @element ANY - * @priority 0 - * @param {expression} ngMouseleave {@link guide/expression Expression} to evaluate upon - * mouseleave. ({@link guide/expression#-event- Event object is available as `$event`}) + * For example given the following fragment of HTML * - * @example - <example> - <file name="index.html"> - <button ng-mouseleave="count = count + 1" ng-init="count=0"> - Increment (when mouse leaves) - </button> - count: {{count}} - </file> - </example> - */ - - - /** - * @ngdoc directive - * @name ngMousemove * - * @description - * Specify custom behavior on mousemove event. + * ```html + * <div ng-model-options="{ allowInvalid: true, debounce: 200 }"> + * <form ng-model-options="{ updateOn: 'blur', allowInvalid: '$inherit' }"> + * <input ng-model-options="{ updateOn: 'default', allowInvalid: '$inherit' }" /> + * </form> + * </div> + * ``` * - * @element ANY - * @priority 0 - * @param {expression} ngMousemove {@link guide/expression Expression} to evaluate upon - * mousemove. ({@link guide/expression#-event- Event object is available as `$event`}) + * the `input` element will have the following settings * - * @example - <example> - <file name="index.html"> - <button ng-mousemove="count = count + 1" ng-init="count=0"> - Increment (when mouse moves) - </button> - count: {{count}} - </file> - </example> - */ - - - /** - * @ngdoc directive - * @name ngKeydown + * ```js + * { allowInvalid: true, updateOn: 'default', debounce: 0 } + * ``` * - * @description - * Specify custom behavior on keydown event. + * Notice that the `debounce` setting was not inherited and used the default value instead. * - * @element ANY - * @priority 0 - * @param {expression} ngKeydown {@link guide/expression Expression} to evaluate upon - * keydown. (Event object is available as `$event` and can be interrogated for keyCode, altKey, etc.) + * You can specify that all undefined settings are automatically inherited from an ancestor by + * including a property with key of `"*"` and value of `"$inherit"`. * - * @example - <example> - <file name="index.html"> - <input ng-keydown="count = count + 1" ng-init="count=0"> - key down count: {{count}} - </file> - </example> - */ - - - /** - * @ngdoc directive - * @name ngKeyup + * For example given the following fragment of HTML * - * @description - * Specify custom behavior on keyup event. * - * @element ANY - * @priority 0 - * @param {expression} ngKeyup {@link guide/expression Expression} to evaluate upon - * keyup. (Event object is available as `$event` and can be interrogated for keyCode, altKey, etc.) + * ```html + * <div ng-model-options="{ allowInvalid: true, debounce: 200 }"> + * <form ng-model-options="{ updateOn: 'blur', "*": '$inherit' }"> + * <input ng-model-options="{ updateOn: 'default', "*": '$inherit' }" /> + * </form> + * </div> + * ``` * - * @example - <example> - <file name="index.html"> - <input ng-keyup="count = count + 1" ng-init="count=0"> - key up count: {{count}} - </file> - </example> - */ - - - /** - * @ngdoc directive - * @name ngKeypress + * the `input` element will have the following settings * - * @description - * Specify custom behavior on keypress event. + * ```js + * { allowInvalid: true, updateOn: 'default', debounce: 200 } + * ``` * - * @element ANY - * @param {expression} ngKeypress {@link guide/expression Expression} to evaluate upon - * keypress. ({@link guide/expression#-event- Event object is available as `$event`} - * and can be interrogated for keyCode, altKey, etc.) + * Notice that the `debounce` setting now inherits the value from the outer `<div>` element. * - * @example - <example> - <file name="index.html"> - <input ng-keypress="count = count + 1" ng-init="count=0"> - key press count: {{count}} - </file> - </example> - */ - - - /** - * @ngdoc directive - * @name ngSubmit + * If you are creating a reusable component then you should be careful when using `"*": "$inherit"` + * since you may inadvertently inherit a setting in the future that changes the behavior of your component. * - * @description - * Enables binding angular expressions to onsubmit events. * - * Additionally it prevents the default action (which for form means sending the request to the - * server and reloading the current page), but only if the form does not contain `action`, - * `data-action`, or `x-action` attributes. + * ## Triggering and debouncing model updates * - * @element form - * @priority 0 - * @param {expression} ngSubmit {@link guide/expression Expression} to eval. - * ({@link guide/expression#-event- Event object is available as `$event`}) + * The `updateOn` and `debounce` properties allow you to specify a custom list of events that will + * trigger a model update and/or a debouncing delay so that the actual update only takes place when + * a timer expires; this timer will be reset after another change takes place. * - * @example - <example> - <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.list = []; - $scope.text = 'hello'; - $scope.submit = function() { - if ($scope.text) { - $scope.list.push(this.text); - $scope.text = ''; - } - }; - } - </script> - <form ng-submit="submit()" ng-controller="Ctrl"> - Enter text and hit enter: - <input type="text" ng-model="text" name="text" /> - <input type="submit" id="submit" value="Submit" /> - <pre>list={{list}}</pre> - </form> - </file> - <file name="protractor.js" type="protractor"> - it('should check ng-submit', function() { - expect(element(by.binding('list')).getText()).toBe('list=[]'); - element(by.css('#submit')).click(); - expect(element(by.binding('list')).getText()).toContain('hello'); - expect(element(by.input('text')).getAttribute('value')).toBe(''); - }); - it('should ignore empty strings', function() { - expect(element(by.binding('list')).getText()).toBe('list=[]'); - element(by.css('#submit')).click(); - element(by.css('#submit')).click(); - expect(element(by.binding('list')).getText()).toContain('hello'); - }); - </file> - </example> - */ - - /** - * @ngdoc directive - * @name ngFocus + * Given the nature of `ngModelOptions`, the value displayed inside input fields in the view might + * be different from the value in the actual model. This means that if you update the model you + * should also invoke {@link ngModel.NgModelController#$rollbackViewValue} on the relevant input field in + * order to make sure it is synchronized with the model and that any debounced action is canceled. * - * @description - * Specify custom behavior on focus event. + * The easiest way to reference the control's {@link ngModel.NgModelController#$rollbackViewValue} + * method is by making sure the input is placed inside a form that has a `name` attribute. This is + * important because `form` controllers are published to the related scope under the name in their + * `name` attribute. * - * @element window, input, select, textarea, a - * @priority 0 - * @param {expression} ngFocus {@link guide/expression Expression} to evaluate upon - * focus. ({@link guide/expression#-event- Event object is available as `$event`}) + * Any pending changes will take place immediately when an enclosing form is submitted via the + * `submit` event. Note that `ngClick` events will occur before the model is updated. Use `ngSubmit` + * to have access to the updated model. * - * @example - * See {@link ng.directive:ngClick ngClick} - */ - - /** - * @ngdoc directive - * @name ngBlur + * ### Overriding immediate updates * - * @description - * Specify custom behavior on blur event. + * The following example shows how to override immediate updates. Changes on the inputs within the + * form will update the model only when the control loses focus (blur event). If `escape` key is + * pressed while the input field is focused, the value is reset to the value in the current model. * - * @element window, input, select, textarea, a - * @priority 0 - * @param {expression} ngBlur {@link guide/expression Expression} to evaluate upon - * blur. ({@link guide/expression#-event- Event object is available as `$event`}) + * <example name="ngModelOptions-directive-blur" module="optionsExample"> + * <file name="index.html"> + * <div ng-controller="ExampleController"> + * <form name="userForm"> + * <label> + * Name: + * <input type="text" name="userName" + * ng-model="user.name" + * ng-model-options="{ updateOn: 'blur' }" + * ng-keyup="cancel($event)" /> + * </label><br /> + * <label> + * Other data: + * <input type="text" ng-model="user.data" /> + * </label><br /> + * </form> + * <pre>user.name = <span ng-bind="user.name"></span></pre> + * </div> + * </file> + * <file name="app.js"> + * angular.module('optionsExample', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.user = { name: 'say', data: '' }; + * + * $scope.cancel = function(e) { + * if (e.keyCode === 27) { + * $scope.userForm.userName.$rollbackViewValue(); + * } + * }; + * }]); + * </file> + * <file name="protractor.js" type="protractor"> + * var model = element(by.binding('user.name')); + * var input = element(by.model('user.name')); + * var other = element(by.model('user.data')); + * + * it('should allow custom events', function() { + * input.sendKeys(' hello'); + * input.click(); + * expect(model.getText()).toEqual('say'); + * other.click(); + * expect(model.getText()).toEqual('say hello'); + * }); * - * @example - * See {@link ng.directive:ngClick ngClick} - */ - - /** - * @ngdoc directive - * @name ngCopy + * it('should $rollbackViewValue when model changes', function() { + * input.sendKeys(' hello'); + * expect(input.getAttribute('value')).toEqual('say hello'); + * input.sendKeys(protractor.Key.ESCAPE); + * expect(input.getAttribute('value')).toEqual('say'); + * other.click(); + * expect(model.getText()).toEqual('say'); + * }); + * </file> + * </example> * - * @description - * Specify custom behavior on copy event. + * ### Debouncing updates * - * @element window, input, select, textarea, a - * @priority 0 - * @param {expression} ngCopy {@link guide/expression Expression} to evaluate upon - * copy. ({@link guide/expression#-event- Event object is available as `$event`}) + * The next example shows how to debounce model changes. Model will be updated only 1 sec after last change. + * If the `Clear` button is pressed, any debounced action is canceled and the value becomes empty. + * + * <example name="ngModelOptions-directive-debounce" module="optionsExample"> + * <file name="index.html"> + * <div ng-controller="ExampleController"> + * <form name="userForm"> + * Name: + * <input type="text" name="userName" + * ng-model="user.name" + * ng-model-options="{ debounce: 1000 }" /> + * <button ng-click="userForm.userName.$rollbackViewValue(); user.name=''">Clear</button><br /> + * </form> + * <pre>user.name = <span ng-bind="user.name"></span></pre> + * </div> + * </file> + * <file name="app.js"> + * angular.module('optionsExample', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.user = { name: 'say' }; + * }]); + * </file> + * </example> + * + * + * ## Model updates and validation + * + * The default behaviour in `ngModel` is that the model value is set to `undefined` when the + * validation determines that the value is invalid. By setting the `allowInvalid` property to true, + * the model will still be updated even if the value is invalid. + * + * + * ## Connecting to the scope + * + * By setting the `getterSetter` property to true you are telling ngModel that the `ngModel` expression + * on the scope refers to a "getter/setter" function rather than the value itself. + * + * The following example shows how to bind to getter/setters: + * + * <example name="ngModelOptions-directive-getter-setter" module="getterSetterExample"> + * <file name="index.html"> + * <div ng-controller="ExampleController"> + * <form name="userForm"> + * <label> + * Name: + * <input type="text" name="userName" + * ng-model="user.name" + * ng-model-options="{ getterSetter: true }" /> + * </label> + * </form> + * <pre>user.name = <span ng-bind="user.name()"></span></pre> + * </div> + * </file> + * <file name="app.js"> + * angular.module('getterSetterExample', []) + * .controller('ExampleController', ['$scope', function($scope) { + * var _name = 'Brian'; + * $scope.user = { + * name: function(newName) { + * return angular.isDefined(newName) ? (_name = newName) : _name; + * } + * }; + * }]); + * </file> + * </example> + * + * + * ## Specifying timezones + * + * You can specify the timezone that date/time input directives expect by providing its name in the + * `timezone` property. + * + * + * ## Programmatically changing options + * + * The `ngModelOptions` expression is only evaluated once when the directive is linked; it is not + * watched for changes. However, it is possible to override the options on a single + * {@link ngModel.NgModelController} instance with + * {@link ngModel.NgModelController#$overrideModelOptions `NgModelController#$overrideModelOptions()`}. + * + * + * @param {Object} ngModelOptions options to apply to {@link ngModel} directives on this element and + * and its descendents. Valid keys are: + * - `updateOn`: string specifying which event should the input be bound to. You can set several + * events using an space delimited list. There is a special event called `default` that + * matches the default events belonging to the control. These are the events that are bound to + * the control, and when fired, update the `$viewValue` via `$setViewValue`. + * + * `ngModelOptions` considers every event that is not listed in `updateOn` a "default" event, + * since different control types use different default events. + * + * See also the section {@link ngModelOptions#triggering-and-debouncing-model-updates + * Triggering and debouncing model updates}. + * + * - `debounce`: integer value which contains the debounce model update value in milliseconds. A + * value of 0 triggers an immediate update. If an object is supplied instead, you can specify a + * custom value for each event. For example: + * ``` + * ng-model-options="{ + * updateOn: 'default blur click', + * debounce: { 'default': 500, 'blur': 0 } + * }" + * ``` + * + * "default" also applies to all events that are listed in `updateOn` but are not + * listed in `debounce`, i.e. "click" would also be debounced by 500 milliseconds. + * + * - `allowInvalid`: boolean value which indicates that the model can be set with values that did + * not validate correctly instead of the default behavior of setting the model to undefined. + * - `getterSetter`: boolean value which determines whether or not to treat functions bound to + * `ngModel` as getters/setters. + * - `timezone`: Defines the timezone to be used to read/write the `Date` instance in the model for + * `<input type="date" />`, `<input type="time" />`, ... . It understands UTC/GMT and the + * continental US time zone abbreviations, but for general use, use a time zone offset, for + * example, `'+0430'` (4 hours, 30 minutes east of the Greenwich meridian) + * If not specified, the timezone of the browser will be used. * - * @example - <example> - <file name="index.html"> - <input ng-copy="copied=true" ng-init="copied=false; value='copy me'" ng-model="value"> - copied: {{copied}} - </file> - </example> */ + var ngModelOptionsDirective = function() { + NgModelOptionsController.$inject = ['$attrs', '$scope']; + function NgModelOptionsController($attrs, $scope) { + this.$$attrs = $attrs; + this.$$scope = $scope; + } + NgModelOptionsController.prototype = { + $onInit: function() { + var parentOptions = this.parentCtrl ? this.parentCtrl.$options : defaultModelOptions; + var modelOptionsDefinition = this.$$scope.$eval(this.$$attrs.ngModelOptions); + + this.$options = parentOptions.createChild(modelOptionsDefinition); + } + }; + + return { + restrict: 'A', + // ngModelOptions needs to run before ngModel and input directives + priority: 10, + require: {parentCtrl: '?^^ngModelOptions'}, + bindToController: true, + controller: NgModelOptionsController + }; + }; + + +// shallow copy over values from `src` that are not already specified on `dst` + function defaults(dst, src) { + forEach(src, function(value, key) { + if (!isDefined(dst[key])) { + dst[key] = value; + } + }); + } /** * @ngdoc directive - * @name ngCut + * @name ngNonBindable + * @restrict AC + * @priority 1000 + * @element ANY * * @description - * Specify custom behavior on cut event. - * - * @element window, input, select, textarea, a - * @priority 0 - * @param {expression} ngCut {@link guide/expression Expression} to evaluate upon - * cut. ({@link guide/expression#-event- Event object is available as `$event`}) + * The `ngNonBindable` directive tells AngularJS not to compile or bind the contents of the current + * DOM element, including directives on the element itself that have a lower priority than + * `ngNonBindable`. This is useful if the element contains what appears to be AngularJS directives + * and bindings but which should be ignored by AngularJS. This could be the case if you have a site + * that displays snippets of code, for instance. * * @example - <example> + * In this example there are two locations where a simple interpolation binding (`{{}}`) is present, + * but the one wrapped in `ngNonBindable` is left alone. + * + <example name="ng-non-bindable"> <file name="index.html"> - <input ng-cut="cut=true" ng-init="cut=false; value='cut me'" ng-model="value"> - cut: {{cut}} + <div>Normal: {{1 + 2}}</div> + <div ng-non-bindable>Ignored: {{1 + 2}}</div> + </file> + <file name="protractor.js" type="protractor"> + it('should check ng-non-bindable', function() { + expect(element(by.binding('1 + 2')).getText()).toContain('3'); + expect(element.all(by.css('div')).last().getText()).toMatch(/1 \+ 2/); + }); </file> </example> */ + var ngNonBindableDirective = ngDirective({ terminal: true, priority: 1000 }); + + /* exported ngOptionsDirective */ + + /* global jqLiteRemove */ + + var ngOptionsMinErr = minErr('ngOptions'); /** * @ngdoc directive - * @name ngPaste + * @name ngOptions + * @restrict A * * @description - * Specify custom behavior on paste event. * - * @element window, input, select, textarea, a - * @priority 0 - * @param {expression} ngPaste {@link guide/expression Expression} to evaluate upon - * paste. ({@link guide/expression#-event- Event object is available as `$event`}) + * The `ngOptions` attribute can be used to dynamically generate a list of `<option>` + * elements for the `<select>` element using the array or object obtained by evaluating the + * `ngOptions` comprehension expression. * - * @example - <example> - <file name="index.html"> - <input ng-paste="paste=true" ng-init="paste=false" placeholder='paste here'> - pasted: {{paste}} - </file> - </example> - */ - - /** - * @ngdoc directive - * @name ngIf - * @restrict A + * In many cases, {@link ng.directive:ngRepeat ngRepeat} can be used on `<option>` elements instead of + * `ngOptions` to achieve a similar result. However, `ngOptions` provides some benefits: + * - more flexibility in how the `<select>`'s model is assigned via the `select` **`as`** part of the + * comprehension expression + * - reduced memory consumption by not creating a new scope for each repeated instance + * - increased render speed by creating the options in a documentFragment instead of individually * - * @description - * The `ngIf` directive removes or recreates a portion of the DOM tree based on an - * {expression}. If the expression assigned to `ngIf` evaluates to a false - * value then the element is removed from the DOM, otherwise a clone of the - * element is reinserted into the DOM. + * When an item in the `<select>` menu is selected, the array element or object property + * represented by the selected option will be bound to the model identified by the `ngModel` + * directive. * - * `ngIf` differs from `ngShow` and `ngHide` in that `ngIf` completely removes and recreates the - * element in the DOM rather than changing its visibility via the `display` css property. A common - * case when this difference is significant is when using css selectors that rely on an element's - * position within the DOM, such as the `:first-child` or `:last-child` pseudo-classes. + * Optionally, a single hard-coded `<option>` element, with the value set to an empty string, can + * be nested into the `<select>` element. This element will then represent the `null` or "not selected" + * option. See example below for demonstration. * - * Note that when an element is removed using `ngIf` its scope is destroyed and a new scope - * is created when the element is restored. The scope created within `ngIf` inherits from - * its parent scope using - * [prototypal inheritance](https://github.com/angular/angular.js/wiki/The-Nuances-of-Scope-Prototypal-Inheritance). - * An important implication of this is if `ngModel` is used within `ngIf` to bind to - * a javascript primitive defined in the parent scope. In this case any modifications made to the - * variable within the child scope will override (hide) the value in the parent scope. + * ## Complex Models (objects or collections) * - * Also, `ngIf` recreates elements using their compiled state. An example of this behavior - * is if an element's class attribute is directly modified after it's compiled, using something like - * jQuery's `.addClass()` method, and the element is later removed. When `ngIf` recreates the element - * the added class will be lost because the original compiled state is used to regenerate the element. + * By default, `ngModel` watches the model by reference, not value. This is important to know when + * binding the select to a model that is an object or a collection. * - * Additionally, you can provide animations via the `ngAnimate` module to animate the `enter` - * and `leave` effects. + * One issue occurs if you want to preselect an option. For example, if you set + * the model to an object that is equal to an object in your collection, `ngOptions` won't be able to set the selection, + * because the objects are not identical. So by default, you should always reference the item in your collection + * for preselections, e.g.: `$scope.selected = $scope.collection[3]`. * - * @animations - * enter - happens just after the ngIf contents change and a new DOM element is created and injected into the ngIf container - * leave - happens just before the ngIf contents are removed from the DOM + * Another solution is to use a `track by` clause, because then `ngOptions` will track the identity + * of the item not by reference, but by the result of the `track by` expression. For example, if your + * collection items have an id property, you would `track by item.id`. * - * @element ANY - * @scope - * @priority 600 - * @param {expression} ngIf If the {@link guide/expression expression} is falsy then - * the element is removed from the DOM tree. If it is truthy a copy of the compiled - * element is added to the DOM tree. + * A different issue with objects or collections is that ngModel won't detect if an object property or + * a collection item changes. For that reason, `ngOptions` additionally watches the model using + * `$watchCollection`, when the expression contains a `track by` clause or the the select has the `multiple` attribute. + * This allows ngOptions to trigger a re-rendering of the options even if the actual object/collection + * has not changed identity, but only a property on the object or an item in the collection changes. * - * @example - <example module="ngAnimate" deps="angular-animate.js" animations="true"> - <file name="index.html"> - Click me: <input type="checkbox" ng-model="checked" ng-init="checked=true" /><br/> - Show when checked: - <span ng-if="checked" class="animate-if"> - I'm removed when the checkbox is unchecked. - </span> - </file> - <file name="animations.css"> - .animate-if { - background:white; - border:1px solid black; - padding:10px; - } - - .animate-if.ng-enter, .animate-if.ng-leave { - -webkit-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; - transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; - } - - .animate-if.ng-enter, - .animate-if.ng-leave.ng-leave-active { - opacity:0; - } - - .animate-if.ng-leave, - .animate-if.ng-enter.ng-enter-active { - opacity:1; - } - </file> - </example> - */ - var ngIfDirective = ['$animate', function($animate) { - return { - transclude: 'element', - priority: 600, - terminal: true, - restrict: 'A', - $$tlb: true, - link: function ($scope, $element, $attr, ctrl, $transclude) { - var block, childScope, previousElements; - $scope.$watch($attr.ngIf, function ngIfWatchAction(value) { - - if (toBoolean(value)) { - if (!childScope) { - childScope = $scope.$new(); - $transclude(childScope, function (clone) { - clone[clone.length++] = document.createComment(' end ngIf: ' + $attr.ngIf + ' '); - // Note: We only need the first/last node of the cloned nodes. - // However, we need to keep the reference to the jqlite wrapper as it might be changed later - // by a directive with templateUrl when it's template arrives. - block = { - clone: clone - }; - $animate.enter(clone, $element.parent(), $element); - }); - } - } else { - if(previousElements) { - previousElements.remove(); - previousElements = null; - } - if(childScope) { - childScope.$destroy(); - childScope = null; - } - if(block) { - previousElements = getBlockElements(block.clone); - $animate.leave(previousElements, function() { - previousElements = null; - }); - block = null; - } - } - }); - } - }; - }]; - - /** - * @ngdoc directive - * @name ngInclude - * @restrict ECA + * Note that `$watchCollection` does a shallow comparison of the properties of the object (or the items in the collection + * if the model is an array). This means that changing a property deeper than the first level inside the + * object/collection will not trigger a re-rendering. * - * @description - * Fetches, compiles and includes an external HTML fragment. + * ## `select` **`as`** * - * By default, the template URL is restricted to the same domain and protocol as the - * application document. This is done by calling {@link ng.$sce#getTrustedResourceUrl - * $sce.getTrustedResourceUrl} on it. To load templates from other domains or protocols - * you may either {@link ng.$sceDelegateProvider#resourceUrlWhitelist whitelist them} or - * [wrap them](ng.$sce#trustAsResourceUrl) as trusted values. Refer to Angular's {@link - * ng.$sce Strict Contextual Escaping}. + * Using `select` **`as`** will bind the result of the `select` expression to the model, but + * the value of the `<select>` and `<option>` html elements will be either the index (for array data sources) + * or property name (for object data sources) of the value within the collection. If a **`track by`** expression + * is used, the result of that expression will be set as the value of the `option` and `select` elements. * - * In addition, the browser's - * [Same Origin Policy](https://code.google.com/p/browsersec/wiki/Part2#Same-origin_policy_for_XMLHttpRequest) - * and [Cross-Origin Resource Sharing (CORS)](http://www.w3.org/TR/cors/) - * policy may further restrict whether the template is successfully loaded. - * For example, `ngInclude` won't work for cross-domain requests on all browsers and for `file://` - * access on some browsers. * - * @animations - * enter - animation is used to bring new content into the browser. - * leave - animation is used to animate existing content away. + * ### `select` **`as`** and **`track by`** * - * The enter and leave animation occur concurrently. + * <div class="alert alert-warning"> + * Be careful when using `select` **`as`** and **`track by`** in the same expression. + * </div> * - * @scope - * @priority 400 + * Given this array of items on the $scope: * - * @param {string} ngInclude|src angular expression evaluating to URL. If the source is a string constant, - * make sure you wrap it in **single** quotes, e.g. `src="'myPartialTemplate.html'"`. - * @param {string=} onload Expression to evaluate when a new partial is loaded. + * ```js + * $scope.items = [{ + * id: 1, + * label: 'aLabel', + * subItem: { name: 'aSubItem' } + * }, { + * id: 2, + * label: 'bLabel', + * subItem: { name: 'bSubItem' } + * }]; + * ``` * - * @param {string=} autoscroll Whether `ngInclude` should call {@link ng.$anchorScroll - * $anchorScroll} to scroll the viewport after the content is loaded. + * This will work: * - * - If the attribute is not set, disable scrolling. - * - If the attribute is set without value, enable scrolling. - * - Otherwise enable scrolling only if the expression evaluates to truthy value. + * ```html + * <select ng-options="item as item.label for item in items track by item.id" ng-model="selected"></select> + * ``` + * ```js + * $scope.selected = $scope.items[0]; + * ``` + * + * but this will not work: + * + * ```html + * <select ng-options="item.subItem as item.label for item in items track by item.id" ng-model="selected"></select> + * ``` + * ```js + * $scope.selected = $scope.items[0].subItem; + * ``` + * + * In both examples, the **`track by`** expression is applied successfully to each `item` in the + * `items` array. Because the selected option has been set programmatically in the controller, the + * **`track by`** expression is also applied to the `ngModel` value. In the first example, the + * `ngModel` value is `items[0]` and the **`track by`** expression evaluates to `items[0].id` with + * no issue. In the second example, the `ngModel` value is `items[0].subItem` and the **`track by`** + * expression evaluates to `items[0].subItem.id` (which is undefined). As a result, the model value + * is not matched against any `<option>` and the `<select>` appears as having no selected value. + * + * + * @param {string} ngModel Assignable AngularJS expression to data-bind to. + * @param {comprehension_expression} ngOptions in one of the following forms: + * + * * for array data sources: + * * `label` **`for`** `value` **`in`** `array` + * * `select` **`as`** `label` **`for`** `value` **`in`** `array` + * * `label` **`group by`** `group` **`for`** `value` **`in`** `array` + * * `label` **`disable when`** `disable` **`for`** `value` **`in`** `array` + * * `label` **`group by`** `group` **`for`** `value` **`in`** `array` **`track by`** `trackexpr` + * * `label` **`disable when`** `disable` **`for`** `value` **`in`** `array` **`track by`** `trackexpr` + * * `label` **`for`** `value` **`in`** `array` | orderBy:`orderexpr` **`track by`** `trackexpr` + * (for including a filter with `track by`) + * * for object data sources: + * * `label` **`for (`**`key` **`,`** `value`**`) in`** `object` + * * `select` **`as`** `label` **`for (`**`key` **`,`** `value`**`) in`** `object` + * * `label` **`group by`** `group` **`for (`**`key`**`,`** `value`**`) in`** `object` + * * `label` **`disable when`** `disable` **`for (`**`key`**`,`** `value`**`) in`** `object` + * * `select` **`as`** `label` **`group by`** `group` + * **`for` `(`**`key`**`,`** `value`**`) in`** `object` + * * `select` **`as`** `label` **`disable when`** `disable` + * **`for` `(`**`key`**`,`** `value`**`) in`** `object` + * + * Where: + * + * * `array` / `object`: an expression which evaluates to an array / object to iterate over. + * * `value`: local variable which will refer to each item in the `array` or each property value + * of `object` during iteration. + * * `key`: local variable which will refer to a property name in `object` during iteration. + * * `label`: The result of this expression will be the label for `<option>` element. The + * `expression` will most likely refer to the `value` variable (e.g. `value.propertyName`). + * * `select`: The result of this expression will be bound to the model of the parent `<select>` + * element. If not specified, `select` expression will default to `value`. + * * `group`: The result of this expression will be used to group options using the `<optgroup>` + * DOM element. + * * `disable`: The result of this expression will be used to disable the rendered `<option>` + * element. Return `true` to disable. + * * `trackexpr`: Used when working with an array of objects. The result of this expression will be + * used to identify the objects in the array. The `trackexpr` will most likely refer to the + * `value` variable (e.g. `value.propertyName`). With this the selection is preserved + * even when the options are recreated (e.g. reloaded from the server). + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} required The control is considered valid only if value is entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {string=} ngAttrSize sets the size of the select element dynamically. Uses the + * {@link guide/interpolation#-ngattr-for-binding-to-arbitrary-attributes ngAttr} directive. * * @example - <example module="ngAnimate" deps="angular-animate.js" animations="true"> + <example module="selectExample" name="select"> <file name="index.html"> - <div ng-controller="Ctrl"> - <select ng-model="template" ng-options="t.name for t in templates"> - <option value="">(blank)</option> - </select> - url of the template: <tt>{{template.url}}</tt> + <script> + angular.module('selectExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.colors = [ + {name:'black', shade:'dark'}, + {name:'white', shade:'light', notAnOption: true}, + {name:'red', shade:'dark'}, + {name:'blue', shade:'dark', notAnOption: true}, + {name:'yellow', shade:'light', notAnOption: false} + ]; + $scope.myColor = $scope.colors[2]; // red + }]); + </script> + <div ng-controller="ExampleController"> + <ul> + <li ng-repeat="color in colors"> + <label>Name: <input ng-model="color.name"></label> + <label><input type="checkbox" ng-model="color.notAnOption"> Disabled?</label> + <button ng-click="colors.splice($index, 1)" aria-label="Remove">X</button> + </li> + <li> + <button ng-click="colors.push({})">add</button> + </li> + </ul> <hr/> - <div class="slide-animate-container"> - <div class="slide-animate" ng-include="template.url"></div> - </div> - </div> - </file> - <file name="script.js"> - function Ctrl($scope) { - $scope.templates = - [ { name: 'template1.html', url: 'template1.html'}, - { name: 'template2.html', url: 'template2.html'} ]; - $scope.template = $scope.templates[0]; - } - </file> - <file name="template1.html"> - Content of template1.html - </file> - <file name="template2.html"> - Content of template2.html - </file> - <file name="animations.css"> - .slide-animate-container { - position:relative; - background:white; - border:1px solid black; - height:40px; - overflow:hidden; - } + <label>Color (null not allowed): + <select ng-model="myColor" ng-options="color.name for color in colors"></select> + </label><br/> + <label>Color (null allowed): + <span class="nullable"> + <select ng-model="myColor" ng-options="color.name for color in colors"> + <option value="">-- choose color --</option> + </select> + </span></label><br/> - .slide-animate { - padding:10px; - } + <label>Color grouped by shade: + <select ng-model="myColor" ng-options="color.name group by color.shade for color in colors"> + </select> + </label><br/> - .slide-animate.ng-enter, .slide-animate.ng-leave { - -webkit-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; - transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; + <label>Color grouped by shade, with some disabled: + <select ng-model="myColor" + ng-options="color.name group by color.shade disable when color.notAnOption for color in colors"> + </select> + </label><br/> - position:absolute; - top:0; - left:0; - right:0; - bottom:0; - display:block; - padding:10px; - } - .slide-animate.ng-enter { - top:-50px; - } - .slide-animate.ng-enter.ng-enter-active { - top:0; - } - .slide-animate.ng-leave { - top:0; - } - .slide-animate.ng-leave.ng-leave-active { - top:50px; - } + Select <button ng-click="myColor = { name:'not in list', shade: 'other' }">bogus</button>. + <br/> + <hr/> + Currently selected: {{ {selected_color:myColor} }} + <div style="border:solid 1px black; height:20px" + ng-style="{'background-color':myColor.name}"> + </div> + </div> </file> <file name="protractor.js" type="protractor"> - var templateSelect = element(by.model('template')); - var includeElem = element(by.css('[ng-include]')); - - it('should load template1.html', function() { - expect(includeElem.getText()).toMatch(/Content of template1.html/); - }); - - it('should load template2.html', function() { - if (browser.params.browser == 'firefox') { - // Firefox can't handle using selects - // See https://github.com/angular/protractor/issues/480 - return; - } - templateSelect.click(); - templateSelect.element.all(by.css('option')).get(2).click(); - expect(includeElem.getText()).toMatch(/Content of template2.html/); - }); - - it('should change to blank', function() { - if (browser.params.browser == 'firefox') { - // Firefox can't handle using selects - return; - } - templateSelect.click(); - templateSelect.element.all(by.css('option')).get(0).click(); - expect(includeElem.isPresent()).toBe(false); - }); + it('should check ng-options', function() { + expect(element(by.binding('{selected_color:myColor}')).getText()).toMatch('red'); + element.all(by.model('myColor')).first().click(); + element.all(by.css('select[ng-model="myColor"] option')).first().click(); + expect(element(by.binding('{selected_color:myColor}')).getText()).toMatch('black'); + element(by.css('.nullable select[ng-model="myColor"]')).click(); + element.all(by.css('.nullable select[ng-model="myColor"] option')).first().click(); + expect(element(by.binding('{selected_color:myColor}')).getText()).toMatch('null'); + }); </file> </example> */ + /* eslint-disable max-len */ +// //00001111111111000000000002222222222000000000000000000000333333333300000000000000000000000004444444444400000000000005555555555555000000000666666666666600000007777777777777000000000000000888888888800000000000000000009999999999 + var NG_OPTIONS_REGEXP = /^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+group\s+by\s+([\s\S]+?))?(?:\s+disable\s+when\s+([\s\S]+?))?\s+for\s+(?:([$\w][$\w]*)|(?:\(\s*([$\w][$\w]*)\s*,\s*([$\w][$\w]*)\s*\)))\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?$/; + // 1: value expression (valueFn) + // 2: label expression (displayFn) + // 3: group by expression (groupByFn) + // 4: disable when expression (disableWhenFn) + // 5: array item variable name + // 6: object item key variable name + // 7: object item value variable name + // 8: collection expression + // 9: track by expression + /* eslint-enable */ + + + var ngOptionsDirective = ['$compile', '$document', '$parse', function($compile, $document, $parse) { + + function parseOptionsExpression(optionsExp, selectElement, scope) { + + var match = optionsExp.match(NG_OPTIONS_REGEXP); + if (!(match)) { + throw ngOptionsMinErr('iexp', + 'Expected expression in form of ' + + '\'_select_ (as _label_)? for (_key_,)?_value_ in _collection_\'' + + ' but got \'{0}\'. Element: {1}', + optionsExp, startingTag(selectElement)); + } - /** - * @ngdoc event - * @name ngInclude#$includeContentRequested - * @eventType emit on the scope ngInclude was declared in - * @description - * Emitted every time the ngInclude content is requested. - */ + // Extract the parts from the ngOptions expression + + // The variable name for the value of the item in the collection + var valueName = match[5] || match[7]; + // The variable name for the key of the item in the collection + var keyName = match[6]; + + // An expression that generates the viewValue for an option if there is a label expression + var selectAs = / as /.test(match[0]) && match[1]; + // An expression that is used to track the id of each object in the options collection + var trackBy = match[9]; + // An expression that generates the viewValue for an option if there is no label expression + var valueFn = $parse(match[2] ? match[1] : valueName); + var selectAsFn = selectAs && $parse(selectAs); + var viewValueFn = selectAsFn || valueFn; + var trackByFn = trackBy && $parse(trackBy); + + // Get the value by which we are going to track the option + // if we have a trackFn then use that (passing scope and locals) + // otherwise just hash the given viewValue + var getTrackByValueFn = trackBy ? + function(value, locals) { return trackByFn(scope, locals); } : + function getHashOfValue(value) { return hashKey(value); }; + var getTrackByValue = function(value, key) { + return getTrackByValueFn(value, getLocals(value, key)); + }; + var displayFn = $parse(match[2] || match[1]); + var groupByFn = $parse(match[3] || ''); + var disableWhenFn = $parse(match[4] || ''); + var valuesFn = $parse(match[8]); + + var locals = {}; + var getLocals = keyName ? function(value, key) { + locals[keyName] = key; + locals[valueName] = value; + return locals; + } : function(value) { + locals[valueName] = value; + return locals; + }; - /** - * @ngdoc event - * @name ngInclude#$includeContentLoaded - * @eventType emit on the current ngInclude scope - * @description - * Emitted every time the ngInclude content is reloaded. - */ - var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$animate', '$sce', - function($http, $templateCache, $anchorScroll, $animate, $sce) { - return { - restrict: 'ECA', - priority: 400, - terminal: true, - transclude: 'element', - controller: angular.noop, - compile: function(element, attr) { - var srcExp = attr.ngInclude || attr.src, - onloadExp = attr.onload || '', - autoScrollExp = attr.autoscroll; - return function(scope, $element, $attr, ctrl, $transclude) { - var changeCounter = 0, - currentScope, - previousElement, - currentElement; + function Option(selectValue, viewValue, label, group, disabled) { + this.selectValue = selectValue; + this.viewValue = viewValue; + this.label = label; + this.group = group; + this.disabled = disabled; + } - var cleanupLastIncludeContent = function() { - if(previousElement) { - previousElement.remove(); - previousElement = null; - } - if(currentScope) { - currentScope.$destroy(); - currentScope = null; - } - if(currentElement) { - $animate.leave(currentElement, function() { - previousElement = null; - }); - previousElement = currentElement; - currentElement = null; - } - }; + function getOptionValuesKeys(optionValues) { + var optionValuesKeys; - scope.$watch($sce.parseAsResourceUrl(srcExp), function ngIncludeWatchAction(src) { - var afterAnimation = function() { - if (isDefined(autoScrollExp) && (!autoScrollExp || scope.$eval(autoScrollExp))) { - $anchorScroll(); - } - }; - var thisChangeId = ++changeCounter; + if (!keyName && isArrayLike(optionValues)) { + optionValuesKeys = optionValues; + } else { + // if object, extract keys, in enumeration order, unsorted + optionValuesKeys = []; + for (var itemKey in optionValues) { + if (optionValues.hasOwnProperty(itemKey) && itemKey.charAt(0) !== '$') { + optionValuesKeys.push(itemKey); + } + } + } + return optionValuesKeys; + } - if (src) { - $http.get(src, {cache: $templateCache}).success(function(response) { - if (thisChangeId !== changeCounter) return; - var newScope = scope.$new(); - ctrl.template = response; + return { + trackBy: trackBy, + getTrackByValue: getTrackByValue, + getWatchables: $parse(valuesFn, function(optionValues) { + // Create a collection of things that we would like to watch (watchedArray) + // so that they can all be watched using a single $watchCollection + // that only runs the handler once if anything changes + var watchedArray = []; + optionValues = optionValues || []; + + var optionValuesKeys = getOptionValuesKeys(optionValues); + var optionValuesLength = optionValuesKeys.length; + for (var index = 0; index < optionValuesLength; index++) { + var key = (optionValues === optionValuesKeys) ? index : optionValuesKeys[index]; + var value = optionValues[key]; + + var locals = getLocals(value, key); + var selectValue = getTrackByValueFn(value, locals); + watchedArray.push(selectValue); + + // Only need to watch the displayFn if there is a specific label expression + if (match[2] || match[1]) { + var label = displayFn(scope, locals); + watchedArray.push(label); + } - // Note: This will also link all children of ng-include that were contained in the original - // html. If that content contains controllers, ... they could pollute/change the scope. - // However, using ng-include on an element with additional content does not make sense... - // Note: We can't remove them in the cloneAttchFn of $transclude as that - // function is called before linking the content, which would apply child - // directives to non existing elements. - var clone = $transclude(newScope, function(clone) { - cleanupLastIncludeContent(); - $animate.enter(clone, null, $element, afterAnimation); - }); + // Only need to watch the disableWhenFn if there is a specific disable expression + if (match[4]) { + var disableWhen = disableWhenFn(scope, locals); + watchedArray.push(disableWhen); + } + } + return watchedArray; + }), - currentScope = newScope; - currentElement = clone; + getOptions: function() { + + var optionItems = []; + var selectValueMap = {}; + + // The option values were already computed in the `getWatchables` fn, + // which must have been called to trigger `getOptions` + var optionValues = valuesFn(scope) || []; + var optionValuesKeys = getOptionValuesKeys(optionValues); + var optionValuesLength = optionValuesKeys.length; + + for (var index = 0; index < optionValuesLength; index++) { + var key = (optionValues === optionValuesKeys) ? index : optionValuesKeys[index]; + var value = optionValues[key]; + var locals = getLocals(value, key); + var viewValue = viewValueFn(scope, locals); + var selectValue = getTrackByValueFn(viewValue, locals); + var label = displayFn(scope, locals); + var group = groupByFn(scope, locals); + var disabled = disableWhenFn(scope, locals); + var optionItem = new Option(selectValue, viewValue, label, group, disabled); + + optionItems.push(optionItem); + selectValueMap[selectValue] = optionItem; + } - currentScope.$emit('$includeContentLoaded'); - scope.$eval(onloadExp); - }).error(function() { - if (thisChangeId === changeCounter) cleanupLastIncludeContent(); - }); - scope.$emit('$includeContentRequested'); - } else { - cleanupLastIncludeContent(); - ctrl.template = null; - } - }); + return { + items: optionItems, + selectValueMap: selectValueMap, + getOptionFromViewValue: function(value) { + return selectValueMap[getTrackByValue(value)]; + }, + getViewValueFromOption: function(option) { + // If the viewValue could be an object that may be mutated by the application, + // we need to make a copy and not return the reference to the value on the option. + return trackBy ? copy(option.viewValue) : option.viewValue; + } }; } }; - }]; + } -// This directive is called during the $transclude call of the first `ngInclude` directive. -// It will replace and compile the content of the element with the loaded template. -// We need this directive so that the element content is already filled when -// the link function of another directive on the same element as ngInclude -// is called. - var ngIncludeFillContentDirective = ['$compile', - function($compile) { - return { - restrict: 'ECA', - priority: -400, - require: 'ngInclude', - link: function(scope, $element, $attr, ctrl) { - $element.html(ctrl.template); - $compile($element.contents())(scope); + + // Support: IE 9 only + // We can't just jqLite('<option>') since jqLite is not smart enough + // to create it in <select> and IE barfs otherwise. + var optionTemplate = window.document.createElement('option'), + optGroupTemplate = window.document.createElement('optgroup'); + + function ngOptionsPostLink(scope, selectElement, attr, ctrls) { + + var selectCtrl = ctrls[0]; + var ngModelCtrl = ctrls[1]; + var multiple = attr.multiple; + + // The emptyOption allows the application developer to provide their own custom "empty" + // option when the viewValue does not match any of the option values. + for (var i = 0, children = selectElement.children(), ii = children.length; i < ii; i++) { + if (children[i].value === '') { + selectCtrl.hasEmptyOption = true; + selectCtrl.emptyOption = children.eq(i); + break; } + } + + // The empty option will be compiled and rendered before we first generate the options + selectElement.empty(); + + var providedEmptyOption = !!selectCtrl.emptyOption; + + var unknownOption = jqLite(optionTemplate.cloneNode(false)); + unknownOption.val('?'); + + var options; + var ngOptions = parseOptionsExpression(attr.ngOptions, selectElement, scope); + // This stores the newly created options before they are appended to the select. + // Since the contents are removed from the fragment when it is appended, + // we only need to create it once. + var listFragment = $document[0].createDocumentFragment(); + + // Overwrite the implementation. ngOptions doesn't use hashes + selectCtrl.generateUnknownOptionValue = function(val) { + return '?'; }; - }]; - /** - * @ngdoc directive - * @name ngInit - * @restrict AC - * - * @description - * The `ngInit` directive allows you to evaluate an expression in the - * current scope. - * - * <div class="alert alert-error"> - * The only appropriate use of `ngInit` is for aliasing special properties of - * {@link ng.directive:ngRepeat `ngRepeat`}, as seen in the demo below. Besides this case, you - * should use {@link guide/controller controllers} rather than `ngInit` - * to initialize values on a scope. - * </div> - * <div class="alert alert-warning"> - * **Note**: If you have assignment in `ngInit` along with {@link ng.$filter `$filter`}, make - * sure you have parenthesis for correct precedence: - * <pre class="prettyprint"> - * <div ng-init="test1 = (data | orderBy:'name')"></div> - * </pre> - * </div> - * - * @priority 450 - * - * @element ANY - * @param {expression} ngInit {@link guide/expression Expression} to eval. - * - * @example - <example> - <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.list = [['a', 'b'], ['c', 'd']]; - } - </script> - <div ng-controller="Ctrl"> - <div ng-repeat="innerList in list" ng-init="outerIndex = $index"> - <div ng-repeat="value in innerList" ng-init="innerIndex = $index"> - <span class="example-init">list[ {{outerIndex}} ][ {{innerIndex}} ] = {{value}};</span> - </div> - </div> - </div> - </file> - <file name="protractor.js" type="protractor"> - it('should alias index positions', function() { - var elements = element.all(by.css('.example-init')); - expect(elements.get(0).getText()).toBe('list[ 0 ][ 0 ] = a;'); - expect(elements.get(1).getText()).toBe('list[ 0 ][ 1 ] = b;'); - expect(elements.get(2).getText()).toBe('list[ 1 ][ 0 ] = c;'); - expect(elements.get(3).getText()).toBe('list[ 1 ][ 1 ] = d;'); - }); - </file> - </example> - */ - var ngInitDirective = ngDirective({ - priority: 450, - compile: function() { - return { - pre: function(scope, element, attrs) { - scope.$eval(attrs.ngInit); + // Update the controller methods for multiple selectable options + if (!multiple) { + + selectCtrl.writeValue = function writeNgOptionsValue(value) { + // The options might not be defined yet when ngModel tries to render + if (!options) return; + + var selectedOption = selectElement[0].options[selectElement[0].selectedIndex]; + var option = options.getOptionFromViewValue(value); + + // Make sure to remove the selected attribute from the previously selected option + // Otherwise, screen readers might get confused + if (selectedOption) selectedOption.removeAttribute('selected'); + + if (option) { + // Don't update the option when it is already selected. + // For example, the browser will select the first option by default. In that case, + // most properties are set automatically - except the `selected` attribute, which we + // set always + + if (selectElement[0].value !== option.selectValue) { + selectCtrl.removeUnknownOption(); + + selectElement[0].value = option.selectValue; + option.element.selected = true; + } + + option.element.setAttribute('selected', 'selected'); + } else { + selectCtrl.selectUnknownOrEmptyOption(value); + } + }; + + selectCtrl.readValue = function readNgOptionsValue() { + + var selectedOption = options.selectValueMap[selectElement.val()]; + + if (selectedOption && !selectedOption.disabled) { + selectCtrl.unselectEmptyOption(); + selectCtrl.removeUnknownOption(); + return options.getViewValueFromOption(selectedOption); + } + return null; + }; + + // If we are using `track by` then we must watch the tracked value on the model + // since ngModel only watches for object identity change + // FIXME: When a user selects an option, this watch will fire needlessly + if (ngOptions.trackBy) { + scope.$watch( + function() { return ngOptions.getTrackByValue(ngModelCtrl.$viewValue); }, + function() { ngModelCtrl.$render(); } + ); + } + + } else { + + selectCtrl.writeValue = function writeNgOptionsMultiple(values) { + // The options might not be defined yet when ngModel tries to render + if (!options) return; + + // Only set `<option>.selected` if necessary, in order to prevent some browsers from + // scrolling to `<option>` elements that are outside the `<select>` element's viewport. + var selectedOptions = values && values.map(getAndUpdateSelectedOption) || []; + + options.items.forEach(function(option) { + if (option.element.selected && !includes(selectedOptions, option)) { + option.element.selected = false; + } + }); + }; + + + selectCtrl.readValue = function readNgOptionsMultiple() { + var selectedValues = selectElement.val() || [], + selections = []; + + forEach(selectedValues, function(value) { + var option = options.selectValueMap[value]; + if (option && !option.disabled) selections.push(options.getViewValueFromOption(option)); + }); + + return selections; + }; + + // If we are using `track by` then we must watch these tracked values on the model + // since ngModel only watches for object identity change + if (ngOptions.trackBy) { + + scope.$watchCollection(function() { + if (isArray(ngModelCtrl.$viewValue)) { + return ngModelCtrl.$viewValue.map(function(value) { + return ngOptions.getTrackByValue(value); + }); + } + }, function() { + ngModelCtrl.$render(); + }); + + } + } + + if (providedEmptyOption) { + + // compile the element since there might be bindings in it + $compile(selectCtrl.emptyOption)(scope); + + selectElement.prepend(selectCtrl.emptyOption); + + if (selectCtrl.emptyOption[0].nodeType === NODE_TYPE_COMMENT) { + // This means the empty option has currently no actual DOM node, probably because + // it has been modified by a transclusion directive. + selectCtrl.hasEmptyOption = false; + + // Redefine the registerOption function, which will catch + // options that are added by ngIf etc. (rendering of the node is async because of + // lazy transclusion) + selectCtrl.registerOption = function(optionScope, optionEl) { + if (optionEl.val() === '') { + selectCtrl.hasEmptyOption = true; + selectCtrl.emptyOption = optionEl; + selectCtrl.emptyOption.removeClass('ng-scope'); + // This ensures the new empty option is selected if previously no option was selected + ngModelCtrl.$render(); + + optionEl.on('$destroy', function() { + var needsRerender = selectCtrl.$isEmptyOptionSelected(); + + selectCtrl.hasEmptyOption = false; + selectCtrl.emptyOption = undefined; + + if (needsRerender) ngModelCtrl.$render(); + }); + } + }; + + } else { + // remove the class, which is added automatically because we recompile the element and it + // becomes the compilation root + selectCtrl.emptyOption.removeClass('ng-scope'); + } + + } + + // We will re-render the option elements if the option values or labels change + scope.$watchCollection(ngOptions.getWatchables, updateOptions); + + // ------------------------------------------------------------------ // + + function addOptionElement(option, parent) { + var optionElement = optionTemplate.cloneNode(false); + parent.appendChild(optionElement); + updateOptionElement(option, optionElement); + } + + function getAndUpdateSelectedOption(viewValue) { + var option = options.getOptionFromViewValue(viewValue); + var element = option && option.element; + + if (element && !element.selected) element.selected = true; + + return option; + } + + function updateOptionElement(option, element) { + option.element = element; + element.disabled = option.disabled; + // Support: IE 11 only, Edge 12-13 only + // NOTE: The label must be set before the value, otherwise IE 11 & Edge create unresponsive + // selects in certain circumstances when multiple selects are next to each other and display + // the option list in listbox style, i.e. the select is [multiple], or specifies a [size]. + // See https://github.com/angular/angular.js/issues/11314 for more info. + // This is unfortunately untestable with unit / e2e tests + if (option.label !== element.label) { + element.label = option.label; + element.textContent = option.label; + } + element.value = option.selectValue; + } + + function updateOptions() { + var previousValue = options && selectCtrl.readValue(); + + // We must remove all current options, but cannot simply set innerHTML = null + // since the providedEmptyOption might have an ngIf on it that inserts comments which we + // must preserve. + // Instead, iterate over the current option elements and remove them or their optgroup + // parents + if (options) { + + for (var i = options.items.length - 1; i >= 0; i--) { + var option = options.items[i]; + if (isDefined(option.group)) { + jqLiteRemove(option.element.parentNode); + } else { + jqLiteRemove(option.element); + } + } + } + + options = ngOptions.getOptions(); + + var groupElementMap = {}; + + options.items.forEach(function addOption(option) { + var groupElement; + + if (isDefined(option.group)) { + + // This option is to live in a group + // See if we have already created this group + groupElement = groupElementMap[option.group]; + + if (!groupElement) { + + groupElement = optGroupTemplate.cloneNode(false); + listFragment.appendChild(groupElement); + + // Update the label on the group element + // "null" is special cased because of Safari + groupElement.label = option.group === null ? 'null' : option.group; + + // Store it for use later + groupElementMap[option.group] = groupElement; + } + + addOptionElement(option, groupElement); + + } else { + + // This option is not in a group + addOptionElement(option, listFragment); + } + }); + + selectElement[0].appendChild(listFragment); + + ngModelCtrl.$render(); + + // Check to see if the value has changed due to the update to the options + if (!ngModelCtrl.$isEmpty(previousValue)) { + var nextValue = selectCtrl.readValue(); + var isNotPrimitive = ngOptions.trackBy || multiple; + if (isNotPrimitive ? !equals(previousValue, nextValue) : previousValue !== nextValue) { + ngModelCtrl.$setViewValue(nextValue); + ngModelCtrl.$render(); + } } - }; + } } - }); - /** - * @ngdoc directive - * @name ngNonBindable - * @restrict AC - * @priority 1000 - * - * @description - * The `ngNonBindable` directive tells Angular not to compile or bind the contents of the current - * DOM element. This is useful if the element contains what appears to be Angular directives and - * bindings but which should be ignored by Angular. This could be the case if you have a site that - * displays snippets of code, for instance. - * - * @element ANY - * - * @example - * In this example there are two locations where a simple interpolation binding (`{{}}`) is present, - * but the one wrapped in `ngNonBindable` is left alone. - * - * @example - <example> - <file name="index.html"> - <div>Normal: {{1 + 2}}</div> - <div ng-non-bindable>Ignored: {{1 + 2}}</div> - </file> - <file name="protractor.js" type="protractor"> - it('should check ng-non-bindable', function() { - expect(element(by.binding('1 + 2')).getText()).toContain('3'); - expect(element.all(by.css('div')).last().getText()).toMatch(/1 \+ 2/); - }); - </file> - </example> - */ - var ngNonBindableDirective = ngDirective({ terminal: true, priority: 1000 }); + return { + restrict: 'A', + terminal: true, + require: ['select', 'ngModel'], + link: { + pre: function ngOptionsPreLink(scope, selectElement, attr, ctrls) { + // Deactivate the SelectController.register method to prevent + // option directives from accidentally registering themselves + // (and unwanted $destroy handlers etc.) + ctrls[0].registerOption = noop; + }, + post: ngOptionsPostLink + } + }; + }]; /** * @ngdoc directive @@ -19507,27 +31067,27 @@ * @description * `ngPluralize` is a directive that displays messages according to en-US localization rules. * These rules are bundled with angular.js, but can be overridden - * (see {@link guide/i18n Angular i18n} dev guide). You configure ngPluralize directive + * (see {@link guide/i18n AngularJS i18n} dev guide). You configure ngPluralize directive * by specifying the mappings between * [plural categories](http://unicode.org/repos/cldr-tmp/trunk/diff/supplemental/language_plural_rules.html) * and the strings to be displayed. * - * # Plural categories and explicit number rules + * ## Plural categories and explicit number rules * There are two * [plural categories](http://unicode.org/repos/cldr-tmp/trunk/diff/supplemental/language_plural_rules.html) - * in Angular's default en-US locale: "one" and "other". + * in AngularJS's default en-US locale: "one" and "other". * * While a plural category may match many numbers (for example, in en-US locale, "other" can match * any number that is not 1), an explicit number rule can only match one number. For example, the * explicit number rule for "3" matches the number 3. There are examples of plural categories * and explicit number rules throughout the rest of this documentation. * - * # Configuring ngPluralize + * ## Configuring ngPluralize * You configure ngPluralize by providing 2 attributes: `count` and `when`. * You can also provide an optional attribute, `offset`. * * The value of the `count` attribute can be either a string or an {@link guide/expression - * Angular expression}; these are evaluated on the current scope for its bound value. + * AngularJS expression}; these are evaluated on the current scope for its bound value. * * The `when` attribute specifies the mappings between plural categories and the actual * string to be displayed. The value of the attribute should be a JSON object. @@ -19537,8 +31097,8 @@ * ```html * <ng-pluralize count="personCount" when="{'0': 'Nobody is viewing.', - * 'one': '1 person is viewing.', - * 'other': '{} people are viewing.'}"> + * 'one': '1 person is viewing.', + * 'other': '{} people are viewing.'}"> * </ng-pluralize> *``` * @@ -19549,11 +31109,14 @@ * show "a dozen people are viewing". * * You can use a set of closed braces (`{}`) as a placeholder for the number that you want substituted - * into pluralized strings. In the previous example, Angular will replace `{}` with + * into pluralized strings. In the previous example, AngularJS will replace `{}` with * <span ng-non-bindable>`{{personCount}}`</span>. The closed braces `{}` is a placeholder * for <span ng-non-bindable>{{numberExpression}}</span>. * - * # Configuring ngPluralize with offset + * If no rule is defined for a category, then an empty string is displayed and a warning is generated. + * Note that some locales define more categories than `one` and `other`. For example, fr-fr defines `few` and `many`. + * + * ## Configuring ngPluralize with offset * The `offset` attribute allows further customization of pluralized text, which can result in * a better user experience. For example, instead of the message "4 people are viewing this document", * you might display "John, Kate and 2 others are viewing this document". @@ -19563,10 +31126,10 @@ * ```html * <ng-pluralize count="personCount" offset=2 * when="{'0': 'Nobody is viewing.', - * '1': '{{person1}} is viewing.', - * '2': '{{person1}} and {{person2}} are viewing.', - * 'one': '{{person1}}, {{person2}} and one other person are viewing.', - * 'other': '{{person1}}, {{person2}} and {} other people are viewing.'}"> + * '1': '{{person1}} is viewing.', + * '2': '{{person1}} and {{person2}} are viewing.', + * 'one': '{{person1}}, {{person2}} and one other person are viewing.', + * 'other': '{{person1}}, {{person2}} and {} other people are viewing.'}"> * </ng-pluralize> * ``` * @@ -19574,8 +31137,8 @@ * three explicit number rules 0, 1 and 2. * When one person, perhaps John, views the document, "John is viewing" will be shown. * When three people view the document, no explicit number rule is found, so - * an offset of 2 is taken off 3, and Angular uses 1 to decide the plural category. - * In this case, plural category 'one' is matched and "John, Marry and one other person are viewing" + * an offset of 2 is taken off 3, and AngularJS uses 1 to decide the plural category. + * In this case, plural category 'one' is matched and "John, Mary and one other person are viewing" * is shown. * * Note that when you specify offsets, you must provide explicit number rules for @@ -19588,19 +31151,20 @@ * @param {number=} offset Offset to deduct from the total number. * * @example - <example> + <example module="pluralizeExample" name="ng-pluralize"> <file name="index.html"> <script> - function Ctrl($scope) { - $scope.person1 = 'Igor'; - $scope.person2 = 'Misko'; - $scope.personCount = 1; - } + angular.module('pluralizeExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.person1 = 'Igor'; + $scope.person2 = 'Misko'; + $scope.personCount = 1; + }]); </script> - <div ng-controller="Ctrl"> - Person 1:<input type="text" ng-model="person1" value="Igor" /><br/> - Person 2:<input type="text" ng-model="person2" value="Misko" /><br/> - Number of People:<input type="text" ng-model="personCount" value="1" /><br/> + <div ng-controller="ExampleController"> + <label>Person 1:<input type="text" ng-model="person1" value="Igor" /></label><br/> + <label>Person 2:<input type="text" ng-model="person2" value="Misko" /></label><br/> + <label>Number of People:<input type="text" ng-model="personCount" value="1" /></label><br/> <!--- Example with simple pluralization rules for en locale ---> Without Offset: @@ -19670,10 +31234,11 @@ </file> </example> */ - var ngPluralizeDirective = ['$locale', '$interpolate', function($locale, $interpolate) { - var BRACE = /{}/g; + var ngPluralizeDirective = ['$locale', '$interpolate', '$log', function($locale, $interpolate, $log) { + var BRACE = /{}/g, + IS_WHEN = /^when(Minus)?(.+)$/; + return { - restrict: 'EA', link: function(scope, element, attr) { var numberExp = attr.count, whenExp = attr.$attr.when && element.attr(attr.$attr.when), // we have {{}} in attrs @@ -19682,41 +31247,64 @@ whensExpFns = {}, startSymbol = $interpolate.startSymbol(), endSymbol = $interpolate.endSymbol(), - isWhen = /^when(Minus)?(.+)$/; + braceReplacement = startSymbol + numberExp + '-' + offset + endSymbol, + watchRemover = angular.noop, + lastCount; forEach(attr, function(expression, attributeName) { - if (isWhen.test(attributeName)) { - whens[lowercase(attributeName.replace('when', '').replace('Minus', '-'))] = - element.attr(attr.$attr[attributeName]); + var tmpMatch = IS_WHEN.exec(attributeName); + if (tmpMatch) { + var whenKey = (tmpMatch[1] ? '-' : '') + lowercase(tmpMatch[2]); + whens[whenKey] = element.attr(attr.$attr[attributeName]); } }); forEach(whens, function(expression, key) { - whensExpFns[key] = - $interpolate(expression.replace(BRACE, startSymbol + numberExp + '-' + - offset + endSymbol)); + whensExpFns[key] = $interpolate(expression.replace(BRACE, braceReplacement)); + }); - scope.$watch(function ngPluralizeWatch() { - var value = parseFloat(scope.$eval(numberExp)); + scope.$watch(numberExp, function ngPluralizeWatchAction(newVal) { + var count = parseFloat(newVal); + var countIsNaN = isNumberNaN(count); - if (!isNaN(value)) { - //if explicit number rule such as 1, 2, 3... is defined, just use it. Otherwise, - //check it against pluralization rules in $locale service - if (!(value in whens)) value = $locale.pluralCat(value - offset); - return whensExpFns[value](scope, element, true); - } else { - return ''; + if (!countIsNaN && !(count in whens)) { + // If an explicit number rule such as 1, 2, 3... is defined, just use it. + // Otherwise, check it against pluralization rules in $locale service. + count = $locale.pluralCat(count - offset); + } + + // If both `count` and `lastCount` are NaN, we don't need to re-register a watch. + // In JS `NaN !== NaN`, so we have to explicitly check. + if ((count !== lastCount) && !(countIsNaN && isNumberNaN(lastCount))) { + watchRemover(); + var whenExpFn = whensExpFns[count]; + if (isUndefined(whenExpFn)) { + if (newVal != null) { + $log.debug('ngPluralize: no rule defined for \'' + count + '\' in ' + whenExp); + } + watchRemover = noop; + updateElementText(); + } else { + watchRemover = scope.$watch(whenExpFn, updateElementText); + } + lastCount = count; } - }, function ngPluralizeWatchAction(newVal) { - element.text(newVal); }); + + function updateElementText(newText) { + element.text(newText || ''); + } } }; }]; + /* exported ngRepeatDirective */ + /** * @ngdoc directive * @name ngRepeat + * @multiElement + * @restrict A * * @description * The `ngRepeat` directive instantiates a template once per item from a collection. Each template @@ -19734,10 +31322,200 @@ * | `$even` | {@type boolean} | true if the iterator position `$index` is even (otherwise false). | * | `$odd` | {@type boolean} | true if the iterator position `$index` is odd (otherwise false). | * - * Creating aliases for these properties is possible with {@link ng.directive:ngInit `ngInit`}. - * This may be useful when, for instance, nesting ngRepeats. + * <div class="alert alert-info"> + * Creating aliases for these properties is possible with {@link ng.directive:ngInit `ngInit`}. + * This may be useful when, for instance, nesting ngRepeats. + * </div> + * + * + * ## Iterating over object properties + * + * It is possible to get `ngRepeat` to iterate over the properties of an object using the following + * syntax: + * + * ```js + * <div ng-repeat="(key, value) in myObj"> ... </div> + * ``` + * + * However, there are a few limitations compared to array iteration: + * + * - The JavaScript specification does not define the order of keys + * returned for an object, so AngularJS relies on the order returned by the browser + * when running `for key in myObj`. Browsers generally follow the strategy of providing + * keys in the order in which they were defined, although there are exceptions when keys are deleted + * and reinstated. See the + * [MDN page on `delete` for more info](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/delete#Cross-browser_notes). + * + * - `ngRepeat` will silently *ignore* object keys starting with `$`, because + * it's a prefix used by AngularJS for public (`$`) and private (`$$`) properties. + * + * - The built-in filters {@link ng.orderBy orderBy} and {@link ng.filter filter} do not work with + * objects, and will throw an error if used with one. + * + * If you are hitting any of these limitations, the recommended workaround is to convert your object into an array + * that is sorted into the order that you prefer before providing it to `ngRepeat`. You could + * do this with a filter such as [toArrayFilter](http://ngmodules.org/modules/angular-toArrayFilter) + * or implement a `$watch` on the object yourself. + * + * + * ## Tracking and Duplicates + * + * `ngRepeat` uses {@link $rootScope.Scope#$watchCollection $watchCollection} to detect changes in + * the collection. When a change happens, `ngRepeat` then makes the corresponding changes to the DOM: + * + * * When an item is added, a new instance of the template is added to the DOM. + * * When an item is removed, its template instance is removed from the DOM. + * * When items are reordered, their respective templates are reordered in the DOM. + * + * To minimize creation of DOM elements, `ngRepeat` uses a function + * to "keep track" of all items in the collection and their corresponding DOM elements. + * For example, if an item is added to the collection, `ngRepeat` will know that all other items + * already have DOM elements, and will not re-render them. + * + * All different types of tracking functions, their syntax, and and their support for duplicate + * items in collections can be found in the + * {@link ngRepeat#ngRepeat-arguments ngRepeat expression description}. * - * # Special repeat start and end points + * <div class="alert alert-success"> + * **Best Practice:** If you are working with objects that have a unique identifier property, you + * should track by this identifier instead of the object instance, + * e.g. `item in items track by item.id`. + * Should you reload your data later, `ngRepeat` will not have to rebuild the DOM elements for items + * it has already rendered, even if the JavaScript objects in the collection have been substituted + * for new ones. For large collections, this significantly improves rendering performance. + * </div> + * + * ### Effects of DOM Element re-use + * + * When DOM elements are re-used, ngRepeat updates the scope for the element, which will + * automatically update any active bindings on the template. However, other + * functionality will not be updated, because the element is not re-created: + * + * - Directives are not re-compiled + * - {@link guide/expression#one-time-binding one-time expressions} on the repeated template are not + * updated if they have stabilized. + * + * The above affects all kinds of element re-use due to tracking, but may be especially visible + * when tracking by `$index` due to the way ngRepeat re-uses elements. + * + * The following example shows the effects of different actions with tracking: + + <example module="ngRepeat" name="ngRepeat-tracking" deps="angular-animate.js" animations="true"> + <file name="script.js"> + angular.module('ngRepeat', ['ngAnimate']).controller('repeatController', function($scope) { + var friends = [ + {name:'John', age:25}, + {name:'Mary', age:40}, + {name:'Peter', age:85} + ]; + + $scope.removeFirst = function() { + $scope.friends.shift(); + }; + + $scope.updateAge = function() { + $scope.friends.forEach(function(el) { + el.age = el.age + 5; + }); + }; + + $scope.copy = function() { + $scope.friends = angular.copy($scope.friends); + }; + + $scope.reset = function() { + $scope.friends = angular.copy(friends); + }; + + $scope.reset(); + }); + </file> + <file name="index.html"> + <div ng-controller="repeatController"> + <ol> + <li>When you click "Update Age", only the first list updates the age, because all others have + a one-time binding on the age property. If you then click "Copy", the current friend list + is copied, and now the second list updates the age, because the identity of the collection items + has changed and the list must be re-rendered. The 3rd and 4th list stay the same, because all the + items are already known according to their tracking functions. + </li> + <li>When you click "Remove First", the 4th list has the wrong age on both remaining items. This is + due to tracking by $index: when the first collection item is removed, ngRepeat reuses the first + DOM element for the new first collection item, and so on. Since the age property is one-time + bound, the value remains from the collection item which was previously at this index. + </li> + </ol> + + <button ng-click="removeFirst()">Remove First</button> + <button ng-click="updateAge()">Update Age</button> + <button ng-click="copy()">Copy</button> + <br><button ng-click="reset()">Reset List</button> + <br> + <code>track by $id(friend)</code> (default): + <ul class="example-animate-container"> + <li class="animate-repeat" ng-repeat="friend in friends"> + {{friend.name}} is {{friend.age}} years old. + </li> + </ul> + <code>track by $id(friend)</code> (default), with age one-time binding: + <ul class="example-animate-container"> + <li class="animate-repeat" ng-repeat="friend in friends"> + {{friend.name}} is {{::friend.age}} years old. + </li> + </ul> + <code>track by friend.name</code>, with age one-time binding: + <ul class="example-animate-container"> + <li class="animate-repeat" ng-repeat="friend in friends track by friend.name"> + {{friend.name}} is {{::friend.age}} years old. + </li> + </ul> + <code>track by $index</code>, with age one-time binding: + <ul class="example-animate-container"> + <li class="animate-repeat" ng-repeat="friend in friends track by $index"> + {{friend.name}} is {{::friend.age}} years old. + </li> + </ul> + </div> + </file> + <file name="animations.css"> + .example-animate-container { + background:white; + border:1px solid black; + list-style:none; + margin:0; + padding:0 10px; + } + + .animate-repeat { + line-height:30px; + list-style:none; + box-sizing:border-box; + } + + .animate-repeat.ng-move, + .animate-repeat.ng-enter, + .animate-repeat.ng-leave { + transition:all linear 0.5s; + } + + .animate-repeat.ng-leave.ng-leave-active, + .animate-repeat.ng-move, + .animate-repeat.ng-enter { + opacity:0; + max-height:0; + } + + .animate-repeat.ng-leave, + .animate-repeat.ng-move.ng-move-active, + .animate-repeat.ng-enter.ng-enter-active { + opacity:1; + max-height:30px; + } + </file> + </example> + + * + * ## Special repeat start and end points * To repeat a series of elements instead of just one parent element, ngRepeat (as well as other ng directives) supports extending * the range of the repeater by defining explicit start and end points by using **ng-repeat-start** and **ng-repeat-end** respectively. * The **ng-repeat-start** directive works the same as **ng-repeat**, but will repeat all the HTML code (including the tag it's defined on) @@ -19782,11 +31560,13 @@ * as **data-ng-repeat-start**, **x-ng-repeat-start** and **ng:repeat-start**). * * @animations - * **.enter** - when a new item is added to the list or when an item is revealed after a filter + * | Animation | Occurs | + * |----------------------------------|-------------------------------------| + * | {@link ng.$animate#enter enter} | when a new item is added to the list or when an item is revealed after a filter | + * | {@link ng.$animate#leave leave} | when an item is removed from the list or when an item is filtered out | + * | {@link ng.$animate#move move } | when an adjacent item is filtered out causing a reorder or when the item contents are reordered | * - * **.leave** - when an item is removed from the list or when an item is filtered out - * - * **.move** - when an adjacent item is filtered out causing a reorder or when the item contents are reordered + * See the example below for defining CSS animations with ngRepeat. * * @element ANY * @scope @@ -19804,54 +31584,91 @@ * * For example: `(name, age) in {'adam':10, 'amalie':12}`. * - * * `variable in expression track by tracking_expression` – You can also provide an optional tracking function - * which can be used to associate the objects in the collection with the DOM elements. If no tracking function - * is specified the ng-repeat associates elements by identity in the collection. It is an error to have - * more than one tracking function to resolve to the same key. (This would mean that two distinct objects are - * mapped to the same DOM element, which is not possible.) Filters should be applied to the expression, - * before specifying a tracking expression. + * * `variable in expression track by tracking_expression` – You can also provide an optional tracking expression + * which can be used to associate the objects in the collection with the DOM elements. If no tracking expression + * is specified, ng-repeat associates elements by identity. It is an error to have + * more than one tracking expression value resolve to the same key. (This would mean that two distinct objects are + * mapped to the same DOM element, which is not possible.) + * + * *Default tracking: $id()*: `item in items` is equivalent to `item in items track by $id(item)`. + * This implies that the DOM elements will be associated by item identity in the collection. + * + * The built-in `$id()` function can be used to assign a unique + * `$$hashKey` property to each item in the collection. This property is then used as a key to associated DOM elements + * with the corresponding item in the collection by identity. Moving the same object would move + * the DOM element in the same way in the DOM. + * Note that the default id function does not support duplicate primitive values (`number`, `string`), + * but supports duplictae non-primitive values (`object`) that are *equal* in shape. + * + * *Custom Expression*: It is possible to use any AngularJS expression to compute the tracking + * id, for example with a function, or using a property on the collection items. + * `item in items track by item.id` is a typical pattern when the items have a unique identifier, + * e.g. database id. In this case the object identity does not matter. Two objects are considered + * equivalent as long as their `id` property is same. + * Tracking by unique identifier is the most performant way and should be used whenever possible. + * + * *$index*: This special property tracks the collection items by their index, and + * re-uses the DOM elements that match that index, e.g. `item in items track by $index`. This can + * be used for a performance improvement if no unique identfier is available and the identity of + * the collection items cannot be easily computed. It also allows duplicates. + * + * <div class="alert alert-warning"> + * <strong>Note:</strong> Re-using DOM elements can have unforeseen effects. Read the + * {@link ngRepeat#tracking-and-duplicates section on tracking and duplicates} for + * more info. + * </div> + * + * <div class="alert alert-warning"> + * <strong>Note:</strong> the `track by` expression must come last - after any filters, and the alias expression: + * `item in items | filter:searchText as results track by item.id` + * </div> * - * For example: `item in items` is equivalent to `item in items track by $id(item)'. This implies that the DOM elements - * will be associated by item identity in the array. + * * `variable in expression as alias_expression` – You can also provide an optional alias expression which will then store the + * intermediate results of the repeater after the filters have been applied. Typically this is used to render a special message + * when a filter is active on the repeater, but the filtered result set is empty. * - * For example: `item in items track by $id(item)`. A built in `$id()` function can be used to assign a unique - * `$$hashKey` property to each item in the array. This property is then used as a key to associated DOM elements - * with the corresponding item in the array by identity. Moving the same object in array would move the DOM - * element in the same way in the DOM. + * For example: `item in items | filter:x as results` will store the fragment of the repeated items as `results`, but only after + * the items have been processed through the filter. * - * For example: `item in items track by item.id` is a typical pattern when the items come from the database. In this - * case the object identity does not matter. Two objects are considered equivalent as long as their `id` - * property is same. + * Please note that `as [variable name] is not an operator but rather a part of ngRepeat + * micro-syntax so it can be used only after all filters (and not as operator, inside an expression). * - * For example: `item in items | filter:searchText track by item.id` is a pattern that might be used to apply a filter - * to items in conjunction with a tracking expression. + * For example: `item in items | filter : x | orderBy : order | limitTo : limit as results track by item.id` . * * @example - * This example initializes the scope to a list of names and - * then uses `ngRepeat` to display every person: - <example module="ngAnimate" deps="angular-animate.js" animations="true"> + * This example uses `ngRepeat` to display a list of people. A filter is used to restrict the displayed + * results by name or by age. New (entering) and removed (leaving) items are animated. + <example module="ngRepeat" name="ngRepeat" deps="angular-animate.js" animations="true"> <file name="index.html"> - <div ng-init="friends = [ - {name:'John', age:25, gender:'boy'}, - {name:'Jessie', age:30, gender:'girl'}, - {name:'Johanna', age:28, gender:'girl'}, - {name:'Joy', age:15, gender:'girl'}, - {name:'Mary', age:28, gender:'girl'}, - {name:'Peter', age:95, gender:'boy'}, - {name:'Sebastian', age:50, gender:'boy'}, - {name:'Erika', age:27, gender:'girl'}, - {name:'Patrick', age:40, gender:'boy'}, - {name:'Samantha', age:60, gender:'girl'} - ]"> + <div ng-controller="repeatController"> I have {{friends.length}} friends. They are: - <input type="search" ng-model="q" placeholder="filter friends..." /> + <input type="search" ng-model="q" placeholder="filter friends..." aria-label="filter friends" /> <ul class="example-animate-container"> - <li class="animate-repeat" ng-repeat="friend in friends | filter:q"> + <li class="animate-repeat" ng-repeat="friend in friends | filter:q as results track by friend.name"> [{{$index + 1}}] {{friend.name}} who is {{friend.age}} years old. </li> + <li class="animate-repeat" ng-if="results.length === 0"> + <strong>No results found...</strong> + </li> </ul> </div> </file> + <file name="script.js"> + angular.module('ngRepeat', ['ngAnimate']).controller('repeatController', function($scope) { + $scope.friends = [ + {name:'John', age:25, gender:'boy'}, + {name:'Jessie', age:30, gender:'girl'}, + {name:'Johanna', age:28, gender:'girl'}, + {name:'Joy', age:15, gender:'girl'}, + {name:'Mary', age:28, gender:'girl'}, + {name:'Peter', age:95, gender:'boy'}, + {name:'Sebastian', age:50, gender:'boy'}, + {name:'Erika', age:27, gender:'girl'}, + {name:'Patrick', age:40, gender:'boy'}, + {name:'Samantha', age:60, gender:'girl'} + ]; + }); + </file> <file name="animations.css"> .example-animate-container { background:white; @@ -19862,7 +31679,7 @@ } .animate-repeat { - line-height:40px; + line-height:30px; list-style:none; box-sizing:border-box; } @@ -19870,7 +31687,6 @@ .animate-repeat.ng-move, .animate-repeat.ng-enter, .animate-repeat.ng-leave { - -webkit-transition:all linear 0.5s; transition:all linear 0.5s; } @@ -19885,7 +31701,7 @@ .animate-repeat.ng-move.ng-move-active, .animate-repeat.ng-enter.ng-enter-active { opacity:1; - max-height:40px; + max-height:30px; } </file> <file name="protractor.js" type="protractor"> @@ -19912,39 +31728,74 @@ </file> </example> */ - var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { + var ngRepeatDirective = ['$parse', '$animate', '$compile', function($parse, $animate, $compile) { var NG_REMOVED = '$$NG_REMOVED'; var ngRepeatMinErr = minErr('ngRepeat'); + + var updateScope = function(scope, index, valueIdentifier, value, keyIdentifier, key, arrayLength) { + // TODO(perf): generate setters to shave off ~40ms or 1-1.5% + scope[valueIdentifier] = value; + if (keyIdentifier) scope[keyIdentifier] = key; + scope.$index = index; + scope.$first = (index === 0); + scope.$last = (index === (arrayLength - 1)); + scope.$middle = !(scope.$first || scope.$last); + // eslint-disable-next-line no-bitwise + scope.$odd = !(scope.$even = (index & 1) === 0); + }; + + var getBlockStart = function(block) { + return block.clone[0]; + }; + + var getBlockEnd = function(block) { + return block.clone[block.clone.length - 1]; + }; + + return { + restrict: 'A', + multiElement: true, transclude: 'element', priority: 1000, terminal: true, $$tlb: true, - link: function($scope, $element, $attr, ctrl, $transclude){ + compile: function ngRepeatCompile($element, $attr) { var expression = $attr.ngRepeat; - var match = expression.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?\s*$/), - trackByExp, trackByExpGetter, trackByIdExpFn, trackByIdArrayFn, trackByIdObjFn, - lhs, rhs, valueIdentifier, keyIdentifier, - hashFnLocals = {$id: hashKey}; + var ngRepeatEndComment = $compile.$$createComment('end ngRepeat', expression); + + var match = expression.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+track\s+by\s+([\s\S]+?))?\s*$/); if (!match) { - throw ngRepeatMinErr('iexp', "Expected expression in form of '_item_ in _collection_[ track by _id_]' but got '{0}'.", + throw ngRepeatMinErr('iexp', 'Expected expression in form of \'_item_ in _collection_[ track by _id_]\' but got \'{0}\'.', expression); } - lhs = match[1]; - rhs = match[2]; - trackByExp = match[3]; + var lhs = match[1]; + var rhs = match[2]; + var aliasAs = match[3]; + var trackByExp = match[4]; + + match = lhs.match(/^(?:(\s*[$\w]+)|\(\s*([$\w]+)\s*,\s*([$\w]+)\s*\))$/); + + if (!match) { + throw ngRepeatMinErr('iidexp', '\'_item_\' in \'_item_ in _collection_\' should be an identifier or \'(_key_, _value_)\' expression, but got \'{0}\'.', + lhs); + } + var valueIdentifier = match[3] || match[1]; + var keyIdentifier = match[2]; + + if (aliasAs && (!/^[$a-zA-Z_][$a-zA-Z0-9_]*$/.test(aliasAs) || + /^(null|undefined|this|\$index|\$first|\$middle|\$last|\$even|\$odd|\$parent|\$root|\$id)$/.test(aliasAs))) { + throw ngRepeatMinErr('badident', 'alias \'{0}\' is invalid --- must be a valid JS identifier which is not a reserved name.', + aliasAs); + } + + var trackByExpGetter, trackByIdExpFn, trackByIdArrayFn, trackByIdObjFn; + var hashFnLocals = {$id: hashKey}; if (trackByExp) { trackByExpGetter = $parse(trackByExp); - trackByIdExpFn = function(key, value, index) { - // assign key, value, and $index to the locals so that they can be used in hash functions - if (keyIdentifier) hashFnLocals[keyIdentifier] = key; - hashFnLocals[valueIdentifier] = value; - hashFnLocals.$index = index; - return trackByExpGetter($scope, hashFnLocals); - }; } else { trackByIdArrayFn = function(key, value) { return hashKey(value); @@ -19954,171 +31805,172 @@ }; } - match = lhs.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/); - if (!match) { - throw ngRepeatMinErr('iidexp', "'_item_' in '_item_ in _collection_' should be an identifier or '(_key_, _value_)' expression, but got '{0}'.", - lhs); - } - valueIdentifier = match[3] || match[1]; - keyIdentifier = match[2]; - - // Store a list of elements from previous run. This is a hash where key is the item from the - // iterator, and the value is objects with following properties. - // - scope: bound scope - // - element: previous element. - // - index: position - var lastBlockMap = {}; - - //watch props - $scope.$watchCollection(rhs, function ngRepeatAction(collection){ - var index, length, - previousNode = $element[0], // current position of the node - nextNode, - // Same as lastBlockMap but it has the current state. It will become the - // lastBlockMap on the next iteration. - nextBlockMap = {}, - arrayLength, - childScope, - key, value, // key/value of iteration - trackById, - trackByIdFn, - collectionKeys, - block, // last object information {scope, element, id} - nextBlockOrder = [], - elementsToRemove; - - - if (isArrayLike(collection)) { - collectionKeys = collection; - trackByIdFn = trackByIdExpFn || trackByIdArrayFn; - } else { - trackByIdFn = trackByIdExpFn || trackByIdObjFn; - // if object, extract keys, sort them and use to determine order of iteration over obj props - collectionKeys = []; - for (key in collection) { - if (collection.hasOwnProperty(key) && key.charAt(0) != '$') { - collectionKeys.push(key); - } + return function ngRepeatLink($scope, $element, $attr, ctrl, $transclude) { + + if (trackByExpGetter) { + trackByIdExpFn = function(key, value, index) { + // assign key, value, and $index to the locals so that they can be used in hash functions + if (keyIdentifier) hashFnLocals[keyIdentifier] = key; + hashFnLocals[valueIdentifier] = value; + hashFnLocals.$index = index; + return trackByExpGetter($scope, hashFnLocals); + }; + } + + // Store a list of elements from previous run. This is a hash where key is the item from the + // iterator, and the value is objects with following properties. + // - scope: bound scope + // - clone: previous element. + // - index: position + // + // We are using no-proto object so that we don't need to guard against inherited props via + // hasOwnProperty. + var lastBlockMap = createMap(); + + //watch props + $scope.$watchCollection(rhs, function ngRepeatAction(collection) { + var index, length, + previousNode = $element[0], // node that cloned nodes should be inserted after + // initialized to the comment node anchor + nextNode, + // Same as lastBlockMap but it has the current state. It will become the + // lastBlockMap on the next iteration. + nextBlockMap = createMap(), + collectionLength, + key, value, // key/value of iteration + trackById, + trackByIdFn, + collectionKeys, + block, // last object information {scope, element, id} + nextBlockOrder, + elementsToRemove; + + if (aliasAs) { + $scope[aliasAs] = collection; } - collectionKeys.sort(); - } - - arrayLength = collectionKeys.length; - - // locate existing items - length = nextBlockOrder.length = collectionKeys.length; - for(index = 0; index < length; index++) { - key = (collection === collectionKeys) ? index : collectionKeys[index]; - value = collection[key]; - trackById = trackByIdFn(key, value, index); - assertNotHasOwnProperty(trackById, '`track by` id'); - if(lastBlockMap.hasOwnProperty(trackById)) { - block = lastBlockMap[trackById]; - delete lastBlockMap[trackById]; - nextBlockMap[trackById] = block; - nextBlockOrder[index] = block; - } else if (nextBlockMap.hasOwnProperty(trackById)) { - // restore lastBlockMap - forEach(nextBlockOrder, function(block) { - if (block && block.scope) lastBlockMap[block.id] = block; - }); - // This is a duplicate and we need to throw an error - throw ngRepeatMinErr('dupes', "Duplicates in a repeater are not allowed. Use 'track by' expression to specify unique keys. Repeater: {0}, Duplicate key: {1}", - expression, trackById); + + if (isArrayLike(collection)) { + collectionKeys = collection; + trackByIdFn = trackByIdExpFn || trackByIdArrayFn; } else { - // new never before seen block - nextBlockOrder[index] = { id: trackById }; - nextBlockMap[trackById] = false; + trackByIdFn = trackByIdExpFn || trackByIdObjFn; + // if object, extract keys, in enumeration order, unsorted + collectionKeys = []; + for (var itemKey in collection) { + if (hasOwnProperty.call(collection, itemKey) && itemKey.charAt(0) !== '$') { + collectionKeys.push(itemKey); + } + } } - } - // remove existing items - for (key in lastBlockMap) { - // lastBlockMap is our own object so we don't need to use special hasOwnPropertyFn - if (lastBlockMap.hasOwnProperty(key)) { - block = lastBlockMap[key]; - elementsToRemove = getBlockElements(block.clone); + collectionLength = collectionKeys.length; + nextBlockOrder = new Array(collectionLength); + + // locate existing items + for (index = 0; index < collectionLength; index++) { + key = (collection === collectionKeys) ? index : collectionKeys[index]; + value = collection[key]; + trackById = trackByIdFn(key, value, index); + if (lastBlockMap[trackById]) { + // found previously seen block + block = lastBlockMap[trackById]; + delete lastBlockMap[trackById]; + nextBlockMap[trackById] = block; + nextBlockOrder[index] = block; + } else if (nextBlockMap[trackById]) { + // if collision detected. restore lastBlockMap and throw an error + forEach(nextBlockOrder, function(block) { + if (block && block.scope) lastBlockMap[block.id] = block; + }); + throw ngRepeatMinErr('dupes', + 'Duplicates in a repeater are not allowed. Use \'track by\' expression to specify unique keys. Repeater: {0}, Duplicate key: {1}, Duplicate value: {2}', + expression, trackById, value); + } else { + // new never before seen block + nextBlockOrder[index] = {id: trackById, scope: undefined, clone: undefined}; + nextBlockMap[trackById] = true; + } + } + + // remove leftover items + for (var blockKey in lastBlockMap) { + block = lastBlockMap[blockKey]; + elementsToRemove = getBlockNodes(block.clone); $animate.leave(elementsToRemove); - forEach(elementsToRemove, function(element) { element[NG_REMOVED] = true; }); + if (elementsToRemove[0].parentNode) { + // if the element was not removed yet because of pending animation, mark it as deleted + // so that we can ignore it later + for (index = 0, length = elementsToRemove.length; index < length; index++) { + elementsToRemove[index][NG_REMOVED] = true; + } + } block.scope.$destroy(); } - } - // we are not using forEach for perf reasons (trying to avoid #call) - for (index = 0, length = collectionKeys.length; index < length; index++) { - key = (collection === collectionKeys) ? index : collectionKeys[index]; - value = collection[key]; - block = nextBlockOrder[index]; - if (nextBlockOrder[index - 1]) previousNode = getBlockEnd(nextBlockOrder[index - 1]); + // we are not using forEach for perf reasons (trying to avoid #call) + for (index = 0; index < collectionLength; index++) { + key = (collection === collectionKeys) ? index : collectionKeys[index]; + value = collection[key]; + block = nextBlockOrder[index]; - if (block.scope) { - // if we have already seen this object, then we need to reuse the - // associated scope/element - childScope = block.scope; + if (block.scope) { + // if we have already seen this object, then we need to reuse the + // associated scope/element - nextNode = previousNode; - do { - nextNode = nextNode.nextSibling; - } while(nextNode && nextNode[NG_REMOVED]); + nextNode = previousNode; - if (getBlockStart(block) != nextNode) { - // existing item which got moved - $animate.move(getBlockElements(block.clone), null, jqLite(previousNode)); - } - previousNode = getBlockEnd(block); - } else { - // new item which we don't know about - childScope = $scope.$new(); - } + // skip nodes that are already pending removal via leave animation + do { + nextNode = nextNode.nextSibling; + } while (nextNode && nextNode[NG_REMOVED]); - childScope[valueIdentifier] = value; - if (keyIdentifier) childScope[keyIdentifier] = key; - childScope.$index = index; - childScope.$first = (index === 0); - childScope.$last = (index === (arrayLength - 1)); - childScope.$middle = !(childScope.$first || childScope.$last); - // jshint bitwise: false - childScope.$odd = !(childScope.$even = (index&1) === 0); - // jshint bitwise: true - - if (!block.scope) { - $transclude(childScope, function(clone) { - clone[clone.length++] = document.createComment(' end ngRepeat: ' + expression + ' '); - $animate.enter(clone, null, jqLite(previousNode)); - previousNode = clone; - block.scope = childScope; - // Note: We only need the first/last node of the cloned nodes. - // However, we need to keep the reference to the jqlite wrapper as it might be changed later - // by a directive with templateUrl when it's template arrives. - block.clone = clone; - nextBlockMap[block.id] = block; - }); + if (getBlockStart(block) !== nextNode) { + // existing item which got moved + $animate.move(getBlockNodes(block.clone), null, previousNode); + } + previousNode = getBlockEnd(block); + updateScope(block.scope, index, valueIdentifier, value, keyIdentifier, key, collectionLength); + } else { + // new item which we don't know about + $transclude(function ngRepeatTransclude(clone, scope) { + block.scope = scope; + // http://jsperf.com/clone-vs-createcomment + var endNode = ngRepeatEndComment.cloneNode(false); + clone[clone.length++] = endNode; + + $animate.enter(clone, null, previousNode); + previousNode = endNode; + // Note: We only need the first/last node of the cloned nodes. + // However, we need to keep the reference to the jqlite wrapper as it might be changed later + // by a directive with templateUrl when its template arrives. + block.clone = clone; + nextBlockMap[block.id] = block; + updateScope(block.scope, index, valueIdentifier, value, keyIdentifier, key, collectionLength); + }); + } } - } - lastBlockMap = nextBlockMap; - }); + lastBlockMap = nextBlockMap; + }); + }; } }; - - function getBlockStart(block) { - return block.clone[0]; - } - - function getBlockEnd(block) { - return block.clone[block.clone.length - 1]; - } }]; + var NG_HIDE_CLASS = 'ng-hide'; + var NG_HIDE_IN_PROGRESS_CLASS = 'ng-hide-animate'; /** * @ngdoc directive * @name ngShow + * @multiElement * * @description - * The `ngShow` directive shows or hides the given HTML element based on the expression - * provided to the ngShow attribute. The element is shown or hidden by removing or adding - * the `ng-hide` CSS class onto the element. The `.ng-hide` CSS class is predefined - * in AngularJS and sets the display style to none (using an !important flag). - * For CSP mode please add `angular-csp.css` to your html file (see {@link ng.directive:ngCsp ngCsp}). + * The `ngShow` directive shows or hides the given HTML element based on the expression provided to + * the `ngShow` attribute. + * + * The element is shown or hidden by removing or adding the `.ng-hide` CSS class onto the element. + * The `.ng-hide` CSS class is predefined in AngularJS and sets the display style to none (using an + * `!important` flag). For CSP mode please add `angular-csp.css` to your HTML file (see + * {@link ng.directive:ngCsp ngCsp}). * * ```html * <!-- when $scope.myValue is truthy (element is visible) --> @@ -20128,59 +31980,58 @@ * <div ng-show="myValue" class="ng-hide"></div> * ``` * - * When the ngShow expression evaluates to false then the ng-hide CSS class is added to the class attribute - * on the element causing it to become hidden. When true, the ng-hide CSS class is removed - * from the element causing the element not to appear hidden. + * When the `ngShow` expression evaluates to a falsy value then the `.ng-hide` CSS class is added + * to the class attribute on the element causing it to become hidden. When truthy, the `.ng-hide` + * CSS class is removed from the element causing the element not to appear hidden. * - * ## Why is !important used? + * ## Why is `!important` used? * - * You may be wondering why !important is used for the .ng-hide CSS class. This is because the `.ng-hide` selector - * can be easily overridden by heavier selectors. For example, something as simple - * as changing the display style on a HTML list item would make hidden elements appear visible. - * This also becomes a bigger issue when dealing with CSS frameworks. + * You may be wondering why `!important` is used for the `.ng-hide` CSS class. This is because the + * `.ng-hide` selector can be easily overridden by heavier selectors. For example, something as + * simple as changing the display style on a HTML list item would make hidden elements appear + * visible. This also becomes a bigger issue when dealing with CSS frameworks. * - * By using !important, the show and hide behavior will work as expected despite any clash between CSS selector - * specificity (when !important isn't used with any conflicting styles). If a developer chooses to override the - * styling to change how to hide an element then it is just a matter of using !important in their own CSS code. + * By using `!important`, the show and hide behavior will work as expected despite any clash between + * CSS selector specificity (when `!important` isn't used with any conflicting styles). If a + * developer chooses to override the styling to change how to hide an element then it is just a + * matter of using `!important` in their own CSS code. * - * ### Overriding .ng-hide + * ### Overriding `.ng-hide` + * + * By default, the `.ng-hide` class will style the element with `display: none !important`. If you + * wish to change the hide behavior with `ngShow`/`ngHide`, you can simply overwrite the styles for + * the `.ng-hide` CSS class. Note that the selector that needs to be used is actually + * `.ng-hide:not(.ng-hide-animate)` to cope with extra animation classes that can be added. * - * If you wish to change the hide behavior with ngShow/ngHide then this can be achieved by - * restating the styles for the .ng-hide class in CSS: * ```css - * .ng-hide { - * //!annotate CSS Specificity|Not to worry, this will override the AngularJS default... - * display:block!important; - * - * //this is just another form of hiding an element - * position:absolute; - * top:-9999px; - * left:-9999px; - * } + * .ng-hide:not(.ng-hide-animate) { + * /* These are just alternative ways of hiding an element */ + * display: block!important; + * position: absolute; + * top: -9999px; + * left: -9999px; + * } * ``` * - * Just remember to include the important flag so the CSS override will function. + * By default you don't need to override anything in CSS and the animations will work around the + * display style. * - * <div class="alert alert-warning"> - * **Note:** Here is a list of values that ngShow will consider as a falsy value (case insensitive):<br /> - * "f" / "0" / "false" / "no" / "n" / "[]" - * </div> - * - * ## A note about animations with ngShow + * @animations + * | Animation | Occurs | + * |-----------------------------------------------------|---------------------------------------------------------------------------------------------------------------| + * | {@link $animate#addClass addClass} `.ng-hide` | After the `ngShow` expression evaluates to a non truthy value and just before the contents are set to hidden. | + * | {@link $animate#removeClass removeClass} `.ng-hide` | After the `ngShow` expression evaluates to a truthy value and just before contents are set to visible. | * - * Animations in ngShow/ngHide work with the show and hide events that are triggered when the directive expression - * is true and false. This system works like the animation system present with ngClass except that - * you must also include the !important flag to override the display property - * so that you can perform an animation when the element is hidden during the time of the animation. + * Animations in `ngShow`/`ngHide` work with the show and hide events that are triggered when the + * directive expression is true and false. This system works like the animation system present with + * `ngClass` except that you must also include the `!important` flag to override the display + * property so that the elements are not actually hidden during the animation. * * ```css - * // - * //a working example can be found at the bottom of this page - * // + * /* A working example can be found at the bottom of this page. */ * .my-element.ng-hide-add, .my-element.ng-hide-remove { - * transition:0.5s linear all; - * display:block!important; - * } + * transition: all 0.5s linear; + * } * * .my-element.ng-hide-add { ... } * .my-element.ng-hide-add.ng-hide-add-active { ... } @@ -20188,83 +32039,121 @@ * .my-element.ng-hide-remove.ng-hide-remove-active { ... } * ``` * - * @animations - * addClass: .ng-hide - happens after the ngShow expression evaluates to a truthy value and the just before contents are set to visible - * removeClass: .ng-hide - happens after the ngShow expression evaluates to a non truthy value and just before the contents are set to hidden + * Keep in mind that, as of AngularJS version 1.3, there is no need to change the display property + * to block during animation states - ngAnimate will automatically handle the style toggling for you. * * @element ANY - * @param {expression} ngShow If the {@link guide/expression expression} is truthy - * then the element is shown or hidden respectively. + * @param {expression} ngShow If the {@link guide/expression expression} is truthy/falsy then the + * element is shown/hidden respectively. * * @example - <example module="ngAnimate" deps="angular-animate.js" animations="true"> + * A simple example, animating the element's opacity: + * + <example module="ngAnimate" deps="angular-animate.js" animations="true" name="ng-show-simple"> <file name="index.html"> - Click me: <input type="checkbox" ng-model="checked"><br/> - <div> - Show: - <div class="check-element animate-show" ng-show="checked"> - <span class="glyphicon glyphicon-thumbs-up"></span> I show up when your checkbox is checked. - </div> - </div> - <div> - Hide: - <div class="check-element animate-show" ng-hide="checked"> - <span class="glyphicon glyphicon-thumbs-down"></span> I hide when your checkbox is checked. - </div> + Show: <input type="checkbox" ng-model="checked" aria-label="Toggle ngShow"><br /> + <div class="check-element animate-show-hide" ng-show="checked"> + I show up when your checkbox is checked. </div> </file> - <file name="glyphicons.css"> - @import url(//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap-glyphicons.css); + <file name="animations.css"> + .animate-show-hide.ng-hide { + opacity: 0; + } + + .animate-show-hide.ng-hide-add, + .animate-show-hide.ng-hide-remove { + transition: all linear 0.5s; + } + + .check-element { + border: 1px solid black; + opacity: 1; + padding: 10px; + } + </file> + <file name="protractor.js" type="protractor"> + it('should check ngShow', function() { + var checkbox = element(by.model('checked')); + var checkElem = element(by.css('.check-element')); + + expect(checkElem.isDisplayed()).toBe(false); + checkbox.click(); + expect(checkElem.isDisplayed()).toBe(true); + }); + </file> + </example> + * + * <hr /> + * @example + * A more complex example, featuring different show/hide animations: + * + <example module="ngAnimate" deps="angular-animate.js" animations="true" name="ng-show-complex"> + <file name="index.html"> + Show: <input type="checkbox" ng-model="checked" aria-label="Toggle ngShow"><br /> + <div class="check-element funky-show-hide" ng-show="checked"> + I show up when your checkbox is checked. + </div> </file> <file name="animations.css"> - .animate-show { - -webkit-transition:all linear 0.5s; - transition:all linear 0.5s; - line-height:20px; - opacity:1; - padding:10px; - border:1px solid black; - background:white; + body { + overflow: hidden; + perspective: 1000px; } - .animate-show.ng-hide-add, - .animate-show.ng-hide-remove { - display:block!important; + .funky-show-hide.ng-hide-add { + transform: rotateZ(0); + transform-origin: right; + transition: all 0.5s ease-in-out; } - .animate-show.ng-hide { - line-height:0; - opacity:0; - padding:0 10px; + .funky-show-hide.ng-hide-add.ng-hide-add-active { + transform: rotateZ(-135deg); + } + + .funky-show-hide.ng-hide-remove { + transform: rotateY(90deg); + transform-origin: left; + transition: all 0.5s ease; + } + + .funky-show-hide.ng-hide-remove.ng-hide-remove-active { + transform: rotateY(0); } .check-element { - padding:10px; - border:1px solid black; - background:white; + border: 1px solid black; + opacity: 1; + padding: 10px; } </file> <file name="protractor.js" type="protractor"> - var thumbsUp = element(by.css('span.glyphicon-thumbs-up')); - var thumbsDown = element(by.css('span.glyphicon-thumbs-down')); - - it('should check ng-show / ng-hide', function() { - expect(thumbsUp.isDisplayed()).toBeFalsy(); - expect(thumbsDown.isDisplayed()).toBeTruthy(); + it('should check ngShow', function() { + var checkbox = element(by.model('checked')); + var checkElem = element(by.css('.check-element')); - element(by.model('checked')).click(); - - expect(thumbsUp.isDisplayed()).toBeTruthy(); - expect(thumbsDown.isDisplayed()).toBeFalsy(); + expect(checkElem.isDisplayed()).toBe(false); + checkbox.click(); + expect(checkElem.isDisplayed()).toBe(true); }); </file> </example> */ var ngShowDirective = ['$animate', function($animate) { - return function(scope, element, attr) { - scope.$watch(attr.ngShow, function ngShowWatchAction(value){ - $animate[toBoolean(value) ? 'removeClass' : 'addClass'](element, 'ng-hide'); - }); + return { + restrict: 'A', + multiElement: true, + link: function(scope, element, attr) { + scope.$watch(attr.ngShow, function ngShowWatchAction(value) { + // we're adding a temporary, animation-specific class for ng-hide since this way + // we can control when the element is actually displayed on screen without having + // to have a global/greedy CSS selector that breaks when other animations are run. + // Read: https://github.com/angular/angular.js/issues/9103#issuecomment-58335845 + $animate[value ? 'removeClass' : 'addClass'](element, NG_HIDE_CLASS, { + tempClasses: NG_HIDE_IN_PROGRESS_CLASS + }); + }); + } }; }]; @@ -20272,75 +32161,77 @@ /** * @ngdoc directive * @name ngHide + * @multiElement * * @description - * The `ngHide` directive shows or hides the given HTML element based on the expression - * provided to the ngHide attribute. The element is shown or hidden by removing or adding - * the `ng-hide` CSS class onto the element. The `.ng-hide` CSS class is predefined - * in AngularJS and sets the display style to none (using an !important flag). - * For CSP mode please add `angular-csp.css` to your html file (see {@link ng.directive:ngCsp ngCsp}). + * The `ngHide` directive shows or hides the given HTML element based on the expression provided to + * the `ngHide` attribute. + * + * The element is shown or hidden by removing or adding the `.ng-hide` CSS class onto the element. + * The `.ng-hide` CSS class is predefined in AngularJS and sets the display style to none (using an + * `!important` flag). For CSP mode please add `angular-csp.css` to your HTML file (see + * {@link ng.directive:ngCsp ngCsp}). * * ```html * <!-- when $scope.myValue is truthy (element is hidden) --> - * <div ng-hide="myValue"></div> + * <div ng-hide="myValue" class="ng-hide"></div> * * <!-- when $scope.myValue is falsy (element is visible) --> - * <div ng-hide="myValue" class="ng-hide"></div> + * <div ng-hide="myValue"></div> * ``` * - * When the ngHide expression evaluates to true then the .ng-hide CSS class is added to the class attribute - * on the element causing it to become hidden. When false, the ng-hide CSS class is removed - * from the element causing the element not to appear hidden. + * When the `ngHide` expression evaluates to a truthy value then the `.ng-hide` CSS class is added + * to the class attribute on the element causing it to become hidden. When falsy, the `.ng-hide` + * CSS class is removed from the element causing the element not to appear hidden. * - * ## Why is !important used? + * ## Why is `!important` used? * - * You may be wondering why !important is used for the .ng-hide CSS class. This is because the `.ng-hide` selector - * can be easily overridden by heavier selectors. For example, something as simple - * as changing the display style on a HTML list item would make hidden elements appear visible. - * This also becomes a bigger issue when dealing with CSS frameworks. + * You may be wondering why `!important` is used for the `.ng-hide` CSS class. This is because the + * `.ng-hide` selector can be easily overridden by heavier selectors. For example, something as + * simple as changing the display style on a HTML list item would make hidden elements appear + * visible. This also becomes a bigger issue when dealing with CSS frameworks. * - * By using !important, the show and hide behavior will work as expected despite any clash between CSS selector - * specificity (when !important isn't used with any conflicting styles). If a developer chooses to override the - * styling to change how to hide an element then it is just a matter of using !important in their own CSS code. + * By using `!important`, the show and hide behavior will work as expected despite any clash between + * CSS selector specificity (when `!important` isn't used with any conflicting styles). If a + * developer chooses to override the styling to change how to hide an element then it is just a + * matter of using `!important` in their own CSS code. * - * ### Overriding .ng-hide + * ### Overriding `.ng-hide` + * + * By default, the `.ng-hide` class will style the element with `display: none !important`. If you + * wish to change the hide behavior with `ngShow`/`ngHide`, you can simply overwrite the styles for + * the `.ng-hide` CSS class. Note that the selector that needs to be used is actually + * `.ng-hide:not(.ng-hide-animate)` to cope with extra animation classes that can be added. * - * If you wish to change the hide behavior with ngShow/ngHide then this can be achieved by - * restating the styles for the .ng-hide class in CSS: * ```css - * .ng-hide { - * //!annotate CSS Specificity|Not to worry, this will override the AngularJS default... - * display:block!important; - * - * //this is just another form of hiding an element - * position:absolute; - * top:-9999px; - * left:-9999px; - * } + * .ng-hide:not(.ng-hide-animate) { + * /* These are just alternative ways of hiding an element */ + * display: block!important; + * position: absolute; + * top: -9999px; + * left: -9999px; + * } * ``` * - * Just remember to include the important flag so the CSS override will function. - * - * <div class="alert alert-warning"> - * **Note:** Here is a list of values that ngHide will consider as a falsy value (case insensitive):<br /> - * "f" / "0" / "false" / "no" / "n" / "[]" - * </div> + * By default you don't need to override in CSS anything and the animations will work around the + * display style. * - * ## A note about animations with ngHide + * @animations + * | Animation | Occurs | + * |-----------------------------------------------------|------------------------------------------------------------------------------------------------------------| + * | {@link $animate#addClass addClass} `.ng-hide` | After the `ngHide` expression evaluates to a truthy value and just before the contents are set to hidden. | + * | {@link $animate#removeClass removeClass} `.ng-hide` | After the `ngHide` expression evaluates to a non truthy value and just before contents are set to visible. | * - * Animations in ngShow/ngHide work with the show and hide events that are triggered when the directive expression - * is true and false. This system works like the animation system present with ngClass, except that - * you must also include the !important flag to override the display property so - * that you can perform an animation when the element is hidden during the time of the animation. + * Animations in `ngShow`/`ngHide` work with the show and hide events that are triggered when the + * directive expression is true and false. This system works like the animation system present with + * `ngClass` except that you must also include the `!important` flag to override the display + * property so that the elements are not actually hidden during the animation. * * ```css - * // - * //a working example can be found at the bottom of this page - * // + * /* A working example can be found at the bottom of this page. */ * .my-element.ng-hide-add, .my-element.ng-hide-remove { - * transition:0.5s linear all; - * display:block!important; - * } + * transition: all 0.5s linear; + * } * * .my-element.ng-hide-add { ... } * .my-element.ng-hide-add.ng-hide-add-active { ... } @@ -20348,83 +32239,119 @@ * .my-element.ng-hide-remove.ng-hide-remove-active { ... } * ``` * - * @animations - * removeClass: .ng-hide - happens after the ngHide expression evaluates to a truthy value and just before the contents are set to hidden - * addClass: .ng-hide - happens after the ngHide expression evaluates to a non truthy value and just before the contents are set to visible + * Keep in mind that, as of AngularJS version 1.3, there is no need to change the display property + * to block during animation states - ngAnimate will automatically handle the style toggling for you. * * @element ANY - * @param {expression} ngHide If the {@link guide/expression expression} is truthy then - * the element is shown or hidden respectively. + * @param {expression} ngHide If the {@link guide/expression expression} is truthy/falsy then the + * element is hidden/shown respectively. * * @example - <example module="ngAnimate" deps="angular-animate.js" animations="true"> + * A simple example, animating the element's opacity: + * + <example module="ngAnimate" deps="angular-animate.js" animations="true" name="ng-hide-simple"> <file name="index.html"> - Click me: <input type="checkbox" ng-model="checked"><br/> - <div> - Show: - <div class="check-element animate-hide" ng-show="checked"> - <span class="glyphicon glyphicon-thumbs-up"></span> I show up when your checkbox is checked. - </div> - </div> - <div> - Hide: - <div class="check-element animate-hide" ng-hide="checked"> - <span class="glyphicon glyphicon-thumbs-down"></span> I hide when your checkbox is checked. - </div> + Hide: <input type="checkbox" ng-model="checked" aria-label="Toggle ngHide"><br /> + <div class="check-element animate-show-hide" ng-hide="checked"> + I hide when your checkbox is checked. </div> </file> - <file name="glyphicons.css"> - @import url(//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap-glyphicons.css); + <file name="animations.css"> + .animate-show-hide.ng-hide { + opacity: 0; + } + + .animate-show-hide.ng-hide-add, + .animate-show-hide.ng-hide-remove { + transition: all linear 0.5s; + } + + .check-element { + border: 1px solid black; + opacity: 1; + padding: 10px; + } + </file> + <file name="protractor.js" type="protractor"> + it('should check ngHide', function() { + var checkbox = element(by.model('checked')); + var checkElem = element(by.css('.check-element')); + + expect(checkElem.isDisplayed()).toBe(true); + checkbox.click(); + expect(checkElem.isDisplayed()).toBe(false); + }); + </file> + </example> + * + * <hr /> + * @example + * A more complex example, featuring different show/hide animations: + * + <example module="ngAnimate" deps="angular-animate.js" animations="true" name="ng-hide-complex"> + <file name="index.html"> + Hide: <input type="checkbox" ng-model="checked" aria-label="Toggle ngHide"><br /> + <div class="check-element funky-show-hide" ng-hide="checked"> + I hide when your checkbox is checked. + </div> </file> <file name="animations.css"> - .animate-hide { - -webkit-transition:all linear 0.5s; - transition:all linear 0.5s; - line-height:20px; - opacity:1; - padding:10px; - border:1px solid black; - background:white; + body { + overflow: hidden; + perspective: 1000px; } - .animate-hide.ng-hide-add, - .animate-hide.ng-hide-remove { - display:block!important; + .funky-show-hide.ng-hide-add { + transform: rotateZ(0); + transform-origin: right; + transition: all 0.5s ease-in-out; } - .animate-hide.ng-hide { - line-height:0; - opacity:0; - padding:0 10px; + .funky-show-hide.ng-hide-add.ng-hide-add-active { + transform: rotateZ(-135deg); + } + + .funky-show-hide.ng-hide-remove { + transform: rotateY(90deg); + transform-origin: left; + transition: all 0.5s ease; + } + + .funky-show-hide.ng-hide-remove.ng-hide-remove-active { + transform: rotateY(0); } .check-element { - padding:10px; - border:1px solid black; - background:white; + border: 1px solid black; + opacity: 1; + padding: 10px; } </file> <file name="protractor.js" type="protractor"> - var thumbsUp = element(by.css('span.glyphicon-thumbs-up')); - var thumbsDown = element(by.css('span.glyphicon-thumbs-down')); - - it('should check ng-show / ng-hide', function() { - expect(thumbsUp.isDisplayed()).toBeFalsy(); - expect(thumbsDown.isDisplayed()).toBeTruthy(); + it('should check ngHide', function() { + var checkbox = element(by.model('checked')); + var checkElem = element(by.css('.check-element')); - element(by.model('checked')).click(); - - expect(thumbsUp.isDisplayed()).toBeTruthy(); - expect(thumbsDown.isDisplayed()).toBeFalsy(); + expect(checkElem.isDisplayed()).toBe(true); + checkbox.click(); + expect(checkElem.isDisplayed()).toBe(false); }); </file> </example> */ var ngHideDirective = ['$animate', function($animate) { - return function(scope, element, attr) { - scope.$watch(attr.ngHide, function ngHideWatchAction(value){ - $animate[toBoolean(value) ? 'addClass' : 'removeClass'](element, 'ng-hide'); - }); + return { + restrict: 'A', + multiElement: true, + link: function(scope, element, attr) { + scope.$watch(attr.ngHide, function ngHideWatchAction(value) { + // The comment inside of the ngShowDirective explains why we add and + // remove a temporary class for the show/hide animation + $animate[value ? 'addClass' : 'removeClass'](element,NG_HIDE_CLASS, { + tempClasses: NG_HIDE_IN_PROGRESS_CLASS + }); + }); + } }; }]; @@ -20436,15 +32363,26 @@ * @description * The `ngStyle` directive allows you to set CSS style on an HTML element conditionally. * + * @knownIssue + * You should not use {@link guide/interpolation interpolation} in the value of the `style` + * attribute, when using the `ngStyle` directive on the same element. + * See {@link guide/interpolation#known-issues here} for more info. + * * @element ANY - * @param {expression} ngStyle {@link guide/expression Expression} which evals to an - * object whose keys are CSS style names and values are corresponding values for those CSS - * keys. + * @param {expression} ngStyle + * + * {@link guide/expression Expression} which evals to an + * object whose keys are CSS style names and values are corresponding values for those CSS + * keys. + * + * Since some CSS style names are not valid keys for an object, they must be quoted. + * See the 'background-color' style in the example below. * * @example - <example> + <example name="ng-style"> <file name="index.html"> - <input type="button" value="set" ng-click="myStyle={color:'red'}"> + <input type="button" value="set color" ng-click="myStyle={color:'red'}"> + <input type="button" value="set background" ng-click="myStyle={'background-color':'blue'}"> <input type="button" value="clear" ng-click="myStyle={}"> <br/> <span ng-style="myStyle">Sample Text</span> @@ -20460,7 +32398,7 @@ it('should check ng-style', function() { expect(colorSpan.getCssValue('color')).toBe('rgba(0, 0, 0, 1)'); - element(by.css('input[value=set]')).click(); + element(by.css('input[value=\'set color\']')).click(); expect(colorSpan.getCssValue('color')).toBe('rgba(255, 0, 0, 1)'); element(by.css('input[value=clear]')).click(); expect(colorSpan.getCssValue('color')).toBe('rgba(0, 0, 0, 1)'); @@ -20504,51 +32442,61 @@ * </div> * @animations - * enter - happens after the ngSwitch contents change and the matched child element is placed inside the container - * leave - happens just after the ngSwitch contents change and just before the former contents are removed from the DOM + * | Animation | Occurs | + * |----------------------------------|-------------------------------------| + * | {@link ng.$animate#enter enter} | after the ngSwitch contents change and the matched child element is placed inside the container | + * | {@link ng.$animate#leave leave} | after the ngSwitch contents change and just before the former contents are removed from the DOM | * * @usage + * + * ``` * <ANY ng-switch="expression"> * <ANY ng-switch-when="matchValue1">...</ANY> * <ANY ng-switch-when="matchValue2">...</ANY> * <ANY ng-switch-default>...</ANY> * </ANY> + * ``` * * * @scope - * @priority 800 - * @param {*} ngSwitch|on expression to match against <tt>ng-switch-when</tt>. + * @priority 1200 + * @param {*} ngSwitch|on expression to match against <code>ng-switch-when</code>. * On child elements add: * * * `ngSwitchWhen`: the case statement to match against. If match then this * case will be displayed. If the same match appears multiple times, all the - * elements will be displayed. + * elements will be displayed. It is possible to associate multiple values to + * the same `ngSwitchWhen` by defining the optional attribute + * `ngSwitchWhenSeparator`. The separator will be used to split the value of + * the `ngSwitchWhen` attribute into multiple tokens, and the element will show + * if any of the `ngSwitch` evaluates to any of these tokens. * * `ngSwitchDefault`: the default case when no other case match. If there * are multiple default cases, all of them will be displayed when no other * case match. * * * @example - <example module="ngAnimate" deps="angular-animate.js" animations="true"> + <example module="switchExample" deps="angular-animate.js" animations="true" name="ng-switch"> <file name="index.html"> - <div ng-controller="Ctrl"> + <div ng-controller="ExampleController"> <select ng-model="selection" ng-options="item for item in items"> </select> - <tt>selection={{selection}}</tt> + <code>selection={{selection}}</code> <hr/> <div class="animate-switch-container" ng-switch on="selection"> - <div class="animate-switch" ng-switch-when="settings">Settings Div</div> + <div class="animate-switch" ng-switch-when="settings|options" ng-switch-when-separator="|">Settings Div</div> <div class="animate-switch" ng-switch-when="home">Home Span</div> <div class="animate-switch" ng-switch-default>default</div> </div> </div> </file> <file name="script.js"> - function Ctrl($scope) { - $scope.items = ['settings', 'home', 'other']; - $scope.selection = $scope.items[0]; - } + angular.module('switchExample', ['ngAnimate']) + .controller('ExampleController', ['$scope', function($scope) { + $scope.items = ['settings', 'home', 'options', 'other']; + $scope.selection = $scope.items[0]; + }]); </file> <file name="animations.css"> .animate-switch-container { @@ -20564,7 +32512,6 @@ } .animate-switch.ng-animate { - -webkit-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; position:absolute; @@ -20591,68 +32538,68 @@ expect(switchElem.getText()).toMatch(/Settings Div/); }); it('should change to home', function() { - select.element.all(by.css('option')).get(1).click(); + select.all(by.css('option')).get(1).click(); expect(switchElem.getText()).toMatch(/Home Span/); }); + it('should change to settings via "options"', function() { + select.all(by.css('option')).get(2).click(); + expect(switchElem.getText()).toMatch(/Settings Div/); + }); it('should select default', function() { - select.element.all(by.css('option')).get(2).click(); + select.all(by.css('option')).get(3).click(); expect(switchElem.getText()).toMatch(/default/); }); </file> </example> */ - var ngSwitchDirective = ['$animate', function($animate) { + var ngSwitchDirective = ['$animate', '$compile', function($animate, $compile) { return { - restrict: 'EA', require: 'ngSwitch', // asks for $scope to fool the BC controller module - controller: ['$scope', function ngSwitchController() { + controller: ['$scope', function NgSwitchController() { this.cases = {}; }], link: function(scope, element, attr, ngSwitchController) { var watchExpr = attr.ngSwitch || attr.on, - selectedTranscludes, - selectedElements, - previousElements, + selectedTranscludes = [], + selectedElements = [], + previousLeaveAnimations = [], selectedScopes = []; + var spliceFactory = function(array, index) { + return function(response) { + if (response !== false) array.splice(index, 1); + }; + }; + scope.$watch(watchExpr, function ngSwitchWatchAction(value) { - var i, ii = selectedScopes.length; - if(ii > 0) { - if(previousElements) { - for (i = 0; i < ii; i++) { - previousElements[i].remove(); - } - previousElements = null; - } + var i, ii; - previousElements = []; - for (i= 0; i<ii; i++) { - var selected = selectedElements[i]; - selectedScopes[i].$destroy(); - previousElements[i] = selected; - $animate.leave(selected, function() { - previousElements.splice(i, 1); - if(previousElements.length === 0) { - previousElements = null; - } - }); - } + // Start with the last, in case the array is modified during the loop + while (previousLeaveAnimations.length) { + $animate.cancel(previousLeaveAnimations.pop()); } - selectedElements = []; - selectedScopes = []; + for (i = 0, ii = selectedScopes.length; i < ii; ++i) { + var selected = getBlockNodes(selectedElements[i].clone); + selectedScopes[i].$destroy(); + var runner = previousLeaveAnimations[i] = $animate.leave(selected); + runner.done(spliceFactory(previousLeaveAnimations, i)); + } + + selectedElements.length = 0; + selectedScopes.length = 0; if ((selectedTranscludes = ngSwitchController.cases['!' + value] || ngSwitchController.cases['?'])) { - scope.$eval(attr.change); forEach(selectedTranscludes, function(selectedTransclude) { - var selectedScope = scope.$new(); - selectedScopes.push(selectedScope); - selectedTransclude.transclude(selectedScope, function(caseElement) { + selectedTransclude.transclude(function(caseElement, selectedScope) { + selectedScopes.push(selectedScope); var anchor = selectedTransclude.element; + caseElement[caseElement.length++] = $compile.$$createComment('end ngSwitchWhen'); + var block = { clone: caseElement }; - selectedElements.push(caseElement); + selectedElements.push(block); $animate.enter(caseElement, anchor.parent(), anchor); }); }); @@ -20664,18 +32611,28 @@ var ngSwitchWhenDirective = ngDirective({ transclude: 'element', - priority: 800, + priority: 1200, require: '^ngSwitch', + multiElement: true, link: function(scope, element, attrs, ctrl, $transclude) { - ctrl.cases['!' + attrs.ngSwitchWhen] = (ctrl.cases['!' + attrs.ngSwitchWhen] || []); - ctrl.cases['!' + attrs.ngSwitchWhen].push({ transclude: $transclude, element: element }); + + var cases = attrs.ngSwitchWhen.split(attrs.ngSwitchWhenSeparator).sort().filter( + // Filter duplicate cases + function(element, index, array) { return array[index - 1] !== element; } + ); + + forEach(cases, function(whenCase) { + ctrl.cases['!' + whenCase] = (ctrl.cases['!' + whenCase] || []); + ctrl.cases['!' + whenCase].push({ transclude: $transclude, element: element }); + }); } }); var ngSwitchDefaultDirective = ngDirective({ transclude: 'element', - priority: 800, + priority: 1200, require: '^ngSwitch', + multiElement: true, link: function(scope, element, attr, ctrl, $transclude) { ctrl.cases['?'] = (ctrl.cases['?'] || []); ctrl.cases['?'].push({ transclude: $transclude, element: element }); @@ -20685,74 +32642,227 @@ /** * @ngdoc directive * @name ngTransclude - * @restrict AC + * @restrict EAC * * @description * Directive that marks the insertion point for the transcluded DOM of the nearest parent directive that uses transclusion. * - * Any existing content of the element that this directive is placed on will be removed before the transcluded content is inserted. + * You can specify that you want to insert a named transclusion slot, instead of the default slot, by providing the slot name + * as the value of the `ng-transclude` or `ng-transclude-slot` attribute. + * + * If the transcluded content is not empty (i.e. contains one or more DOM nodes, including whitespace text nodes), any existing + * content of this element will be removed before the transcluded content is inserted. + * If the transcluded content is empty (or only whitespace), the existing content is left intact. This lets you provide fallback + * content in the case that no transcluded content is provided. * * @element ANY * + * @param {string} ngTransclude|ngTranscludeSlot the name of the slot to insert at this point. If this is not provided, is empty + * or its value is the same as the name of the attribute then the default slot is used. + * * @example - <example module="transclude"> - <file name="index.html"> - <script> - function Ctrl($scope) { - $scope.title = 'Lorem Ipsum'; - $scope.text = 'Neque porro quisquam est qui dolorem ipsum quia dolor...'; - } - - angular.module('transclude', []) - .directive('pane', function(){ - return { - restrict: 'E', - transclude: true, - scope: { title:'@' }, - template: '<div style="border: 1px solid black;">' + - '<div style="background-color: gray">{{title}}</div>' + - '<div ng-transclude></div>' + - '</div>' - }; - }); - </script> - <div ng-controller="Ctrl"> - <input ng-model="title"><br> - <textarea ng-model="text"></textarea> <br/> - <pane title="{{title}}">{{text}}</pane> - </div> - </file> - <file name="protractor.js" type="protractor"> - it('should have transcluded', function() { - var titleElement = element(by.model('title')); - titleElement.clear(); - titleElement.sendKeys('TITLE'); - var textElement = element(by.model('text')); - textElement.clear(); - textElement.sendKeys('TEXT'); - expect(element(by.binding('title')).getText()).toEqual('TITLE'); - expect(element(by.binding('text')).getText()).toEqual('TEXT'); - }); - </file> - </example> + * ### Basic transclusion + * This example demonstrates basic transclusion of content into a component directive. + * <example name="simpleTranscludeExample" module="transcludeExample"> + * <file name="index.html"> + * <script> + * angular.module('transcludeExample', []) + * .directive('pane', function(){ + * return { + * restrict: 'E', + * transclude: true, + * scope: { title:'@' }, + * template: '<div style="border: 1px solid black;">' + + * '<div style="background-color: gray">{{title}}</div>' + + * '<ng-transclude></ng-transclude>' + + * '</div>' + * }; + * }) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.title = 'Lorem Ipsum'; + * $scope.text = 'Neque porro quisquam est qui dolorem ipsum quia dolor...'; + * }]); + * </script> + * <div ng-controller="ExampleController"> + * <input ng-model="title" aria-label="title"> <br/> + * <textarea ng-model="text" aria-label="text"></textarea> <br/> + * <pane title="{{title}}"><span>{{text}}</span></pane> + * </div> + * </file> + * <file name="protractor.js" type="protractor"> + * it('should have transcluded', function() { + * var titleElement = element(by.model('title')); + * titleElement.clear(); + * titleElement.sendKeys('TITLE'); + * var textElement = element(by.model('text')); + * textElement.clear(); + * textElement.sendKeys('TEXT'); + * expect(element(by.binding('title')).getText()).toEqual('TITLE'); + * expect(element(by.binding('text')).getText()).toEqual('TEXT'); + * }); + * </file> + * </example> + * + * @example + * ### Transclude fallback content + * This example shows how to use `NgTransclude` with fallback content, that + * is displayed if no transcluded content is provided. + * + * <example module="transcludeFallbackContentExample" name="ng-transclude"> + * <file name="index.html"> + * <script> + * angular.module('transcludeFallbackContentExample', []) + * .directive('myButton', function(){ + * return { + * restrict: 'E', + * transclude: true, + * scope: true, + * template: '<button style="cursor: pointer;">' + + * '<ng-transclude>' + + * '<b style="color: red;">Button1</b>' + + * '</ng-transclude>' + + * '</button>' + * }; + * }); + * </script> + * <!-- fallback button content --> + * <my-button id="fallback"></my-button> + * <!-- modified button content --> + * <my-button id="modified"> + * <i style="color: green;">Button2</i> + * </my-button> + * </file> + * <file name="protractor.js" type="protractor"> + * it('should have different transclude element content', function() { + * expect(element(by.id('fallback')).getText()).toBe('Button1'); + * expect(element(by.id('modified')).getText()).toBe('Button2'); + * }); + * </file> + * </example> * + * @example + * ### Multi-slot transclusion + * This example demonstrates using multi-slot transclusion in a component directive. + * <example name="multiSlotTranscludeExample" module="multiSlotTranscludeExample"> + * <file name="index.html"> + * <style> + * .title, .footer { + * background-color: gray + * } + * </style> + * <div ng-controller="ExampleController"> + * <input ng-model="title" aria-label="title"> <br/> + * <textarea ng-model="text" aria-label="text"></textarea> <br/> + * <pane> + * <pane-title><a ng-href="{{link}}">{{title}}</a></pane-title> + * <pane-body><p>{{text}}</p></pane-body> + * </pane> + * </div> + * </file> + * <file name="app.js"> + * angular.module('multiSlotTranscludeExample', []) + * .directive('pane', function() { + * return { + * restrict: 'E', + * transclude: { + * 'title': '?paneTitle', + * 'body': 'paneBody', + * 'footer': '?paneFooter' + * }, + * template: '<div style="border: 1px solid black;">' + + * '<div class="title" ng-transclude="title">Fallback Title</div>' + + * '<div ng-transclude="body"></div>' + + * '<div class="footer" ng-transclude="footer">Fallback Footer</div>' + + * '</div>' + * }; + * }) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.title = 'Lorem Ipsum'; + * $scope.link = 'https://google.com'; + * $scope.text = 'Neque porro quisquam est qui dolorem ipsum quia dolor...'; + * }]); + * </file> + * <file name="protractor.js" type="protractor"> + * it('should have transcluded the title and the body', function() { + * var titleElement = element(by.model('title')); + * titleElement.clear(); + * titleElement.sendKeys('TITLE'); + * var textElement = element(by.model('text')); + * textElement.clear(); + * textElement.sendKeys('TEXT'); + * expect(element(by.css('.title')).getText()).toEqual('TITLE'); + * expect(element(by.binding('text')).getText()).toEqual('TEXT'); + * expect(element(by.css('.footer')).getText()).toEqual('Fallback Footer'); + * }); + * </file> + * </example> */ - var ngTranscludeDirective = ngDirective({ - link: function($scope, $element, $attrs, controller, $transclude) { - if (!$transclude) { - throw minErr('ngTransclude')('orphan', - 'Illegal use of ngTransclude directive in the template! ' + - 'No parent directive that requires a transclusion found. ' + - 'Element: {0}', - startingTag($element)); - } - - $transclude(function(clone) { - $element.empty(); - $element.append(clone); - }); - } - }); + var ngTranscludeMinErr = minErr('ngTransclude'); + var ngTranscludeDirective = ['$compile', function($compile) { + return { + restrict: 'EAC', + compile: function ngTranscludeCompile(tElement) { + + // Remove and cache any original content to act as a fallback + var fallbackLinkFn = $compile(tElement.contents()); + tElement.empty(); + + return function ngTranscludePostLink($scope, $element, $attrs, controller, $transclude) { + + if (!$transclude) { + throw ngTranscludeMinErr('orphan', + 'Illegal use of ngTransclude directive in the template! ' + + 'No parent directive that requires a transclusion found. ' + + 'Element: {0}', + startingTag($element)); + } + + + // If the attribute is of the form: `ng-transclude="ng-transclude"` then treat it like the default + if ($attrs.ngTransclude === $attrs.$attr.ngTransclude) { + $attrs.ngTransclude = ''; + } + var slotName = $attrs.ngTransclude || $attrs.ngTranscludeSlot; + + // If the slot is required and no transclusion content is provided then this call will throw an error + $transclude(ngTranscludeCloneAttachFn, null, slotName); + + // If the slot is optional and no transclusion content is provided then use the fallback content + if (slotName && !$transclude.isSlotFilled(slotName)) { + useFallbackContent(); + } + + function ngTranscludeCloneAttachFn(clone, transcludedScope) { + if (clone.length && notWhitespace(clone)) { + $element.append(clone); + } else { + useFallbackContent(); + // There is nothing linked against the transcluded scope since no content was available, + // so it should be safe to clean up the generated scope. + transcludedScope.$destroy(); + } + } + + function useFallbackContent() { + // Since this is the fallback content rather than the transcluded content, + // we link against the scope of this directive rather than the transcluded scope + fallbackLinkFn($scope, function(clone) { + $element.append(clone); + }); + } + + function notWhitespace(nodes) { + for (var i = 0, ii = nodes.length; i < ii; i++) { + var node = nodes[i]; + if (node.nodeType !== NODE_TYPE_TEXT || node.nodeValue.trim()) { + return true; + } + } + } + }; + } + }; + }]; /** * @ngdoc directive @@ -20770,7 +32880,7 @@ * @param {string} id Cache name of the template. * * @example - <example> + <example name="script-tag"> <file name="index.html"> <script type="text/ng-template" id="/tpl.html"> Content of the template. @@ -20792,9 +32902,8 @@ restrict: 'E', terminal: true, compile: function(element, attr) { - if (attr.type == 'text/ng-template') { + if (attr.type === 'text/ng-template') { var templateUrl = attr.id, - // IE is not consistent, in scripts we have to read .text but in other nodes we have to read .textContent text = element[0].text; $templateCache.put(templateUrl, text); @@ -20803,663 +32912,1448 @@ }; }]; - var ngOptionsMinErr = minErr('ngOptions'); + /* exported selectDirective, optionDirective */ + + var noopNgModelController = { $setViewValue: noop, $render: noop }; + + function setOptionSelectedStatus(optionEl, value) { + optionEl.prop('selected', value); + /** + * When unselecting an option, setting the property to null / false should be enough + * However, screenreaders might react to the selected attribute instead, see + * https://github.com/angular/angular.js/issues/14419 + * Note: "selected" is a boolean attr and will be removed when the "value" arg in attr() is false + * or null + */ + optionEl.attr('selected', value); + } + /** - * @ngdoc directive - * @name select - * @restrict E + * @ngdoc type + * @name select.SelectController * * @description - * HTML `SELECT` element with angular data-binding. + * The controller for the {@link ng.select select} directive. The controller exposes + * a few utility methods that can be used to augment the behavior of a regular or an + * {@link ng.ngOptions ngOptions} select element. * - * # `ngOptions` + * @example + * ### Set a custom error when the unknown option is selected * - * The `ngOptions` attribute can be used to dynamically generate a list of `<option>` - * elements for the `<select>` element using the array or object obtained by evaluating the - * `ngOptions` comprehension_expression. + * This example sets a custom error "unknownValue" on the ngModelController + * when the select element's unknown option is selected, i.e. when the model is set to a value + * that is not matched by any option. + * + * <example name="select-unknown-value-error" module="staticSelect"> + * <file name="index.html"> + * <div ng-controller="ExampleController"> + * <form name="myForm"> + * <label for="testSelect"> Single select: </label><br> + * <select name="testSelect" ng-model="selected" unknown-value-error> + * <option value="option-1">Option 1</option> + * <option value="option-2">Option 2</option> + * </select><br> + * <span class="error" ng-if="myForm.testSelect.$error.unknownValue"> + * Error: The current model doesn't match any option</span><br> + * + * <button ng-click="forceUnknownOption()">Force unknown option</button><br> + * </form> + * </div> + * </file> + * <file name="app.js"> + * angular.module('staticSelect', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.selected = null; + * + * $scope.forceUnknownOption = function() { + * $scope.selected = 'nonsense'; + * }; + * }]) + * .directive('unknownValueError', function() { + * return { + * require: ['ngModel', 'select'], + * link: function(scope, element, attrs, ctrls) { + * var ngModelCtrl = ctrls[0]; + * var selectCtrl = ctrls[1]; + * + * ngModelCtrl.$validators.unknownValue = function(modelValue, viewValue) { + * if (selectCtrl.$isUnknownOptionSelected()) { + * return false; + * } + * + * return true; + * }; + * } + * + * }; + * }); + * </file> + *</example> * - * When an item in the `<select>` menu is selected, the array element or object property - * represented by the selected option will be bound to the model identified by the `ngModel` - * directive. * - * <div class="alert alert-warning"> - * **Note:** `ngModel` compares by reference, not value. This is important when binding to an - * array of objects. See an example [in this jsfiddle](http://jsfiddle.net/qWzTb/). - * </div> + * @example + * ### Set the "required" error when the unknown option is selected. * - * Optionally, a single hard-coded `<option>` element, with the value set to an empty string, can - * be nested into the `<select>` element. This element will then represent the `null` or "not selected" - * option. See example below for demonstration. + * By default, the "required" error on the ngModelController is only set on a required select + * when the empty option is selected. This example adds a custom directive that also sets the + * error when the unknown option is selected. * - * <div class="alert alert-warning"> - * **Note:** `ngOptions` provides an iterator facility for the `<option>` element which should be used instead - * of {@link ng.directive:ngRepeat ngRepeat} when you want the - * `select` model to be bound to a non-string value. This is because an option element can only - * be bound to string values at present. + * <example name="select-unknown-value-required" module="staticSelect"> + * <file name="index.html"> + * <div ng-controller="ExampleController"> + * <form name="myForm"> + * <label for="testSelect"> Select: </label><br> + * <select name="testSelect" ng-model="selected" required unknown-value-required> + * <option value="option-1">Option 1</option> + * <option value="option-2">Option 2</option> + * </select><br> + * <span class="error" ng-if="myForm.testSelect.$error.required">Error: Please select a value</span><br> + * + * <button ng-click="forceUnknownOption()">Force unknown option</button><br> + * </form> * </div> + * </file> + * <file name="app.js"> + * angular.module('staticSelect', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.selected = null; + * + * $scope.forceUnknownOption = function() { + * $scope.selected = 'nonsense'; + * }; + * }]) + * .directive('unknownValueRequired', function() { + * return { + * priority: 1, // This directive must run after the required directive has added its validator + * require: ['ngModel', 'select'], + * link: function(scope, element, attrs, ctrls) { + * var ngModelCtrl = ctrls[0]; + * var selectCtrl = ctrls[1]; * - * @param {string} ngModel Assignable angular expression to data-bind to. - * @param {string=} name Property name of the form under which the control is published. - * @param {string=} required The control is considered valid only if value is entered. - * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to - * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of - * `required` when you want to data-bind to the `required` attribute. - * @param {comprehension_expression=} ngOptions in one of the following forms: + * var originalRequiredValidator = ngModelCtrl.$validators.required; * - * * for array data sources: - * * `label` **`for`** `value` **`in`** `array` - * * `select` **`as`** `label` **`for`** `value` **`in`** `array` - * * `label` **`group by`** `group` **`for`** `value` **`in`** `array` - * * `select` **`as`** `label` **`group by`** `group` **`for`** `value` **`in`** `array` **`track by`** `trackexpr` - * * for object data sources: - * * `label` **`for (`**`key` **`,`** `value`**`) in`** `object` - * * `select` **`as`** `label` **`for (`**`key` **`,`** `value`**`) in`** `object` - * * `label` **`group by`** `group` **`for (`**`key`**`,`** `value`**`) in`** `object` - * * `select` **`as`** `label` **`group by`** `group` - * **`for` `(`**`key`**`,`** `value`**`) in`** `object` + * ngModelCtrl.$validators.required = function() { + * if (attrs.required && selectCtrl.$isUnknownOptionSelected()) { + * return false; + * } * - * Where: + * return originalRequiredValidator.apply(this, arguments); + * }; + * } + * }; + * }); + * </file> + * <file name="protractor.js" type="protractor"> + * it('should show the error message when the unknown option is selected', function() { + + var error = element(by.className('error')); + + expect(error.getText()).toBe('Error: Please select a value'); + + element(by.cssContainingText('option', 'Option 1')).click(); + + expect(error.isPresent()).toBe(false); + + element(by.tagName('button')).click(); + + expect(error.getText()).toBe('Error: Please select a value'); + }); + * </file> + *</example> * - * * `array` / `object`: an expression which evaluates to an array / object to iterate over. - * * `value`: local variable which will refer to each item in the `array` or each property value - * of `object` during iteration. - * * `key`: local variable which will refer to a property name in `object` during iteration. - * * `label`: The result of this expression will be the label for `<option>` element. The - * `expression` will most likely refer to the `value` variable (e.g. `value.propertyName`). - * * `select`: The result of this expression will be bound to the model of the parent `<select>` - * element. If not specified, `select` expression will default to `value`. - * * `group`: The result of this expression will be used to group options using the `<optgroup>` - * DOM element. - * * `trackexpr`: Used when working with an array of objects. The result of this expression will be - * used to identify the objects in the array. The `trackexpr` will most likely refer to the - * `value` variable (e.g. `value.propertyName`). * - * @example - <example> - <file name="index.html"> - <script> - function MyCntrl($scope) { - $scope.colors = [ - {name:'black', shade:'dark'}, - {name:'white', shade:'light'}, - {name:'red', shade:'dark'}, - {name:'blue', shade:'dark'}, - {name:'yellow', shade:'light'} - ]; - $scope.color = $scope.colors[2]; // red - } - </script> - <div ng-controller="MyCntrl"> - <ul> - <li ng-repeat="color in colors"> - Name: <input ng-model="color.name"> - [<a href ng-click="colors.splice($index, 1)">X</a>] - </li> - <li> - [<a href ng-click="colors.push({})">add</a>] - </li> - </ul> - <hr/> - Color (null not allowed): - <select ng-model="color" ng-options="c.name for c in colors"></select><br> + */ + var SelectController = + ['$element', '$scope', /** @this */ function($element, $scope) { + + var self = this, + optionsMap = new NgMap(); + + self.selectValueMap = {}; // Keys are the hashed values, values the original values + + // If the ngModel doesn't get provided then provide a dummy noop version to prevent errors + self.ngModelCtrl = noopNgModelController; + self.multiple = false; + + // The "unknown" option is one that is prepended to the list if the viewValue + // does not match any of the options. When it is rendered the value of the unknown + // option is '? XXX ?' where XXX is the hashKey of the value that is not known. + // + // Support: IE 9 only + // We can't just jqLite('<option>') since jqLite is not smart enough + // to create it in <select> and IE barfs otherwise. + self.unknownOption = jqLite(window.document.createElement('option')); + + // The empty option is an option with the value '' that the application developer can + // provide inside the select. It is always selectable and indicates that a "null" selection has + // been made by the user. + // If the select has an empty option, and the model of the select is set to "undefined" or "null", + // the empty option is selected. + // If the model is set to a different unmatched value, the unknown option is rendered and + // selected, i.e both are present, because a "null" selection and an unknown value are different. + self.hasEmptyOption = false; + self.emptyOption = undefined; + + self.renderUnknownOption = function(val) { + var unknownVal = self.generateUnknownOptionValue(val); + self.unknownOption.val(unknownVal); + $element.prepend(self.unknownOption); + setOptionSelectedStatus(self.unknownOption, true); + $element.val(unknownVal); + }; - Color (null allowed): - <span class="nullable"> - <select ng-model="color" ng-options="c.name for c in colors"> - <option value="">-- choose color --</option> - </select> - </span><br/> + self.updateUnknownOption = function(val) { + var unknownVal = self.generateUnknownOptionValue(val); + self.unknownOption.val(unknownVal); + setOptionSelectedStatus(self.unknownOption, true); + $element.val(unknownVal); + }; - Color grouped by shade: - <select ng-model="color" ng-options="c.name group by c.shade for c in colors"> - </select><br/> + self.generateUnknownOptionValue = function(val) { + return '? ' + hashKey(val) + ' ?'; + }; + self.removeUnknownOption = function() { + if (self.unknownOption.parent()) self.unknownOption.remove(); + }; - Select <a href ng-click="color={name:'not in list'}">bogus</a>.<br> - <hr/> - Currently selected: {{ {selected_color:color} }} - <div style="border:solid 1px black; height:20px" - ng-style="{'background-color':color.name}"> - </div> - </div> - </file> - <file name="protractor.js" type="protractor"> - it('should check ng-options', function() { - expect(element(by.binding('{selected_color:color}')).getText()).toMatch('red'); - element.all(by.select('color')).first().click(); - element.all(by.css('select[ng-model="color"] option')).first().click(); - expect(element(by.binding('{selected_color:color}')).getText()).toMatch('black'); - element(by.css('.nullable select[ng-model="color"]')).click(); - element.all(by.css('.nullable select[ng-model="color"] option')).first().click(); - expect(element(by.binding('{selected_color:color}')).getText()).toMatch('null'); - }); - </file> - </example> - */ + self.selectEmptyOption = function() { + if (self.emptyOption) { + $element.val(''); + setOptionSelectedStatus(self.emptyOption, true); + } + }; - var ngOptionsDirective = valueFn({ terminal: true }); -// jshint maxlen: false - var selectDirective = ['$compile', '$parse', function($compile, $parse) { - //000011111111110000000000022222222220000000000000000000003333333333000000000000004444444444444440000000005555555555555550000000666666666666666000000000000000777777777700000000000000000008888888888 - var NG_OPTIONS_REGEXP = /^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+group\s+by\s+([\s\S]+?))?\s+for\s+(?:([\$\w][\$\w]*)|(?:\(\s*([\$\w][\$\w]*)\s*,\s*([\$\w][\$\w]*)\s*\)))\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?$/, - nullModelCtrl = {$setViewValue: noop}; -// jshint maxlen: 100 + self.unselectEmptyOption = function() { + if (self.hasEmptyOption) { + setOptionSelectedStatus(self.emptyOption, false); + } + }; - return { - restrict: 'E', - require: ['select', '?ngModel'], - controller: ['$element', '$scope', '$attrs', function($element, $scope, $attrs) { - var self = this, - optionsMap = {}, - ngModelCtrl = nullModelCtrl, - nullOption, - unknownOption; + $scope.$on('$destroy', function() { + // disable unknown option so that we don't do work when the whole select is being destroyed + self.renderUnknownOption = noop; + }); + + // Read the value of the select control, the implementation of this changes depending + // upon whether the select can have multiple values and whether ngOptions is at work. + self.readValue = function readSingleValue() { + var val = $element.val(); + // ngValue added option values are stored in the selectValueMap, normal interpolations are not + var realVal = val in self.selectValueMap ? self.selectValueMap[val] : val; + if (self.hasOption(realVal)) { + return realVal; + } - self.databound = $attrs.ngModel; + return null; + }; - self.init = function(ngModelCtrl_, nullOption_, unknownOption_) { - ngModelCtrl = ngModelCtrl_; - nullOption = nullOption_; - unknownOption = unknownOption_; - }; + // Write the value to the select control, the implementation of this changes depending + // upon whether the select can have multiple values and whether ngOptions is at work. + self.writeValue = function writeSingleValue(value) { + // Make sure to remove the selected attribute from the previously selected option + // Otherwise, screen readers might get confused + var currentlySelectedOption = $element[0].options[$element[0].selectedIndex]; + if (currentlySelectedOption) setOptionSelectedStatus(jqLite(currentlySelectedOption), false); + if (self.hasOption(value)) { + self.removeUnknownOption(); - self.addOption = function(value) { - assertNotHasOwnProperty(value, '"option value"'); - optionsMap[value] = true; + var hashedVal = hashKey(value); + $element.val(hashedVal in self.selectValueMap ? hashedVal : value); - if (ngModelCtrl.$viewValue == value) { - $element.val(value); - if (unknownOption.parent()) unknownOption.remove(); - } - }; + // Set selected attribute and property on selected option for screen readers + var selectedOption = $element[0].options[$element[0].selectedIndex]; + setOptionSelectedStatus(jqLite(selectedOption), true); + } else { + self.selectUnknownOrEmptyOption(value); + } + }; - self.removeOption = function(value) { - if (this.hasOption(value)) { - delete optionsMap[value]; - if (ngModelCtrl.$viewValue == value) { - this.renderUnknownOption(value); + // Tell the select control that an option, with the given value, has been added + self.addOption = function(value, element) { + // Skip comment nodes, as they only pollute the `optionsMap` + if (element[0].nodeType === NODE_TYPE_COMMENT) return; + + assertNotHasOwnProperty(value, '"option value"'); + if (value === '') { + self.hasEmptyOption = true; + self.emptyOption = element; + } + var count = optionsMap.get(value) || 0; + optionsMap.set(value, count + 1); + // Only render at the end of a digest. This improves render performance when many options + // are added during a digest and ensures all relevant options are correctly marked as selected + scheduleRender(); + }; + + // Tell the select control that an option, with the given value, has been removed + self.removeOption = function(value) { + var count = optionsMap.get(value); + if (count) { + if (count === 1) { + optionsMap.delete(value); + if (value === '') { + self.hasEmptyOption = false; + self.emptyOption = undefined; } + } else { + optionsMap.set(value, count - 1); } - }; + } + }; + // Check whether the select control has an option matching the given value + self.hasOption = function(value) { + return !!optionsMap.get(value); + }; - self.renderUnknownOption = function(val) { - var unknownVal = '? ' + hashKey(val) + ' ?'; - unknownOption.val(unknownVal); - $element.prepend(unknownOption); - $element.val(unknownVal); - unknownOption.prop('selected', true); // needed for IE - }; + /** + * @ngdoc method + * @name select.SelectController#$hasEmptyOption + * + * @description + * + * Returns `true` if the select element currently has an empty option + * element, i.e. an option that signifies that the select is empty / the selection is null. + * + */ + self.$hasEmptyOption = function() { + return self.hasEmptyOption; + }; + + /** + * @ngdoc method + * @name select.SelectController#$isUnknownOptionSelected + * + * @description + * + * Returns `true` if the select element's unknown option is selected. The unknown option is added + * and automatically selected whenever the select model doesn't match any option. + * + */ + self.$isUnknownOptionSelected = function() { + // Presence of the unknown option means it is selected + return $element[0].options[0] === self.unknownOption[0]; + }; + /** + * @ngdoc method + * @name select.SelectController#$isEmptyOptionSelected + * + * @description + * + * Returns `true` if the select element has an empty option and this empty option is currently + * selected. Returns `false` if the select element has no empty option or it is not selected. + * + */ + self.$isEmptyOptionSelected = function() { + return self.hasEmptyOption && $element[0].options[$element[0].selectedIndex] === self.emptyOption[0]; + }; - self.hasOption = function(value) { - return optionsMap.hasOwnProperty(value); - }; + self.selectUnknownOrEmptyOption = function(value) { + if (value == null && self.emptyOption) { + self.removeUnknownOption(); + self.selectEmptyOption(); + } else if (self.unknownOption.parent().length) { + self.updateUnknownOption(value); + } else { + self.renderUnknownOption(value); + } + }; - $scope.$on('$destroy', function() { - // disable unknown option so that we don't do work when the whole select is being destroyed - self.renderUnknownOption = noop; + var renderScheduled = false; + function scheduleRender() { + if (renderScheduled) return; + renderScheduled = true; + $scope.$$postDigest(function() { + renderScheduled = false; + self.ngModelCtrl.$render(); }); - }], + } - link: function(scope, element, attr, ctrls) { - // if ngModel is not defined, we don't need to do anything - if (!ctrls[1]) return; - - var selectCtrl = ctrls[0], - ngModelCtrl = ctrls[1], - multiple = attr.multiple, - optionsExp = attr.ngOptions, - nullOption = false, // if false, user will not be able to select it (used by ngOptions) - emptyOption, - // we can't just jqLite('<option>') since jqLite is not smart enough - // to create it in <select> and IE barfs otherwise. - optionTemplate = jqLite(document.createElement('option')), - optGroupTemplate =jqLite(document.createElement('optgroup')), - unknownOption = optionTemplate.clone(); - - // find "null" option - for(var i = 0, children = element.children(), ii = children.length; i < ii; i++) { - if (children[i].value === '') { - emptyOption = nullOption = children.eq(i); - break; - } - } + var updateScheduled = false; + function scheduleViewValueUpdate(renderAfter) { + if (updateScheduled) return; - selectCtrl.init(ngModelCtrl, nullOption, unknownOption); + updateScheduled = true; - // required validator - if (multiple) { - ngModelCtrl.$isEmpty = function(value) { - return !value || value.length === 0; - }; - } + $scope.$$postDigest(function() { + if ($scope.$$destroyed) return; - if (optionsExp) setupAsOptions(scope, element, ngModelCtrl); - else if (multiple) setupAsMultiple(scope, element, ngModelCtrl); - else setupAsSingle(scope, element, ngModelCtrl, selectCtrl); + updateScheduled = false; + self.ngModelCtrl.$setViewValue(self.readValue()); + if (renderAfter) self.ngModelCtrl.$render(); + }); + } - //////////////////////////// + self.registerOption = function(optionScope, optionElement, optionAttrs, interpolateValueFn, interpolateTextFn) { + if (optionAttrs.$attr.ngValue) { + // The value attribute is set by ngValue + var oldVal, hashedVal = NaN; + optionAttrs.$observe('value', function valueAttributeObserveAction(newVal) { + var removal; + var previouslySelected = optionElement.prop('selected'); - function setupAsSingle(scope, selectElement, ngModelCtrl, selectCtrl) { - ngModelCtrl.$render = function() { - var viewValue = ngModelCtrl.$viewValue; + if (isDefined(hashedVal)) { + self.removeOption(oldVal); + delete self.selectValueMap[hashedVal]; + removal = true; + } - if (selectCtrl.hasOption(viewValue)) { - if (unknownOption.parent()) unknownOption.remove(); - selectElement.val(viewValue); - if (viewValue === '') emptyOption.prop('selected', true); // to make IE9 happy - } else { - if (isUndefined(viewValue) && emptyOption) { - selectElement.val(''); - } else { - selectCtrl.renderUnknownOption(viewValue); - } + hashedVal = hashKey(newVal); + oldVal = newVal; + self.selectValueMap[hashedVal] = newVal; + self.addOption(newVal, optionElement); + // Set the attribute directly instead of using optionAttrs.$set - this stops the observer + // from firing a second time. Other $observers on value will also get the result of the + // ngValue expression, not the hashed value + optionElement.attr('value', hashedVal); + + if (removal && previouslySelected) { + scheduleViewValueUpdate(); } - }; - selectElement.on('change', function() { - scope.$apply(function() { - if (unknownOption.parent()) unknownOption.remove(); - ngModelCtrl.$setViewValue(selectElement.val()); - }); }); - } - - function setupAsMultiple(scope, selectElement, ctrl) { - var lastView; - ctrl.$render = function() { - var items = new HashMap(ctrl.$viewValue); - forEach(selectElement.find('option'), function(option) { - option.selected = isDefined(items.get(option.value)); - }); - }; + } else if (interpolateValueFn) { + // The value attribute is interpolated + optionAttrs.$observe('value', function valueAttributeObserveAction(newVal) { + // This method is overwritten in ngOptions and has side-effects! + self.readValue(); + + var removal; + var previouslySelected = optionElement.prop('selected'); + + if (isDefined(oldVal)) { + self.removeOption(oldVal); + removal = true; + } + oldVal = newVal; + self.addOption(newVal, optionElement); - // we have to do it on each watch since ngModel watches reference, but - // we need to work of an array, so we need to see if anything was inserted/removed - scope.$watch(function selectMultipleWatch() { - if (!equals(lastView, ctrl.$viewValue)) { - lastView = copy(ctrl.$viewValue); - ctrl.$render(); + if (removal && previouslySelected) { + scheduleViewValueUpdate(); } }); + } else if (interpolateTextFn) { + // The text content is interpolated + optionScope.$watch(interpolateTextFn, function interpolateWatchAction(newVal, oldVal) { + optionAttrs.$set('value', newVal); + var previouslySelected = optionElement.prop('selected'); + if (oldVal !== newVal) { + self.removeOption(oldVal); + } + self.addOption(newVal, optionElement); - selectElement.on('change', function() { - scope.$apply(function() { - var array = []; - forEach(selectElement.find('option'), function(option) { - if (option.selected) { - array.push(option.value); - } - }); - ctrl.$setViewValue(array); - }); + if (oldVal && previouslySelected) { + scheduleViewValueUpdate(); + } }); + } else { + // The value attribute is static + self.addOption(optionAttrs.value, optionElement); } - function setupAsOptions(scope, selectElement, ctrl) { - var match; - - if (!(match = optionsExp.match(NG_OPTIONS_REGEXP))) { - throw ngOptionsMinErr('iexp', - "Expected expression in form of " + - "'_select_ (as _label_)? for (_key_,)?_value_ in _collection_'" + - " but got '{0}'. Element: {1}", - optionsExp, startingTag(selectElement)); - } - - var displayFn = $parse(match[2] || match[1]), - valueName = match[4] || match[6], - keyName = match[5], - groupByFn = $parse(match[3] || ''), - valueFn = $parse(match[2] ? match[1] : valueName), - valuesFn = $parse(match[7]), - track = match[8], - trackFn = track ? $parse(match[8]) : null, - // This is an array of array of existing option groups in DOM. - // We try to reuse these if possible - // - optionGroupsCache[0] is the options with no option group - // - optionGroupsCache[?][0] is the parent: either the SELECT or OPTGROUP element - optionGroupsCache = [[{element: selectElement, label:''}]]; - - if (nullOption) { - // compile the element since there might be bindings in it - $compile(nullOption)(scope); - - // remove the class, which is added automatically because we recompile the element and it - // becomes the compilation root - nullOption.removeClass('ng-scope'); - - // we need to remove it before calling selectElement.empty() because otherwise IE will - // remove the label from the element. wtf? - nullOption.remove(); - } - - // clear contents, we'll add what's needed based on the model - selectElement.empty(); - - selectElement.on('change', function() { - scope.$apply(function() { - var optionGroup, - collection = valuesFn(scope) || [], - locals = {}, - key, value, optionElement, index, groupIndex, length, groupLength, trackIndex; - - if (multiple) { - value = []; - for (groupIndex = 0, groupLength = optionGroupsCache.length; - groupIndex < groupLength; - groupIndex++) { - // list of options for that group. (first item has the parent) - optionGroup = optionGroupsCache[groupIndex]; - - for(index = 1, length = optionGroup.length; index < length; index++) { - if ((optionElement = optionGroup[index].element)[0].selected) { - key = optionElement.val(); - if (keyName) locals[keyName] = key; - if (trackFn) { - for (trackIndex = 0; trackIndex < collection.length; trackIndex++) { - locals[valueName] = collection[trackIndex]; - if (trackFn(scope, locals) == key) break; - } - } else { - locals[valueName] = collection[key]; - } - value.push(valueFn(scope, locals)); - } - } - } - } else { - key = selectElement.val(); - if (key == '?') { - value = undefined; - } else if (key === ''){ - value = null; - } else { - if (trackFn) { - for (trackIndex = 0; trackIndex < collection.length; trackIndex++) { - locals[valueName] = collection[trackIndex]; - if (trackFn(scope, locals) == key) { - value = valueFn(scope, locals); - break; - } - } - } else { - locals[valueName] = collection[key]; - if (keyName) locals[keyName] = key; - value = valueFn(scope, locals); - } - } - // Update the null option's selected property here so $render cleans it up correctly - if (optionGroupsCache[0].length > 1) { - if (optionGroupsCache[0][1].id !== key) { - optionGroupsCache[0][1].selected = false; - } - } - } - ctrl.$setViewValue(value); - }); - }); - ctrl.$render = render; - - // TODO(vojta): can't we optimize this ? - scope.$watch(render); - - function render() { - // Temporary location for the option groups before we render them - var optionGroups = {'':[]}, - optionGroupNames = [''], - optionGroupName, - optionGroup, - option, - existingParent, existingOptions, existingOption, - modelValue = ctrl.$modelValue, - values = valuesFn(scope) || [], - keys = keyName ? sortedKeys(values) : values, - key, - groupLength, length, - groupIndex, index, - locals = {}, - selected, - selectedSet = false, // nothing is selected yet - lastElement, - element, - label; - - if (multiple) { - if (trackFn && isArray(modelValue)) { - selectedSet = new HashMap([]); - for (var trackIndex = 0; trackIndex < modelValue.length; trackIndex++) { - locals[valueName] = modelValue[trackIndex]; - selectedSet.put(trackFn(scope, locals), modelValue[trackIndex]); - } - } else { - selectedSet = new HashMap(modelValue); - } + optionAttrs.$observe('disabled', function(newVal) { + + // Since model updates will also select disabled options (like ngOptions), + // we only have to handle options becoming disabled, not enabled + + if (newVal === 'true' || newVal && optionElement.prop('selected')) { + if (self.multiple) { + scheduleViewValueUpdate(true); + } else { + self.ngModelCtrl.$setViewValue(null); + self.ngModelCtrl.$render(); } + } + }); + + optionElement.on('$destroy', function() { + var currentValue = self.readValue(); + var removeValue = optionAttrs.value; + + self.removeOption(removeValue); + scheduleRender(); + + if (self.multiple && currentValue && currentValue.indexOf(removeValue) !== -1 || + currentValue === removeValue + ) { + // When multiple (selected) options are destroyed at the same time, we don't want + // to run a model update for each of them. Instead, run a single update in the $$postDigest + scheduleViewValueUpdate(true); + } + }); + }; + }]; - // We now build up the list of options we need (we merge later) - for (index = 0; length = keys.length, index < length; index++) { + /** + * @ngdoc directive + * @name select + * @restrict E + * + * @description + * HTML `select` element with AngularJS data-binding. + * + * The `select` directive is used together with {@link ngModel `ngModel`} to provide data-binding + * between the scope and the `<select>` control (including setting default values). + * It also handles dynamic `<option>` elements, which can be added using the {@link ngRepeat `ngRepeat}` or + * {@link ngOptions `ngOptions`} directives. + * + * When an item in the `<select>` menu is selected, the value of the selected option will be bound + * to the model identified by the `ngModel` directive. With static or repeated options, this is + * the content of the `value` attribute or the textContent of the `<option>`, if the value attribute is missing. + * Value and textContent can be interpolated. + * + * The {@link select.SelectController select controller} exposes utility functions that can be used + * to manipulate the select's behavior. + * + * ## Matching model and option values + * + * In general, the match between the model and an option is evaluated by strictly comparing the model + * value against the value of the available options. + * + * If you are setting the option value with the option's `value` attribute, or textContent, the + * value will always be a `string` which means that the model value must also be a string. + * Otherwise the `select` directive cannot match them correctly. + * + * To bind the model to a non-string value, you can use one of the following strategies: + * - the {@link ng.ngOptions `ngOptions`} directive + * ({@link ng.select#using-select-with-ngoptions-and-setting-a-default-value}) + * - the {@link ng.ngValue `ngValue`} directive, which allows arbitrary expressions to be + * option values ({@link ng.select#using-ngvalue-to-bind-the-model-to-an-array-of-objects Example}) + * - model $parsers / $formatters to convert the string value + * ({@link ng.select#binding-select-to-a-non-string-value-via-ngmodel-parsing-formatting Example}) + * + * If the viewValue of `ngModel` does not match any of the options, then the control + * will automatically add an "unknown" option, which it then removes when the mismatch is resolved. + * + * Optionally, a single hard-coded `<option>` element, with the value set to an empty string, can + * be nested into the `<select>` element. This element will then represent the `null` or "not selected" + * option. See example below for demonstration. + * + * ## Choosing between `ngRepeat` and `ngOptions` + * + * In many cases, `ngRepeat` can be used on `<option>` elements instead of {@link ng.directive:ngOptions + * ngOptions} to achieve a similar result. However, `ngOptions` provides some benefits: + * - more flexibility in how the `<select>`'s model is assigned via the `select` **`as`** part of the + * comprehension expression + * - reduced memory consumption by not creating a new scope for each repeated instance + * - increased render speed by creating the options in a documentFragment instead of individually + * + * Specifically, select with repeated options slows down significantly starting at 2000 options in + * Chrome and Internet Explorer / Edge. + * + * + * @param {string} ngModel Assignable AngularJS expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} multiple Allows multiple options to be selected. The selected values will be + * bound to the model as an array. + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds required attribute and required validation constraint to + * the element when the ngRequired expression evaluates to true. Use ngRequired instead of required + * when you want to data-bind to the required attribute. + * @param {string=} ngChange AngularJS expression to be executed when selected option(s) changes due to user + * interaction with the select element. + * @param {string=} ngOptions sets the options that the select is populated with and defines what is + * set on the model on selection. See {@link ngOptions `ngOptions`}. + * @param {string=} ngAttrSize sets the size of the select element dynamically. Uses the + * {@link guide/interpolation#-ngattr-for-binding-to-arbitrary-attributes ngAttr} directive. + * + * + * @knownIssue + * + * In Firefox, the select model is only updated when the select element is blurred. For example, + * when switching between options with the keyboard, the select model is only set to the + * currently selected option when the select is blurred, e.g via tab key or clicking the mouse + * outside the select. + * + * This is due to an ambiguity in the select element specification. See the + * [issue on the Firefox bug tracker](https://bugzilla.mozilla.org/show_bug.cgi?id=126379) + * for more information, and this + * [Github comment for a workaround](https://github.com/angular/angular.js/issues/9134#issuecomment-130800488) + * + * @example + * ### Simple `select` elements with static options + * + * <example name="static-select" module="staticSelect"> + * <file name="index.html"> + * <div ng-controller="ExampleController"> + * <form name="myForm"> + * <label for="singleSelect"> Single select: </label><br> + * <select name="singleSelect" ng-model="data.singleSelect"> + * <option value="option-1">Option 1</option> + * <option value="option-2">Option 2</option> + * </select><br> + * + * <label for="singleSelect"> Single select with "not selected" option and dynamic option values: </label><br> + * <select name="singleSelect" id="singleSelect" ng-model="data.singleSelect"> + * <option value="">---Please select---</option> <!-- not selected / blank option --> + * <option value="{{data.option1}}">Option 1</option> <!-- interpolation --> + * <option value="option-2">Option 2</option> + * </select><br> + * <button ng-click="forceUnknownOption()">Force unknown option</button><br> + * <tt>singleSelect = {{data.singleSelect}}</tt> + * + * <hr> + * <label for="multipleSelect"> Multiple select: </label><br> + * <select name="multipleSelect" id="multipleSelect" ng-model="data.multipleSelect" multiple> + * <option value="option-1">Option 1</option> + * <option value="option-2">Option 2</option> + * <option value="option-3">Option 3</option> + * </select><br> + * <tt>multipleSelect = {{data.multipleSelect}}</tt><br/> + * </form> + * </div> + * </file> + * <file name="app.js"> + * angular.module('staticSelect', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.data = { + * singleSelect: null, + * multipleSelect: [], + * option1: 'option-1' + * }; + * + * $scope.forceUnknownOption = function() { + * $scope.data.singleSelect = 'nonsense'; + * }; + * }]); + * </file> + *</example> + * + * @example + * ### Using `ngRepeat` to generate `select` options + * <example name="select-ngrepeat" module="ngrepeatSelect"> + * <file name="index.html"> + * <div ng-controller="ExampleController"> + * <form name="myForm"> + * <label for="repeatSelect"> Repeat select: </label> + * <select name="repeatSelect" id="repeatSelect" ng-model="data.model"> + * <option ng-repeat="option in data.availableOptions" value="{{option.id}}">{{option.name}}</option> + * </select> + * </form> + * <hr> + * <tt>model = {{data.model}}</tt><br/> + * </div> + * </file> + * <file name="app.js"> + * angular.module('ngrepeatSelect', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.data = { + * model: null, + * availableOptions: [ + * {id: '1', name: 'Option A'}, + * {id: '2', name: 'Option B'}, + * {id: '3', name: 'Option C'} + * ] + * }; + * }]); + * </file> + *</example> + * + * @example + * ### Using `ngValue` to bind the model to an array of objects + * <example name="select-ngvalue" module="ngvalueSelect"> + * <file name="index.html"> + * <div ng-controller="ExampleController"> + * <form name="myForm"> + * <label for="ngvalueselect"> ngvalue select: </label> + * <select size="6" name="ngvalueselect" ng-model="data.model" multiple> + * <option ng-repeat="option in data.availableOptions" ng-value="option.value">{{option.name}}</option> + * </select> + * </form> + * <hr> + * <pre>model = {{data.model | json}}</pre><br/> + * </div> + * </file> + * <file name="app.js"> + * angular.module('ngvalueSelect', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.data = { + * model: null, + * availableOptions: [ + {value: 'myString', name: 'string'}, + {value: 1, name: 'integer'}, + {value: true, name: 'boolean'}, + {value: null, name: 'null'}, + {value: {prop: 'value'}, name: 'object'}, + {value: ['a'], name: 'array'} + * ] + * }; + * }]); + * </file> + *</example> + * + * @example + * ### Using `select` with `ngOptions` and setting a default value + * See the {@link ngOptions ngOptions documentation} for more `ngOptions` usage examples. + * + * <example name="select-with-default-values" module="defaultValueSelect"> + * <file name="index.html"> + * <div ng-controller="ExampleController"> + * <form name="myForm"> + * <label for="mySelect">Make a choice:</label> + * <select name="mySelect" id="mySelect" + * ng-options="option.name for option in data.availableOptions track by option.id" + * ng-model="data.selectedOption"></select> + * </form> + * <hr> + * <tt>option = {{data.selectedOption}}</tt><br/> + * </div> + * </file> + * <file name="app.js"> + * angular.module('defaultValueSelect', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.data = { + * availableOptions: [ + * {id: '1', name: 'Option A'}, + * {id: '2', name: 'Option B'}, + * {id: '3', name: 'Option C'} + * ], + * selectedOption: {id: '3', name: 'Option C'} //This sets the default value of the select in the ui + * }; + * }]); + * </file> + *</example> + * + * @example + * ### Binding `select` to a non-string value via `ngModel` parsing / formatting + * + * <example name="select-with-non-string-options" module="nonStringSelect"> + * <file name="index.html"> + * <select ng-model="model.id" convert-to-number> + * <option value="0">Zero</option> + * <option value="1">One</option> + * <option value="2">Two</option> + * </select> + * {{ model }} + * </file> + * <file name="app.js"> + * angular.module('nonStringSelect', []) + * .run(function($rootScope) { + * $rootScope.model = { id: 2 }; + * }) + * .directive('convertToNumber', function() { + * return { + * require: 'ngModel', + * link: function(scope, element, attrs, ngModel) { + * ngModel.$parsers.push(function(val) { + * return parseInt(val, 10); + * }); + * ngModel.$formatters.push(function(val) { + * return '' + val; + * }); + * } + * }; + * }); + * </file> + * <file name="protractor.js" type="protractor"> + * it('should initialize to model', function() { + * expect(element(by.model('model.id')).$('option:checked').getText()).toEqual('Two'); + * }); + * </file> + * </example> + * + */ + var selectDirective = function() { - key = index; - if (keyName) { - key = keys[index]; - if ( key.charAt(0) === '$' ) continue; - locals[keyName] = key; - } + return { + restrict: 'E', + require: ['select', '?ngModel'], + controller: SelectController, + priority: 1, + link: { + pre: selectPreLink, + post: selectPostLink + } + }; - locals[valueName] = values[key]; + function selectPreLink(scope, element, attr, ctrls) { - optionGroupName = groupByFn(scope, locals) || ''; - if (!(optionGroup = optionGroups[optionGroupName])) { - optionGroup = optionGroups[optionGroupName] = []; - optionGroupNames.push(optionGroupName); - } - if (multiple) { - selected = isDefined( - selectedSet.remove(trackFn ? trackFn(scope, locals) : valueFn(scope, locals)) - ); - } else { - if (trackFn) { - var modelCast = {}; - modelCast[valueName] = modelValue; - selected = trackFn(scope, modelCast) === trackFn(scope, locals); - } else { - selected = modelValue === valueFn(scope, locals); - } - selectedSet = selectedSet || selected; // see if at least one item is selected - } - label = displayFn(scope, locals); // what will be seen by the user - - // doing displayFn(scope, locals) || '' overwrites zero values - label = isDefined(label) ? label : ''; - optionGroup.push({ - // either the index into array or key from object - id: trackFn ? trackFn(scope, locals) : (keyName ? keys[index] : index), - label: label, - selected: selected // determine if we should be selected - }); - } - if (!multiple) { - if (nullOption || modelValue === null) { - // insert null option if we have a placeholder, or the model is null - optionGroups[''].unshift({id:'', label:'', selected:!selectedSet}); - } else if (!selectedSet) { - // option could not be found, we have to insert the undefined item - optionGroups[''].unshift({id:'?', label:'', selected:true}); - } - } + var selectCtrl = ctrls[0]; + var ngModelCtrl = ctrls[1]; - // Now we need to update the list of DOM nodes to match the optionGroups we computed above - for (groupIndex = 0, groupLength = optionGroupNames.length; - groupIndex < groupLength; - groupIndex++) { - // current option group name or '' if no group - optionGroupName = optionGroupNames[groupIndex]; - - // list of options for that group. (first item has the parent) - optionGroup = optionGroups[optionGroupName]; - - if (optionGroupsCache.length <= groupIndex) { - // we need to grow the optionGroups - existingParent = { - element: optGroupTemplate.clone().attr('label', optionGroupName), - label: optionGroup.label - }; - existingOptions = [existingParent]; - optionGroupsCache.push(existingOptions); - selectElement.append(existingParent.element); - } else { - existingOptions = optionGroupsCache[groupIndex]; - existingParent = existingOptions[0]; // either SELECT (no group) or OPTGROUP element + // if ngModel is not defined, we don't need to do anything but set the registerOption + // function to noop, so options don't get added internally + if (!ngModelCtrl) { + selectCtrl.registerOption = noop; + return; + } - // update the OPTGROUP label if not the same. - if (existingParent.label != optionGroupName) { - existingParent.element.attr('label', existingParent.label = optionGroupName); - } - } - lastElement = null; // start at the beginning - for(index = 0, length = optionGroup.length; index < length; index++) { - option = optionGroup[index]; - if ((existingOption = existingOptions[index+1])) { - // reuse elements - lastElement = existingOption.element; - if (existingOption.label !== option.label) { - lastElement.text(existingOption.label = option.label); - } - if (existingOption.id !== option.id) { - lastElement.val(existingOption.id = option.id); - } - // lastElement.prop('selected') provided by jQuery has side-effects - if (existingOption.selected !== option.selected) { - lastElement.prop('selected', (existingOption.selected = option.selected)); - } - } else { - // grow elements + selectCtrl.ngModelCtrl = ngModelCtrl; - // if it's a null option - if (option.id === '' && nullOption) { - // put back the pre-compiled element - element = nullOption; - } else { - // jQuery(v1.4.2) Bug: We should be able to chain the method calls, but - // in this version of jQuery on some browser the .text() returns a string - // rather then the element. - (element = optionTemplate.clone()) - .val(option.id) - .attr('selected', option.selected) - .text(option.label); - } + // When the selected item(s) changes we delegate getting the value of the select control + // to the `readValue` method, which can be changed if the select can have multiple + // selected values or if the options are being generated by `ngOptions` + element.on('change', function() { + selectCtrl.removeUnknownOption(); + scope.$apply(function() { + ngModelCtrl.$setViewValue(selectCtrl.readValue()); + }); + }); - existingOptions.push(existingOption = { - element: element, - label: option.label, - id: option.id, - selected: option.selected - }); - if (lastElement) { - lastElement.after(element); - } else { - existingParent.element.append(element); - } - lastElement = element; - } - } - // remove any excessive OPTIONs in a group - index++; // increment since the existingOptions[0] is parent element not OPTION - while(existingOptions.length > index) { - existingOptions.pop().element.remove(); - } + // If the select allows multiple values then we need to modify how we read and write + // values from and to the control; also what it means for the value to be empty and + // we have to add an extra watch since ngModel doesn't work well with arrays - it + // doesn't trigger rendering if only an item in the array changes. + if (attr.multiple) { + selectCtrl.multiple = true; + + // Read value now needs to check each option to see if it is selected + selectCtrl.readValue = function readMultipleValue() { + var array = []; + forEach(element.find('option'), function(option) { + if (option.selected && !option.disabled) { + var val = option.value; + array.push(val in selectCtrl.selectValueMap ? selectCtrl.selectValueMap[val] : val); } - // remove any excessive OPTGROUPs from select - while(optionGroupsCache.length > groupIndex) { - optionGroupsCache.pop()[0].element.remove(); + }); + return array; + }; + + // Write value now needs to set the selected property of each matching option + selectCtrl.writeValue = function writeMultipleValue(value) { + forEach(element.find('option'), function(option) { + var shouldBeSelected = !!value && (includes(value, option.value) || + includes(value, selectCtrl.selectValueMap[option.value])); + var currentlySelected = option.selected; + + // Support: IE 9-11 only, Edge 12-15+ + // In IE and Edge adding options to the selection via shift+click/UP/DOWN + // will de-select already selected options if "selected" on those options was set + // more than once (i.e. when the options were already selected) + // So we only modify the selected property if necessary. + // Note: this behavior cannot be replicated via unit tests because it only shows in the + // actual user interface. + if (shouldBeSelected !== currentlySelected) { + setOptionSelectedStatus(jqLite(option), shouldBeSelected); } + + }); + }; + + // we have to do it on each watch since ngModel watches reference, but + // we need to work of an array, so we need to see if anything was inserted/removed + var lastView, lastViewRef = NaN; + scope.$watch(function selectMultipleWatch() { + if (lastViewRef === ngModelCtrl.$viewValue && !equals(lastView, ngModelCtrl.$viewValue)) { + lastView = shallowCopy(ngModelCtrl.$viewValue); + ngModelCtrl.$render(); } - } + lastViewRef = ngModelCtrl.$viewValue; + }); + + // If we are a multiple select then value is now a collection + // so the meaning of $isEmpty changes + ngModelCtrl.$isEmpty = function(value) { + return !value || value.length === 0; + }; + } - }; - }]; + } - var optionDirective = ['$interpolate', function($interpolate) { - var nullSelectCtrl = { - addOption: noop, - removeOption: noop - }; + function selectPostLink(scope, element, attrs, ctrls) { + // if ngModel is not defined, we don't need to do anything + var ngModelCtrl = ctrls[1]; + if (!ngModelCtrl) return; + + var selectCtrl = ctrls[0]; + + // We delegate rendering to the `writeValue` method, which can be changed + // if the select can have multiple selected values or if the options are being + // generated by `ngOptions`. + // This must be done in the postLink fn to prevent $render to be called before + // all nodes have been linked correctly. + ngModelCtrl.$render = function() { + selectCtrl.writeValue(ngModelCtrl.$viewValue); + }; + } + }; + +// The option directive is purely designed to communicate the existence (or lack of) +// of dynamically created (and destroyed) option elements to their containing select +// directive via its controller. + var optionDirective = ['$interpolate', function($interpolate) { return { restrict: 'E', priority: 100, compile: function(element, attr) { - if (isUndefined(attr.value)) { - var interpolateFn = $interpolate(element.text(), true); - if (!interpolateFn) { + var interpolateValueFn, interpolateTextFn; + + if (isDefined(attr.ngValue)) { + // Will be handled by registerOption + } else if (isDefined(attr.value)) { + // If the value attribute is defined, check if it contains an interpolation + interpolateValueFn = $interpolate(attr.value, true); + } else { + // If the value attribute is not defined then we fall back to the + // text content of the option element, which may be interpolated + interpolateTextFn = $interpolate(element.text(), true); + if (!interpolateTextFn) { attr.$set('value', element.text()); } } - return function (scope, element, attr) { + return function(scope, element, attr) { + // This is an optimization over using ^^ since we don't want to have to search + // all the way to the root of the DOM for every single option element var selectCtrlName = '$selectController', parent = element.parent(), selectCtrl = parent.data(selectCtrlName) || parent.parent().data(selectCtrlName); // in case we are in optgroup - if (selectCtrl && selectCtrl.databound) { - // For some reason Opera defaults to true and if not overridden this messes up the repeater. - // We don't want the view to drive the initialization of the model anyway. - element.prop('selected', false); - } else { - selectCtrl = nullSelectCtrl; + if (selectCtrl) { + selectCtrl.registerOption(scope, element, attr, interpolateValueFn, interpolateTextFn); } + }; + } + }; + }]; - if (interpolateFn) { - scope.$watch(interpolateFn, function interpolateWatchAction(newVal, oldVal) { - attr.$set('value', newVal); - if (newVal !== oldVal) selectCtrl.removeOption(oldVal); - selectCtrl.addOption(newVal); - }); - } else { - selectCtrl.addOption(attr.value); + /** + * @ngdoc directive + * @name ngRequired + * @restrict A + * + * @param {expression} ngRequired AngularJS expression. If it evaluates to `true`, it sets the + * `required` attribute to the element and adds the `required` + * {@link ngModel.NgModelController#$validators `validator`}. + * + * @description + * + * ngRequired adds the required {@link ngModel.NgModelController#$validators `validator`} to {@link ngModel `ngModel`}. + * It is most often used for {@link input `input`} and {@link select `select`} controls, but can also be + * applied to custom controls. + * + * The directive sets the `required` attribute on the element if the AngularJS expression inside + * `ngRequired` evaluates to true. A special directive for setting `required` is necessary because we + * cannot use interpolation inside `required`. See the {@link guide/interpolation interpolation guide} + * for more info. + * + * The validator will set the `required` error key to true if the `required` attribute is set and + * calling {@link ngModel.NgModelController#$isEmpty `NgModelController.$isEmpty`} with the + * {@link ngModel.NgModelController#$viewValue `ngModel.$viewValue`} returns `true`. For example, the + * `$isEmpty()` implementation for `input[text]` checks the length of the `$viewValue`. When developing + * custom controls, `$isEmpty()` can be overwritten to account for a $viewValue that is not string-based. + * + * @example + * <example name="ngRequiredDirective" module="ngRequiredExample"> + * <file name="index.html"> + * <script> + * angular.module('ngRequiredExample', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.required = true; + * }]); + * </script> + * <div ng-controller="ExampleController"> + * <form name="form"> + * <label for="required">Toggle required: </label> + * <input type="checkbox" ng-model="required" id="required" /> + * <br> + * <label for="input">This input must be filled if `required` is true: </label> + * <input type="text" ng-model="model" id="input" name="input" ng-required="required" /><br> + * <hr> + * required error set? = <code>{{form.input.$error.required}}</code><br> + * model = <code>{{model}}</code> + * </form> + * </div> + * </file> + * <file name="protractor.js" type="protractor"> + var required = element(by.binding('form.input.$error.required')); + var model = element(by.binding('model')); + var input = element(by.id('input')); + + it('should set the required error', function() { + expect(required.getText()).toContain('true'); + + input.sendKeys('123'); + expect(required.getText()).not.toContain('true'); + expect(model.getText()).toContain('123'); + }); + * </file> + * </example> + */ + var requiredDirective = function() { + return { + restrict: 'A', + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + attr.required = true; // force truthy in case we are on non input element + + ctrl.$validators.required = function(modelValue, viewValue) { + return !attr.required || !ctrl.$isEmpty(viewValue); + }; + + attr.$observe('required', function() { + ctrl.$validate(); + }); + } + }; + }; + + /** + * @ngdoc directive + * @name ngPattern + * @restrict A + * + * @param {expression|RegExp} ngPattern AngularJS expression that must evaluate to a `RegExp` or a `String` + * parsable into a `RegExp`, or a `RegExp` literal. See above for + * more details. + * + * @description + * + * ngPattern adds the pattern {@link ngModel.NgModelController#$validators `validator`} to {@link ngModel `ngModel`}. + * It is most often used for text-based {@link input `input`} controls, but can also be applied to custom text-based controls. + * + * The validator sets the `pattern` error key if the {@link ngModel.NgModelController#$viewValue `ngModel.$viewValue`} + * does not match a RegExp which is obtained from the `ngPattern` attribute value: + * - the value is an AngularJS expression: + * - If the expression evaluates to a RegExp object, then this is used directly. + * - If the expression evaluates to a string, then it will be converted to a RegExp after wrapping it + * in `^` and `$` characters. For instance, `"abc"` will be converted to `new RegExp('^abc$')`. + * - If the value is a RegExp literal, e.g. `ngPattern="/^\d+$/"`, it is used directly. + * + * <div class="alert alert-info"> + * **Note:** Avoid using the `g` flag on the RegExp, as it will cause each successive search to + * start at the index of the last search's match, thus not taking the whole input value into + * account. + * </div> + * + * <div class="alert alert-info"> + * **Note:** This directive is also added when the plain `pattern` attribute is used, with two + * differences: + * <ol> + * <li> + * `ngPattern` does not set the `pattern` attribute and therefore HTML5 constraint validation is + * not available. + * </li> + * <li> + * The `ngPattern` attribute must be an expression, while the `pattern` value must be + * interpolated. + * </li> + * </ol> + * </div> + * + * @example + * <example name="ngPatternDirective" module="ngPatternExample"> + * <file name="index.html"> + * <script> + * angular.module('ngPatternExample', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.regex = '\\d+'; + * }]); + * </script> + * <div ng-controller="ExampleController"> + * <form name="form"> + * <label for="regex">Set a pattern (regex string): </label> + * <input type="text" ng-model="regex" id="regex" /> + * <br> + * <label for="input">This input is restricted by the current pattern: </label> + * <input type="text" ng-model="model" id="input" name="input" ng-pattern="regex" /><br> + * <hr> + * input valid? = <code>{{form.input.$valid}}</code><br> + * model = <code>{{model}}</code> + * </form> + * </div> + * </file> + * <file name="protractor.js" type="protractor"> + var model = element(by.binding('model')); + var input = element(by.id('input')); + + it('should validate the input with the default pattern', function() { + input.sendKeys('aaa'); + expect(model.getText()).not.toContain('aaa'); + + input.clear().then(function() { + input.sendKeys('123'); + expect(model.getText()).toContain('123'); + }); + }); + * </file> + * </example> + */ + var patternDirective = function() { + return { + restrict: 'A', + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + + var regexp, patternExp = attr.ngPattern || attr.pattern; + attr.$observe('pattern', function(regex) { + if (isString(regex) && regex.length > 0) { + regex = new RegExp('^' + regex + '$'); } - element.on('$destroy', function() { - selectCtrl.removeOption(attr.value); - }); + if (regex && !regex.test) { + throw minErr('ngPattern')('noregexp', + 'Expected {0} to be a RegExp but was {1}. Element: {2}', patternExp, + regex, startingTag(elm)); + } + + regexp = regex || undefined; + ctrl.$validate(); + }); + + ctrl.$validators.pattern = function(modelValue, viewValue) { + // HTML5 pattern constraint validates the input value, so we validate the viewValue + return ctrl.$isEmpty(viewValue) || isUndefined(regexp) || regexp.test(viewValue); }; } }; - }]; + }; - var styleDirective = valueFn({ - restrict: 'E', - terminal: true - }); + /** + * @ngdoc directive + * @name ngMaxlength + * @restrict A + * + * @param {expression} ngMaxlength AngularJS expression that must evaluate to a `Number` or `String` + * parsable into a `Number`. Used as value for the `maxlength` + * {@link ngModel.NgModelController#$validators validator}. + * + * @description + * + * ngMaxlength adds the maxlength {@link ngModel.NgModelController#$validators `validator`} to {@link ngModel `ngModel`}. + * It is most often used for text-based {@link input `input`} controls, but can also be applied to custom text-based controls. + * + * The validator sets the `maxlength` error key if the {@link ngModel.NgModelController#$viewValue `ngModel.$viewValue`} + * is longer than the integer obtained by evaluating the AngularJS expression given in the + * `ngMaxlength` attribute value. + * + * <div class="alert alert-info"> + * **Note:** This directive is also added when the plain `maxlength` attribute is used, with two + * differences: + * <ol> + * <li> + * `ngMaxlength` does not set the `maxlength` attribute and therefore HTML5 constraint + * validation is not available. + * </li> + * <li> + * The `ngMaxlength` attribute must be an expression, while the `maxlength` value must be + * interpolated. + * </li> + * </ol> + * </div> + * + * @example + * <example name="ngMaxlengthDirective" module="ngMaxlengthExample"> + * <file name="index.html"> + * <script> + * angular.module('ngMaxlengthExample', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.maxlength = 5; + * }]); + * </script> + * <div ng-controller="ExampleController"> + * <form name="form"> + * <label for="maxlength">Set a maxlength: </label> + * <input type="number" ng-model="maxlength" id="maxlength" /> + * <br> + * <label for="input">This input is restricted by the current maxlength: </label> + * <input type="text" ng-model="model" id="input" name="input" ng-maxlength="maxlength" /><br> + * <hr> + * input valid? = <code>{{form.input.$valid}}</code><br> + * model = <code>{{model}}</code> + * </form> + * </div> + * </file> + * <file name="protractor.js" type="protractor"> + var model = element(by.binding('model')); + var input = element(by.id('input')); + + it('should validate the input with the default maxlength', function() { + input.sendKeys('abcdef'); + expect(model.getText()).not.toContain('abcdef'); + + input.clear().then(function() { + input.sendKeys('abcde'); + expect(model.getText()).toContain('abcde'); + }); + }); + * </file> + * </example> + */ + var maxlengthDirective = function() { + return { + restrict: 'A', + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + + var maxlength = -1; + attr.$observe('maxlength', function(value) { + var intVal = toInt(value); + maxlength = isNumberNaN(intVal) ? -1 : intVal; + ctrl.$validate(); + }); + ctrl.$validators.maxlength = function(modelValue, viewValue) { + return (maxlength < 0) || ctrl.$isEmpty(viewValue) || (viewValue.length <= maxlength); + }; + } + }; + }; + + /** + * @ngdoc directive + * @name ngMinlength + * @restrict A + * + * @param {expression} ngMinlength AngularJS expression that must evaluate to a `Number` or `String` + * parsable into a `Number`. Used as value for the `minlength` + * {@link ngModel.NgModelController#$validators validator}. + * + * @description + * + * ngMinlength adds the minlength {@link ngModel.NgModelController#$validators `validator`} to {@link ngModel `ngModel`}. + * It is most often used for text-based {@link input `input`} controls, but can also be applied to custom text-based controls. + * + * The validator sets the `minlength` error key if the {@link ngModel.NgModelController#$viewValue `ngModel.$viewValue`} + * is shorter than the integer obtained by evaluating the AngularJS expression given in the + * `ngMinlength` attribute value. + * + * <div class="alert alert-info"> + * **Note:** This directive is also added when the plain `minlength` attribute is used, with two + * differences: + * <ol> + * <li> + * `ngMinlength` does not set the `minlength` attribute and therefore HTML5 constraint + * validation is not available. + * </li> + * <li> + * The `ngMinlength` value must be an expression, while the `minlength` value must be + * interpolated. + * </li> + * </ol> + * </div> + * + * @example + * <example name="ngMinlengthDirective" module="ngMinlengthExample"> + * <file name="index.html"> + * <script> + * angular.module('ngMinlengthExample', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.minlength = 3; + * }]); + * </script> + * <div ng-controller="ExampleController"> + * <form name="form"> + * <label for="minlength">Set a minlength: </label> + * <input type="number" ng-model="minlength" id="minlength" /> + * <br> + * <label for="input">This input is restricted by the current minlength: </label> + * <input type="text" ng-model="model" id="input" name="input" ng-minlength="minlength" /><br> + * <hr> + * input valid? = <code>{{form.input.$valid}}</code><br> + * model = <code>{{model}}</code> + * </form> + * </div> + * </file> + * <file name="protractor.js" type="protractor"> + var model = element(by.binding('model')); + var input = element(by.id('input')); + + it('should validate the input with the default minlength', function() { + input.sendKeys('ab'); + expect(model.getText()).not.toContain('ab'); + + input.sendKeys('abc'); + expect(model.getText()).toContain('abc'); + }); + * </file> + * </example> + */ + var minlengthDirective = function() { + return { + restrict: 'A', + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + + var minlength = 0; + attr.$observe('minlength', function(value) { + minlength = toInt(value) || 0; + ctrl.$validate(); + }); + ctrl.$validators.minlength = function(modelValue, viewValue) { + return ctrl.$isEmpty(viewValue) || viewValue.length >= minlength; + }; + } + }; + }; if (window.angular.bootstrap) { - //AngularJS is already loaded, so we can return here... - console.log('WARNING: Tried to load angular more than once.'); + // AngularJS is already loaded, so we can return here... + if (window.console) { + console.log('WARNING: Tried to load AngularJS more than once.'); + } return; } - //try to bind to jquery now so that one can write angular.element().read() - //but we will rebind on bootstrap again. +// try to bind to jquery now so that one can write jqLite(fn) +// but we will rebind on bootstrap again. bindJQuery(); publishExternalAPI(angular); - jqLite(document).ready(function() { - angularInit(document, bootstrap); + angular.module("ngLocale", [], ["$provide", function($provide) { + var PLURAL_CATEGORY = {ZERO: "zero", ONE: "one", TWO: "two", FEW: "few", MANY: "many", OTHER: "other"}; + function getDecimals(n) { + n = n + ''; + var i = n.indexOf('.'); + return (i == -1) ? 0 : n.length - i - 1; + } + + function getVF(n, opt_precision) { + var v = opt_precision; + + if (undefined === v) { + v = Math.min(getDecimals(n), 3); + } + + var base = Math.pow(10, v); + var f = ((n * base) | 0) % base; + return {v: v, f: f}; + } + + $provide.value("$locale", { + "DATETIME_FORMATS": { + "AMPMS": [ + "AM", + "PM" + ], + "DAY": [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday" + ], + "ERANAMES": [ + "Before Christ", + "Anno Domini" + ], + "ERAS": [ + "BC", + "AD" + ], + "FIRSTDAYOFWEEK": 6, + "MONTH": [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December" + ], + "SHORTDAY": [ + "Sun", + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat" + ], + "SHORTMONTH": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ], + "STANDALONEMONTH": [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December" + ], + "WEEKENDRANGE": [ + 5, + 6 + ], + "fullDate": "EEEE, MMMM d, y", + "longDate": "MMMM d, y", + "medium": "MMM d, y h:mm:ss a", + "mediumDate": "MMM d, y", + "mediumTime": "h:mm:ss a", + "short": "M/d/yy h:mm a", + "shortDate": "M/d/yy", + "shortTime": "h:mm a" + }, + "NUMBER_FORMATS": { + "CURRENCY_SYM": "$", + "DECIMAL_SEP": ".", + "GROUP_SEP": ",", + "PATTERNS": [ + { + "gSize": 3, + "lgSize": 3, + "maxFrac": 3, + "minFrac": 0, + "minInt": 1, + "negPre": "-", + "negSuf": "", + "posPre": "", + "posSuf": "" + }, + { + "gSize": 3, + "lgSize": 3, + "maxFrac": 2, + "minFrac": 2, + "minInt": 1, + "negPre": "-\u00a4", + "negSuf": "", + "posPre": "\u00a4", + "posSuf": "" + } + ] + }, + "id": "en-us", + "localeID": "en_US", + "pluralCat": function(n, opt_precision) { var i = n | 0; var vf = getVF(n, opt_precision); if (i == 1 && vf.v == 0) { return PLURAL_CATEGORY.ONE; } return PLURAL_CATEGORY.OTHER;} + }); + }]); + + jqLite(function() { + angularInit(window.document, bootstrap); }); -})(window, document); +})(window); -!angular.$$csp() && angular.element(document).find('head').prepend('<style type="text/css">@charset "UTF-8";[ng\\:cloak],[ng-cloak],[data-ng-cloak],[x-ng-cloak],.ng-cloak,.x-ng-cloak,.ng-hide{display:none !important;}ng\\:form{display:block;}.ng-animate-block-transitions{transition:0s all!important;-webkit-transition:0s all!important;}</style>'); \ No newline at end of file +!window.angular.$$csp().noInlineStyle && window.angular.element(document.head).prepend('<style type="text/css">@charset "UTF-8";[ng\\:cloak],[ng-cloak],[data-ng-cloak],[x-ng-cloak],.ng-cloak,.x-ng-cloak,.ng-hide:not(.ng-hide-animate){display:none !important;}ng\\:form{display:block;}.ng-animate-shim{visibility:hidden;}.ng-anchor{position:absolute;}</style>'); \ No newline at end of file diff --git a/setup/pub/angular/angular.min.js b/setup/pub/angular/angular.min.js index c7d2ea7388e33..4c57937335798 100644 --- a/setup/pub/angular/angular.min.js +++ b/setup/pub/angular/angular.min.js @@ -1,212 +1,337 @@ /* - AngularJS v1.2.17-build.178+sha.2406084 - (c) 2010-2014 Google, Inc. http://angularjs.org + AngularJS v1.6.9 + (c) 2010-2018 Google, Inc. http://angularjs.org License: MIT */ -(function(P,U,s){'use strict';function u(b){return function(){var a=arguments[0],c,a="["+(b?b+":":"")+a+"] http://errors.angularjs.org/1.2.17-build.178+sha.2406084/"+(b?b+"/":"")+a;for(c=1;c<arguments.length;c++)a=a+(1==c?"?":"&")+"p"+(c-1)+"="+encodeURIComponent("function"==typeof arguments[c]?arguments[c].toString().replace(/ \{[\s\S]*$/,""):"undefined"==typeof arguments[c]?"undefined":"string"!=typeof arguments[c]?JSON.stringify(arguments[c]):arguments[c]);return Error(a)}}function bb(b){if(null== -b||Da(b))return!1;var a=b.length;return 1===b.nodeType&&a?!0:C(b)||L(b)||0===a||"number"===typeof a&&0<a&&a-1 in b}function q(b,a,c){var d;if(b)if(Q(b))for(d in b)"prototype"==d||("length"==d||"name"==d||b.hasOwnProperty&&!b.hasOwnProperty(d))||a.call(c,b[d],d);else if(b.forEach&&b.forEach!==q)b.forEach(a,c);else if(bb(b))for(d=0;d<b.length;d++)a.call(c,b[d],d);else for(d in b)b.hasOwnProperty(d)&&a.call(c,b[d],d);return b}function Ub(b){var a=[],c;for(c in b)b.hasOwnProperty(c)&&a.push(c);return a.sort()} -function Sc(b,a,c){for(var d=Ub(b),e=0;e<d.length;e++)a.call(c,b[d[e]],d[e]);return d}function Vb(b){return function(a,c){b(c,a)}}function cb(){for(var b=ka.length,a;b;){b--;a=ka[b].charCodeAt(0);if(57==a)return ka[b]="A",ka.join("");if(90==a)ka[b]="0";else return ka[b]=String.fromCharCode(a+1),ka.join("")}ka.unshift("0");return ka.join("")}function Wb(b,a){a?b.$$hashKey=a:delete b.$$hashKey}function E(b){var a=b.$$hashKey;q(arguments,function(a){a!==b&&q(a,function(a,c){b[c]=a})});Wb(b,a);return b} -function Y(b){return parseInt(b,10)}function Xb(b,a){return E(new (E(function(){},{prototype:b})),a)}function w(){}function Ea(b){return b}function aa(b){return function(){return b}}function H(b){return"undefined"===typeof b}function z(b){return"undefined"!==typeof b}function X(b){return null!=b&&"object"===typeof b}function C(b){return"string"===typeof b}function yb(b){return"number"===typeof b}function Ma(b){return"[object Date]"===wa.call(b)}function L(b){return"[object Array]"===wa.call(b)}function Q(b){return"function"=== -typeof b}function db(b){return"[object RegExp]"===wa.call(b)}function Da(b){return b&&b.document&&b.location&&b.alert&&b.setInterval}function Tc(b){return!(!b||!(b.nodeName||b.prop&&b.attr&&b.find))}function Uc(b,a,c){var d=[];q(b,function(b,g,f){d.push(a.call(c,b,g,f))});return d}function eb(b,a){if(b.indexOf)return b.indexOf(a);for(var c=0;c<b.length;c++)if(a===b[c])return c;return-1}function Na(b,a){var c=eb(b,a);0<=c&&b.splice(c,1);return a}function ca(b,a){if(Da(b)||b&&b.$evalAsync&&b.$watch)throw Oa("cpws"); -if(a){if(b===a)throw Oa("cpi");if(L(b))for(var c=a.length=0;c<b.length;c++)a.push(ca(b[c]));else{c=a.$$hashKey;q(a,function(b,c){delete a[c]});for(var d in b)a[d]=ca(b[d]);Wb(a,c)}}else(a=b)&&(L(b)?a=ca(b,[]):Ma(b)?a=new Date(b.getTime()):db(b)?a=RegExp(b.source):X(b)&&(a=ca(b,{})));return a}function Yb(b,a){a=a||{};for(var c in b)!b.hasOwnProperty(c)||"$"===c.charAt(0)&&"$"===c.charAt(1)||(a[c]=b[c]);return a}function xa(b,a){if(b===a)return!0;if(null===b||null===a)return!1;if(b!==b&&a!==a)return!0; -var c=typeof b,d;if(c==typeof a&&"object"==c)if(L(b)){if(!L(a))return!1;if((c=b.length)==a.length){for(d=0;d<c;d++)if(!xa(b[d],a[d]))return!1;return!0}}else{if(Ma(b))return Ma(a)&&b.getTime()==a.getTime();if(db(b)&&db(a))return b.toString()==a.toString();if(b&&b.$evalAsync&&b.$watch||a&&a.$evalAsync&&a.$watch||Da(b)||Da(a)||L(a))return!1;c={};for(d in b)if("$"!==d.charAt(0)&&!Q(b[d])){if(!xa(b[d],a[d]))return!1;c[d]=!0}for(d in a)if(!c.hasOwnProperty(d)&&"$"!==d.charAt(0)&&a[d]!==s&&!Q(a[d]))return!1; -return!0}return!1}function Zb(){return U.securityPolicy&&U.securityPolicy.isActive||U.querySelector&&!(!U.querySelector("[ng-csp]")&&!U.querySelector("[data-ng-csp]"))}function fb(b,a){var c=2<arguments.length?ya.call(arguments,2):[];return!Q(a)||a instanceof RegExp?a:c.length?function(){return arguments.length?a.apply(b,c.concat(ya.call(arguments,0))):a.apply(b,c)}:function(){return arguments.length?a.apply(b,arguments):a.call(b)}}function Vc(b,a){var c=a;"string"===typeof b&&"$"===b.charAt(0)?c= -s:Da(a)?c="$WINDOW":a&&U===a?c="$DOCUMENT":a&&(a.$evalAsync&&a.$watch)&&(c="$SCOPE");return c}function qa(b,a){return"undefined"===typeof b?s:JSON.stringify(b,Vc,a?" ":null)}function $b(b){return C(b)?JSON.parse(b):b}function Pa(b){"function"===typeof b?b=!0:b&&0!==b.length?(b=J(""+b),b=!("f"==b||"0"==b||"false"==b||"no"==b||"n"==b||"[]"==b)):b=!1;return b}function ia(b){b=y(b).clone();try{b.empty()}catch(a){}var c=y("<div>").append(b).html();try{return 3===b[0].nodeType?J(c):c.match(/^(<[^>]+>)/)[1].replace(/^<([\w\-]+)/, -function(a,b){return"<"+J(b)})}catch(d){return J(c)}}function ac(b){try{return decodeURIComponent(b)}catch(a){}}function bc(b){var a={},c,d;q((b||"").split("&"),function(b){b&&(c=b.split("="),d=ac(c[0]),z(d)&&(b=z(c[1])?ac(c[1]):!0,a[d]?L(a[d])?a[d].push(b):a[d]=[a[d],b]:a[d]=b))});return a}function zb(b){var a=[];q(b,function(b,d){L(b)?q(b,function(b){a.push(za(d,!0)+(!0===b?"":"="+za(b,!0)))}):a.push(za(d,!0)+(!0===b?"":"="+za(b,!0)))});return a.length?a.join("&"):""}function gb(b){return za(b, -!0).replace(/%26/gi,"&").replace(/%3D/gi,"=").replace(/%2B/gi,"+")}function za(b,a){return encodeURIComponent(b).replace(/%40/gi,"@").replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,a?"%20":"+")}function Wc(b,a){function c(a){a&&d.push(a)}var d=[b],e,g,f=["ng:app","ng-app","x-ng-app","data-ng-app"],h=/\sng[:\-]app(:\s*([\w\d_]+);?)?\s/;q(f,function(a){f[a]=!0;c(U.getElementById(a));a=a.replace(":","\\:");b.querySelectorAll&&(q(b.querySelectorAll("."+a),c),q(b.querySelectorAll("."+ -a+"\\:"),c),q(b.querySelectorAll("["+a+"]"),c))});q(d,function(a){if(!e){var b=h.exec(" "+a.className+" ");b?(e=a,g=(b[2]||"").replace(/\s+/g,",")):q(a.attributes,function(b){!e&&f[b.name]&&(e=a,g=b.value)})}});e&&a(e,g?[g]:[])}function cc(b,a){var c=function(){b=y(b);if(b.injector()){var c=b[0]===U?"document":ia(b);throw Oa("btstrpd",c);}a=a||[];a.unshift(["$provide",function(a){a.value("$rootElement",b)}]);a.unshift("ng");c=dc(a);c.invoke(["$rootScope","$rootElement","$compile","$injector","$animate", -function(a,b,c,d,e){a.$apply(function(){b.data("$injector",d);c(b)(a)})}]);return c},d=/^NG_DEFER_BOOTSTRAP!/;if(P&&!d.test(P.name))return c();P.name=P.name.replace(d,"");Qa.resumeBootstrap=function(b){q(b,function(b){a.push(b)});c()}}function hb(b,a){a=a||"_";return b.replace(Xc,function(b,d){return(d?a:"")+b.toLowerCase()})}function Ab(b,a,c){if(!b)throw Oa("areq",a||"?",c||"required");return b}function Ra(b,a,c){c&&L(b)&&(b=b[b.length-1]);Ab(Q(b),a,"not a function, got "+(b&&"object"==typeof b? -b.constructor.name||"Object":typeof b));return b}function Aa(b,a){if("hasOwnProperty"===b)throw Oa("badname",a);}function ec(b,a,c){if(!a)return b;a=a.split(".");for(var d,e=b,g=a.length,f=0;f<g;f++)d=a[f],b&&(b=(e=b)[d]);return!c&&Q(b)?fb(e,b):b}function Bb(b){var a=b[0];b=b[b.length-1];if(a===b)return y(a);var c=[a];do{a=a.nextSibling;if(!a)break;c.push(a)}while(a!==b);return y(c)}function Yc(b){var a=u("$injector"),c=u("ng");b=b.angular||(b.angular={});b.$$minErr=b.$$minErr||u;return b.module|| -(b.module=function(){var b={};return function(e,g,f){if("hasOwnProperty"===e)throw c("badname","module");g&&b.hasOwnProperty(e)&&(b[e]=null);return b[e]||(b[e]=function(){function b(a,d,e){return function(){c[e||"push"]([a,d,arguments]);return n}}if(!g)throw a("nomod",e);var c=[],d=[],l=b("$injector","invoke"),n={_invokeQueue:c,_runBlocks:d,requires:g,name:e,provider:b("$provide","provider"),factory:b("$provide","factory"),service:b("$provide","service"),value:b("$provide","value"),constant:b("$provide", -"constant","unshift"),animation:b("$animateProvider","register"),filter:b("$filterProvider","register"),controller:b("$controllerProvider","register"),directive:b("$compileProvider","directive"),config:l,run:function(a){d.push(a);return this}};f&&l(f);return n}())}}())}function Zc(b){E(b,{bootstrap:cc,copy:ca,extend:E,equals:xa,element:y,forEach:q,injector:dc,noop:w,bind:fb,toJson:qa,fromJson:$b,identity:Ea,isUndefined:H,isDefined:z,isString:C,isFunction:Q,isObject:X,isNumber:yb,isElement:Tc,isArray:L, -version:$c,isDate:Ma,lowercase:J,uppercase:Fa,callbacks:{counter:0},$$minErr:u,$$csp:Zb});Sa=Yc(P);try{Sa("ngLocale")}catch(a){Sa("ngLocale",[]).provider("$locale",ad)}Sa("ng",["ngLocale"],["$provide",function(a){a.provider({$$sanitizeUri:bd});a.provider("$compile",fc).directive({a:cd,input:gc,textarea:gc,form:dd,script:ed,select:fd,style:gd,option:hd,ngBind:id,ngBindHtml:jd,ngBindTemplate:kd,ngClass:ld,ngClassEven:md,ngClassOdd:nd,ngCloak:od,ngController:pd,ngForm:qd,ngHide:rd,ngIf:sd,ngInclude:td, -ngInit:ud,ngNonBindable:vd,ngPluralize:wd,ngRepeat:xd,ngShow:yd,ngStyle:zd,ngSwitch:Ad,ngSwitchWhen:Bd,ngSwitchDefault:Cd,ngOptions:Dd,ngTransclude:Ed,ngModel:Fd,ngList:Gd,ngChange:Hd,required:hc,ngRequired:hc,ngValue:Id}).directive({ngInclude:Jd}).directive(Cb).directive(ic);a.provider({$anchorScroll:Kd,$animate:Ld,$browser:Md,$cacheFactory:Nd,$controller:Od,$document:Pd,$exceptionHandler:Qd,$filter:jc,$interpolate:Rd,$interval:Sd,$http:Td,$httpBackend:Ud,$location:Vd,$log:Wd,$parse:Xd,$rootScope:Yd, -$q:Zd,$sce:$d,$sceDelegate:ae,$sniffer:be,$templateCache:ce,$timeout:de,$window:ee,$$rAF:fe,$$asyncCallback:ge})}])}function Ta(b){return b.replace(he,function(a,b,d,e){return e?d.toUpperCase():d}).replace(ie,"Moz$1")}function Db(b,a,c,d){function e(b){var e=c&&b?[this.filter(b)]:[this],m=a,k,l,n,p,r,A;if(!d||null!=b)for(;e.length;)for(k=e.shift(),l=0,n=k.length;l<n;l++)for(p=y(k[l]),m?p.triggerHandler("$destroy"):m=!m,r=0,p=(A=p.children()).length;r<p;r++)e.push(Ba(A[r]));return g.apply(this,arguments)} -var g=Ba.fn[b],g=g.$original||g;e.$original=g;Ba.fn[b]=e}function M(b){if(b instanceof M)return b;C(b)&&(b=ba(b));if(!(this instanceof M)){if(C(b)&&"<"!=b.charAt(0))throw Eb("nosel");return new M(b)}if(C(b)){var a=b;b=U;var c;if(c=je.exec(a))b=[b.createElement(c[1])];else{var d=b,e;b=d.createDocumentFragment();c=[];if(Fb.test(a)){d=b.appendChild(d.createElement("div"));e=(ke.exec(a)||["",""])[1].toLowerCase();e=ea[e]||ea._default;d.innerHTML="<div> </div>"+e[1]+a.replace(le,"<$1></$2>")+e[2]; -d.removeChild(d.firstChild);for(a=e[0];a--;)d=d.lastChild;a=0;for(e=d.childNodes.length;a<e;++a)c.push(d.childNodes[a]);d=b.firstChild;d.textContent=""}else c.push(d.createTextNode(a));b.textContent="";b.innerHTML="";b=c}Gb(this,b);y(U.createDocumentFragment()).append(this)}else Gb(this,b)}function Hb(b){return b.cloneNode(!0)}function Ga(b){kc(b);var a=0;for(b=b.childNodes||[];a<b.length;a++)Ga(b[a])}function lc(b,a,c,d){if(z(d))throw Eb("offargs");var e=la(b,"events");la(b,"handle")&&(H(a)?q(e, -function(a,c){Ua(b,c,a);delete e[c]}):q(a.split(" "),function(a){H(c)?(Ua(b,a,e[a]),delete e[a]):Na(e[a]||[],c)}))}function kc(b,a){var c=b[ib],d=Va[c];d&&(a?delete Va[c].data[a]:(d.handle&&(d.events.$destroy&&d.handle({},"$destroy"),lc(b)),delete Va[c],b[ib]=s))}function la(b,a,c){var d=b[ib],d=Va[d||-1];if(z(c))d||(b[ib]=d=++me,d=Va[d]={}),d[a]=c;else return d&&d[a]}function mc(b,a,c){var d=la(b,"data"),e=z(c),g=!e&&z(a),f=g&&!X(a);d||f||la(b,"data",d={});if(e)d[a]=c;else if(g){if(f)return d&&d[a]; -E(d,a)}else return d}function Ib(b,a){return b.getAttribute?-1<(" "+(b.getAttribute("class")||"")+" ").replace(/[\n\t]/g," ").indexOf(" "+a+" "):!1}function jb(b,a){a&&b.setAttribute&&q(a.split(" "),function(a){b.setAttribute("class",ba((" "+(b.getAttribute("class")||"")+" ").replace(/[\n\t]/g," ").replace(" "+ba(a)+" "," ")))})}function kb(b,a){if(a&&b.setAttribute){var c=(" "+(b.getAttribute("class")||"")+" ").replace(/[\n\t]/g," ");q(a.split(" "),function(a){a=ba(a);-1===c.indexOf(" "+a+" ")&& -(c+=a+" ")});b.setAttribute("class",ba(c))}}function Gb(b,a){if(a){a=a.nodeName||!z(a.length)||Da(a)?[a]:a;for(var c=0;c<a.length;c++)b.push(a[c])}}function nc(b,a){return lb(b,"$"+(a||"ngController")+"Controller")}function lb(b,a,c){b=y(b);9==b[0].nodeType&&(b=b.find("html"));for(a=L(a)?a:[a];b.length;){for(var d=b[0],e=0,g=a.length;e<g;e++)if((c=b.data(a[e]))!==s)return c;b=y(d.parentNode||11===d.nodeType&&d.host)}}function oc(b){for(var a=0,c=b.childNodes;a<c.length;a++)Ga(c[a]);for(;b.firstChild;)b.removeChild(b.firstChild)} -function pc(b,a){var c=mb[a.toLowerCase()];return c&&qc[b.nodeName]&&c}function ne(b,a){var c=function(c,e){c.preventDefault||(c.preventDefault=function(){c.returnValue=!1});c.stopPropagation||(c.stopPropagation=function(){c.cancelBubble=!0});c.target||(c.target=c.srcElement||U);if(H(c.defaultPrevented)){var g=c.preventDefault;c.preventDefault=function(){c.defaultPrevented=!0;g.call(c)};c.defaultPrevented=!1}c.isDefaultPrevented=function(){return c.defaultPrevented||!1===c.returnValue};var f=Yb(a[e|| -c.type]||[]);q(f,function(a){a.call(b,c)});8>=S?(c.preventDefault=null,c.stopPropagation=null,c.isDefaultPrevented=null):(delete c.preventDefault,delete c.stopPropagation,delete c.isDefaultPrevented)};c.elem=b;return c}function Ha(b){var a=typeof b,c;"object"==a&&null!==b?"function"==typeof(c=b.$$hashKey)?c=b.$$hashKey():c===s&&(c=b.$$hashKey=cb()):c=b;return a+":"+c}function Wa(b){q(b,this.put,this)}function rc(b){var a,c;"function"==typeof b?(a=b.$inject)||(a=[],b.length&&(c=b.toString().replace(oe, -""),c=c.match(pe),q(c[1].split(qe),function(b){b.replace(re,function(b,c,d){a.push(d)})})),b.$inject=a):L(b)?(c=b.length-1,Ra(b[c],"fn"),a=b.slice(0,c)):Ra(b,"fn",!0);return a}function dc(b){function a(a){return function(b,c){if(X(b))q(b,Vb(a));else return a(b,c)}}function c(a,b){Aa(a,"service");if(Q(b)||L(b))b=n.instantiate(b);if(!b.$get)throw Xa("pget",a);return l[a+h]=b}function d(a,b){return c(a,{$get:b})}function e(a){var b=[],c,d,g,h;q(a,function(a){if(!k.get(a)){k.put(a,!0);try{if(C(a))for(c= -Sa(a),b=b.concat(e(c.requires)).concat(c._runBlocks),d=c._invokeQueue,g=0,h=d.length;g<h;g++){var f=d[g],m=n.get(f[0]);m[f[1]].apply(m,f[2])}else Q(a)?b.push(n.invoke(a)):L(a)?b.push(n.invoke(a)):Ra(a,"module")}catch(l){throw L(a)&&(a=a[a.length-1]),l.message&&(l.stack&&-1==l.stack.indexOf(l.message))&&(l=l.message+"\n"+l.stack),Xa("modulerr",a,l.stack||l.message||l);}}});return b}function g(a,b){function c(d){if(a.hasOwnProperty(d)){if(a[d]===f)throw Xa("cdep",m.join(" <- "));return a[d]}try{return m.unshift(d), -a[d]=f,a[d]=b(d)}catch(e){throw a[d]===f&&delete a[d],e;}finally{m.shift()}}function d(a,b,e){var g=[],h=rc(a),f,m,k;m=0;for(f=h.length;m<f;m++){k=h[m];if("string"!==typeof k)throw Xa("itkn",k);g.push(e&&e.hasOwnProperty(k)?e[k]:c(k))}a.$inject||(a=a[f]);return a.apply(b,g)}return{invoke:d,instantiate:function(a,b){var c=function(){},e;c.prototype=(L(a)?a[a.length-1]:a).prototype;c=new c;e=d(a,c,b);return X(e)||Q(e)?e:c},get:c,annotate:rc,has:function(b){return l.hasOwnProperty(b+h)||a.hasOwnProperty(b)}}} -var f={},h="Provider",m=[],k=new Wa,l={$provide:{provider:a(c),factory:a(d),service:a(function(a,b){return d(a,["$injector",function(a){return a.instantiate(b)}])}),value:a(function(a,b){return d(a,aa(b))}),constant:a(function(a,b){Aa(a,"constant");l[a]=b;p[a]=b}),decorator:function(a,b){var c=n.get(a+h),d=c.$get;c.$get=function(){var a=r.invoke(d,c);return r.invoke(b,null,{$delegate:a})}}}},n=l.$injector=g(l,function(){throw Xa("unpr",m.join(" <- "));}),p={},r=p.$injector=g(p,function(a){a=n.get(a+ -h);return r.invoke(a.$get,a)});q(e(b),function(a){r.invoke(a||w)});return r}function Kd(){var b=!0;this.disableAutoScrolling=function(){b=!1};this.$get=["$window","$location","$rootScope",function(a,c,d){function e(a){var b=null;q(a,function(a){b||"a"!==J(a.nodeName)||(b=a)});return b}function g(){var b=c.hash(),d;b?(d=f.getElementById(b))?d.scrollIntoView():(d=e(f.getElementsByName(b)))?d.scrollIntoView():"top"===b&&a.scrollTo(0,0):a.scrollTo(0,0)}var f=a.document;b&&d.$watch(function(){return c.hash()}, -function(){d.$evalAsync(g)});return g}]}function ge(){this.$get=["$$rAF","$timeout",function(b,a){return b.supported?function(a){return b(a)}:function(b){return a(b,0,!1)}}]}function se(b,a,c,d){function e(a){try{a.apply(null,ya.call(arguments,1))}finally{if(A--,0===A)for(;D.length;)try{D.pop()()}catch(b){c.error(b)}}}function g(a,b){(function T(){q(x,function(a){a()});t=b(T,a)})()}function f(){v=null;N!=h.url()&&(N=h.url(),q(ma,function(a){a(h.url())}))}var h=this,m=a[0],k=b.location,l=b.history, -n=b.setTimeout,p=b.clearTimeout,r={};h.isMock=!1;var A=0,D=[];h.$$completeOutstandingRequest=e;h.$$incOutstandingRequestCount=function(){A++};h.notifyWhenNoOutstandingRequests=function(a){q(x,function(a){a()});0===A?a():D.push(a)};var x=[],t;h.addPollFn=function(a){H(t)&&g(100,n);x.push(a);return a};var N=k.href,B=a.find("base"),v=null;h.url=function(a,c){k!==b.location&&(k=b.location);l!==b.history&&(l=b.history);if(a){if(N!=a)return N=a,d.history?c?l.replaceState(null,"",a):(l.pushState(null,"", -a),B.attr("href",B.attr("href"))):(v=a,c?k.replace(a):k.href=a),h}else return v||k.href.replace(/%27/g,"'")};var ma=[],K=!1;h.onUrlChange=function(a){if(!K){if(d.history)y(b).on("popstate",f);if(d.hashchange)y(b).on("hashchange",f);else h.addPollFn(f);K=!0}ma.push(a);return a};h.baseHref=function(){var a=B.attr("href");return a?a.replace(/^(https?\:)?\/\/[^\/]*/,""):""};var O={},da="",F=h.baseHref();h.cookies=function(a,b){var d,e,g,h;if(a)b===s?m.cookie=escape(a)+"=;path="+F+";expires=Thu, 01 Jan 1970 00:00:00 GMT": -C(b)&&(d=(m.cookie=escape(a)+"="+escape(b)+";path="+F).length+1,4096<d&&c.warn("Cookie '"+a+"' possibly not set or overflowed because it was too large ("+d+" > 4096 bytes)!"));else{if(m.cookie!==da)for(da=m.cookie,d=da.split("; "),O={},g=0;g<d.length;g++)e=d[g],h=e.indexOf("="),0<h&&(a=unescape(e.substring(0,h)),O[a]===s&&(O[a]=unescape(e.substring(h+1))));return O}};h.defer=function(a,b){var c;A++;c=n(function(){delete r[c];e(a)},b||0);r[c]=!0;return c};h.defer.cancel=function(a){return r[a]?(delete r[a], -p(a),e(w),!0):!1}}function Md(){this.$get=["$window","$log","$sniffer","$document",function(b,a,c,d){return new se(b,d,a,c)}]}function Nd(){this.$get=function(){function b(b,d){function e(a){a!=n&&(p?p==a&&(p=a.n):p=a,g(a.n,a.p),g(a,n),n=a,n.n=null)}function g(a,b){a!=b&&(a&&(a.p=b),b&&(b.n=a))}if(b in a)throw u("$cacheFactory")("iid",b);var f=0,h=E({},d,{id:b}),m={},k=d&&d.capacity||Number.MAX_VALUE,l={},n=null,p=null;return a[b]={put:function(a,b){if(k<Number.MAX_VALUE){var c=l[a]||(l[a]={key:a}); -e(c)}if(!H(b))return a in m||f++,m[a]=b,f>k&&this.remove(p.key),b},get:function(a){if(k<Number.MAX_VALUE){var b=l[a];if(!b)return;e(b)}return m[a]},remove:function(a){if(k<Number.MAX_VALUE){var b=l[a];if(!b)return;b==n&&(n=b.p);b==p&&(p=b.n);g(b.n,b.p);delete l[a]}delete m[a];f--},removeAll:function(){m={};f=0;l={};n=p=null},destroy:function(){l=h=m=null;delete a[b]},info:function(){return E({},h,{size:f})}}}var a={};b.info=function(){var b={};q(a,function(a,e){b[e]=a.info()});return b};b.get=function(b){return a[b]}; -return b}}function ce(){this.$get=["$cacheFactory",function(b){return b("templates")}]}function fc(b,a){var c={},d="Directive",e=/^\s*directive\:\s*([\d\w_\-]+)\s+(.*)$/,g=/(([\d\w_\-]+)(?:\:([^;]+))?;?)/,f=/^(on[a-z]+|formaction)$/;this.directive=function m(a,e){Aa(a,"directive");C(a)?(Ab(e,"directiveFactory"),c.hasOwnProperty(a)||(c[a]=[],b.factory(a+d,["$injector","$exceptionHandler",function(b,d){var e=[];q(c[a],function(c,g){try{var f=b.invoke(c);Q(f)?f={compile:aa(f)}:!f.compile&&f.link&&(f.compile= -aa(f.link));f.priority=f.priority||0;f.index=g;f.name=f.name||a;f.require=f.require||f.controller&&f.name;f.restrict=f.restrict||"A";e.push(f)}catch(m){d(m)}});return e}])),c[a].push(e)):q(a,Vb(m));return this};this.aHrefSanitizationWhitelist=function(b){return z(b)?(a.aHrefSanitizationWhitelist(b),this):a.aHrefSanitizationWhitelist()};this.imgSrcSanitizationWhitelist=function(b){return z(b)?(a.imgSrcSanitizationWhitelist(b),this):a.imgSrcSanitizationWhitelist()};this.$get=["$injector","$interpolate", -"$exceptionHandler","$http","$templateCache","$parse","$controller","$rootScope","$document","$sce","$animate","$$sanitizeUri",function(a,b,l,n,p,r,A,D,x,t,N,B){function v(a,b,c,d,e){a instanceof y||(a=y(a));q(a,function(b,c){3==b.nodeType&&b.nodeValue.match(/\S+/)&&(a[c]=y(b).wrap("<span></span>").parent()[0])});var g=K(a,b,a,c,d,e);ma(a,"ng-scope");return function(b,c,d){Ab(b,"scope");var e=c?Ia.clone.call(a):a;q(d,function(a,b){e.data("$"+b+"Controller",a)});d=0;for(var f=e.length;d<f;d++){var m= -e[d].nodeType;1!==m&&9!==m||e.eq(d).data("$scope",b)}c&&c(e,b);g&&g(b,e,e);return e}}function ma(a,b){try{a.addClass(b)}catch(c){}}function K(a,b,c,d,e,g){function f(a,c,d,e){var g,k,l,r,n,p,A;g=c.length;var I=Array(g);for(n=0;n<g;n++)I[n]=c[n];A=n=0;for(p=m.length;n<p;A++)k=I[A],c=m[n++],g=m[n++],l=y(k),c?(c.scope?(r=a.$new(),l.data("$scope",r)):r=a,(l=c.transclude)||!e&&b?c(g,r,k,d,O(a,l||b)):c(g,r,k,d,e)):g&&g(a,k.childNodes,s,e)}for(var m=[],k,l,r,n,p=0;p<a.length;p++)k=new Jb,l=da(a[p],[],k, -0===p?d:s,e),(g=l.length?fa(l,a[p],k,b,c,null,[],[],g):null)&&g.scope&&ma(y(a[p]),"ng-scope"),k=g&&g.terminal||!(r=a[p].childNodes)||!r.length?null:K(r,g?g.transclude:b),m.push(g,k),n=n||g||k,g=null;return n?f:null}function O(a,b){return function(c,d,e){var g=!1;c||(c=a.$new(),g=c.$$transcluded=!0);d=b(c,d,e);if(g)d.on("$destroy",fb(c,c.$destroy));return d}}function da(a,b,c,d,f){var m=c.$attr,k;switch(a.nodeType){case 1:T(b,na(Ja(a).toLowerCase()),"E",d,f);var l,r,n;k=a.attributes;for(var p=0,A= -k&&k.length;p<A;p++){var x=!1,D=!1;l=k[p];if(!S||8<=S||l.specified){r=l.name;n=na(r);W.test(n)&&(r=hb(n.substr(6),"-"));var N=n.replace(/(Start|End)$/,"");n===N+"Start"&&(x=r,D=r.substr(0,r.length-5)+"end",r=r.substr(0,r.length-6));n=na(r.toLowerCase());m[n]=r;c[n]=l=ba(l.value);pc(a,n)&&(c[n]=!0);M(a,b,l,n);T(b,n,"A",d,f,x,D)}}a=a.className;if(C(a)&&""!==a)for(;k=g.exec(a);)n=na(k[2]),T(b,n,"C",d,f)&&(c[n]=ba(k[3])),a=a.substr(k.index+k[0].length);break;case 3:u(b,a.nodeValue);break;case 8:try{if(k= -e.exec(a.nodeValue))n=na(k[1]),T(b,n,"M",d,f)&&(c[n]=ba(k[2]))}catch(t){}}b.sort(H);return b}function F(a,b,c){var d=[],e=0;if(b&&a.hasAttribute&&a.hasAttribute(b)){do{if(!a)throw ja("uterdir",b,c);1==a.nodeType&&(a.hasAttribute(b)&&e++,a.hasAttribute(c)&&e--);d.push(a);a=a.nextSibling}while(0<e)}else d.push(a);return y(d)}function R(a,b,c){return function(d,e,g,f,k){e=F(e[0],b,c);return a(d,e,g,f,k)}}function fa(a,c,d,e,g,f,m,n,p){function x(a,b,c,d){if(a){c&&(a=R(a,c,d));a.require=G.require;a.directiveName= -u;if(O===G||G.$$isolateScope)a=tc(a,{isolateScope:!0});m.push(a)}if(b){c&&(b=R(b,c,d));b.require=G.require;b.directiveName=u;if(O===G||G.$$isolateScope)b=tc(b,{isolateScope:!0});n.push(b)}}function D(a,b,c,d){var e,g="data",f=!1;if(C(b)){for(;"^"==(e=b.charAt(0))||"?"==e;)b=b.substr(1),"^"==e&&(g="inheritedData"),f=f||"?"==e;e=null;d&&"data"===g&&(e=d[b]);e=e||c[g]("$"+b+"Controller");if(!e&&!f)throw ja("ctreq",b,a);}else L(b)&&(e=[],q(b,function(b){e.push(D(a,b,c,d))}));return e}function N(a,e,g, -f,p){function x(a,b){var c;2>arguments.length&&(b=a,a=s);E&&(c=da);return p(a,b,c)}var t,I,v,B,R,F,da={},nb;t=c===g?d:Yb(d,new Jb(y(g),d.$attr));I=t.$$element;if(O){var T=/^\s*([@=&])(\??)\s*(\w*)\s*$/;f=y(g);F=e.$new(!0);!fa||fa!==O&&fa!==O.$$originalDirective?f.data("$isolateScopeNoTemplate",F):f.data("$isolateScope",F);ma(f,"ng-isolate-scope");q(O.scope,function(a,c){var d=a.match(T)||[],g=d[3]||c,f="?"==d[2],d=d[1],m,l,n,p;F.$$isolateBindings[c]=d+g;switch(d){case "@":t.$observe(g,function(a){F[c]= -a});t.$$observers[g].$$scope=e;t[g]&&(F[c]=b(t[g])(e));break;case "=":if(f&&!t[g])break;l=r(t[g]);p=l.literal?xa:function(a,b){return a===b};n=l.assign||function(){m=F[c]=l(e);throw ja("nonassign",t[g],O.name);};m=F[c]=l(e);F.$watch(function(){var a=l(e);p(a,F[c])||(p(a,m)?n(e,a=F[c]):F[c]=a);return m=a},null,l.literal);break;case "&":l=r(t[g]);F[c]=function(a){return l(e,a)};break;default:throw ja("iscp",O.name,c,a);}})}nb=p&&x;K&&q(K,function(a){var b={$scope:a===O||a.$$isolateScope?F:e,$element:I, -$attrs:t,$transclude:nb},c;R=a.controller;"@"==R&&(R=t[a.name]);c=A(R,b);da[a.name]=c;E||I.data("$"+a.name+"Controller",c);a.controllerAs&&(b.$scope[a.controllerAs]=c)});f=0;for(v=m.length;f<v;f++)try{B=m[f],B(B.isolateScope?F:e,I,t,B.require&&D(B.directiveName,B.require,I,da),nb)}catch(G){l(G,ia(I))}f=e;O&&(O.template||null===O.templateUrl)&&(f=F);a&&a(f,g.childNodes,s,p);for(f=n.length-1;0<=f;f--)try{B=n[f],B(B.isolateScope?F:e,I,t,B.require&&D(B.directiveName,B.require,I,da),nb)}catch(z){l(z,ia(I))}} -p=p||{};for(var t=-Number.MAX_VALUE,B,K=p.controllerDirectives,O=p.newIsolateScopeDirective,fa=p.templateDirective,T=p.nonTlbTranscludeDirective,H=!1,E=p.hasElementTranscludeDirective,Z=d.$$element=y(c),G,u,V,Ya=e,P,M=0,S=a.length;M<S;M++){G=a[M];var ra=G.$$start,W=G.$$end;ra&&(Z=F(c,ra,W));V=s;if(t>G.priority)break;if(V=G.scope)B=B||G,G.templateUrl||(J("new/isolated scope",O,G,Z),X(V)&&(O=G));u=G.name;!G.templateUrl&&G.controller&&(V=G.controller,K=K||{},J("'"+u+"' controller",K[u],G,Z),K[u]=G); -if(V=G.transclude)H=!0,G.$$tlb||(J("transclusion",T,G,Z),T=G),"element"==V?(E=!0,t=G.priority,V=F(c,ra,W),Z=d.$$element=y(U.createComment(" "+u+": "+d[u]+" ")),c=Z[0],ob(g,y(ya.call(V,0)),c),Ya=v(V,e,t,f&&f.name,{nonTlbTranscludeDirective:T})):(V=y(Hb(c)).contents(),Z.empty(),Ya=v(V,e));if(G.template)if(J("template",fa,G,Z),fa=G,V=Q(G.template)?G.template(Z,d):G.template,V=Y(V),G.replace){f=G;V=Fb.test(V)?y(ba(V)):[];c=V[0];if(1!=V.length||1!==c.nodeType)throw ja("tplrt",u,"");ob(g,Z,c);S={$attr:{}}; -V=da(c,[],S);var $=a.splice(M+1,a.length-(M+1));O&&sc(V);a=a.concat(V).concat($);z(d,S);S=a.length}else Z.html(V);if(G.templateUrl)J("template",fa,G,Z),fa=G,G.replace&&(f=G),N=w(a.splice(M,a.length-M),Z,d,g,Ya,m,n,{controllerDirectives:K,newIsolateScopeDirective:O,templateDirective:fa,nonTlbTranscludeDirective:T}),S=a.length;else if(G.compile)try{P=G.compile(Z,d,Ya),Q(P)?x(null,P,ra,W):P&&x(P.pre,P.post,ra,W)}catch(aa){l(aa,ia(Z))}G.terminal&&(N.terminal=!0,t=Math.max(t,G.priority))}N.scope=B&&!0=== -B.scope;N.transclude=H&&Ya;p.hasElementTranscludeDirective=E;return N}function sc(a){for(var b=0,c=a.length;b<c;b++)a[b]=Xb(a[b],{$$isolateScope:!0})}function T(b,e,g,f,k,r,n){if(e===k)return null;k=null;if(c.hasOwnProperty(e)){var p;e=a.get(e+d);for(var A=0,x=e.length;A<x;A++)try{p=e[A],(f===s||f>p.priority)&&-1!=p.restrict.indexOf(g)&&(r&&(p=Xb(p,{$$start:r,$$end:n})),b.push(p),k=p)}catch(D){l(D)}}return k}function z(a,b){var c=b.$attr,d=a.$attr,e=a.$$element;q(a,function(d,e){"$"!=e.charAt(0)&& -(b[e]&&(d+=("style"===e?";":" ")+b[e]),a.$set(e,d,!0,c[e]))});q(b,function(b,g){"class"==g?(ma(e,b),a["class"]=(a["class"]?a["class"]+" ":"")+b):"style"==g?(e.attr("style",e.attr("style")+";"+b),a.style=(a.style?a.style+";":"")+b):"$"==g.charAt(0)||a.hasOwnProperty(g)||(a[g]=b,d[g]=c[g])})}function w(a,b,c,d,e,g,f,k){var m=[],l,r,A=b[0],x=a.shift(),D=E({},x,{templateUrl:null,transclude:null,replace:null,$$originalDirective:x}),N=Q(x.templateUrl)?x.templateUrl(b,c):x.templateUrl;b.empty();n.get(t.getTrustedResourceUrl(N), -{cache:p}).success(function(n){var p,t;n=Y(n);if(x.replace){n=Fb.test(n)?y(ba(n)):[];p=n[0];if(1!=n.length||1!==p.nodeType)throw ja("tplrt",x.name,N);n={$attr:{}};ob(d,b,p);var v=da(p,[],n);X(x.scope)&&sc(v);a=v.concat(a);z(c,n)}else p=A,b.html(n);a.unshift(D);l=fa(a,p,c,e,b,x,g,f,k);q(d,function(a,c){a==p&&(d[c]=b[0])});for(r=K(b[0].childNodes,e);m.length;){n=m.shift();t=m.shift();var B=m.shift(),R=m.shift(),v=b[0];if(t!==A){var F=t.className;k.hasElementTranscludeDirective&&x.replace||(v=Hb(p)); -ob(B,y(t),v);ma(y(v),F)}t=l.transclude?O(n,l.transclude):R;l(r,n,v,d,t)}m=null}).error(function(a,b,c,d){throw ja("tpload",d.url);});return function(a,b,c,d,e){m?(m.push(b),m.push(c),m.push(d),m.push(e)):l(r,b,c,d,e)}}function H(a,b){var c=b.priority-a.priority;return 0!==c?c:a.name!==b.name?a.name<b.name?-1:1:a.index-b.index}function J(a,b,c,d){if(b)throw ja("multidir",b.name,c.name,a,ia(d));}function u(a,c){var d=b(c,!0);d&&a.push({priority:0,compile:aa(function(a,b){var c=b.parent(),e=c.data("$binding")|| -[];e.push(d);ma(c.data("$binding",e),"ng-binding");a.$watch(d,function(a){b[0].nodeValue=a})})})}function P(a,b){if("srcdoc"==b)return t.HTML;var c=Ja(a);if("xlinkHref"==b||"FORM"==c&&"action"==b||"IMG"!=c&&("src"==b||"ngSrc"==b))return t.RESOURCE_URL}function M(a,c,d,e){var g=b(d,!0);if(g){if("multiple"===e&&"SELECT"===Ja(a))throw ja("selmulti",ia(a));c.push({priority:100,compile:function(){return{pre:function(c,d,m){d=m.$$observers||(m.$$observers={});if(f.test(e))throw ja("nodomevents");if(g=b(m[e], -!0,P(a,e)))m[e]=g(c),(d[e]||(d[e]=[])).$$inter=!0,(m.$$observers&&m.$$observers[e].$$scope||c).$watch(g,function(a,b){"class"===e&&a!=b?m.$updateClass(a,b):m.$set(e,a)})}}}})}}function ob(a,b,c){var d=b[0],e=b.length,g=d.parentNode,f,m;if(a)for(f=0,m=a.length;f<m;f++)if(a[f]==d){a[f++]=c;m=f+e-1;for(var k=a.length;f<k;f++,m++)m<k?a[f]=a[m]:delete a[f];a.length-=e-1;break}g&&g.replaceChild(c,d);a=U.createDocumentFragment();a.appendChild(d);c[y.expando]=d[y.expando];d=1;for(e=b.length;d<e;d++)g=b[d], -y(g).remove(),a.appendChild(g),delete b[d];b[0]=c;b.length=1}function tc(a,b){return E(function(){return a.apply(null,arguments)},a,b)}var Jb=function(a,b){this.$$element=a;this.$attr=b||{}};Jb.prototype={$normalize:na,$addClass:function(a){a&&0<a.length&&N.addClass(this.$$element,a)},$removeClass:function(a){a&&0<a.length&&N.removeClass(this.$$element,a)},$updateClass:function(a,b){var c=uc(a,b),d=uc(b,a);0===c.length?N.removeClass(this.$$element,d):0===d.length?N.addClass(this.$$element,c):N.setClass(this.$$element, -c,d)},$set:function(a,b,c,d){var e=pc(this.$$element[0],a);e&&(this.$$element.prop(a,b),d=e);this[a]=b;d?this.$attr[a]=d:(d=this.$attr[a])||(this.$attr[a]=d=hb(a,"-"));e=Ja(this.$$element);if("A"===e&&"href"===a||"IMG"===e&&"src"===a)this[a]=b=B(b,"src"===a);!1!==c&&(null===b||b===s?this.$$element.removeAttr(d):this.$$element.attr(d,b));(c=this.$$observers)&&q(c[a],function(a){try{a(b)}catch(c){l(c)}})},$observe:function(a,b){var c=this,d=c.$$observers||(c.$$observers={}),e=d[a]||(d[a]=[]);e.push(b); -D.$evalAsync(function(){e.$$inter||b(c[a])});return b}};var Z=b.startSymbol(),ra=b.endSymbol(),Y="{{"==Z||"}}"==ra?Ea:function(a){return a.replace(/\{\{/g,Z).replace(/}}/g,ra)},W=/^ngAttr[A-Z]/;return v}]}function na(b){return Ta(b.replace(te,""))}function uc(b,a){var c="",d=b.split(/\s+/),e=a.split(/\s+/),g=0;a:for(;g<d.length;g++){for(var f=d[g],h=0;h<e.length;h++)if(f==e[h])continue a;c+=(0<c.length?" ":"")+f}return c}function Od(){var b={},a=/^(\S+)(\s+as\s+(\w+))?$/;this.register=function(a, -d){Aa(a,"controller");X(a)?E(b,a):b[a]=d};this.$get=["$injector","$window",function(c,d){return function(e,g){var f,h,m;C(e)&&(f=e.match(a),h=f[1],m=f[3],e=b.hasOwnProperty(h)?b[h]:ec(g.$scope,h,!0)||ec(d,h,!0),Ra(e,h,!0));f=c.instantiate(e,g);if(m){if(!g||"object"!=typeof g.$scope)throw u("$controller")("noscp",h||e.name,m);g.$scope[m]=f}return f}}]}function Pd(){this.$get=["$window",function(b){return y(b.document)}]}function Qd(){this.$get=["$log",function(b){return function(a,c){b.error.apply(b, -arguments)}}]}function vc(b){var a={},c,d,e;if(!b)return a;q(b.split("\n"),function(b){e=b.indexOf(":");c=J(ba(b.substr(0,e)));d=ba(b.substr(e+1));c&&(a[c]=a[c]?a[c]+(", "+d):d)});return a}function wc(b){var a=X(b)?b:s;return function(c){a||(a=vc(b));return c?a[J(c)]||null:a}}function xc(b,a,c){if(Q(c))return c(b,a);q(c,function(c){b=c(b,a)});return b}function Td(){var b=/^\s*(\[|\{[^\{])/,a=/[\}\]]\s*$/,c=/^\)\]\}',?\n/,d={"Content-Type":"application/json;charset=utf-8"},e=this.defaults={transformResponse:[function(d){C(d)&& -(d=d.replace(c,""),b.test(d)&&a.test(d)&&(d=$b(d)));return d}],transformRequest:[function(a){return X(a)&&"[object File]"!==wa.call(a)&&"[object Blob]"!==wa.call(a)?qa(a):a}],headers:{common:{Accept:"application/json, text/plain, */*"},post:ca(d),put:ca(d),patch:ca(d)},xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN"},g=this.interceptors=[],f=this.responseInterceptors=[];this.$get=["$httpBackend","$browser","$cacheFactory","$rootScope","$q","$injector",function(a,b,c,d,n,p){function r(a){function c(a){var b= -E({},a,{data:xc(a.data,a.headers,d.transformResponse)});return 200<=a.status&&300>a.status?b:n.reject(b)}var d={method:"get",transformRequest:e.transformRequest,transformResponse:e.transformResponse},g=function(a){function b(a){var c;q(a,function(b,d){Q(b)&&(c=b(),null!=c?a[d]=c:delete a[d])})}var c=e.headers,d=E({},a.headers),g,f,c=E({},c.common,c[J(a.method)]);b(c);b(d);a:for(g in c){a=J(g);for(f in d)if(J(f)===a)continue a;d[g]=c[g]}return d}(a);E(d,a);d.headers=g;d.method=Fa(d.method);(a=Kb(d.url)? -b.cookies()[d.xsrfCookieName||e.xsrfCookieName]:s)&&(g[d.xsrfHeaderName||e.xsrfHeaderName]=a);var f=[function(a){g=a.headers;var b=xc(a.data,wc(g),a.transformRequest);H(a.data)&&q(g,function(a,b){"content-type"===J(b)&&delete g[b]});H(a.withCredentials)&&!H(e.withCredentials)&&(a.withCredentials=e.withCredentials);return A(a,b,g).then(c,c)},s],h=n.when(d);for(q(t,function(a){(a.request||a.requestError)&&f.unshift(a.request,a.requestError);(a.response||a.responseError)&&f.push(a.response,a.responseError)});f.length;){a= -f.shift();var k=f.shift(),h=h.then(a,k)}h.success=function(a){h.then(function(b){a(b.data,b.status,b.headers,d)});return h};h.error=function(a){h.then(null,function(b){a(b.data,b.status,b.headers,d)});return h};return h}function A(b,c,g){function f(a,b,c,e){t&&(200<=a&&300>a?t.put(s,[a,b,vc(c),e]):t.remove(s));m(b,a,c,e);d.$$phase||d.$apply()}function m(a,c,d,e){c=Math.max(c,0);(200<=c&&300>c?p.resolve:p.reject)({data:a,status:c,headers:wc(d),config:b,statusText:e})}function k(){var a=eb(r.pendingRequests, -b);-1!==a&&r.pendingRequests.splice(a,1)}var p=n.defer(),A=p.promise,t,q,s=D(b.url,b.params);r.pendingRequests.push(b);A.then(k,k);(b.cache||e.cache)&&(!1!==b.cache&&"GET"==b.method)&&(t=X(b.cache)?b.cache:X(e.cache)?e.cache:x);if(t)if(q=t.get(s),z(q)){if(q.then)return q.then(k,k),q;L(q)?m(q[1],q[0],ca(q[2]),q[3]):m(q,200,{},"OK")}else t.put(s,A);H(q)&&a(b.method,s,c,f,g,b.timeout,b.withCredentials,b.responseType);return A}function D(a,b){if(!b)return a;var c=[];Sc(b,function(a,b){null===a||H(a)|| -(L(a)||(a=[a]),q(a,function(a){X(a)&&(a=qa(a));c.push(za(b)+"="+za(a))}))});0<c.length&&(a+=(-1==a.indexOf("?")?"?":"&")+c.join("&"));return a}var x=c("$http"),t=[];q(g,function(a){t.unshift(C(a)?p.get(a):p.invoke(a))});q(f,function(a,b){var c=C(a)?p.get(a):p.invoke(a);t.splice(b,0,{response:function(a){return c(n.when(a))},responseError:function(a){return c(n.reject(a))}})});r.pendingRequests=[];(function(a){q(arguments,function(a){r[a]=function(b,c){return r(E(c||{},{method:a,url:b}))}})})("get", -"delete","head","jsonp");(function(a){q(arguments,function(a){r[a]=function(b,c,d){return r(E(d||{},{method:a,url:b,data:c}))}})})("post","put");r.defaults=e;return r}]}function ue(b){if(8>=S&&(!b.match(/^(get|post|head|put|delete|options)$/i)||!P.XMLHttpRequest))return new P.ActiveXObject("Microsoft.XMLHTTP");if(P.XMLHttpRequest)return new P.XMLHttpRequest;throw u("$httpBackend")("noxhr");}function Ud(){this.$get=["$browser","$window","$document",function(b,a,c){return ve(b,ue,b.defer,a.angular.callbacks, -c[0])}]}function ve(b,a,c,d,e){function g(a,b,c){var g=e.createElement("script"),f=null;g.type="text/javascript";g.src=a;g.async=!0;f=function(a){Ua(g,"load",f);Ua(g,"error",f);e.body.removeChild(g);g=null;var h=-1,A="unknown";a&&("load"!==a.type||d[b].called||(a={type:"error"}),A=a.type,h="error"===a.type?404:200);c&&c(h,A)};pb(g,"load",f);pb(g,"error",f);8>=S&&(g.onreadystatechange=function(){C(g.readyState)&&/loaded|complete/.test(g.readyState)&&(g.onreadystatechange=null,f({type:"load"}))});e.body.appendChild(g); -return f}var f=-1;return function(e,m,k,l,n,p,r,A){function D(){t=f;B&&B();v&&v.abort()}function x(a,d,e,g,f){K&&c.cancel(K);B=v=null;0===d&&(d=e?200:"file"==sa(m).protocol?404:0);a(1223===d?204:d,e,g,f||"");b.$$completeOutstandingRequest(w)}var t;b.$$incOutstandingRequestCount();m=m||b.url();if("jsonp"==J(e)){var N="_"+(d.counter++).toString(36);d[N]=function(a){d[N].data=a;d[N].called=!0};var B=g(m.replace("JSON_CALLBACK","angular.callbacks."+N),N,function(a,b){x(l,a,d[N].data,"",b);d[N]=w})}else{var v= -a(e);v.open(e,m,!0);q(n,function(a,b){z(a)&&v.setRequestHeader(b,a)});v.onreadystatechange=function(){if(v&&4==v.readyState){var a=null,b=null;t!==f&&(a=v.getAllResponseHeaders(),b="response"in v?v.response:v.responseText);x(l,t||v.status,b,a,v.statusText||"")}};r&&(v.withCredentials=!0);if(A)try{v.responseType=A}catch(s){if("json"!==A)throw s;}v.send(k||null)}if(0<p)var K=c(D,p);else p&&p.then&&p.then(D)}}function Rd(){var b="{{",a="}}";this.startSymbol=function(a){return a?(b=a,this):b};this.endSymbol= -function(b){return b?(a=b,this):a};this.$get=["$parse","$exceptionHandler","$sce",function(c,d,e){function g(g,k,l){for(var n,p,r=0,A=[],D=g.length,x=!1,t=[];r<D;)-1!=(n=g.indexOf(b,r))&&-1!=(p=g.indexOf(a,n+f))?(r!=n&&A.push(g.substring(r,n)),A.push(r=c(x=g.substring(n+f,p))),r.exp=x,r=p+h,x=!0):(r!=D&&A.push(g.substring(r)),r=D);(D=A.length)||(A.push(""),D=1);if(l&&1<A.length)throw yc("noconcat",g);if(!k||x)return t.length=D,r=function(a){try{for(var b=0,c=D,f;b<c;b++){if("function"==typeof(f=A[b]))if(f= -f(a),f=l?e.getTrusted(l,f):e.valueOf(f),null==f)f="";else switch(typeof f){case "string":break;case "number":f=""+f;break;default:f=qa(f)}t[b]=f}return t.join("")}catch(h){a=yc("interr",g,h.toString()),d(a)}},r.exp=g,r.parts=A,r}var f=b.length,h=a.length;g.startSymbol=function(){return b};g.endSymbol=function(){return a};return g}]}function Sd(){this.$get=["$rootScope","$window","$q",function(b,a,c){function d(d,f,h,m){var k=a.setInterval,l=a.clearInterval,n=c.defer(),p=n.promise,r=0,A=z(m)&&!m;h= -z(h)?h:0;p.then(null,null,d);p.$$intervalId=k(function(){n.notify(r++);0<h&&r>=h&&(n.resolve(r),l(p.$$intervalId),delete e[p.$$intervalId]);A||b.$apply()},f);e[p.$$intervalId]=n;return p}var e={};d.cancel=function(a){return a&&a.$$intervalId in e?(e[a.$$intervalId].reject("canceled"),clearInterval(a.$$intervalId),delete e[a.$$intervalId],!0):!1};return d}]}function ad(){this.$get=function(){return{id:"en-us",NUMBER_FORMATS:{DECIMAL_SEP:".",GROUP_SEP:",",PATTERNS:[{minInt:1,minFrac:0,maxFrac:3,posPre:"", -posSuf:"",negPre:"-",negSuf:"",gSize:3,lgSize:3},{minInt:1,minFrac:2,maxFrac:2,posPre:"\u00a4",posSuf:"",negPre:"(\u00a4",negSuf:")",gSize:3,lgSize:3}],CURRENCY_SYM:"$"},DATETIME_FORMATS:{MONTH:"January February March April May June July August September October November December".split(" "),SHORTMONTH:"Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" "),DAY:"Sunday Monday Tuesday Wednesday Thursday Friday Saturday".split(" "),SHORTDAY:"Sun Mon Tue Wed Thu Fri Sat".split(" "),AMPMS:["AM", -"PM"],medium:"MMM d, y h:mm:ss a","short":"M/d/yy h:mm a",fullDate:"EEEE, MMMM d, y",longDate:"MMMM d, y",mediumDate:"MMM d, y",shortDate:"M/d/yy",mediumTime:"h:mm:ss a",shortTime:"h:mm a"},pluralCat:function(b){return 1===b?"one":"other"}}}}function Lb(b){b=b.split("/");for(var a=b.length;a--;)b[a]=gb(b[a]);return b.join("/")}function zc(b,a,c){b=sa(b,c);a.$$protocol=b.protocol;a.$$host=b.hostname;a.$$port=Y(b.port)||we[b.protocol]||null}function Ac(b,a,c){var d="/"!==b.charAt(0);d&&(b="/"+b);b= -sa(b,c);a.$$path=decodeURIComponent(d&&"/"===b.pathname.charAt(0)?b.pathname.substring(1):b.pathname);a.$$search=bc(b.search);a.$$hash=decodeURIComponent(b.hash);a.$$path&&"/"!=a.$$path.charAt(0)&&(a.$$path="/"+a.$$path)}function oa(b,a){if(0===a.indexOf(b))return a.substr(b.length)}function Za(b){var a=b.indexOf("#");return-1==a?b:b.substr(0,a)}function Mb(b){return b.substr(0,Za(b).lastIndexOf("/")+1)}function Bc(b,a){this.$$html5=!0;a=a||"";var c=Mb(b);zc(b,this,b);this.$$parse=function(a){var e= -oa(c,a);if(!C(e))throw Nb("ipthprfx",a,c);Ac(e,this,b);this.$$path||(this.$$path="/");this.$$compose()};this.$$compose=function(){var a=zb(this.$$search),b=this.$$hash?"#"+gb(this.$$hash):"";this.$$url=Lb(this.$$path)+(a?"?"+a:"")+b;this.$$absUrl=c+this.$$url.substr(1)};this.$$rewrite=function(d){var e;if((e=oa(b,d))!==s)return d=e,(e=oa(a,e))!==s?c+(oa("/",e)||e):b+d;if((e=oa(c,d))!==s)return c+e;if(c==d+"/")return c}}function Ob(b,a){var c=Mb(b);zc(b,this,b);this.$$parse=function(d){var e=oa(b, -d)||oa(c,d),e="#"==e.charAt(0)?oa(a,e):this.$$html5?e:"";if(!C(e))throw Nb("ihshprfx",d,a);Ac(e,this,b);d=this.$$path;var g=/^\/[A-Z]:(\/.*)/;0===e.indexOf(b)&&(e=e.replace(b,""));g.exec(e)||(d=(e=g.exec(d))?e[1]:d);this.$$path=d;this.$$compose()};this.$$compose=function(){var c=zb(this.$$search),e=this.$$hash?"#"+gb(this.$$hash):"";this.$$url=Lb(this.$$path)+(c?"?"+c:"")+e;this.$$absUrl=b+(this.$$url?a+this.$$url:"")};this.$$rewrite=function(a){if(Za(b)==Za(a))return a}}function Pb(b,a){this.$$html5= -!0;Ob.apply(this,arguments);var c=Mb(b);this.$$rewrite=function(d){var e;if(b==Za(d))return d;if(e=oa(c,d))return b+a+e;if(c===d+"/")return c};this.$$compose=function(){var c=zb(this.$$search),e=this.$$hash?"#"+gb(this.$$hash):"";this.$$url=Lb(this.$$path)+(c?"?"+c:"")+e;this.$$absUrl=b+a+this.$$url}}function qb(b){return function(){return this[b]}}function Cc(b,a){return function(c){if(H(c))return this[b];this[b]=a(c);this.$$compose();return this}}function Vd(){var b="",a=!1;this.hashPrefix=function(a){return z(a)? -(b=a,this):b};this.html5Mode=function(b){return z(b)?(a=b,this):a};this.$get=["$rootScope","$browser","$sniffer","$rootElement",function(c,d,e,g){function f(a){c.$broadcast("$locationChangeSuccess",h.absUrl(),a)}var h,m,k=d.baseHref(),l=d.url(),n;a?(n=l.substring(0,l.indexOf("/",l.indexOf("//")+2))+(k||"/"),m=e.history?Bc:Pb):(n=Za(l),m=Ob);h=new m(n,"#"+b);h.$$parse(h.$$rewrite(l));g.on("click",function(a){if(!a.ctrlKey&&!a.metaKey&&2!=a.which){for(var e=y(a.target);"a"!==J(e[0].nodeName);)if(e[0]=== -g[0]||!(e=e.parent())[0])return;var f=e.prop("href");X(f)&&"[object SVGAnimatedString]"===f.toString()&&(f=sa(f.animVal).href);if(m===Pb){var k=e.attr("href")||e.attr("xlink:href");if(0>k.indexOf("://"))if(f="#"+b,"/"==k[0])f=n+f+k;else if("#"==k[0])f=n+f+(h.path()||"/")+k;else{for(var l=h.path().split("/"),k=k.split("/"),p=0;p<k.length;p++)"."!=k[p]&&(".."==k[p]?l.pop():k[p].length&&l.push(k[p]));f=n+f+l.join("/")}}l=h.$$rewrite(f);f&&(!e.attr("target")&&l&&!a.isDefaultPrevented())&&(a.preventDefault(), -l!=d.url()&&(h.$$parse(l),c.$apply(),P.angular["ff-684208-preventDefault"]=!0))}});h.absUrl()!=l&&d.url(h.absUrl(),!0);d.onUrlChange(function(a){h.absUrl()!=a&&(c.$evalAsync(function(){var b=h.absUrl();h.$$parse(a);c.$broadcast("$locationChangeStart",a,b).defaultPrevented?(h.$$parse(b),d.url(b)):f(b)}),c.$$phase||c.$digest())});var p=0;c.$watch(function(){var a=d.url(),b=h.$$replace;p&&a==h.absUrl()||(p++,c.$evalAsync(function(){c.$broadcast("$locationChangeStart",h.absUrl(),a).defaultPrevented?h.$$parse(a): -(d.url(h.absUrl(),b),f(a))}));h.$$replace=!1;return p});return h}]}function Wd(){var b=!0,a=this;this.debugEnabled=function(a){return z(a)?(b=a,this):b};this.$get=["$window",function(c){function d(a){a instanceof Error&&(a.stack?a=a.message&&-1===a.stack.indexOf(a.message)?"Error: "+a.message+"\n"+a.stack:a.stack:a.sourceURL&&(a=a.message+"\n"+a.sourceURL+":"+a.line));return a}function e(a){var b=c.console||{},e=b[a]||b.log||w;a=!1;try{a=!!e.apply}catch(m){}return a?function(){var a=[];q(arguments, -function(b){a.push(d(b))});return e.apply(b,a)}:function(a,b){e(a,null==b?"":b)}}return{log:e("log"),info:e("info"),warn:e("warn"),error:e("error"),debug:function(){var c=e("debug");return function(){b&&c.apply(a,arguments)}}()}}]}function ga(b,a){if("constructor"===b)throw Ca("isecfld",a);return b}function $a(b,a){if(b){if(b.constructor===b)throw Ca("isecfn",a);if(b.document&&b.location&&b.alert&&b.setInterval)throw Ca("isecwindow",a);if(b.children&&(b.nodeName||b.prop&&b.attr&&b.find))throw Ca("isecdom", -a);}return b}function rb(b,a,c,d,e){e=e||{};a=a.split(".");for(var g,f=0;1<a.length;f++){g=ga(a.shift(),d);var h=b[g];h||(h={},b[g]=h);b=h;b.then&&e.unwrapPromises&&(ta(d),"$$v"in b||function(a){a.then(function(b){a.$$v=b})}(b),b.$$v===s&&(b.$$v={}),b=b.$$v)}g=ga(a.shift(),d);return b[g]=c}function Dc(b,a,c,d,e,g,f){ga(b,g);ga(a,g);ga(c,g);ga(d,g);ga(e,g);return f.unwrapPromises?function(f,m){var k=m&&m.hasOwnProperty(b)?m:f,l;if(null==k)return k;(k=k[b])&&k.then&&(ta(g),"$$v"in k||(l=k,l.$$v=s,l.then(function(a){l.$$v= -a})),k=k.$$v);if(!a)return k;if(null==k)return s;(k=k[a])&&k.then&&(ta(g),"$$v"in k||(l=k,l.$$v=s,l.then(function(a){l.$$v=a})),k=k.$$v);if(!c)return k;if(null==k)return s;(k=k[c])&&k.then&&(ta(g),"$$v"in k||(l=k,l.$$v=s,l.then(function(a){l.$$v=a})),k=k.$$v);if(!d)return k;if(null==k)return s;(k=k[d])&&k.then&&(ta(g),"$$v"in k||(l=k,l.$$v=s,l.then(function(a){l.$$v=a})),k=k.$$v);if(!e)return k;if(null==k)return s;(k=k[e])&&k.then&&(ta(g),"$$v"in k||(l=k,l.$$v=s,l.then(function(a){l.$$v=a})),k=k.$$v); -return k}:function(g,f){var k=f&&f.hasOwnProperty(b)?f:g;if(null==k)return k;k=k[b];if(!a)return k;if(null==k)return s;k=k[a];if(!c)return k;if(null==k)return s;k=k[c];if(!d)return k;if(null==k)return s;k=k[d];return e?null==k?s:k=k[e]:k}}function xe(b,a){ga(b,a);return function(a,d){return null==a?s:(d&&d.hasOwnProperty(b)?d:a)[b]}}function ye(b,a,c){ga(b,c);ga(a,c);return function(c,e){if(null==c)return s;c=(e&&e.hasOwnProperty(b)?e:c)[b];return null==c?s:c[a]}}function Ec(b,a,c){if(Qb.hasOwnProperty(b))return Qb[b]; -var d=b.split("."),e=d.length,g;if(a.unwrapPromises||1!==e)if(a.unwrapPromises||2!==e)if(a.csp)g=6>e?Dc(d[0],d[1],d[2],d[3],d[4],c,a):function(b,g){var f=0,h;do h=Dc(d[f++],d[f++],d[f++],d[f++],d[f++],c,a)(b,g),g=s,b=h;while(f<e);return h};else{var f="var p;\n";q(d,function(b,d){ga(b,c);f+="if(s == null) return undefined;\ns="+(d?"s":'((k&&k.hasOwnProperty("'+b+'"))?k:s)')+'["'+b+'"];\n'+(a.unwrapPromises?'if (s && s.then) {\n pw("'+c.replace(/(["\r\n])/g,"\\$1")+'");\n if (!("$$v" in s)) {\n p=s;\n p.$$v = undefined;\n p.then(function(v) {p.$$v=v;});\n}\n s=s.$$v\n}\n': -"")});var f=f+"return s;",h=new Function("s","k","pw",f);h.toString=aa(f);g=a.unwrapPromises?function(a,b){return h(a,b,ta)}:h}else g=ye(d[0],d[1],c);else g=xe(d[0],c);"hasOwnProperty"!==b&&(Qb[b]=g);return g}function Xd(){var b={},a={csp:!1,unwrapPromises:!1,logPromiseWarnings:!0};this.unwrapPromises=function(b){return z(b)?(a.unwrapPromises=!!b,this):a.unwrapPromises};this.logPromiseWarnings=function(b){return z(b)?(a.logPromiseWarnings=b,this):a.logPromiseWarnings};this.$get=["$filter","$sniffer", -"$log",function(c,d,e){a.csp=d.csp;ta=function(b){a.logPromiseWarnings&&!Fc.hasOwnProperty(b)&&(Fc[b]=!0,e.warn("[$parse] Promise found in the expression `"+b+"`. Automatic unwrapping of promises in Angular expressions is deprecated."))};return function(d){var e;switch(typeof d){case "string":if(b.hasOwnProperty(d))return b[d];e=new Rb(a);e=(new ab(e,c,a)).parse(d,!1);"hasOwnProperty"!==d&&(b[d]=e);return e;case "function":return d;default:return w}}}]}function Zd(){this.$get=["$rootScope","$exceptionHandler", -function(b,a){return ze(function(a){b.$evalAsync(a)},a)}]}function ze(b,a){function c(a){return a}function d(a){return f(a)}var e=function(){var f=[],k,l;return l={resolve:function(a){if(f){var c=f;f=s;k=g(a);c.length&&b(function(){for(var a,b=0,d=c.length;b<d;b++)a=c[b],k.then(a[0],a[1],a[2])})}},reject:function(a){l.resolve(h(a))},notify:function(a){if(f){var c=f;f.length&&b(function(){for(var b,d=0,e=c.length;d<e;d++)b=c[d],b[2](a)})}},promise:{then:function(b,g,h){var l=e(),D=function(d){try{l.resolve((Q(b)? -b:c)(d))}catch(e){l.reject(e),a(e)}},x=function(b){try{l.resolve((Q(g)?g:d)(b))}catch(c){l.reject(c),a(c)}},t=function(b){try{l.notify((Q(h)?h:c)(b))}catch(d){a(d)}};f?f.push([D,x,t]):k.then(D,x,t);return l.promise},"catch":function(a){return this.then(null,a)},"finally":function(a){function b(a,c){var d=e();c?d.resolve(a):d.reject(a);return d.promise}function d(e,g){var f=null;try{f=(a||c)()}catch(h){return b(h,!1)}return f&&Q(f.then)?f.then(function(){return b(e,g)},function(a){return b(a,!1)}): -b(e,g)}return this.then(function(a){return d(a,!0)},function(a){return d(a,!1)})}}}},g=function(a){return a&&Q(a.then)?a:{then:function(c){var d=e();b(function(){d.resolve(c(a))});return d.promise}}},f=function(a){var b=e();b.reject(a);return b.promise},h=function(c){return{then:function(g,f){var h=e();b(function(){try{h.resolve((Q(f)?f:d)(c))}catch(b){h.reject(b),a(b)}});return h.promise}}};return{defer:e,reject:f,when:function(h,k,l,n){var p=e(),r,A=function(b){try{return(Q(k)?k:c)(b)}catch(d){return a(d), -f(d)}},D=function(b){try{return(Q(l)?l:d)(b)}catch(c){return a(c),f(c)}},x=function(b){try{return(Q(n)?n:c)(b)}catch(d){a(d)}};b(function(){g(h).then(function(a){r||(r=!0,p.resolve(g(a).then(A,D,x)))},function(a){r||(r=!0,p.resolve(D(a)))},function(a){r||p.notify(x(a))})});return p.promise},all:function(a){var b=e(),c=0,d=L(a)?[]:{};q(a,function(a,e){c++;g(a).then(function(a){d.hasOwnProperty(e)||(d[e]=a,--c||b.resolve(d))},function(a){d.hasOwnProperty(e)||b.reject(a)})});0===c&&b.resolve(d);return b.promise}}} -function fe(){this.$get=["$window","$timeout",function(b,a){var c=b.requestAnimationFrame||b.webkitRequestAnimationFrame||b.mozRequestAnimationFrame,d=b.cancelAnimationFrame||b.webkitCancelAnimationFrame||b.mozCancelAnimationFrame||b.webkitCancelRequestAnimationFrame,e=!!c,g=e?function(a){var b=c(a);return function(){d(b)}}:function(b){var c=a(b,16.66,!1);return function(){a.cancel(c)}};g.supported=e;return g}]}function Yd(){var b=10,a=u("$rootScope"),c=null;this.digestTtl=function(a){arguments.length&& -(b=a);return b};this.$get=["$injector","$exceptionHandler","$parse","$browser",function(d,e,g,f){function h(){this.$id=cb();this.$$phase=this.$parent=this.$$watchers=this.$$nextSibling=this.$$prevSibling=this.$$childHead=this.$$childTail=null;this["this"]=this.$root=this;this.$$destroyed=!1;this.$$asyncQueue=[];this.$$postDigestQueue=[];this.$$listeners={};this.$$listenerCount={};this.$$isolateBindings={}}function m(b){if(p.$$phase)throw a("inprog",p.$$phase);p.$$phase=b}function k(a,b){var c=g(a); -Ra(c,b);return c}function l(a,b,c){do a.$$listenerCount[c]-=b,0===a.$$listenerCount[c]&&delete a.$$listenerCount[c];while(a=a.$parent)}function n(){}h.prototype={constructor:h,$new:function(a){a?(a=new h,a.$root=this.$root,a.$$asyncQueue=this.$$asyncQueue,a.$$postDigestQueue=this.$$postDigestQueue):(this.$$childScopeClass||(this.$$childScopeClass=function(){this.$$watchers=this.$$nextSibling=this.$$childHead=this.$$childTail=null;this.$$listeners={};this.$$listenerCount={};this.$id=cb();this.$$childScopeClass= -null},this.$$childScopeClass.prototype=this),a=new this.$$childScopeClass);a["this"]=a;a.$parent=this;a.$$prevSibling=this.$$childTail;this.$$childHead?this.$$childTail=this.$$childTail.$$nextSibling=a:this.$$childHead=this.$$childTail=a;return a},$watch:function(a,b,d){var e=k(a,"watch"),g=this.$$watchers,f={fn:b,last:n,get:e,exp:a,eq:!!d};c=null;if(!Q(b)){var h=k(b||w,"listener");f.fn=function(a,b,c){h(c)}}if("string"==typeof a&&e.constant){var m=f.fn;f.fn=function(a,b,c){m.call(this,a,b,c);Na(g, -f)}}g||(g=this.$$watchers=[]);g.unshift(f);return function(){Na(g,f);c=null}},$watchCollection:function(a,b){var c=this,d,e,f,h=1<b.length,k=0,m=g(a),l=[],n={},p=!0,q=0;return this.$watch(function(){d=m(c);var a,b;if(X(d))if(bb(d))for(e!==l&&(e=l,q=e.length=0,k++),a=d.length,q!==a&&(k++,e.length=q=a),b=0;b<a;b++)e[b]!==e[b]&&d[b]!==d[b]||e[b]===d[b]||(k++,e[b]=d[b]);else{e!==n&&(e=n={},q=0,k++);a=0;for(b in d)d.hasOwnProperty(b)&&(a++,e.hasOwnProperty(b)?e[b]!==d[b]&&(k++,e[b]=d[b]):(q++,e[b]=d[b], -k++));if(q>a)for(b in k++,e)e.hasOwnProperty(b)&&!d.hasOwnProperty(b)&&(q--,delete e[b])}else e!==d&&(e=d,k++);return k},function(){p?(p=!1,b(d,d,c)):b(d,f,c);if(h)if(X(d))if(bb(d)){f=Array(d.length);for(var a=0;a<d.length;a++)f[a]=d[a]}else for(a in f={},d)Gc.call(d,a)&&(f[a]=d[a]);else f=d})},$digest:function(){var d,g,f,h,k=this.$$asyncQueue,l=this.$$postDigestQueue,q,v,s=b,K,O=[],y,F,R;m("$digest");c=null;do{v=!1;for(K=this;k.length;){try{R=k.shift(),R.scope.$eval(R.expression)}catch(z){p.$$phase= -null,e(z)}c=null}a:do{if(h=K.$$watchers)for(q=h.length;q--;)try{if(d=h[q])if((g=d.get(K))!==(f=d.last)&&!(d.eq?xa(g,f):"number"==typeof g&&"number"==typeof f&&isNaN(g)&&isNaN(f)))v=!0,c=d,d.last=d.eq?ca(g):g,d.fn(g,f===n?g:f,K),5>s&&(y=4-s,O[y]||(O[y]=[]),F=Q(d.exp)?"fn: "+(d.exp.name||d.exp.toString()):d.exp,F+="; newVal: "+qa(g)+"; oldVal: "+qa(f),O[y].push(F));else if(d===c){v=!1;break a}}catch(C){p.$$phase=null,e(C)}if(!(h=K.$$childHead||K!==this&&K.$$nextSibling))for(;K!==this&&!(h=K.$$nextSibling);)K= -K.$parent}while(K=h);if((v||k.length)&&!s--)throw p.$$phase=null,a("infdig",b,qa(O));}while(v||k.length);for(p.$$phase=null;l.length;)try{l.shift()()}catch(T){e(T)}},$destroy:function(){if(!this.$$destroyed){var a=this.$parent;this.$broadcast("$destroy");this.$$destroyed=!0;this!==p&&(q(this.$$listenerCount,fb(null,l,this)),a.$$childHead==this&&(a.$$childHead=this.$$nextSibling),a.$$childTail==this&&(a.$$childTail=this.$$prevSibling),this.$$prevSibling&&(this.$$prevSibling.$$nextSibling=this.$$nextSibling), -this.$$nextSibling&&(this.$$nextSibling.$$prevSibling=this.$$prevSibling),this.$parent=this.$$nextSibling=this.$$prevSibling=this.$$childHead=this.$$childTail=this.$root=null,this.$$listeners={},this.$$watchers=this.$$asyncQueue=this.$$postDigestQueue=[],this.$destroy=this.$digest=this.$apply=w,this.$on=this.$watch=function(){return w})}},$eval:function(a,b){return g(a)(this,b)},$evalAsync:function(a){p.$$phase||p.$$asyncQueue.length||f.defer(function(){p.$$asyncQueue.length&&p.$digest()});this.$$asyncQueue.push({scope:this, -expression:a})},$$postDigest:function(a){this.$$postDigestQueue.push(a)},$apply:function(a){try{return m("$apply"),this.$eval(a)}catch(b){e(b)}finally{p.$$phase=null;try{p.$digest()}catch(c){throw e(c),c;}}},$on:function(a,b){var c=this.$$listeners[a];c||(this.$$listeners[a]=c=[]);c.push(b);var d=this;do d.$$listenerCount[a]||(d.$$listenerCount[a]=0),d.$$listenerCount[a]++;while(d=d.$parent);var e=this;return function(){c[eb(c,b)]=null;l(e,1,a)}},$emit:function(a,b){var c=[],d,g=this,f=!1,h={name:a, -targetScope:g,stopPropagation:function(){f=!0},preventDefault:function(){h.defaultPrevented=!0},defaultPrevented:!1},k=[h].concat(ya.call(arguments,1)),m,l;do{d=g.$$listeners[a]||c;h.currentScope=g;m=0;for(l=d.length;m<l;m++)if(d[m])try{d[m].apply(null,k)}catch(n){e(n)}else d.splice(m,1),m--,l--;if(f)break;g=g.$parent}while(g);return h},$broadcast:function(a,b){for(var c=this,d=this,g={name:a,targetScope:this,preventDefault:function(){g.defaultPrevented=!0},defaultPrevented:!1},f=[g].concat(ya.call(arguments, -1)),h,k;c=d;){g.currentScope=c;d=c.$$listeners[a]||[];h=0;for(k=d.length;h<k;h++)if(d[h])try{d[h].apply(null,f)}catch(m){e(m)}else d.splice(h,1),h--,k--;if(!(d=c.$$listenerCount[a]&&c.$$childHead||c!==this&&c.$$nextSibling))for(;c!==this&&!(d=c.$$nextSibling);)c=c.$parent}return g}};var p=new h;return p}]}function bd(){var b=/^\s*(https?|ftp|mailto|tel|file):/,a=/^\s*(https?|ftp|file):|data:image\//;this.aHrefSanitizationWhitelist=function(a){return z(a)?(b=a,this):b};this.imgSrcSanitizationWhitelist= -function(b){return z(b)?(a=b,this):a};this.$get=function(){return function(c,d){var e=d?a:b,g;if(!S||8<=S)if(g=sa(c).href,""!==g&&!g.match(e))return"unsafe:"+g;return c}}}function Ae(b){if("self"===b)return b;if(C(b)){if(-1<b.indexOf("***"))throw ua("iwcard",b);b=b.replace(/([-()\[\]{}+?*.$\^|,:#<!\\])/g,"\\$1").replace(/\x08/g,"\\x08").replace("\\*\\*",".*").replace("\\*","[^:/.?&;]*");return RegExp("^"+b+"$")}if(db(b))return RegExp("^"+b.source+"$");throw ua("imatcher");}function Hc(b){var a=[]; -z(b)&&q(b,function(b){a.push(Ae(b))});return a}function ae(){this.SCE_CONTEXTS=ha;var b=["self"],a=[];this.resourceUrlWhitelist=function(a){arguments.length&&(b=Hc(a));return b};this.resourceUrlBlacklist=function(b){arguments.length&&(a=Hc(b));return a};this.$get=["$injector",function(c){function d(a){var b=function(a){this.$$unwrapTrustedValue=function(){return a}};a&&(b.prototype=new a);b.prototype.valueOf=function(){return this.$$unwrapTrustedValue()};b.prototype.toString=function(){return this.$$unwrapTrustedValue().toString()}; -return b}var e=function(a){throw ua("unsafe");};c.has("$sanitize")&&(e=c.get("$sanitize"));var g=d(),f={};f[ha.HTML]=d(g);f[ha.CSS]=d(g);f[ha.URL]=d(g);f[ha.JS]=d(g);f[ha.RESOURCE_URL]=d(f[ha.URL]);return{trustAs:function(a,b){var c=f.hasOwnProperty(a)?f[a]:null;if(!c)throw ua("icontext",a,b);if(null===b||b===s||""===b)return b;if("string"!==typeof b)throw ua("itype",a);return new c(b)},getTrusted:function(c,d){if(null===d||d===s||""===d)return d;var g=f.hasOwnProperty(c)?f[c]:null;if(g&&d instanceof -g)return d.$$unwrapTrustedValue();if(c===ha.RESOURCE_URL){var g=sa(d.toString()),l,n,p=!1;l=0;for(n=b.length;l<n;l++)if("self"===b[l]?Kb(g):b[l].exec(g.href)){p=!0;break}if(p)for(l=0,n=a.length;l<n;l++)if("self"===a[l]?Kb(g):a[l].exec(g.href)){p=!1;break}if(p)return d;throw ua("insecurl",d.toString());}if(c===ha.HTML)return e(d);throw ua("unsafe");},valueOf:function(a){return a instanceof g?a.$$unwrapTrustedValue():a}}}]}function $d(){var b=!0;this.enabled=function(a){arguments.length&&(b=!!a);return b}; -this.$get=["$parse","$sniffer","$sceDelegate",function(a,c,d){if(b&&c.msie&&8>c.msieDocumentMode)throw ua("iequirks");var e=ca(ha);e.isEnabled=function(){return b};e.trustAs=d.trustAs;e.getTrusted=d.getTrusted;e.valueOf=d.valueOf;b||(e.trustAs=e.getTrusted=function(a,b){return b},e.valueOf=Ea);e.parseAs=function(b,c){var d=a(c);return d.literal&&d.constant?d:function(a,c){return e.getTrusted(b,d(a,c))}};var g=e.parseAs,f=e.getTrusted,h=e.trustAs;q(ha,function(a,b){var c=J(b);e[Ta("parse_as_"+c)]= -function(b){return g(a,b)};e[Ta("get_trusted_"+c)]=function(b){return f(a,b)};e[Ta("trust_as_"+c)]=function(b){return h(a,b)}});return e}]}function be(){this.$get=["$window","$document",function(b,a){var c={},d=Y((/android (\d+)/.exec(J((b.navigator||{}).userAgent))||[])[1]),e=/Boxee/i.test((b.navigator||{}).userAgent),g=a[0]||{},f=g.documentMode,h,m=/^(Moz|webkit|O|ms)(?=[A-Z])/,k=g.body&&g.body.style,l=!1,n=!1;if(k){for(var p in k)if(l=m.exec(p)){h=l[0];h=h.substr(0,1).toUpperCase()+h.substr(1); -break}h||(h="WebkitOpacity"in k&&"webkit");l=!!("transition"in k||h+"Transition"in k);n=!!("animation"in k||h+"Animation"in k);!d||l&&n||(l=C(g.body.style.webkitTransition),n=C(g.body.style.webkitAnimation))}return{history:!(!b.history||!b.history.pushState||4>d||e),hashchange:"onhashchange"in b&&(!f||7<f),hasEvent:function(a){if("input"==a&&9==S)return!1;if(H(c[a])){var b=g.createElement("div");c[a]="on"+a in b}return c[a]},csp:Zb(),vendorPrefix:h,transitions:l,animations:n,android:d,msie:S,msieDocumentMode:f}}]} -function de(){this.$get=["$rootScope","$browser","$q","$exceptionHandler",function(b,a,c,d){function e(e,h,m){var k=c.defer(),l=k.promise,n=z(m)&&!m;h=a.defer(function(){try{k.resolve(e())}catch(a){k.reject(a),d(a)}finally{delete g[l.$$timeoutId]}n||b.$apply()},h);l.$$timeoutId=h;g[h]=k;return l}var g={};e.cancel=function(b){return b&&b.$$timeoutId in g?(g[b.$$timeoutId].reject("canceled"),delete g[b.$$timeoutId],a.defer.cancel(b.$$timeoutId)):!1};return e}]}function sa(b,a){var c=b;S&&(W.setAttribute("href", -c),c=W.href);W.setAttribute("href",c);return{href:W.href,protocol:W.protocol?W.protocol.replace(/:$/,""):"",host:W.host,search:W.search?W.search.replace(/^\?/,""):"",hash:W.hash?W.hash.replace(/^#/,""):"",hostname:W.hostname,port:W.port,pathname:"/"===W.pathname.charAt(0)?W.pathname:"/"+W.pathname}}function Kb(b){b=C(b)?sa(b):b;return b.protocol===Ic.protocol&&b.host===Ic.host}function ee(){this.$get=aa(P)}function jc(b){function a(d,e){if(X(d)){var g={};q(d,function(b,c){g[c]=a(c,b)});return g}return b.factory(d+ -c,e)}var c="Filter";this.register=a;this.$get=["$injector",function(a){return function(b){return a.get(b+c)}}];a("currency",Jc);a("date",Kc);a("filter",Be);a("json",Ce);a("limitTo",De);a("lowercase",Ee);a("number",Lc);a("orderBy",Mc);a("uppercase",Fe)}function Be(){return function(b,a,c){if(!L(b))return b;var d=typeof c,e=[];e.check=function(a){for(var b=0;b<e.length;b++)if(!e[b](a))return!1;return!0};"function"!==d&&(c="boolean"===d&&c?function(a,b){return Qa.equals(a,b)}:function(a,b){if(a&&b&& -"object"===typeof a&&"object"===typeof b){for(var d in a)if("$"!==d.charAt(0)&&Gc.call(a,d)&&c(a[d],b[d]))return!0;return!1}b=(""+b).toLowerCase();return-1<(""+a).toLowerCase().indexOf(b)});var g=function(a,b){if("string"==typeof b&&"!"===b.charAt(0))return!g(a,b.substr(1));switch(typeof a){case "boolean":case "number":case "string":return c(a,b);case "object":switch(typeof b){case "object":return c(a,b);default:for(var d in a)if("$"!==d.charAt(0)&&g(a[d],b))return!0}return!1;case "array":for(d=0;d< -a.length;d++)if(g(a[d],b))return!0;return!1;default:return!1}};switch(typeof a){case "boolean":case "number":case "string":a={$:a};case "object":for(var f in a)(function(b){"undefined"!=typeof a[b]&&e.push(function(c){return g("$"==b?c:c&&c[b],a[b])})})(f);break;case "function":e.push(a);break;default:return b}d=[];for(f=0;f<b.length;f++){var h=b[f];e.check(h)&&d.push(h)}return d}}function Jc(b){var a=b.NUMBER_FORMATS;return function(b,d){H(d)&&(d=a.CURRENCY_SYM);return Nc(b,a.PATTERNS[1],a.GROUP_SEP, -a.DECIMAL_SEP,2).replace(/\u00A4/g,d)}}function Lc(b){var a=b.NUMBER_FORMATS;return function(b,d){return Nc(b,a.PATTERNS[0],a.GROUP_SEP,a.DECIMAL_SEP,d)}}function Nc(b,a,c,d,e){if(null==b||!isFinite(b)||X(b))return"";var g=0>b;b=Math.abs(b);var f=b+"",h="",m=[],k=!1;if(-1!==f.indexOf("e")){var l=f.match(/([\d\.]+)e(-?)(\d+)/);l&&"-"==l[2]&&l[3]>e+1?f="0":(h=f,k=!0)}if(k)0<e&&(-1<b&&1>b)&&(h=b.toFixed(e));else{f=(f.split(Oc)[1]||"").length;H(e)&&(e=Math.min(Math.max(a.minFrac,f),a.maxFrac));f=Math.pow(10, -e+1);b=Math.floor(b*f+5)/f;b=(""+b).split(Oc);f=b[0];b=b[1]||"";var l=0,n=a.lgSize,p=a.gSize;if(f.length>=n+p)for(l=f.length-n,k=0;k<l;k++)0===(l-k)%p&&0!==k&&(h+=c),h+=f.charAt(k);for(k=l;k<f.length;k++)0===(f.length-k)%n&&0!==k&&(h+=c),h+=f.charAt(k);for(;b.length<e;)b+="0";e&&"0"!==e&&(h+=d+b.substr(0,e))}m.push(g?a.negPre:a.posPre);m.push(h);m.push(g?a.negSuf:a.posSuf);return m.join("")}function Sb(b,a,c){var d="";0>b&&(d="-",b=-b);for(b=""+b;b.length<a;)b="0"+b;c&&(b=b.substr(b.length-a));return d+ -b}function $(b,a,c,d){c=c||0;return function(e){e=e["get"+b]();if(0<c||e>-c)e+=c;0===e&&-12==c&&(e=12);return Sb(e,a,d)}}function sb(b,a){return function(c,d){var e=c["get"+b](),g=Fa(a?"SHORT"+b:b);return d[g][e]}}function Kc(b){function a(a){var b;if(b=a.match(c)){a=new Date(0);var g=0,f=0,h=b[8]?a.setUTCFullYear:a.setFullYear,m=b[8]?a.setUTCHours:a.setHours;b[9]&&(g=Y(b[9]+b[10]),f=Y(b[9]+b[11]));h.call(a,Y(b[1]),Y(b[2])-1,Y(b[3]));g=Y(b[4]||0)-g;f=Y(b[5]||0)-f;h=Y(b[6]||0);b=Math.round(1E3*parseFloat("0."+ -(b[7]||0)));m.call(a,g,f,h,b)}return a}var c=/^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(Z|([+-])(\d\d):?(\d\d))?)?$/;return function(c,e){var g="",f=[],h,m;e=e||"mediumDate";e=b.DATETIME_FORMATS[e]||e;C(c)&&(c=Ge.test(c)?Y(c):a(c));yb(c)&&(c=new Date(c));if(!Ma(c))return c;for(;e;)(m=He.exec(e))?(f=f.concat(ya.call(m,1)),e=f.pop()):(f.push(e),e=null);q(f,function(a){h=Ie[a];g+=h?h(c,b.DATETIME_FORMATS):a.replace(/(^'|'$)/g,"").replace(/''/g,"'")});return g}}function Ce(){return function(b){return qa(b, -!0)}}function De(){return function(b,a){if(!L(b)&&!C(b))return b;a=Infinity===Math.abs(Number(a))?Number(a):Y(a);if(C(b))return a?0<=a?b.slice(0,a):b.slice(a,b.length):"";var c=[],d,e;a>b.length?a=b.length:a<-b.length&&(a=-b.length);0<a?(d=0,e=a):(d=b.length+a,e=b.length);for(;d<e;d++)c.push(b[d]);return c}}function Mc(b){return function(a,c,d){function e(a,b){return Pa(b)?function(b,c){return a(c,b)}:a}function g(a,b){var c=typeof a,d=typeof b;return c==d?("string"==c&&(a=a.toLowerCase(),b=b.toLowerCase()), -a===b?0:a<b?-1:1):c<d?-1:1}if(!L(a)||!c)return a;c=L(c)?c:[c];c=Uc(c,function(a){var c=!1,d=a||Ea;if(C(a)){if("+"==a.charAt(0)||"-"==a.charAt(0))c="-"==a.charAt(0),a=a.substring(1);d=b(a);if(d.constant){var f=d();return e(function(a,b){return g(a[f],b[f])},c)}}return e(function(a,b){return g(d(a),d(b))},c)});for(var f=[],h=0;h<a.length;h++)f.push(a[h]);return f.sort(e(function(a,b){for(var d=0;d<c.length;d++){var e=c[d](a,b);if(0!==e)return e}return 0},d))}}function va(b){Q(b)&&(b={link:b});b.restrict= -b.restrict||"AC";return aa(b)}function Pc(b,a,c,d){function e(a,c){c=c?"-"+hb(c,"-"):"";d.removeClass(b,(a?tb:ub)+c);d.addClass(b,(a?ub:tb)+c)}var g=this,f=b.parent().controller("form")||vb,h=0,m=g.$error={},k=[];g.$name=a.name||a.ngForm;g.$dirty=!1;g.$pristine=!0;g.$valid=!0;g.$invalid=!1;f.$addControl(g);b.addClass(Ka);e(!0);g.$addControl=function(a){Aa(a.$name,"input");k.push(a);a.$name&&(g[a.$name]=a)};g.$removeControl=function(a){a.$name&&g[a.$name]===a&&delete g[a.$name];q(m,function(b,c){g.$setValidity(c, -!0,a)});Na(k,a)};g.$setValidity=function(a,b,c){var d=m[a];if(b)d&&(Na(d,c),d.length||(h--,h||(e(b),g.$valid=!0,g.$invalid=!1),m[a]=!1,e(!0,a),f.$setValidity(a,!0,g)));else{h||e(b);if(d){if(-1!=eb(d,c))return}else m[a]=d=[],h++,e(!1,a),f.$setValidity(a,!1,g);d.push(c);g.$valid=!1;g.$invalid=!0}};g.$setDirty=function(){d.removeClass(b,Ka);d.addClass(b,wb);g.$dirty=!0;g.$pristine=!1;f.$setDirty()};g.$setPristine=function(){d.removeClass(b,wb);d.addClass(b,Ka);g.$dirty=!1;g.$pristine=!0;q(k,function(a){a.$setPristine()})}} -function pa(b,a,c,d){b.$setValidity(a,c);return c?d:s}function Je(b,a,c){var d=c.prop("validity");X(d)&&b.$parsers.push(function(c){if(b.$error[a]||!(d.badInput||d.customError||d.typeMismatch)||d.valueMissing)return c;b.$setValidity(a,!1)})}function xb(b,a,c,d,e,g){var f=a.prop("validity"),h=a[0].placeholder,m={};if(!e.android){var k=!1;a.on("compositionstart",function(a){k=!0});a.on("compositionend",function(){k=!1;l()})}var l=function(e){if(!k){var g=a.val();if(S&&"input"===(e||m).type&&a[0].placeholder!== -h)h=a[0].placeholder;else if(Pa(c.ngTrim||"T")&&(g=ba(g)),d.$viewValue!==g||f&&""===g&&!f.valueMissing)b.$$phase?d.$setViewValue(g):b.$apply(function(){d.$setViewValue(g)})}};if(e.hasEvent("input"))a.on("input",l);else{var n,p=function(){n||(n=g.defer(function(){l();n=null}))};a.on("keydown",function(a){a=a.keyCode;91===a||(15<a&&19>a||37<=a&&40>=a)||p()});if(e.hasEvent("paste"))a.on("paste cut",p)}a.on("change",l);d.$render=function(){a.val(d.$isEmpty(d.$viewValue)?"":d.$viewValue)};var r=c.ngPattern; -r&&((e=r.match(/^\/(.*)\/([gim]*)$/))?(r=RegExp(e[1],e[2]),e=function(a){return pa(d,"pattern",d.$isEmpty(a)||r.test(a),a)}):e=function(c){var e=b.$eval(r);if(!e||!e.test)throw u("ngPattern")("noregexp",r,e,ia(a));return pa(d,"pattern",d.$isEmpty(c)||e.test(c),c)},d.$formatters.push(e),d.$parsers.push(e));if(c.ngMinlength){var q=Y(c.ngMinlength);e=function(a){return pa(d,"minlength",d.$isEmpty(a)||a.length>=q,a)};d.$parsers.push(e);d.$formatters.push(e)}if(c.ngMaxlength){var D=Y(c.ngMaxlength);e= -function(a){return pa(d,"maxlength",d.$isEmpty(a)||a.length<=D,a)};d.$parsers.push(e);d.$formatters.push(e)}}function Tb(b,a){b="ngClass"+b;return["$animate",function(c){function d(a,b){var c=[],d=0;a:for(;d<a.length;d++){for(var e=a[d],l=0;l<b.length;l++)if(e==b[l])continue a;c.push(e)}return c}function e(a){if(!L(a)){if(C(a))return a.split(" ");if(X(a)){var b=[];q(a,function(a,c){a&&b.push(c)});return b}}return a}return{restrict:"AC",link:function(g,f,h){function m(a,b){var c=f.data("$classCounts")|| -{},d=[];q(a,function(a){if(0<b||c[a])c[a]=(c[a]||0)+b,c[a]===+(0<b)&&d.push(a)});f.data("$classCounts",c);return d.join(" ")}function k(b){if(!0===a||g.$index%2===a){var k=e(b||[]);if(!l){var r=m(k,1);h.$addClass(r)}else if(!xa(b,l)){var q=e(l),r=d(k,q),k=d(q,k),k=m(k,-1),r=m(r,1);0===r.length?c.removeClass(f,k):0===k.length?c.addClass(f,r):c.setClass(f,r,k)}}l=ca(b)}var l;g.$watch(h[b],k,!0);h.$observe("class",function(a){k(g.$eval(h[b]))});"ngClass"!==b&&g.$watch("$index",function(c,d){var f=c& -1;if(f!==(d&1)){var k=e(g.$eval(h[b]));f===a?(f=m(k,1),h.$addClass(f)):(f=m(k,-1),h.$removeClass(f))}})}}}]}var J=function(b){return C(b)?b.toLowerCase():b},Gc=Object.prototype.hasOwnProperty,Fa=function(b){return C(b)?b.toUpperCase():b},S,y,Ba,ya=[].slice,Ke=[].push,wa=Object.prototype.toString,Oa=u("ng"),Qa=P.angular||(P.angular={}),Sa,Ja,ka=["0","0","0"];S=Y((/msie (\d+)/.exec(J(navigator.userAgent))||[])[1]);isNaN(S)&&(S=Y((/trident\/.*; rv:(\d+)/.exec(J(navigator.userAgent))||[])[1]));w.$inject= -[];Ea.$inject=[];var ba=function(){return String.prototype.trim?function(b){return C(b)?b.trim():b}:function(b){return C(b)?b.replace(/^\s\s*/,"").replace(/\s\s*$/,""):b}}();Ja=9>S?function(b){b=b.nodeName?b:b[0];return b.scopeName&&"HTML"!=b.scopeName?Fa(b.scopeName+":"+b.nodeName):b.nodeName}:function(b){return b.nodeName?b.nodeName:b[0].nodeName};var Xc=/[A-Z]/g,$c={full:"1.2.17-build.178+sha.2406084",major:1,minor:2,dot:17,codeName:"snapshot"},Va=M.cache={},ib=M.expando="ng-"+(new Date).getTime(), -me=1,pb=P.document.addEventListener?function(b,a,c){b.addEventListener(a,c,!1)}:function(b,a,c){b.attachEvent("on"+a,c)},Ua=P.document.removeEventListener?function(b,a,c){b.removeEventListener(a,c,!1)}:function(b,a,c){b.detachEvent("on"+a,c)};M._data=function(b){return this.cache[b[this.expando]]||{}};var he=/([\:\-\_]+(.))/g,ie=/^moz([A-Z])/,Eb=u("jqLite"),je=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,Fb=/<|&#?\w+;/,ke=/<([\w:]+)/,le=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,ea= -{option:[1,'<select multiple="multiple">',"</select>"],thead:[1,"<table>","</table>"],col:[2,"<table><colgroup>","</colgroup></table>"],tr:[2,"<table><tbody>","</tbody></table>"],td:[3,"<table><tbody><tr>","</tr></tbody></table>"],_default:[0,"",""]};ea.optgroup=ea.option;ea.tbody=ea.tfoot=ea.colgroup=ea.caption=ea.thead;ea.th=ea.td;var Ia=M.prototype={ready:function(b){function a(){c||(c=!0,b())}var c=!1;"complete"===U.readyState?setTimeout(a):(this.on("DOMContentLoaded",a),M(P).on("load",a))},toString:function(){var b= -[];q(this,function(a){b.push(""+a)});return"["+b.join(", ")+"]"},eq:function(b){return 0<=b?y(this[b]):y(this[this.length+b])},length:0,push:Ke,sort:[].sort,splice:[].splice},mb={};q("multiple selected checked disabled readOnly required open".split(" "),function(b){mb[J(b)]=b});var qc={};q("input select option textarea button form details".split(" "),function(b){qc[Fa(b)]=!0});q({data:mc,inheritedData:lb,scope:function(b){return y(b).data("$scope")||lb(b.parentNode||b,["$isolateScope","$scope"])}, -isolateScope:function(b){return y(b).data("$isolateScope")||y(b).data("$isolateScopeNoTemplate")},controller:nc,injector:function(b){return lb(b,"$injector")},removeAttr:function(b,a){b.removeAttribute(a)},hasClass:Ib,css:function(b,a,c){a=Ta(a);if(z(c))b.style[a]=c;else{var d;8>=S&&(d=b.currentStyle&&b.currentStyle[a],""===d&&(d="auto"));d=d||b.style[a];8>=S&&(d=""===d?s:d);return d}},attr:function(b,a,c){var d=J(a);if(mb[d])if(z(c))c?(b[a]=!0,b.setAttribute(a,d)):(b[a]=!1,b.removeAttribute(d)); -else return b[a]||(b.attributes.getNamedItem(a)||w).specified?d:s;else if(z(c))b.setAttribute(a,c);else if(b.getAttribute)return b=b.getAttribute(a,2),null===b?s:b},prop:function(b,a,c){if(z(c))b[a]=c;else return b[a]},text:function(){function b(b,d){var e=a[b.nodeType];if(H(d))return e?b[e]:"";b[e]=d}var a=[];9>S?(a[1]="innerText",a[3]="nodeValue"):a[1]=a[3]="textContent";b.$dv="";return b}(),val:function(b,a){if(H(a)){if("SELECT"===Ja(b)&&b.multiple){var c=[];q(b.options,function(a){a.selected&& -c.push(a.value||a.text)});return 0===c.length?null:c}return b.value}b.value=a},html:function(b,a){if(H(a))return b.innerHTML;for(var c=0,d=b.childNodes;c<d.length;c++)Ga(d[c]);b.innerHTML=a},empty:oc},function(b,a){M.prototype[a]=function(a,d){var e,g;if(b!==oc&&(2==b.length&&b!==Ib&&b!==nc?a:d)===s){if(X(a)){for(e=0;e<this.length;e++)if(b===mc)b(this[e],a);else for(g in a)b(this[e],g,a[g]);return this}e=b.$dv;g=e===s?Math.min(this.length,1):this.length;for(var f=0;f<g;f++){var h=b(this[f],a,d);e= -e?e+h:h}return e}for(e=0;e<this.length;e++)b(this[e],a,d);return this}});q({removeData:kc,dealoc:Ga,on:function a(c,d,e,g){if(z(g))throw Eb("onargs");var f=la(c,"events"),h=la(c,"handle");f||la(c,"events",f={});h||la(c,"handle",h=ne(c,f));q(d.split(" "),function(d){var g=f[d];if(!g){if("mouseenter"==d||"mouseleave"==d){var l=U.body.contains||U.body.compareDocumentPosition?function(a,c){var d=9===a.nodeType?a.documentElement:a,e=c&&c.parentNode;return a===e||!!(e&&1===e.nodeType&&(d.contains?d.contains(e): -a.compareDocumentPosition&&a.compareDocumentPosition(e)&16))}:function(a,c){if(c)for(;c=c.parentNode;)if(c===a)return!0;return!1};f[d]=[];a(c,{mouseleave:"mouseout",mouseenter:"mouseover"}[d],function(a){var c=a.relatedTarget;c&&(c===this||l(this,c))||h(a,d)})}else pb(c,d,h),f[d]=[];g=f[d]}g.push(e)})},off:lc,one:function(a,c,d){a=y(a);a.on(c,function g(){a.off(c,d);a.off(c,g)});a.on(c,d)},replaceWith:function(a,c){var d,e=a.parentNode;Ga(a);q(new M(c),function(c){d?e.insertBefore(c,d.nextSibling): -e.replaceChild(c,a);d=c})},children:function(a){var c=[];q(a.childNodes,function(a){1===a.nodeType&&c.push(a)});return c},contents:function(a){return a.contentDocument||a.childNodes||[]},append:function(a,c){q(new M(c),function(c){1!==a.nodeType&&11!==a.nodeType||a.appendChild(c)})},prepend:function(a,c){if(1===a.nodeType){var d=a.firstChild;q(new M(c),function(c){a.insertBefore(c,d)})}},wrap:function(a,c){c=y(c)[0];var d=a.parentNode;d&&d.replaceChild(c,a);c.appendChild(a)},remove:function(a){Ga(a); -var c=a.parentNode;c&&c.removeChild(a)},after:function(a,c){var d=a,e=a.parentNode;q(new M(c),function(a){e.insertBefore(a,d.nextSibling);d=a})},addClass:kb,removeClass:jb,toggleClass:function(a,c,d){c&&q(c.split(" "),function(c){var g=d;H(g)&&(g=!Ib(a,c));(g?kb:jb)(a,c)})},parent:function(a){return(a=a.parentNode)&&11!==a.nodeType?a:null},next:function(a){if(a.nextElementSibling)return a.nextElementSibling;for(a=a.nextSibling;null!=a&&1!==a.nodeType;)a=a.nextSibling;return a},find:function(a,c){return a.getElementsByTagName? -a.getElementsByTagName(c):[]},clone:Hb,triggerHandler:function(a,c,d){c=(la(a,"events")||{})[c];d=d||[];var e=[{preventDefault:w,stopPropagation:w}];q(c,function(c){c.apply(a,e.concat(d))})}},function(a,c){M.prototype[c]=function(c,e,g){for(var f,h=0;h<this.length;h++)H(f)?(f=a(this[h],c,e,g),z(f)&&(f=y(f))):Gb(f,a(this[h],c,e,g));return z(f)?f:this};M.prototype.bind=M.prototype.on;M.prototype.unbind=M.prototype.off});Wa.prototype={put:function(a,c){this[Ha(a)]=c},get:function(a){return this[Ha(a)]}, -remove:function(a){var c=this[a=Ha(a)];delete this[a];return c}};var pe=/^function\s*[^\(]*\(\s*([^\)]*)\)/m,qe=/,/,re=/^\s*(_?)(\S+?)\1\s*$/,oe=/((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg,Xa=u("$injector"),Le=u("$animate"),Ld=["$provide",function(a){this.$$selectors={};this.register=function(c,d){var e=c+"-animation";if(c&&"."!=c.charAt(0))throw Le("notcsel",c);this.$$selectors[c.substr(1)]=e;a.factory(e,d)};this.classNameFilter=function(a){1===arguments.length&&(this.$$classNameFilter=a instanceof RegExp? -a:null);return this.$$classNameFilter};this.$get=["$timeout","$$asyncCallback",function(a,d){return{enter:function(a,c,f,h){f?f.after(a):(c&&c[0]||(c=f.parent()),c.append(a));h&&d(h)},leave:function(a,c){a.remove();c&&d(c)},move:function(a,c,d,h){this.enter(a,c,d,h)},addClass:function(a,c,f){c=C(c)?c:L(c)?c.join(" "):"";q(a,function(a){kb(a,c)});f&&d(f)},removeClass:function(a,c,f){c=C(c)?c:L(c)?c.join(" "):"";q(a,function(a){jb(a,c)});f&&d(f)},setClass:function(a,c,f,h){q(a,function(a){kb(a,c);jb(a, -f)});h&&d(h)},enabled:w}}]}],ja=u("$compile");fc.$inject=["$provide","$$sanitizeUriProvider"];var te=/^(x[\:\-_]|data[\:\-_])/i,yc=u("$interpolate"),Me=/^([^\?#]*)(\?([^#]*))?(#(.*))?$/,we={http:80,https:443,ftp:21},Nb=u("$location");Pb.prototype=Ob.prototype=Bc.prototype={$$html5:!1,$$replace:!1,absUrl:qb("$$absUrl"),url:function(a,c){if(H(a))return this.$$url;var d=Me.exec(a);d[1]&&this.path(decodeURIComponent(d[1]));(d[2]||d[1])&&this.search(d[3]||"");this.hash(d[5]||"",c);return this},protocol:qb("$$protocol"), -host:qb("$$host"),port:qb("$$port"),path:Cc("$$path",function(a){return"/"==a.charAt(0)?a:"/"+a}),search:function(a,c){switch(arguments.length){case 0:return this.$$search;case 1:if(C(a))this.$$search=bc(a);else if(X(a))this.$$search=a;else throw Nb("isrcharg");break;default:H(c)||null===c?delete this.$$search[a]:this.$$search[a]=c}this.$$compose();return this},hash:Cc("$$hash",Ea),replace:function(){this.$$replace=!0;return this}};var Ca=u("$parse"),Fc={},ta,La={"null":function(){return null},"true":function(){return!0}, -"false":function(){return!1},undefined:w,"+":function(a,c,d,e){d=d(a,c);e=e(a,c);return z(d)?z(e)?d+e:d:z(e)?e:s},"-":function(a,c,d,e){d=d(a,c);e=e(a,c);return(z(d)?d:0)-(z(e)?e:0)},"*":function(a,c,d,e){return d(a,c)*e(a,c)},"/":function(a,c,d,e){return d(a,c)/e(a,c)},"%":function(a,c,d,e){return d(a,c)%e(a,c)},"^":function(a,c,d,e){return d(a,c)^e(a,c)},"=":w,"===":function(a,c,d,e){return d(a,c)===e(a,c)},"!==":function(a,c,d,e){return d(a,c)!==e(a,c)},"==":function(a,c,d,e){return d(a,c)==e(a, -c)},"!=":function(a,c,d,e){return d(a,c)!=e(a,c)},"<":function(a,c,d,e){return d(a,c)<e(a,c)},">":function(a,c,d,e){return d(a,c)>e(a,c)},"<=":function(a,c,d,e){return d(a,c)<=e(a,c)},">=":function(a,c,d,e){return d(a,c)>=e(a,c)},"&&":function(a,c,d,e){return d(a,c)&&e(a,c)},"||":function(a,c,d,e){return d(a,c)||e(a,c)},"&":function(a,c,d,e){return d(a,c)&e(a,c)},"|":function(a,c,d,e){return e(a,c)(a,c,d(a,c))},"!":function(a,c,d){return!d(a,c)}},Ne={n:"\n",f:"\f",r:"\r",t:"\t",v:"\v","'":"'",'"':'"'}, -Rb=function(a){this.options=a};Rb.prototype={constructor:Rb,lex:function(a){this.text=a;this.index=0;this.ch=s;this.lastCh=":";this.tokens=[];var c;for(a=[];this.index<this.text.length;){this.ch=this.text.charAt(this.index);if(this.is("\"'"))this.readString(this.ch);else if(this.isNumber(this.ch)||this.is(".")&&this.isNumber(this.peek()))this.readNumber();else if(this.isIdent(this.ch))this.readIdent(),this.was("{,")&&("{"===a[0]&&(c=this.tokens[this.tokens.length-1]))&&(c.json=-1===c.text.indexOf(".")); -else if(this.is("(){}[].,;:?"))this.tokens.push({index:this.index,text:this.ch,json:this.was(":[,")&&this.is("{[")||this.is("}]:,")}),this.is("{[")&&a.unshift(this.ch),this.is("}]")&&a.shift(),this.index++;else if(this.isWhitespace(this.ch)){this.index++;continue}else{var d=this.ch+this.peek(),e=d+this.peek(2),g=La[this.ch],f=La[d],h=La[e];h?(this.tokens.push({index:this.index,text:e,fn:h}),this.index+=3):f?(this.tokens.push({index:this.index,text:d,fn:f}),this.index+=2):g?(this.tokens.push({index:this.index, -text:this.ch,fn:g,json:this.was("[,:")&&this.is("+-")}),this.index+=1):this.throwError("Unexpected next character ",this.index,this.index+1)}this.lastCh=this.ch}return this.tokens},is:function(a){return-1!==a.indexOf(this.ch)},was:function(a){return-1!==a.indexOf(this.lastCh)},peek:function(a){a=a||1;return this.index+a<this.text.length?this.text.charAt(this.index+a):!1},isNumber:function(a){return"0"<=a&&"9">=a},isWhitespace:function(a){return" "===a||"\r"===a||"\t"===a||"\n"===a||"\v"===a||"\u00a0"=== -a},isIdent:function(a){return"a"<=a&&"z">=a||"A"<=a&&"Z">=a||"_"===a||"$"===a},isExpOperator:function(a){return"-"===a||"+"===a||this.isNumber(a)},throwError:function(a,c,d){d=d||this.index;c=z(c)?"s "+c+"-"+this.index+" ["+this.text.substring(c,d)+"]":" "+d;throw Ca("lexerr",a,c,this.text);},readNumber:function(){for(var a="",c=this.index;this.index<this.text.length;){var d=J(this.text.charAt(this.index));if("."==d||this.isNumber(d))a+=d;else{var e=this.peek();if("e"==d&&this.isExpOperator(e))a+= -d;else if(this.isExpOperator(d)&&e&&this.isNumber(e)&&"e"==a.charAt(a.length-1))a+=d;else if(!this.isExpOperator(d)||e&&this.isNumber(e)||"e"!=a.charAt(a.length-1))break;else this.throwError("Invalid exponent")}this.index++}a*=1;this.tokens.push({index:c,text:a,json:!0,fn:function(){return a}})},readIdent:function(){for(var a=this,c="",d=this.index,e,g,f,h;this.index<this.text.length;){h=this.text.charAt(this.index);if("."===h||this.isIdent(h)||this.isNumber(h))"."===h&&(e=this.index),c+=h;else break; -this.index++}if(e)for(g=this.index;g<this.text.length;){h=this.text.charAt(g);if("("===h){f=c.substr(e-d+1);c=c.substr(0,e-d);this.index=g;break}if(this.isWhitespace(h))g++;else break}d={index:d,text:c};if(La.hasOwnProperty(c))d.fn=La[c],d.json=La[c];else{var m=Ec(c,this.options,this.text);d.fn=E(function(a,c){return m(a,c)},{assign:function(d,e){return rb(d,c,e,a.text,a.options)}})}this.tokens.push(d);f&&(this.tokens.push({index:e,text:".",json:!1}),this.tokens.push({index:e+1,text:f,json:!1}))}, -readString:function(a){var c=this.index;this.index++;for(var d="",e=a,g=!1;this.index<this.text.length;){var f=this.text.charAt(this.index),e=e+f;if(g)"u"===f?(f=this.text.substring(this.index+1,this.index+5),f.match(/[\da-f]{4}/i)||this.throwError("Invalid unicode escape [\\u"+f+"]"),this.index+=4,d+=String.fromCharCode(parseInt(f,16))):d=(g=Ne[f])?d+g:d+f,g=!1;else if("\\"===f)g=!0;else{if(f===a){this.index++;this.tokens.push({index:c,text:e,string:d,json:!0,fn:function(){return d}});return}d+= -f}this.index++}this.throwError("Unterminated quote",c)}};var ab=function(a,c,d){this.lexer=a;this.$filter=c;this.options=d};ab.ZERO=E(function(){return 0},{constant:!0});ab.prototype={constructor:ab,parse:function(a,c){this.text=a;this.json=c;this.tokens=this.lexer.lex(a);c&&(this.assignment=this.logicalOR,this.functionCall=this.fieldAccess=this.objectIndex=this.filterChain=function(){this.throwError("is not valid json",{text:a,index:0})});var d=c?this.primary():this.statements();0!==this.tokens.length&& -this.throwError("is an unexpected token",this.tokens[0]);d.literal=!!d.literal;d.constant=!!d.constant;return d},primary:function(){var a;if(this.expect("("))a=this.filterChain(),this.consume(")");else if(this.expect("["))a=this.arrayDeclaration();else if(this.expect("{"))a=this.object();else{var c=this.expect();(a=c.fn)||this.throwError("not a primary expression",c);c.json&&(a.constant=!0,a.literal=!0)}for(var d;c=this.expect("(","[",".");)"("===c.text?(a=this.functionCall(a,d),d=null):"["===c.text? -(d=a,a=this.objectIndex(a)):"."===c.text?(d=a,a=this.fieldAccess(a)):this.throwError("IMPOSSIBLE");return a},throwError:function(a,c){throw Ca("syntax",c.text,a,c.index+1,this.text,this.text.substring(c.index));},peekToken:function(){if(0===this.tokens.length)throw Ca("ueoe",this.text);return this.tokens[0]},peek:function(a,c,d,e){if(0<this.tokens.length){var g=this.tokens[0],f=g.text;if(f===a||f===c||f===d||f===e||!(a||c||d||e))return g}return!1},expect:function(a,c,d,e){return(a=this.peek(a,c,d, -e))?(this.json&&!a.json&&this.throwError("is not valid json",a),this.tokens.shift(),a):!1},consume:function(a){this.expect(a)||this.throwError("is unexpected, expecting ["+a+"]",this.peek())},unaryFn:function(a,c){return E(function(d,e){return a(d,e,c)},{constant:c.constant})},ternaryFn:function(a,c,d){return E(function(e,g){return a(e,g)?c(e,g):d(e,g)},{constant:a.constant&&c.constant&&d.constant})},binaryFn:function(a,c,d){return E(function(e,g){return c(e,g,a,d)},{constant:a.constant&&d.constant})}, -statements:function(){for(var a=[];;)if(0<this.tokens.length&&!this.peek("}",")",";","]")&&a.push(this.filterChain()),!this.expect(";"))return 1===a.length?a[0]:function(c,d){for(var e,g=0;g<a.length;g++){var f=a[g];f&&(e=f(c,d))}return e}},filterChain:function(){for(var a=this.expression(),c;;)if(c=this.expect("|"))a=this.binaryFn(a,c.fn,this.filter());else return a},filter:function(){for(var a=this.expect(),c=this.$filter(a.text),d=[];;)if(a=this.expect(":"))d.push(this.expression());else{var e= -function(a,e,h){h=[h];for(var m=0;m<d.length;m++)h.push(d[m](a,e));return c.apply(a,h)};return function(){return e}}},expression:function(){return this.assignment()},assignment:function(){var a=this.ternary(),c,d;return(d=this.expect("="))?(a.assign||this.throwError("implies assignment but ["+this.text.substring(0,d.index)+"] can not be assigned to",d),c=this.ternary(),function(d,g){return a.assign(d,c(d,g),g)}):a},ternary:function(){var a=this.logicalOR(),c,d;if(this.expect("?")){c=this.ternary(); -if(d=this.expect(":"))return this.ternaryFn(a,c,this.ternary());this.throwError("expected :",d)}else return a},logicalOR:function(){for(var a=this.logicalAND(),c;;)if(c=this.expect("||"))a=this.binaryFn(a,c.fn,this.logicalAND());else return a},logicalAND:function(){var a=this.equality(),c;if(c=this.expect("&&"))a=this.binaryFn(a,c.fn,this.logicalAND());return a},equality:function(){var a=this.relational(),c;if(c=this.expect("==","!=","===","!=="))a=this.binaryFn(a,c.fn,this.equality());return a}, -relational:function(){var a=this.additive(),c;if(c=this.expect("<",">","<=",">="))a=this.binaryFn(a,c.fn,this.relational());return a},additive:function(){for(var a=this.multiplicative(),c;c=this.expect("+","-");)a=this.binaryFn(a,c.fn,this.multiplicative());return a},multiplicative:function(){for(var a=this.unary(),c;c=this.expect("*","/","%");)a=this.binaryFn(a,c.fn,this.unary());return a},unary:function(){var a;return this.expect("+")?this.primary():(a=this.expect("-"))?this.binaryFn(ab.ZERO,a.fn, -this.unary()):(a=this.expect("!"))?this.unaryFn(a.fn,this.unary()):this.primary()},fieldAccess:function(a){var c=this,d=this.expect().text,e=Ec(d,this.options,this.text);return E(function(c,d,h){return e(h||a(c,d))},{assign:function(e,f,h){return rb(a(e,h),d,f,c.text,c.options)}})},objectIndex:function(a){var c=this,d=this.expression();this.consume("]");return E(function(e,g){var f=a(e,g),h=d(e,g),m;if(!f)return s;(f=$a(f[h],c.text))&&(f.then&&c.options.unwrapPromises)&&(m=f,"$$v"in f||(m.$$v=s,m.then(function(a){m.$$v= -a})),f=f.$$v);return f},{assign:function(e,g,f){var h=d(e,f);return $a(a(e,f),c.text)[h]=g}})},functionCall:function(a,c){var d=[];if(")"!==this.peekToken().text){do d.push(this.expression());while(this.expect(","))}this.consume(")");var e=this;return function(g,f){for(var h=[],m=c?c(g,f):g,k=0;k<d.length;k++)h.push(d[k](g,f));k=a(g,f,m)||w;$a(m,e.text);$a(k,e.text);h=k.apply?k.apply(m,h):k(h[0],h[1],h[2],h[3],h[4]);return $a(h,e.text)}},arrayDeclaration:function(){var a=[],c=!0;if("]"!==this.peekToken().text){do{if(this.peek("]"))break; -var d=this.expression();a.push(d);d.constant||(c=!1)}while(this.expect(","))}this.consume("]");return E(function(c,d){for(var f=[],h=0;h<a.length;h++)f.push(a[h](c,d));return f},{literal:!0,constant:c})},object:function(){var a=[],c=!0;if("}"!==this.peekToken().text){do{if(this.peek("}"))break;var d=this.expect(),d=d.string||d.text;this.consume(":");var e=this.expression();a.push({key:d,value:e});e.constant||(c=!1)}while(this.expect(","))}this.consume("}");return E(function(c,d){for(var e={},m=0;m< -a.length;m++){var k=a[m];e[k.key]=k.value(c,d)}return e},{literal:!0,constant:c})}};var Qb={},ua=u("$sce"),ha={HTML:"html",CSS:"css",URL:"url",RESOURCE_URL:"resourceUrl",JS:"js"},W=U.createElement("a"),Ic=sa(P.location.href,!0);jc.$inject=["$provide"];Jc.$inject=["$locale"];Lc.$inject=["$locale"];var Oc=".",Ie={yyyy:$("FullYear",4),yy:$("FullYear",2,0,!0),y:$("FullYear",1),MMMM:sb("Month"),MMM:sb("Month",!0),MM:$("Month",2,1),M:$("Month",1,1),dd:$("Date",2),d:$("Date",1),HH:$("Hours",2),H:$("Hours", -1),hh:$("Hours",2,-12),h:$("Hours",1,-12),mm:$("Minutes",2),m:$("Minutes",1),ss:$("Seconds",2),s:$("Seconds",1),sss:$("Milliseconds",3),EEEE:sb("Day"),EEE:sb("Day",!0),a:function(a,c){return 12>a.getHours()?c.AMPMS[0]:c.AMPMS[1]},Z:function(a){a=-1*a.getTimezoneOffset();return a=(0<=a?"+":"")+(Sb(Math[0<a?"floor":"ceil"](a/60),2)+Sb(Math.abs(a%60),2))}},He=/((?:[^yMdHhmsaZE']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d+|H+|h+|m+|s+|a|Z))(.*)/,Ge=/^\-?\d+$/;Kc.$inject=["$locale"];var Ee=aa(J),Fe=aa(Fa);Mc.$inject= -["$parse"];var cd=aa({restrict:"E",compile:function(a,c){8>=S&&(c.href||c.name||c.$set("href",""),a.append(U.createComment("IE fix")));if(!c.href&&!c.xlinkHref&&!c.name)return function(a,c){var g="[object SVGAnimatedString]"===wa.call(c.prop("href"))?"xlink:href":"href";c.on("click",function(a){c.attr(g)||a.preventDefault()})}}}),Cb={};q(mb,function(a,c){if("multiple"!=a){var d=na("ng-"+c);Cb[d]=function(){return{priority:100,link:function(a,g,f){a.$watch(f[d],function(a){f.$set(c,!!a)})}}}}});q(["src", -"srcset","href"],function(a){var c=na("ng-"+a);Cb[c]=function(){return{priority:99,link:function(d,e,g){var f=a,h=a;"href"===a&&"[object SVGAnimatedString]"===wa.call(e.prop("href"))&&(h="xlinkHref",g.$attr[h]="xlink:href",f=null);g.$observe(c,function(a){a&&(g.$set(h,a),S&&f&&e.prop(f,g[h]))})}}}});var vb={$addControl:w,$removeControl:w,$setValidity:w,$setDirty:w,$setPristine:w};Pc.$inject=["$element","$attrs","$scope","$animate"];var Qc=function(a){return["$timeout",function(c){return{name:"form", -restrict:a?"EAC":"E",controller:Pc,compile:function(){return{pre:function(a,e,g,f){if(!g.action){var h=function(a){a.preventDefault?a.preventDefault():a.returnValue=!1};pb(e[0],"submit",h);e.on("$destroy",function(){c(function(){Ua(e[0],"submit",h)},0,!1)})}var m=e.parent().controller("form"),k=g.name||g.ngForm;k&&rb(a,k,f,k);if(m)e.on("$destroy",function(){m.$removeControl(f);k&&rb(a,k,s,k);E(f,vb)})}}}}}]},dd=Qc(),qd=Qc(!0),Oe=/^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/, -Pe=/^[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(\.[a-z0-9-]+)*$/i,Qe=/^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/,Rc={text:xb,number:function(a,c,d,e,g,f){xb(a,c,d,e,g,f);e.$parsers.push(function(a){var c=e.$isEmpty(a);if(c||Qe.test(a))return e.$setValidity("number",!0),""===a?null:c?a:parseFloat(a);e.$setValidity("number",!1);return s});Je(e,"number",c);e.$formatters.push(function(a){return e.$isEmpty(a)?"":""+a});d.min&&(a=function(a){var c=parseFloat(d.min);return pa(e,"min",e.$isEmpty(a)||a>=c,a)},e.$parsers.push(a), -e.$formatters.push(a));d.max&&(a=function(a){var c=parseFloat(d.max);return pa(e,"max",e.$isEmpty(a)||a<=c,a)},e.$parsers.push(a),e.$formatters.push(a));e.$formatters.push(function(a){return pa(e,"number",e.$isEmpty(a)||yb(a),a)})},url:function(a,c,d,e,g,f){xb(a,c,d,e,g,f);a=function(a){return pa(e,"url",e.$isEmpty(a)||Oe.test(a),a)};e.$formatters.push(a);e.$parsers.push(a)},email:function(a,c,d,e,g,f){xb(a,c,d,e,g,f);a=function(a){return pa(e,"email",e.$isEmpty(a)||Pe.test(a),a)};e.$formatters.push(a); -e.$parsers.push(a)},radio:function(a,c,d,e){H(d.name)&&c.attr("name",cb());c.on("click",function(){c[0].checked&&a.$apply(function(){e.$setViewValue(d.value)})});e.$render=function(){c[0].checked=d.value==e.$viewValue};d.$observe("value",e.$render)},checkbox:function(a,c,d,e){var g=d.ngTrueValue,f=d.ngFalseValue;C(g)||(g=!0);C(f)||(f=!1);c.on("click",function(){a.$apply(function(){e.$setViewValue(c[0].checked)})});e.$render=function(){c[0].checked=e.$viewValue};e.$isEmpty=function(a){return a!==g}; -e.$formatters.push(function(a){return a===g});e.$parsers.push(function(a){return a?g:f})},hidden:w,button:w,submit:w,reset:w,file:w},gc=["$browser","$sniffer",function(a,c){return{restrict:"E",require:"?ngModel",link:function(d,e,g,f){f&&(Rc[J(g.type)]||Rc.text)(d,e,g,f,c,a)}}}],ub="ng-valid",tb="ng-invalid",Ka="ng-pristine",wb="ng-dirty",Re=["$scope","$exceptionHandler","$attrs","$element","$parse","$animate",function(a,c,d,e,g,f){function h(a,c){c=c?"-"+hb(c,"-"):"";f.removeClass(e,(a?tb:ub)+c); -f.addClass(e,(a?ub:tb)+c)}this.$modelValue=this.$viewValue=Number.NaN;this.$parsers=[];this.$formatters=[];this.$viewChangeListeners=[];this.$pristine=!0;this.$dirty=!1;this.$valid=!0;this.$invalid=!1;this.$name=d.name;var m=g(d.ngModel),k=m.assign;if(!k)throw u("ngModel")("nonassign",d.ngModel,ia(e));this.$render=w;this.$isEmpty=function(a){return H(a)||""===a||null===a||a!==a};var l=e.inheritedData("$formController")||vb,n=0,p=this.$error={};e.addClass(Ka);h(!0);this.$setValidity=function(a,c){p[a]!== -!c&&(c?(p[a]&&n--,n||(h(!0),this.$valid=!0,this.$invalid=!1)):(h(!1),this.$invalid=!0,this.$valid=!1,n++),p[a]=!c,h(c,a),l.$setValidity(a,c,this))};this.$setPristine=function(){this.$dirty=!1;this.$pristine=!0;f.removeClass(e,wb);f.addClass(e,Ka)};this.$setViewValue=function(d){this.$viewValue=d;this.$pristine&&(this.$dirty=!0,this.$pristine=!1,f.removeClass(e,Ka),f.addClass(e,wb),l.$setDirty());q(this.$parsers,function(a){d=a(d)});this.$modelValue!==d&&(this.$modelValue=d,k(a,d),q(this.$viewChangeListeners, -function(a){try{a()}catch(d){c(d)}}))};var r=this;a.$watch(function(){var c=m(a);if(r.$modelValue!==c){var d=r.$formatters,e=d.length;for(r.$modelValue=c;e--;)c=d[e](c);r.$viewValue!==c&&(r.$viewValue=c,r.$render())}return c})}],Fd=function(){return{require:["ngModel","^?form"],controller:Re,link:function(a,c,d,e){var g=e[0],f=e[1]||vb;f.$addControl(g);a.$on("$destroy",function(){f.$removeControl(g)})}}},Hd=aa({require:"ngModel",link:function(a,c,d,e){e.$viewChangeListeners.push(function(){a.$eval(d.ngChange)})}}), -hc=function(){return{require:"?ngModel",link:function(a,c,d,e){if(e){d.required=!0;var g=function(a){if(d.required&&e.$isEmpty(a))e.$setValidity("required",!1);else return e.$setValidity("required",!0),a};e.$formatters.push(g);e.$parsers.unshift(g);d.$observe("required",function(){g(e.$viewValue)})}}}},Gd=function(){return{require:"ngModel",link:function(a,c,d,e){var g=(a=/\/(.*)\//.exec(d.ngList))&&RegExp(a[1])||d.ngList||",";e.$parsers.push(function(a){if(!H(a)){var c=[];a&&q(a.split(g),function(a){a&& -c.push(ba(a))});return c}});e.$formatters.push(function(a){return L(a)?a.join(", "):s});e.$isEmpty=function(a){return!a||!a.length}}}},Se=/^(true|false|\d+)$/,Id=function(){return{priority:100,compile:function(a,c){return Se.test(c.ngValue)?function(a,c,g){g.$set("value",a.$eval(g.ngValue))}:function(a,c,g){a.$watch(g.ngValue,function(a){g.$set("value",a)})}}}},id=va(function(a,c,d){c.addClass("ng-binding").data("$binding",d.ngBind);a.$watch(d.ngBind,function(a){c.text(a==s?"":a)})}),kd=["$interpolate", -function(a){return function(c,d,e){c=a(d.attr(e.$attr.ngBindTemplate));d.addClass("ng-binding").data("$binding",c);e.$observe("ngBindTemplate",function(a){d.text(a)})}}],jd=["$sce","$parse",function(a,c){return function(d,e,g){e.addClass("ng-binding").data("$binding",g.ngBindHtml);var f=c(g.ngBindHtml);d.$watch(function(){return(f(d)||"").toString()},function(c){e.html(a.getTrustedHtml(f(d))||"")})}}],ld=Tb("",!0),nd=Tb("Odd",0),md=Tb("Even",1),od=va({compile:function(a,c){c.$set("ngCloak",s);a.removeClass("ng-cloak")}}), -pd=[function(){return{scope:!0,controller:"@",priority:500}}],ic={};q("click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste".split(" "),function(a){var c=na("ng-"+a);ic[c]=["$parse",function(d){return{compile:function(e,g){var f=d(g[c]);return function(c,d,e){d.on(J(a),function(a){c.$apply(function(){f(c,{$event:a})})})}}}}]});var sd=["$animate",function(a){return{transclude:"element",priority:600,terminal:!0,restrict:"A", -$$tlb:!0,link:function(c,d,e,g,f){var h,m,k;c.$watch(e.ngIf,function(g){Pa(g)?m||(m=c.$new(),f(m,function(c){c[c.length++]=U.createComment(" end ngIf: "+e.ngIf+" ");h={clone:c};a.enter(c,d.parent(),d)})):(k&&(k.remove(),k=null),m&&(m.$destroy(),m=null),h&&(k=Bb(h.clone),a.leave(k,function(){k=null}),h=null))})}}}],td=["$http","$templateCache","$anchorScroll","$animate","$sce",function(a,c,d,e,g){return{restrict:"ECA",priority:400,terminal:!0,transclude:"element",controller:Qa.noop,compile:function(f, -h){var m=h.ngInclude||h.src,k=h.onload||"",l=h.autoscroll;return function(f,h,q,s,D){var x=0,t,y,B,v=function(){y&&(y.remove(),y=null);t&&(t.$destroy(),t=null);B&&(e.leave(B,function(){y=null}),y=B,B=null)};f.$watch(g.parseAsResourceUrl(m),function(g){var m=function(){!z(l)||l&&!f.$eval(l)||d()},q=++x;g?(a.get(g,{cache:c}).success(function(a){if(q===x){var c=f.$new();s.template=a;a=D(c,function(a){v();e.enter(a,null,h,m)});t=c;B=a;t.$emit("$includeContentLoaded");f.$eval(k)}}).error(function(){q=== -x&&v()}),f.$emit("$includeContentRequested")):(v(),s.template=null)})}}}}],Jd=["$compile",function(a){return{restrict:"ECA",priority:-400,require:"ngInclude",link:function(c,d,e,g){d.html(g.template);a(d.contents())(c)}}}],ud=va({priority:450,compile:function(){return{pre:function(a,c,d){a.$eval(d.ngInit)}}}}),vd=va({terminal:!0,priority:1E3}),wd=["$locale","$interpolate",function(a,c){var d=/{}/g;return{restrict:"EA",link:function(e,g,f){var h=f.count,m=f.$attr.when&&g.attr(f.$attr.when),k=f.offset|| -0,l=e.$eval(m)||{},n={},p=c.startSymbol(),r=c.endSymbol(),s=/^when(Minus)?(.+)$/;q(f,function(a,c){s.test(c)&&(l[J(c.replace("when","").replace("Minus","-"))]=g.attr(f.$attr[c]))});q(l,function(a,e){n[e]=c(a.replace(d,p+h+"-"+k+r))});e.$watch(function(){var c=parseFloat(e.$eval(h));if(isNaN(c))return"";c in l||(c=a.pluralCat(c-k));return n[c](e,g,!0)},function(a){g.text(a)})}}}],xd=["$parse","$animate",function(a,c){var d=u("ngRepeat");return{transclude:"element",priority:1E3,terminal:!0,$$tlb:!0, -link:function(e,g,f,h,m){var k=f.ngRepeat,l=k.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?\s*$/),n,p,r,s,D,x,t={$id:Ha};if(!l)throw d("iexp",k);f=l[1];h=l[2];(l=l[3])?(n=a(l),p=function(a,c,d){x&&(t[x]=a);t[D]=c;t.$index=d;return n(e,t)}):(r=function(a,c){return Ha(c)},s=function(a){return a});l=f.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/);if(!l)throw d("iidexp",f);D=l[3]||l[1];x=l[2];var z={};e.$watchCollection(h,function(a){var f,h,l=g[0],n,t={},F,R,C,w,T,u, -E=[];if(bb(a))T=a,n=p||r;else{n=p||s;T=[];for(C in a)a.hasOwnProperty(C)&&"$"!=C.charAt(0)&&T.push(C);T.sort()}F=T.length;h=E.length=T.length;for(f=0;f<h;f++)if(C=a===T?f:T[f],w=a[C],w=n(C,w,f),Aa(w,"`track by` id"),z.hasOwnProperty(w))u=z[w],delete z[w],t[w]=u,E[f]=u;else{if(t.hasOwnProperty(w))throw q(E,function(a){a&&a.scope&&(z[a.id]=a)}),d("dupes",k,w);E[f]={id:w};t[w]=!1}for(C in z)z.hasOwnProperty(C)&&(u=z[C],f=Bb(u.clone),c.leave(f),q(f,function(a){a.$$NG_REMOVED=!0}),u.scope.$destroy()); -f=0;for(h=T.length;f<h;f++){C=a===T?f:T[f];w=a[C];u=E[f];E[f-1]&&(l=E[f-1].clone[E[f-1].clone.length-1]);if(u.scope){R=u.scope;n=l;do n=n.nextSibling;while(n&&n.$$NG_REMOVED);u.clone[0]!=n&&c.move(Bb(u.clone),null,y(l));l=u.clone[u.clone.length-1]}else R=e.$new();R[D]=w;x&&(R[x]=C);R.$index=f;R.$first=0===f;R.$last=f===F-1;R.$middle=!(R.$first||R.$last);R.$odd=!(R.$even=0===(f&1));u.scope||m(R,function(a){a[a.length++]=U.createComment(" end ngRepeat: "+k+" ");c.enter(a,null,y(l));l=a;u.scope=R;u.clone= -a;t[u.id]=u})}z=t})}}}],yd=["$animate",function(a){return function(c,d,e){c.$watch(e.ngShow,function(c){a[Pa(c)?"removeClass":"addClass"](d,"ng-hide")})}}],rd=["$animate",function(a){return function(c,d,e){c.$watch(e.ngHide,function(c){a[Pa(c)?"addClass":"removeClass"](d,"ng-hide")})}}],zd=va(function(a,c,d){a.$watch(d.ngStyle,function(a,d){d&&a!==d&&q(d,function(a,d){c.css(d,"")});a&&c.css(a)},!0)}),Ad=["$animate",function(a){return{restrict:"EA",require:"ngSwitch",controller:["$scope",function(){this.cases= -{}}],link:function(c,d,e,g){var f=[],h=[],m=[],k=[];c.$watch(e.ngSwitch||e.on,function(d){var n,p;n=0;for(p=m.length;n<p;++n)m[n].remove();n=m.length=0;for(p=k.length;n<p;++n){var r=h[n];k[n].$destroy();m[n]=r;a.leave(r,function(){m.splice(n,1)})}h.length=0;k.length=0;if(f=g.cases["!"+d]||g.cases["?"])c.$eval(e.change),q(f,function(d){var e=c.$new();k.push(e);d.transclude(e,function(c){var e=d.element;h.push(c);a.enter(c,e.parent(),e)})})})}}}],Bd=va({transclude:"element",priority:800,require:"^ngSwitch", -link:function(a,c,d,e,g){e.cases["!"+d.ngSwitchWhen]=e.cases["!"+d.ngSwitchWhen]||[];e.cases["!"+d.ngSwitchWhen].push({transclude:g,element:c})}}),Cd=va({transclude:"element",priority:800,require:"^ngSwitch",link:function(a,c,d,e,g){e.cases["?"]=e.cases["?"]||[];e.cases["?"].push({transclude:g,element:c})}}),Ed=va({link:function(a,c,d,e,g){if(!g)throw u("ngTransclude")("orphan",ia(c));g(function(a){c.empty();c.append(a)})}}),ed=["$templateCache",function(a){return{restrict:"E",terminal:!0,compile:function(c, -d){"text/ng-template"==d.type&&a.put(d.id,c[0].text)}}}],Te=u("ngOptions"),Dd=aa({terminal:!0}),fd=["$compile","$parse",function(a,c){var d=/^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+group\s+by\s+([\s\S]+?))?\s+for\s+(?:([\$\w][\$\w]*)|(?:\(\s*([\$\w][\$\w]*)\s*,\s*([\$\w][\$\w]*)\s*\)))\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?$/,e={$setViewValue:w};return{restrict:"E",require:["select","?ngModel"],controller:["$element","$scope","$attrs",function(a,c,d){var m=this,k={},l=e,n;m.databound= -d.ngModel;m.init=function(a,c,d){l=a;n=d};m.addOption=function(c){Aa(c,'"option value"');k[c]=!0;l.$viewValue==c&&(a.val(c),n.parent()&&n.remove())};m.removeOption=function(a){this.hasOption(a)&&(delete k[a],l.$viewValue==a&&this.renderUnknownOption(a))};m.renderUnknownOption=function(c){c="? "+Ha(c)+" ?";n.val(c);a.prepend(n);a.val(c);n.prop("selected",!0)};m.hasOption=function(a){return k.hasOwnProperty(a)};c.$on("$destroy",function(){m.renderUnknownOption=w})}],link:function(e,f,h,m){function k(a, -c,d,e){d.$render=function(){var a=d.$viewValue;e.hasOption(a)?(w.parent()&&w.remove(),c.val(a),""===a&&x.prop("selected",!0)):H(a)&&x?c.val(""):e.renderUnknownOption(a)};c.on("change",function(){a.$apply(function(){w.parent()&&w.remove();d.$setViewValue(c.val())})})}function l(a,c,d){var e;d.$render=function(){var a=new Wa(d.$viewValue);q(c.find("option"),function(c){c.selected=z(a.get(c.value))})};a.$watch(function(){xa(e,d.$viewValue)||(e=ca(d.$viewValue),d.$render())});c.on("change",function(){a.$apply(function(){var a= -[];q(c.find("option"),function(c){c.selected&&a.push(c.value)});d.$setViewValue(a)})})}function n(e,f,g){function h(){var a={"":[]},c=[""],d,k,s,u,v;u=g.$modelValue;v=y(e)||[];var A=n?Ub(v):v,E,I,B;I={};s=!1;var F,H;if(r)if(w&&L(u))for(s=new Wa([]),B=0;B<u.length;B++)I[m]=u[B],s.put(w(e,I),u[B]);else s=new Wa(u);for(B=0;E=A.length,B<E;B++){k=B;if(n){k=A[B];if("$"===k.charAt(0))continue;I[n]=k}I[m]=v[k];d=p(e,I)||"";(k=a[d])||(k=a[d]=[],c.push(d));r?d=z(s.remove(w?w(e,I):q(e,I))):(w?(d={},d[m]=u,d= -w(e,d)===w(e,I)):d=u===q(e,I),s=s||d);F=l(e,I);F=z(F)?F:"";k.push({id:w?w(e,I):n?A[B]:B,label:F,selected:d})}r||(D||null===u?a[""].unshift({id:"",label:"",selected:!s}):s||a[""].unshift({id:"?",label:"",selected:!0}));I=0;for(A=c.length;I<A;I++){d=c[I];k=a[d];x.length<=I?(u={element:C.clone().attr("label",d),label:k.label},v=[u],x.push(v),f.append(u.element)):(v=x[I],u=v[0],u.label!=d&&u.element.attr("label",u.label=d));F=null;B=0;for(E=k.length;B<E;B++)s=k[B],(d=v[B+1])?(F=d.element,d.label!==s.label&& -F.text(d.label=s.label),d.id!==s.id&&F.val(d.id=s.id),d.selected!==s.selected&&F.prop("selected",d.selected=s.selected)):(""===s.id&&D?H=D:(H=t.clone()).val(s.id).attr("selected",s.selected).text(s.label),v.push({element:H,label:s.label,id:s.id,selected:s.selected}),F?F.after(H):u.element.append(H),F=H);for(B++;v.length>B;)v.pop().element.remove()}for(;x.length>I;)x.pop()[0].element.remove()}var k;if(!(k=u.match(d)))throw Te("iexp",u,ia(f));var l=c(k[2]||k[1]),m=k[4]||k[6],n=k[5],p=c(k[3]||""),q= -c(k[2]?k[1]:m),y=c(k[7]),w=k[8]?c(k[8]):null,x=[[{element:f,label:""}]];D&&(a(D)(e),D.removeClass("ng-scope"),D.remove());f.empty();f.on("change",function(){e.$apply(function(){var a,c=y(e)||[],d={},h,k,l,p,t,u,v;if(r)for(k=[],p=0,u=x.length;p<u;p++)for(a=x[p],l=1,t=a.length;l<t;l++){if((h=a[l].element)[0].selected){h=h.val();n&&(d[n]=h);if(w)for(v=0;v<c.length&&(d[m]=c[v],w(e,d)!=h);v++);else d[m]=c[h];k.push(q(e,d))}}else{h=f.val();if("?"==h)k=s;else if(""===h)k=null;else if(w)for(v=0;v<c.length;v++){if(d[m]= -c[v],w(e,d)==h){k=q(e,d);break}}else d[m]=c[h],n&&(d[n]=h),k=q(e,d);1<x[0].length&&x[0][1].id!==h&&(x[0][1].selected=!1)}g.$setViewValue(k)})});g.$render=h;e.$watch(h)}if(m[1]){var p=m[0];m=m[1];var r=h.multiple,u=h.ngOptions,D=!1,x,t=y(U.createElement("option")),C=y(U.createElement("optgroup")),w=t.clone();h=0;for(var v=f.children(),E=v.length;h<E;h++)if(""===v[h].value){x=D=v.eq(h);break}p.init(m,D,w);r&&(m.$isEmpty=function(a){return!a||0===a.length});u?n(e,f,m):r?l(e,f,m):k(e,f,m,p)}}}}],hd=["$interpolate", -function(a){var c={addOption:w,removeOption:w};return{restrict:"E",priority:100,compile:function(d,e){if(H(e.value)){var g=a(d.text(),!0);g||e.$set("value",d.text())}return function(a,d,e){var k=d.parent(),l=k.data("$selectController")||k.parent().data("$selectController");l&&l.databound?d.prop("selected",!1):l=c;g?a.$watch(g,function(a,c){e.$set("value",a);a!==c&&l.removeOption(c);l.addOption(a)}):l.addOption(e.value);d.on("$destroy",function(){l.removeOption(e.value)})}}}}],gd=aa({restrict:"E", -terminal:!0});P.angular.bootstrap?console.log("WARNING: Tried to load angular more than once."):((Ba=P.jQuery)&&Ba.fn.on?(y=Ba,E(Ba.fn,{scope:Ia.scope,isolateScope:Ia.isolateScope,controller:Ia.controller,injector:Ia.injector,inheritedData:Ia.inheritedData}),Db("remove",!0,!0,!1),Db("empty",!1,!1,!1),Db("html",!1,!1,!0)):y=M,Qa.element=y,Zc(Qa),y(U).ready(function(){Wc(U,cc)}))})(window,document);!window.angular.$$csp()&&window.angular.element(document).find("head").prepend('<style type="text/css">@charset "UTF-8";[ng\\:cloak],[ng-cloak],[data-ng-cloak],[x-ng-cloak],.ng-cloak,.x-ng-cloak,.ng-hide{display:none !important;}ng\\:form{display:block;}.ng-animate-block-transitions{transition:0s all!important;-webkit-transition:0s all!important;}</style>'); -//# sourceMappingURL=angular.min.js.map +(function(w){'use strict';function oe(a){if(B(a))u(a.objectMaxDepth)&&(Mc.objectMaxDepth=Wb(a.objectMaxDepth)?a.objectMaxDepth:NaN);else return Mc}function Wb(a){return Y(a)&&0<a}function K(a,b){b=b||Error;return function(){var d=arguments[0],c;c="["+(a?a+":":"")+d+"] http://errors.angularjs.org/1.6.9/"+(a?a+"/":"")+d;for(d=1;d<arguments.length;d++){c=c+(1==d?"?":"&")+"p"+(d-1)+"=";var e=encodeURIComponent,f;f=arguments[d];f="function"==typeof f?f.toString().replace(/ \{[\s\S]*$/,""):"undefined"== +typeof f?"undefined":"string"!=typeof f?JSON.stringify(f):f;c+=e(f)}return new b(c)}}function wa(a){if(null==a||Za(a))return!1;if(I(a)||E(a)||z&&a instanceof z)return!0;var b="length"in Object(a)&&a.length;return Y(b)&&(0<=b&&(b-1 in a||a instanceof Array)||"function"===typeof a.item)}function r(a,b,d){var c,e;if(a)if(C(a))for(c in a)"prototype"!==c&&"length"!==c&&"name"!==c&&a.hasOwnProperty(c)&&b.call(d,a[c],c,a);else if(I(a)||wa(a)){var f="object"!==typeof a;c=0;for(e=a.length;c<e;c++)(f||c in + a)&&b.call(d,a[c],c,a)}else if(a.forEach&&a.forEach!==r)a.forEach(b,d,a);else if(Nc(a))for(c in a)b.call(d,a[c],c,a);else if("function"===typeof a.hasOwnProperty)for(c in a)a.hasOwnProperty(c)&&b.call(d,a[c],c,a);else for(c in a)ra.call(a,c)&&b.call(d,a[c],c,a);return a}function Oc(a,b,d){for(var c=Object.keys(a).sort(),e=0;e<c.length;e++)b.call(d,a[c[e]],c[e]);return c}function Xb(a){return function(b,d){a(d,b)}}function pe(){return++qb}function Yb(a,b,d){for(var c=a.$$hashKey,e=0,f=b.length;e<f;++e){var g= + b[e];if(B(g)||C(g))for(var h=Object.keys(g),k=0,l=h.length;k<l;k++){var m=h[k],p=g[m];d&&B(p)?fa(p)?a[m]=new Date(p.valueOf()):$a(p)?a[m]=new RegExp(p):p.nodeName?a[m]=p.cloneNode(!0):Zb(p)?a[m]=p.clone():(B(a[m])||(a[m]=I(p)?[]:{}),Yb(a[m],[p],!0)):a[m]=p}}c?a.$$hashKey=c:delete a.$$hashKey;return a}function O(a){return Yb(a,xa.call(arguments,1),!1)}function qe(a){return Yb(a,xa.call(arguments,1),!0)}function Z(a){return parseInt(a,10)}function $b(a,b){return O(Object.create(a),b)}function D(){} + function ab(a){return a}function la(a){return function(){return a}}function ac(a){return C(a.toString)&&a.toString!==ia}function x(a){return"undefined"===typeof a}function u(a){return"undefined"!==typeof a}function B(a){return null!==a&&"object"===typeof a}function Nc(a){return null!==a&&"object"===typeof a&&!Pc(a)}function E(a){return"string"===typeof a}function Y(a){return"number"===typeof a}function fa(a){return"[object Date]"===ia.call(a)}function bc(a){switch(ia.call(a)){case "[object Error]":return!0; + case "[object Exception]":return!0;case "[object DOMException]":return!0;default:return a instanceof Error}}function C(a){return"function"===typeof a}function $a(a){return"[object RegExp]"===ia.call(a)}function Za(a){return a&&a.window===a}function bb(a){return a&&a.$evalAsync&&a.$watch}function Na(a){return"boolean"===typeof a}function re(a){return a&&Y(a.length)&&se.test(ia.call(a))}function Zb(a){return!(!a||!(a.nodeName||a.prop&&a.attr&&a.find))}function te(a){var b={};a=a.split(",");var d;for(d= + 0;d<a.length;d++)b[a[d]]=!0;return b}function ya(a){return L(a.nodeName||a[0]&&a[0].nodeName)}function cb(a,b){var d=a.indexOf(b);0<=d&&a.splice(d,1);return d}function pa(a,b,d){function c(a,b,c){c--;if(0>c)return"...";var d=b.$$hashKey,g;if(I(a)){g=0;for(var f=a.length;g<f;g++)b.push(e(a[g],c))}else if(Nc(a))for(g in a)b[g]=e(a[g],c);else if(a&&"function"===typeof a.hasOwnProperty)for(g in a)a.hasOwnProperty(g)&&(b[g]=e(a[g],c));else for(g in a)ra.call(a,g)&&(b[g]=e(a[g],c));d?b.$$hashKey=d:delete b.$$hashKey; + return b}function e(a,b){if(!B(a))return a;var d=g.indexOf(a);if(-1!==d)return h[d];if(Za(a)||bb(a))throw qa("cpws");var d=!1,e=f(a);void 0===e&&(e=I(a)?[]:Object.create(Pc(a)),d=!0);g.push(a);h.push(e);return d?c(a,e,b):e}function f(a){switch(ia.call(a)){case "[object Int8Array]":case "[object Int16Array]":case "[object Int32Array]":case "[object Float32Array]":case "[object Float64Array]":case "[object Uint8Array]":case "[object Uint8ClampedArray]":case "[object Uint16Array]":case "[object Uint32Array]":return new a.constructor(e(a.buffer), + a.byteOffset,a.length);case "[object ArrayBuffer]":if(!a.slice){var b=new ArrayBuffer(a.byteLength);(new Uint8Array(b)).set(new Uint8Array(a));return b}return a.slice(0);case "[object Boolean]":case "[object Number]":case "[object String]":case "[object Date]":return new a.constructor(a.valueOf());case "[object RegExp]":return b=new RegExp(a.source,a.toString().match(/[^/]*$/)[0]),b.lastIndex=a.lastIndex,b;case "[object Blob]":return new a.constructor([a],{type:a.type})}if(C(a.cloneNode))return a.cloneNode(!0)} + var g=[],h=[];d=Wb(d)?d:NaN;if(b){if(re(b)||"[object ArrayBuffer]"===ia.call(b))throw qa("cpta");if(a===b)throw qa("cpi");I(b)?b.length=0:r(b,function(a,c){"$$hashKey"!==c&&delete b[c]});g.push(a);h.push(b);return c(a,b,d)}return e(a,d)}function cc(a,b){return a===b||a!==a&&b!==b}function sa(a,b){if(a===b)return!0;if(null===a||null===b)return!1;if(a!==a&&b!==b)return!0;var d=typeof a,c;if(d===typeof b&&"object"===d)if(I(a)){if(!I(b))return!1;if((d=a.length)===b.length){for(c=0;c<d;c++)if(!sa(a[c], + b[c]))return!1;return!0}}else{if(fa(a))return fa(b)?cc(a.getTime(),b.getTime()):!1;if($a(a))return $a(b)?a.toString()===b.toString():!1;if(bb(a)||bb(b)||Za(a)||Za(b)||I(b)||fa(b)||$a(b))return!1;d=S();for(c in a)if("$"!==c.charAt(0)&&!C(a[c])){if(!sa(a[c],b[c]))return!1;d[c]=!0}for(c in b)if(!(c in d)&&"$"!==c.charAt(0)&&u(b[c])&&!C(b[c]))return!1;return!0}return!1}function db(a,b,d){return a.concat(xa.call(b,d))}function Ra(a,b){var d=2<arguments.length?xa.call(arguments,2):[];return!C(b)||b instanceof + RegExp?b:d.length?function(){return arguments.length?b.apply(a,db(d,arguments,0)):b.apply(a,d)}:function(){return arguments.length?b.apply(a,arguments):b.call(a)}}function Qc(a,b){var d=b;"string"===typeof a&&"$"===a.charAt(0)&&"$"===a.charAt(1)?d=void 0:Za(b)?d="$WINDOW":b&&w.document===b?d="$DOCUMENT":bb(b)&&(d="$SCOPE");return d}function eb(a,b){if(!x(a))return Y(b)||(b=b?2:null),JSON.stringify(a,Qc,b)}function Rc(a){return E(a)?JSON.parse(a):a}function Sc(a,b){a=a.replace(ue,"");var d=Date.parse("Jan 01, 1970 00:00:00 "+ + a)/6E4;return U(d)?b:d}function dc(a,b,d){d=d?-1:1;var c=a.getTimezoneOffset();b=Sc(b,c);d*=b-c;a=new Date(a.getTime());a.setMinutes(a.getMinutes()+d);return a}function za(a){a=z(a).clone().empty();var b=z("<div>").append(a).html();try{return a[0].nodeType===Oa?L(b):b.match(/^(<[^>]+>)/)[1].replace(/^<([\w-]+)/,function(a,b){return"<"+L(b)})}catch(d){return L(b)}}function Tc(a){try{return decodeURIComponent(a)}catch(b){}}function ec(a){var b={};r((a||"").split("&"),function(a){var c,e,f;a&&(e=a=a.replace(/\+/g, + "%20"),c=a.indexOf("="),-1!==c&&(e=a.substring(0,c),f=a.substring(c+1)),e=Tc(e),u(e)&&(f=u(f)?Tc(f):!0,ra.call(b,e)?I(b[e])?b[e].push(f):b[e]=[b[e],f]:b[e]=f))});return b}function fc(a){var b=[];r(a,function(a,c){I(a)?r(a,function(a){b.push(ja(c,!0)+(!0===a?"":"="+ja(a,!0)))}):b.push(ja(c,!0)+(!0===a?"":"="+ja(a,!0)))});return b.length?b.join("&"):""}function fb(a){return ja(a,!0).replace(/%26/gi,"&").replace(/%3D/gi,"=").replace(/%2B/gi,"+")}function ja(a,b){return encodeURIComponent(a).replace(/%40/gi, + "@").replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%3B/gi,";").replace(/%20/g,b?"%20":"+")}function ve(a,b){var d,c,e=Ha.length;for(c=0;c<e;++c)if(d=Ha[c]+b,E(d=a.getAttribute(d)))return d;return null}function we(a,b){var d,c,e={};r(Ha,function(b){b+="app";!d&&a.hasAttribute&&a.hasAttribute(b)&&(d=a,c=a.getAttribute(b))});r(Ha,function(b){b+="app";var e;!d&&(e=a.querySelector("["+b.replace(":","\\:")+"]"))&&(d=e,c=e.getAttribute(b))});d&&(xe?(e.strictDi=null!==ve(d,"strict-di"), + b(d,c?[c]:[],e)):w.console.error("AngularJS: disabling automatic bootstrap. <script> protocol indicates an extension, document.location.href does not match."))}function Uc(a,b,d){B(d)||(d={});d=O({strictDi:!1},d);var c=function(){a=z(a);if(a.injector()){var c=a[0]===w.document?"document":za(a);throw qa("btstrpd",c.replace(/</,"<").replace(/>/,">"));}b=b||[];b.unshift(["$provide",function(b){b.value("$rootElement",a)}]);d.debugInfoEnabled&&b.push(["$compileProvider",function(a){a.debugInfoEnabled(!0)}]); + b.unshift("ng");c=gb(b,d.strictDi);c.invoke(["$rootScope","$rootElement","$compile","$injector",function(a,b,c,d){a.$apply(function(){b.data("$injector",d);c(b)(a)})}]);return c},e=/^NG_ENABLE_DEBUG_INFO!/,f=/^NG_DEFER_BOOTSTRAP!/;w&&e.test(w.name)&&(d.debugInfoEnabled=!0,w.name=w.name.replace(e,""));if(w&&!f.test(w.name))return c();w.name=w.name.replace(f,"");$.resumeBootstrap=function(a){r(a,function(a){b.push(a)});return c()};C($.resumeDeferredBootstrap)&&$.resumeDeferredBootstrap()}function ye(){w.name= + "NG_ENABLE_DEBUG_INFO!"+w.name;w.location.reload()}function ze(a){a=$.element(a).injector();if(!a)throw qa("test");return a.get("$$testability")}function Vc(a,b){b=b||"_";return a.replace(Ae,function(a,c){return(c?b:"")+a.toLowerCase()})}function Be(){var a;if(!Wc){var b=rb();(ma=x(b)?w.jQuery:b?w[b]:void 0)&&ma.fn.on?(z=ma,O(ma.fn,{scope:Sa.scope,isolateScope:Sa.isolateScope,controller:Sa.controller,injector:Sa.injector,inheritedData:Sa.inheritedData}),a=ma.cleanData,ma.cleanData=function(b){for(var c, + e=0,f;null!=(f=b[e]);e++)(c=ma._data(f,"events"))&&c.$destroy&&ma(f).triggerHandler("$destroy");a(b)}):z=V;$.element=z;Wc=!0}}function hb(a,b,d){if(!a)throw qa("areq",b||"?",d||"required");return a}function sb(a,b,d){d&&I(a)&&(a=a[a.length-1]);hb(C(a),b,"not a function, got "+(a&&"object"===typeof a?a.constructor.name||"Object":typeof a));return a}function Ia(a,b){if("hasOwnProperty"===a)throw qa("badname",b);}function Xc(a,b,d){if(!b)return a;b=b.split(".");for(var c,e=a,f=b.length,g=0;g<f;g++)c= + b[g],a&&(a=(e=a)[c]);return!d&&C(a)?Ra(e,a):a}function tb(a){for(var b=a[0],d=a[a.length-1],c,e=1;b!==d&&(b=b.nextSibling);e++)if(c||a[e]!==b)c||(c=z(xa.call(a,0,e))),c.push(b);return c||a}function S(){return Object.create(null)}function gc(a){if(null==a)return"";switch(typeof a){case "string":break;case "number":a=""+a;break;default:a=!ac(a)||I(a)||fa(a)?eb(a):a.toString()}return a}function Ce(a){function b(a,b,c){return a[b]||(a[b]=c())}var d=K("$injector"),c=K("ng");a=b(a,"angular",Object);a.$$minErr= + a.$$minErr||K;return b(a,"module",function(){var a={};return function(f,g,h){var k={};if("hasOwnProperty"===f)throw c("badname","module");g&&a.hasOwnProperty(f)&&(a[f]=null);return b(a,f,function(){function a(b,c,d,g){g||(g=e);return function(){g[d||"push"]([b,c,arguments]);return v}}function b(a,c,d){d||(d=e);return function(b,e){e&&C(e)&&(e.$$moduleName=f);d.push([a,c,arguments]);return v}}if(!g)throw d("nomod",f);var e=[],n=[],F=[],s=a("$injector","invoke","push",n),v={_invokeQueue:e,_configBlocks:n, + _runBlocks:F,info:function(a){if(u(a)){if(!B(a))throw c("aobj","value");k=a;return this}return k},requires:g,name:f,provider:b("$provide","provider"),factory:b("$provide","factory"),service:b("$provide","service"),value:a("$provide","value"),constant:a("$provide","constant","unshift"),decorator:b("$provide","decorator",n),animation:b("$animateProvider","register"),filter:b("$filterProvider","register"),controller:b("$controllerProvider","register"),directive:b("$compileProvider","directive"),component:b("$compileProvider", + "component"),config:s,run:function(a){F.push(a);return this}};h&&s(h);return v})}})}function ka(a,b){if(I(a)){b=b||[];for(var d=0,c=a.length;d<c;d++)b[d]=a[d]}else if(B(a))for(d in b=b||{},a)if("$"!==d.charAt(0)||"$"!==d.charAt(1))b[d]=a[d];return b||a}function De(a,b){var d=[];Wb(b)&&(a=$.copy(a,null,b));return JSON.stringify(a,function(a,b){b=Qc(a,b);if(B(b)){if(0<=d.indexOf(b))return"...";d.push(b)}return b})}function Ee(a){O(a,{errorHandlingConfig:oe,bootstrap:Uc,copy:pa,extend:O,merge:qe,equals:sa, + element:z,forEach:r,injector:gb,noop:D,bind:Ra,toJson:eb,fromJson:Rc,identity:ab,isUndefined:x,isDefined:u,isString:E,isFunction:C,isObject:B,isNumber:Y,isElement:Zb,isArray:I,version:Fe,isDate:fa,lowercase:L,uppercase:ub,callbacks:{$$counter:0},getTestability:ze,reloadWithDebugInfo:ye,$$minErr:K,$$csp:Ja,$$encodeUriSegment:fb,$$encodeUriQuery:ja,$$stringify:gc});ic=Ce(w);ic("ng",["ngLocale"],["$provide",function(a){a.provider({$$sanitizeUri:Ge});a.provider("$compile",Yc).directive({a:He,input:Zc, + textarea:Zc,form:Ie,script:Je,select:Ke,option:Le,ngBind:Me,ngBindHtml:Ne,ngBindTemplate:Oe,ngClass:Pe,ngClassEven:Qe,ngClassOdd:Re,ngCloak:Se,ngController:Te,ngForm:Ue,ngHide:Ve,ngIf:We,ngInclude:Xe,ngInit:Ye,ngNonBindable:Ze,ngPluralize:$e,ngRepeat:af,ngShow:bf,ngStyle:cf,ngSwitch:df,ngSwitchWhen:ef,ngSwitchDefault:ff,ngOptions:gf,ngTransclude:hf,ngModel:jf,ngList:kf,ngChange:lf,pattern:$c,ngPattern:$c,required:ad,ngRequired:ad,minlength:bd,ngMinlength:bd,maxlength:cd,ngMaxlength:cd,ngValue:mf, + ngModelOptions:nf}).directive({ngInclude:of}).directive(vb).directive(dd);a.provider({$anchorScroll:pf,$animate:qf,$animateCss:rf,$$animateJs:sf,$$animateQueue:tf,$$AnimateRunner:uf,$$animateAsyncRun:vf,$browser:wf,$cacheFactory:xf,$controller:yf,$document:zf,$$isDocumentHidden:Af,$exceptionHandler:Bf,$filter:ed,$$forceReflow:Cf,$interpolate:Df,$interval:Ef,$http:Ff,$httpParamSerializer:Gf,$httpParamSerializerJQLike:Hf,$httpBackend:If,$xhrFactory:Jf,$jsonpCallbacks:Kf,$location:Lf,$log:Mf,$parse:Nf, + $rootScope:Of,$q:Pf,$$q:Qf,$sce:Rf,$sceDelegate:Sf,$sniffer:Tf,$templateCache:Uf,$templateRequest:Vf,$$testability:Wf,$timeout:Xf,$window:Yf,$$rAF:Zf,$$jqLite:$f,$$Map:ag,$$cookieReader:bg})}]).info({angularVersion:"1.6.9"})}function wb(a,b){return b.toUpperCase()}function xb(a){return a.replace(cg,wb)}function jc(a){a=a.nodeType;return 1===a||!a||9===a}function fd(a,b){var d,c,e=b.createDocumentFragment(),f=[];if(kc.test(a)){d=e.appendChild(b.createElement("div"));c=(dg.exec(a)||["",""])[1].toLowerCase(); + c=aa[c]||aa._default;d.innerHTML=c[1]+a.replace(eg,"<$1></$2>")+c[2];for(c=c[0];c--;)d=d.lastChild;f=db(f,d.childNodes);d=e.firstChild;d.textContent=""}else f.push(b.createTextNode(a));e.textContent="";e.innerHTML="";r(f,function(a){e.appendChild(a)});return e}function V(a){if(a instanceof V)return a;var b;E(a)&&(a=Q(a),b=!0);if(!(this instanceof V)){if(b&&"<"!==a.charAt(0))throw lc("nosel");return new V(a)}if(b){b=w.document;var d;a=(d=fg.exec(a))?[b.createElement(d[1])]:(d=fd(a,b))?d.childNodes: + [];mc(this,a)}else C(a)?gd(a):mc(this,a)}function nc(a){return a.cloneNode(!0)}function yb(a,b){!b&&jc(a)&&z.cleanData([a]);a.querySelectorAll&&z.cleanData(a.querySelectorAll("*"))}function hd(a,b,d,c){if(u(c))throw lc("offargs");var e=(c=zb(a))&&c.events,f=c&&c.handle;if(f)if(b){var g=function(b){var c=e[b];u(d)&&cb(c||[],d);u(d)&&c&&0<c.length||(a.removeEventListener(b,f),delete e[b])};r(b.split(" "),function(a){g(a);Ab[a]&&g(Ab[a])})}else for(b in e)"$destroy"!==b&&a.removeEventListener(b,f),delete e[b]} + function oc(a,b){var d=a.ng339,c=d&&ib[d];c&&(b?delete c.data[b]:(c.handle&&(c.events.$destroy&&c.handle({},"$destroy"),hd(a)),delete ib[d],a.ng339=void 0))}function zb(a,b){var d=a.ng339,d=d&&ib[d];b&&!d&&(a.ng339=d=++gg,d=ib[d]={events:{},data:{},handle:void 0});return d}function pc(a,b,d){if(jc(a)){var c,e=u(d),f=!e&&b&&!B(b),g=!b;a=(a=zb(a,!f))&&a.data;if(e)a[xb(b)]=d;else{if(g)return a;if(f)return a&&a[xb(b)];for(c in b)a[xb(c)]=b[c]}}}function Bb(a,b){return a.getAttribute?-1<(" "+(a.getAttribute("class")|| + "")+" ").replace(/[\n\t]/g," ").indexOf(" "+b+" "):!1}function Cb(a,b){if(b&&a.setAttribute){var d=(" "+(a.getAttribute("class")||"")+" ").replace(/[\n\t]/g," "),c=d;r(b.split(" "),function(a){a=Q(a);c=c.replace(" "+a+" "," ")});c!==d&&a.setAttribute("class",Q(c))}}function Db(a,b){if(b&&a.setAttribute){var d=(" "+(a.getAttribute("class")||"")+" ").replace(/[\n\t]/g," "),c=d;r(b.split(" "),function(a){a=Q(a);-1===c.indexOf(" "+a+" ")&&(c+=a+" ")});c!==d&&a.setAttribute("class",Q(c))}}function mc(a, + b){if(b)if(b.nodeType)a[a.length++]=b;else{var d=b.length;if("number"===typeof d&&b.window!==b){if(d)for(var c=0;c<d;c++)a[a.length++]=b[c]}else a[a.length++]=b}}function id(a,b){return Eb(a,"$"+(b||"ngController")+"Controller")}function Eb(a,b,d){9===a.nodeType&&(a=a.documentElement);for(b=I(b)?b:[b];a;){for(var c=0,e=b.length;c<e;c++)if(u(d=z.data(a,b[c])))return d;a=a.parentNode||11===a.nodeType&&a.host}}function jd(a){for(yb(a,!0);a.firstChild;)a.removeChild(a.firstChild)}function Fb(a,b){b|| + yb(a);var d=a.parentNode;d&&d.removeChild(a)}function hg(a,b){b=b||w;if("complete"===b.document.readyState)b.setTimeout(a);else z(b).on("load",a)}function gd(a){function b(){w.document.removeEventListener("DOMContentLoaded",b);w.removeEventListener("load",b);a()}"complete"===w.document.readyState?w.setTimeout(a):(w.document.addEventListener("DOMContentLoaded",b),w.addEventListener("load",b))}function kd(a,b){var d=Gb[b.toLowerCase()];return d&&ld[ya(a)]&&d}function ig(a,b){var d=function(c,d){c.isDefaultPrevented= + function(){return c.defaultPrevented};var f=b[d||c.type],g=f?f.length:0;if(g){if(x(c.immediatePropagationStopped)){var h=c.stopImmediatePropagation;c.stopImmediatePropagation=function(){c.immediatePropagationStopped=!0;c.stopPropagation&&c.stopPropagation();h&&h.call(c)}}c.isImmediatePropagationStopped=function(){return!0===c.immediatePropagationStopped};var k=f.specialHandlerWrapper||jg;1<g&&(f=ka(f));for(var l=0;l<g;l++)c.isImmediatePropagationStopped()||k(a,c,f[l])}};d.elem=a;return d}function jg(a, + b,d){d.call(a,b)}function kg(a,b,d){var c=b.relatedTarget;c&&(c===a||lg.call(a,c))||d.call(a,b)}function $f(){this.$get=function(){return O(V,{hasClass:function(a,b){a.attr&&(a=a[0]);return Bb(a,b)},addClass:function(a,b){a.attr&&(a=a[0]);return Db(a,b)},removeClass:function(a,b){a.attr&&(a=a[0]);return Cb(a,b)}})}}function Pa(a,b){var d=a&&a.$$hashKey;if(d)return"function"===typeof d&&(d=a.$$hashKey()),d;d=typeof a;return d="function"===d||"object"===d&&null!==a?a.$$hashKey=d+":"+(b||pe)():d+":"+ + a}function md(){this._keys=[];this._values=[];this._lastKey=NaN;this._lastIndex=-1}function nd(a){a=Function.prototype.toString.call(a).replace(mg,"");return a.match(ng)||a.match(og)}function pg(a){return(a=nd(a))?"function("+(a[1]||"").replace(/[\s\r\n]+/," ")+")":"fn"}function gb(a,b){function d(a){return function(b,c){if(B(b))r(b,Xb(a));else return a(b,c)}}function c(a,b){Ia(a,"service");if(C(b)||I(b))b=n.instantiate(b);if(!b.$get)throw Ba("pget",a);return p[a+"Provider"]=b}function e(a,b){return function(){var c= + v.invoke(b,this);if(x(c))throw Ba("undef",a);return c}}function f(a,b,d){return c(a,{$get:!1!==d?e(a,b):b})}function g(a){hb(x(a)||I(a),"modulesToLoad","not an array");var b=[],c;r(a,function(a){function d(a){var b,c;b=0;for(c=a.length;b<c;b++){var e=a[b],g=n.get(e[0]);g[e[1]].apply(g,e[2])}}if(!m.get(a)){m.set(a,!0);try{E(a)?(c=ic(a),v.modules[a]=c,b=b.concat(g(c.requires)).concat(c._runBlocks),d(c._invokeQueue),d(c._configBlocks)):C(a)?b.push(n.invoke(a)):I(a)?b.push(n.invoke(a)):sb(a,"module")}catch(e){throw I(a)&& + (a=a[a.length-1]),e.message&&e.stack&&-1===e.stack.indexOf(e.message)&&(e=e.message+"\n"+e.stack),Ba("modulerr",a,e.stack||e.message||e);}}});return b}function h(a,c){function d(b,e){if(a.hasOwnProperty(b)){if(a[b]===k)throw Ba("cdep",b+" <- "+l.join(" <- "));return a[b]}try{return l.unshift(b),a[b]=k,a[b]=c(b,e),a[b]}catch(g){throw a[b]===k&&delete a[b],g;}finally{l.shift()}}function e(a,c,g){var f=[];a=gb.$$annotate(a,b,g);for(var k=0,h=a.length;k<h;k++){var l=a[k];if("string"!==typeof l)throw Ba("itkn", + l);f.push(c&&c.hasOwnProperty(l)?c[l]:d(l,g))}return f}return{invoke:function(a,b,c,d){"string"===typeof c&&(d=c,c=null);c=e(a,c,d);I(a)&&(a=a[a.length-1]);d=a;if(Ca||"function"!==typeof d)d=!1;else{var g=d.$$ngIsClass;Na(g)||(g=d.$$ngIsClass=/^(?:class\b|constructor\()/.test(Function.prototype.toString.call(d)));d=g}return d?(c.unshift(null),new (Function.prototype.bind.apply(a,c))):a.apply(b,c)},instantiate:function(a,b,c){var d=I(a)?a[a.length-1]:a;a=e(a,b,c);a.unshift(null);return new (Function.prototype.bind.apply(d, + a))},get:d,annotate:gb.$$annotate,has:function(b){return p.hasOwnProperty(b+"Provider")||a.hasOwnProperty(b)}}}b=!0===b;var k={},l=[],m=new Hb,p={$provide:{provider:d(c),factory:d(f),service:d(function(a,b){return f(a,["$injector",function(a){return a.instantiate(b)}])}),value:d(function(a,b){return f(a,la(b),!1)}),constant:d(function(a,b){Ia(a,"constant");p[a]=b;F[a]=b}),decorator:function(a,b){var c=n.get(a+"Provider"),d=c.$get;c.$get=function(){var a=v.invoke(d,c);return v.invoke(b,null,{$delegate:a})}}}}, + n=p.$injector=h(p,function(a,b){$.isString(b)&&l.push(b);throw Ba("unpr",l.join(" <- "));}),F={},s=h(F,function(a,b){var c=n.get(a+"Provider",b);return v.invoke(c.$get,c,void 0,a)}),v=s;p.$injectorProvider={$get:la(s)};v.modules=n.modules=S();var y=g(a),v=s.get("$injector");v.strictDi=b;r(y,function(a){a&&v.invoke(a)});v.loadNewModules=function(a){r(g(a),function(a){a&&v.invoke(a)})};return v}function pf(){var a=!0;this.disableAutoScrolling=function(){a=!1};this.$get=["$window","$location","$rootScope", + function(b,d,c){function e(a){var b=null;Array.prototype.some.call(a,function(a){if("a"===ya(a))return b=a,!0});return b}function f(a){if(a){a.scrollIntoView();var c;c=g.yOffset;C(c)?c=c():Zb(c)?(c=c[0],c="fixed"!==b.getComputedStyle(c).position?0:c.getBoundingClientRect().bottom):Y(c)||(c=0);c&&(a=a.getBoundingClientRect().top,b.scrollBy(0,a-c))}else b.scrollTo(0,0)}function g(a){a=E(a)?a:Y(a)?a.toString():d.hash();var b;a?(b=h.getElementById(a))?f(b):(b=e(h.getElementsByName(a)))?f(b):"top"===a&& + f(null):f(null)}var h=b.document;a&&c.$watch(function(){return d.hash()},function(a,b){a===b&&""===a||hg(function(){c.$evalAsync(g)})});return g}]}function jb(a,b){if(!a&&!b)return"";if(!a)return b;if(!b)return a;I(a)&&(a=a.join(" "));I(b)&&(b=b.join(" "));return a+" "+b}function qg(a){E(a)&&(a=a.split(" "));var b=S();r(a,function(a){a.length&&(b[a]=!0)});return b}function Ka(a){return B(a)?a:{}}function rg(a,b,d,c){function e(a){try{a.apply(null,xa.call(arguments,1))}finally{if(s--,0===s)for(;v.length;)try{v.pop()()}catch(b){d.error(b)}}} + function f(){A=null;h()}function g(){y=H();y=x(y)?null:y;sa(y,J)&&(y=J);t=J=y}function h(){var a=t;g();if(Aa!==k.url()||a!==y)Aa=k.url(),t=y,r(G,function(a){a(k.url(),y)})}var k=this,l=a.location,m=a.history,p=a.setTimeout,n=a.clearTimeout,F={};k.isMock=!1;var s=0,v=[];k.$$completeOutstandingRequest=e;k.$$incOutstandingRequestCount=function(){s++};k.notifyWhenNoOutstandingRequests=function(a){0===s?a():v.push(a)};var y,t,Aa=l.href,hc=b.find("base"),A=null,H=c.history?function(){try{return m.state}catch(a){}}: + D;g();k.url=function(b,d,e){x(e)&&(e=null);l!==a.location&&(l=a.location);m!==a.history&&(m=a.history);if(b){var f=t===e;if(Aa===b&&(!c.history||f))return k;var h=Aa&&La(Aa)===La(b);Aa=b;t=e;!c.history||h&&f?(h||(A=b),d?l.replace(b):h?(d=l,e=b.indexOf("#"),e=-1===e?"":b.substr(e),d.hash=e):l.href=b,l.href!==b&&(A=b)):(m[d?"replaceState":"pushState"](e,"",b),g());A&&(A=b);return k}return A||l.href.replace(/%27/g,"'")};k.state=function(){return y};var G=[],ba=!1,J=null;k.onUrlChange=function(b){if(!ba){if(c.history)z(a).on("popstate", + f);z(a).on("hashchange",f);ba=!0}G.push(b);return b};k.$$applicationDestroyed=function(){z(a).off("hashchange popstate",f)};k.$$checkUrlChange=h;k.baseHref=function(){var a=hc.attr("href");return a?a.replace(/^(https?:)?\/\/[^/]*/,""):""};k.defer=function(a,b){var c;s++;c=p(function(){delete F[c];e(a)},b||0);F[c]=!0;return c};k.defer.cancel=function(a){return F[a]?(delete F[a],n(a),e(D),!0):!1}}function wf(){this.$get=["$window","$log","$sniffer","$document",function(a,b,d,c){return new rg(a,c,b, + d)}]}function xf(){this.$get=function(){function a(a,c){function e(a){a!==p&&(n?n===a&&(n=a.n):n=a,f(a.n,a.p),f(a,p),p=a,p.n=null)}function f(a,b){a!==b&&(a&&(a.p=b),b&&(b.n=a))}if(a in b)throw K("$cacheFactory")("iid",a);var g=0,h=O({},c,{id:a}),k=S(),l=c&&c.capacity||Number.MAX_VALUE,m=S(),p=null,n=null;return b[a]={put:function(a,b){if(!x(b)){if(l<Number.MAX_VALUE){var c=m[a]||(m[a]={key:a});e(c)}a in k||g++;k[a]=b;g>l&&this.remove(n.key);return b}},get:function(a){if(l<Number.MAX_VALUE){var b= + m[a];if(!b)return;e(b)}return k[a]},remove:function(a){if(l<Number.MAX_VALUE){var b=m[a];if(!b)return;b===p&&(p=b.p);b===n&&(n=b.n);f(b.n,b.p);delete m[a]}a in k&&(delete k[a],g--)},removeAll:function(){k=S();g=0;m=S();p=n=null},destroy:function(){m=h=k=null;delete b[a]},info:function(){return O({},h,{size:g})}}}var b={};a.info=function(){var a={};r(b,function(b,e){a[e]=b.info()});return a};a.get=function(a){return b[a]};return a}}function Uf(){this.$get=["$cacheFactory",function(a){return a("templates")}]} + function Yc(a,b){function d(a,b,c){var d=/^\s*([@&<]|=(\*?))(\??)\s*([\w$]*)\s*$/,e=S();r(a,function(a,g){if(a in p)e[g]=p[a];else{var f=a.match(d);if(!f)throw ca("iscp",b,g,a,c?"controller bindings definition":"isolate scope definition");e[g]={mode:f[1][0],collection:"*"===f[2],optional:"?"===f[3],attrName:f[4]||g};f[4]&&(p[a]=e[g])}});return e}function c(a){var b=a.charAt(0);if(!b||b!==L(b))throw ca("baddir",a);if(a!==a.trim())throw ca("baddir",a);}function e(a){var b=a.require||a.controller&&a.name; + !I(b)&&B(b)&&r(b,function(a,c){var d=a.match(l);a.substring(d[0].length)||(b[c]=d[0]+c)});return b}var f={},g=/^\s*directive:\s*([\w-]+)\s+(.*)$/,h=/(([\w-]+)(?::([^;]+))?;?)/,k=te("ngSrc,ngSrcset,src,srcset"),l=/^(?:(\^\^?)?(\?)?(\^\^?)?)?/,m=/^(on[a-z]+|formaction)$/,p=S();this.directive=function hc(b,d){hb(b,"name");Ia(b,"directive");E(b)?(c(b),hb(d,"directiveFactory"),f.hasOwnProperty(b)||(f[b]=[],a.factory(b+"Directive",["$injector","$exceptionHandler",function(a,c){var d=[];r(f[b],function(g, + f){try{var h=a.invoke(g);C(h)?h={compile:la(h)}:!h.compile&&h.link&&(h.compile=la(h.link));h.priority=h.priority||0;h.index=f;h.name=h.name||b;h.require=e(h);var k=h,l=h.restrict;if(l&&(!E(l)||!/[EACM]/.test(l)))throw ca("badrestrict",l,b);k.restrict=l||"EA";h.$$moduleName=g.$$moduleName;d.push(h)}catch(m){c(m)}});return d}])),f[b].push(d)):r(b,Xb(hc));return this};this.component=function A(a,b){function c(a){function e(b){return C(b)||I(b)?function(c,d){return a.invoke(b,this,{$element:c,$attrs:d})}: + b}var g=b.template||b.templateUrl?b.template:"",f={controller:d,controllerAs:sg(b.controller)||b.controllerAs||"$ctrl",template:e(g),templateUrl:e(b.templateUrl),transclude:b.transclude,scope:{},bindToController:b.bindings||{},restrict:"E",require:b.require};r(b,function(a,b){"$"===b.charAt(0)&&(f[b]=a)});return f}if(!E(a))return r(a,Xb(Ra(this,A))),this;var d=b.controller||function(){};r(b,function(a,b){"$"===b.charAt(0)&&(c[b]=a,C(d)&&(d[b]=a))});c.$inject=["$injector"];return this.directive(a, + c)};this.aHrefSanitizationWhitelist=function(a){return u(a)?(b.aHrefSanitizationWhitelist(a),this):b.aHrefSanitizationWhitelist()};this.imgSrcSanitizationWhitelist=function(a){return u(a)?(b.imgSrcSanitizationWhitelist(a),this):b.imgSrcSanitizationWhitelist()};var n=!0;this.debugInfoEnabled=function(a){return u(a)?(n=a,this):n};var F=!1;this.preAssignBindingsEnabled=function(a){return u(a)?(F=a,this):F};var s=!1;this.strictComponentBindingsEnabled=function(a){return u(a)?(s=a,this):s};var v=10;this.onChangesTtl= + function(a){return arguments.length?(v=a,this):v};var y=!0;this.commentDirectivesEnabled=function(a){return arguments.length?(y=a,this):y};var t=!0;this.cssClassDirectivesEnabled=function(a){return arguments.length?(t=a,this):t};this.$get=["$injector","$interpolate","$exceptionHandler","$templateRequest","$parse","$controller","$rootScope","$sce","$animate","$$sanitizeUri",function(a,b,c,e,p,R,M,T,P,q){function N(){try{if(!--Fa)throw ha=void 0,ca("infchng",v);M.$apply(function(){for(var a=[],b=0, + c=ha.length;b<c;++b)try{ha[b]()}catch(d){a.push(d)}ha=void 0;if(a.length)throw a;})}finally{Fa++}}function qc(a,b){if(b){var c=Object.keys(b),d,e,g;d=0;for(e=c.length;d<e;d++)g=c[d],this[g]=b[g]}else this.$attr={};this.$$element=a}function Ta(a,b,c){Ba.innerHTML="<span "+b+">";b=Ba.firstChild.attributes;var d=b[0];b.removeNamedItem(d.name);d.value=c;a.attributes.setNamedItem(d)}function na(a,b){try{a.addClass(b)}catch(c){}}function da(a,b,c,d,e){a instanceof z||(a=z(a));var g=Ua(a,b,a,c,d,e);da.$$addScopeClass(a); + var f=null;return function(b,c,d){if(!a)throw ca("multilink");hb(b,"scope");e&&e.needsNewScope&&(b=b.$parent.$new());d=d||{};var h=d.parentBoundTranscludeFn,k=d.transcludeControllers;d=d.futureParentElement;h&&h.$$boundTransclude&&(h=h.$$boundTransclude);f||(f=(d=d&&d[0])?"foreignobject"!==ya(d)&&ia.call(d).match(/SVG/)?"svg":"html":"html");d="html"!==f?z(ka(f,z("<div>").append(a).html())):c?Sa.clone.call(a):a;if(k)for(var l in k)d.data("$"+l+"Controller",k[l].instance);da.$$addScopeInfo(d,b);c&& + c(d,b);g&&g(b,d,d,h);c||(a=g=null);return d}}function Ua(a,b,c,d,e,g){function f(a,c,d,e){var g,k,l,m,p,n,G;if(t)for(G=Array(c.length),m=0;m<h.length;m+=3)g=h[m],G[g]=c[g];else G=c;m=0;for(p=h.length;m<p;)k=G[h[m++]],c=h[m++],g=h[m++],c?(c.scope?(l=a.$new(),da.$$addScopeInfo(z(k),l)):l=a,n=c.transcludeOnThisElement?Ma(a,c.transclude,e):!c.templateOnThisElement&&e?e:!e&&b?Ma(a,b):null,c(g,l,k,d,n)):g&&g(a,k.childNodes,void 0,e)}for(var h=[],k=I(a)||a instanceof z,l,m,p,n,t,G=0;G<a.length;G++){l=new qc; + 11===Ca&&Da(a,G,k);m=K(a[G],[],l,0===G?d:void 0,e);(g=m.length?Y(m,a[G],l,b,c,null,[],[],g):null)&&g.scope&&da.$$addScopeClass(l.$$element);l=g&&g.terminal||!(p=a[G].childNodes)||!p.length?null:Ua(p,g?(g.transcludeOnThisElement||!g.templateOnThisElement)&&g.transclude:b);if(g||l)h.push(G,g,l),n=!0,t=t||g;g=null}return n?f:null}function Da(a,b,c){var d=a[b],e=d.parentNode,g;if(d.nodeType===Oa)for(;;){g=e?d.nextSibling:a[b+1];if(!g||g.nodeType!==Oa)break;d.nodeValue+=g.nodeValue;g.parentNode&&g.parentNode.removeChild(g); + c&&g===a[b+1]&&a.splice(b+1,1)}}function Ma(a,b,c){function d(e,g,f,h,k){e||(e=a.$new(!1,k),e.$$transcluded=!0);return b(e,g,{parentBoundTranscludeFn:c,transcludeControllers:f,futureParentElement:h})}var e=d.$$slots=S(),g;for(g in b.$$slots)e[g]=b.$$slots[g]?Ma(a,b.$$slots[g],c):null;return d}function K(a,b,c,d,e){var g=c.$attr,f;switch(a.nodeType){case 1:f=ya(a);U(b,Ea(f),"E",d,e);for(var k,l,m,p,n=a.attributes,t=0,G=n&&n.length;t<G;t++){var H=!1,F=!1;k=n[t];l=k.name;m=k.value;k=Ea(l);(p=Pa.test(k))&& + (l=l.replace(od,"").substr(8).replace(/_(.)/g,function(a,b){return b.toUpperCase()}));(k=k.match(Qa))&&$(k[1])&&(H=l,F=l.substr(0,l.length-5)+"end",l=l.substr(0,l.length-6));k=Ea(l.toLowerCase());g[k]=l;if(p||!c.hasOwnProperty(k))c[k]=m,kd(a,k)&&(c[k]=!0);wa(a,b,m,k,p);U(b,k,"A",d,e,H,F)}"input"===f&&"hidden"===a.getAttribute("type")&&a.setAttribute("autocomplete","off");if(!La)break;g=a.className;B(g)&&(g=g.animVal);if(E(g)&&""!==g)for(;a=h.exec(g);)k=Ea(a[2]),U(b,k,"C",d,e)&&(c[k]=Q(a[3])),g=g.substr(a.index+ + a[0].length);break;case Oa:oa(b,a.nodeValue);break;case 8:if(!Ka)break;rc(a,b,c,d,e)}b.sort(la);return b}function rc(a,b,c,d,e){try{var f=g.exec(a.nodeValue);if(f){var h=Ea(f[1]);U(b,h,"M",d,e)&&(c[h]=Q(f[2]))}}catch(k){}}function pd(a,b,c){var d=[],e=0;if(b&&a.hasAttribute&&a.hasAttribute(b)){do{if(!a)throw ca("uterdir",b,c);1===a.nodeType&&(a.hasAttribute(b)&&e++,a.hasAttribute(c)&&e--);d.push(a);a=a.nextSibling}while(0<e)}else d.push(a);return z(d)}function V(a,b,c){return function(d,e,g,f,h){e= + pd(e[0],b,c);return a(d,e,g,f,h)}}function W(a,b,c,d,e,g){var f;return a?da(b,c,d,e,g):function(){f||(f=da(b,c,d,e,g),b=c=g=null);return f.apply(this,arguments)}}function Y(a,b,d,e,g,f,h,k,l){function m(a,b,c,d){if(a){c&&(a=V(a,c,d));a.require=s.require;a.directiveName=R;if(J===s||s.$$isolateScope)a=ta(a,{isolateScope:!0});h.push(a)}if(b){c&&(b=V(b,c,d));b.require=s.require;b.directiveName=R;if(J===s||s.$$isolateScope)b=ta(b,{isolateScope:!0});k.push(b)}}function p(a,e,g,f,l){function m(a,b,c,d){var e; + bb(a)||(d=c,c=b,b=a,a=void 0);T&&(e=M);c||(c=T?ga.parent():ga);if(d){var g=l.$$slots[d];if(g)return g(a,b,e,c,N);if(x(g))throw ca("noslot",d,za(ga));}else return l(a,b,e,c,N)}var n,s,v,y,ba,M,R,ga;b===g?(f=d,ga=d.$$element):(ga=z(g),f=new qc(ga,d));ba=e;J?y=e.$new(!0):t&&(ba=e.$parent);l&&(R=m,R.$$boundTransclude=l,R.isSlotFilled=function(a){return!!l.$$slots[a]});H&&(M=ea(ga,f,R,H,y,e,J));J&&(da.$$addScopeInfo(ga,y,!0,!(A&&(A===J||A===J.$$originalDirective))),da.$$addScopeClass(ga,!0),y.$$isolateBindings= + J.$$isolateBindings,s=qa(e,f,y,y.$$isolateBindings,J),s.removeWatches&&y.$on("$destroy",s.removeWatches));for(n in M){s=H[n];v=M[n];var P=s.$$bindings.bindToController;if(F){v.bindingInfo=P?qa(ba,f,v.instance,P,s):{};var q=v();q!==v.instance&&(v.instance=q,ga.data("$"+s.name+"Controller",q),v.bindingInfo.removeWatches&&v.bindingInfo.removeWatches(),v.bindingInfo=qa(ba,f,v.instance,P,s))}else v.instance=v(),ga.data("$"+s.name+"Controller",v.instance),v.bindingInfo=qa(ba,f,v.instance,P,s)}r(H,function(a, + b){var c=a.require;a.bindToController&&!I(c)&&B(c)&&O(M[b].instance,X(b,c,ga,M))});r(M,function(a){var b=a.instance;if(C(b.$onChanges))try{b.$onChanges(a.bindingInfo.initialChanges)}catch(d){c(d)}if(C(b.$onInit))try{b.$onInit()}catch(e){c(e)}C(b.$doCheck)&&(ba.$watch(function(){b.$doCheck()}),b.$doCheck());C(b.$onDestroy)&&ba.$on("$destroy",function(){b.$onDestroy()})});n=0;for(s=h.length;n<s;n++)v=h[n],va(v,v.isolateScope?y:e,ga,f,v.require&&X(v.directiveName,v.require,ga,M),R);var N=e;J&&(J.template|| + null===J.templateUrl)&&(N=y);a&&a(N,g.childNodes,void 0,l);for(n=k.length-1;0<=n;n--)v=k[n],va(v,v.isolateScope?y:e,ga,f,v.require&&X(v.directiveName,v.require,ga,M),R);r(M,function(a){a=a.instance;C(a.$postLink)&&a.$postLink()})}l=l||{};for(var n=-Number.MAX_VALUE,t=l.newScopeDirective,H=l.controllerDirectives,J=l.newIsolateScopeDirective,A=l.templateDirective,y=l.nonTlbTranscludeDirective,ba=!1,M=!1,T=l.hasElementTranscludeDirective,v=d.$$element=z(b),s,R,P,q=e,N,u=!1,Ib=!1,w,Da=0,D=a.length;Da< + D;Da++){s=a[Da];var Ta=s.$$start,E=s.$$end;Ta&&(v=pd(b,Ta,E));P=void 0;if(n>s.priority)break;if(w=s.scope)s.templateUrl||(B(w)?(aa("new/isolated scope",J||t,s,v),J=s):aa("new/isolated scope",J,s,v)),t=t||s;R=s.name;if(!u&&(s.replace&&(s.templateUrl||s.template)||s.transclude&&!s.$$tlb)){for(w=Da+1;u=a[w++];)if(u.transclude&&!u.$$tlb||u.replace&&(u.templateUrl||u.template)){Ib=!0;break}u=!0}!s.templateUrl&&s.controller&&(H=H||S(),aa("'"+R+"' controller",H[R],s,v),H[R]=s);if(w=s.transclude)if(ba=!0, + s.$$tlb||(aa("transclusion",y,s,v),y=s),"element"===w)T=!0,n=s.priority,P=v,v=d.$$element=z(da.$$createComment(R,d[R])),b=v[0],ma(g,xa.call(P,0),b),P[0].$$parentNode=P[0].parentNode,q=W(Ib,P,e,n,f&&f.name,{nonTlbTranscludeDirective:y});else{var na=S();if(B(w)){P=[];var Ua=S(),Ma=S();r(w,function(a,b){var c="?"===a.charAt(0);a=c?a.substring(1):a;Ua[a]=b;na[b]=null;Ma[b]=c});r(v.contents(),function(a){var b=Ua[Ea(ya(a))];b?(Ma[b]=!0,na[b]=na[b]||[],na[b].push(a)):P.push(a)});r(Ma,function(a,b){if(!a)throw ca("reqslot", + b);});for(var L in na)na[L]&&(na[L]=W(Ib,na[L],e))}else P=z(nc(b)).contents();v.empty();q=W(Ib,P,e,void 0,void 0,{needsNewScope:s.$$isolateScope||s.$$newScope});q.$$slots=na}if(s.template)if(M=!0,aa("template",A,s,v),A=s,w=C(s.template)?s.template(v,d):s.template,w=Ia(w),s.replace){f=s;P=kc.test(w)?qd(ka(s.templateNamespace,Q(w))):[];b=P[0];if(1!==P.length||1!==b.nodeType)throw ca("tplrt",R,"");ma(g,v,b);D={$attr:{}};w=K(b,[],D);var rc=a.splice(Da+1,a.length-(Da+1));(J||t)&&Z(w,J,t);a=a.concat(w).concat(rc); + fa(d,D);D=a.length}else v.html(w);if(s.templateUrl)M=!0,aa("template",A,s,v),A=s,s.replace&&(f=s),p=ja(a.splice(Da,a.length-Da),v,d,g,ba&&q,h,k,{controllerDirectives:H,newScopeDirective:t!==s&&t,newIsolateScopeDirective:J,templateDirective:A,nonTlbTranscludeDirective:y}),D=a.length;else if(s.compile)try{N=s.compile(v,d,q);var U=s.$$originalDirective||s;C(N)?m(null,Ra(U,N),Ta,E):N&&m(Ra(U,N.pre),Ra(U,N.post),Ta,E)}catch($){c($,za(v))}s.terminal&&(p.terminal=!0,n=Math.max(n,s.priority))}p.scope=t&& + !0===t.scope;p.transcludeOnThisElement=ba;p.templateOnThisElement=M;p.transclude=q;l.hasElementTranscludeDirective=T;return p}function X(a,b,c,d){var e;if(E(b)){var g=b.match(l);b=b.substring(g[0].length);var f=g[1]||g[3],g="?"===g[2];"^^"===f?c=c.parent():e=(e=d&&d[b])&&e.instance;if(!e){var h="$"+b+"Controller";e=f?c.inheritedData(h):c.data(h)}if(!e&&!g)throw ca("ctreq",b,a);}else if(I(b))for(e=[],f=0,g=b.length;f<g;f++)e[f]=X(a,b[f],c,d);else B(b)&&(e={},r(b,function(b,g){e[g]=X(a,b,c,d)}));return e|| + null}function ea(a,b,c,d,e,g,f){var h=S(),k;for(k in d){var l=d[k],m={$scope:l===f||l.$$isolateScope?e:g,$element:a,$attrs:b,$transclude:c},p=l.controller;"@"===p&&(p=b[l.name]);m=R(p,m,!0,l.controllerAs);h[l.name]=m;a.data("$"+l.name+"Controller",m.instance)}return h}function Z(a,b,c){for(var d=0,e=a.length;d<e;d++)a[d]=$b(a[d],{$$isolateScope:b,$$newScope:c})}function U(b,c,e,g,h,k,l){if(c===h)return null;var m=null;if(f.hasOwnProperty(c)){h=a.get(c+"Directive");for(var p=0,n=h.length;p<n;p++)if(c= + h[p],(x(g)||g>c.priority)&&-1!==c.restrict.indexOf(e)){k&&(c=$b(c,{$$start:k,$$end:l}));if(!c.$$bindings){var t=m=c,G=c.name,H={isolateScope:null,bindToController:null};B(t.scope)&&(!0===t.bindToController?(H.bindToController=d(t.scope,G,!0),H.isolateScope={}):H.isolateScope=d(t.scope,G,!1));B(t.bindToController)&&(H.bindToController=d(t.bindToController,G,!0));if(H.bindToController&&!t.controller)throw ca("noctrl",G);m=m.$$bindings=H;B(m.isolateScope)&&(c.$$isolateBindings=m.isolateScope)}b.push(c); + m=c}}return m}function $(b){if(f.hasOwnProperty(b))for(var c=a.get(b+"Directive"),d=0,e=c.length;d<e;d++)if(b=c[d],b.multiElement)return!0;return!1}function fa(a,b){var c=b.$attr,d=a.$attr;r(a,function(d,e){"$"!==e.charAt(0)&&(b[e]&&b[e]!==d&&(d=d.length?d+(("style"===e?";":" ")+b[e]):b[e]),a.$set(e,d,!0,c[e]))});r(b,function(b,e){a.hasOwnProperty(e)||"$"===e.charAt(0)||(a[e]=b,"class"!==e&&"style"!==e&&(d[e]=c[e]))})}function ja(a,b,d,g,f,h,k,l){var m=[],p,n,t=b[0],H=a.shift(),s=$b(H,{templateUrl:null, + transclude:null,replace:null,$$originalDirective:H}),F=C(H.templateUrl)?H.templateUrl(b,d):H.templateUrl,v=H.templateNamespace;b.empty();e(F).then(function(c){var e,G;c=Ia(c);if(H.replace){c=kc.test(c)?qd(ka(v,Q(c))):[];e=c[0];if(1!==c.length||1!==e.nodeType)throw ca("tplrt",H.name,F);c={$attr:{}};ma(g,b,e);var J=K(e,[],c);B(H.scope)&&Z(J,!0);a=J.concat(a);fa(d,c)}else e=t,b.html(c);a.unshift(s);p=Y(a,e,d,f,b,H,h,k,l);r(g,function(a,c){a===e&&(g[c]=b[0])});for(n=Ua(b[0].childNodes,f);m.length;){c= + m.shift();G=m.shift();var y=m.shift(),A=m.shift(),J=b[0];if(!c.$$destroyed){if(G!==t){var M=G.className;l.hasElementTranscludeDirective&&H.replace||(J=nc(e));ma(y,z(G),J);na(z(J),M)}G=p.transcludeOnThisElement?Ma(c,p.transclude,A):A;p(n,c,J,g,G)}}m=null}).catch(function(a){bc(a)&&c(a)});return function(a,b,c,d,e){a=e;b.$$destroyed||(m?m.push(b,c,d,a):(p.transcludeOnThisElement&&(a=Ma(b,p.transclude,e)),p(n,b,c,d,a)))}}function la(a,b){var c=b.priority-a.priority;return 0!==c?c:a.name!==b.name?a.name< + b.name?-1:1:a.index-b.index}function aa(a,b,c,d){function e(a){return a?" (module: "+a+")":""}if(b)throw ca("multidir",b.name,e(b.$$moduleName),c.name,e(c.$$moduleName),a,za(d));}function oa(a,c){var d=b(c,!0);d&&a.push({priority:0,compile:function(a){a=a.parent();var b=!!a.length;b&&da.$$addBindingClass(a);return function(a,c){var e=c.parent();b||da.$$addBindingClass(e);da.$$addBindingInfo(e,d.expressions);a.$watch(d,function(a){c[0].nodeValue=a})}}})}function ka(a,b){a=L(a||"html");switch(a){case "svg":case "math":var c= + w.document.createElement("div");c.innerHTML="<"+a+">"+b+"</"+a+">";return c.childNodes[0].childNodes;default:return b}}function ua(a,b){if("srcdoc"===b)return T.HTML;var c=ya(a);if("src"===b||"ngSrc"===b){if(-1===["img","video","audio","source","track"].indexOf(c))return T.RESOURCE_URL}else if("xlinkHref"===b||"form"===c&&"action"===b||"link"===c&&"href"===b)return T.RESOURCE_URL}function wa(a,c,d,e,g){var f=ua(a,e),h=k[e]||g,l=b(d,!g,f,h);if(l){if("multiple"===e&&"select"===ya(a))throw ca("selmulti", + za(a));if(m.test(e))throw ca("nodomevents");c.push({priority:100,compile:function(){return{pre:function(a,c,g){c=g.$$observers||(g.$$observers=S());var k=g[e];k!==d&&(l=k&&b(k,!0,f,h),d=k);l&&(g[e]=l(a),(c[e]||(c[e]=[])).$$inter=!0,(g.$$observers&&g.$$observers[e].$$scope||a).$watch(l,function(a,b){"class"===e&&a!==b?g.$updateClass(a,b):g.$set(e,a)}))}}}})}}function ma(a,b,c){var d=b[0],e=b.length,g=d.parentNode,f,h;if(a)for(f=0,h=a.length;f<h;f++)if(a[f]===d){a[f++]=c;h=f+e-1;for(var k=a.length;f< + k;f++,h++)h<k?a[f]=a[h]:delete a[f];a.length-=e-1;a.context===d&&(a.context=c);break}g&&g.replaceChild(c,d);a=w.document.createDocumentFragment();for(f=0;f<e;f++)a.appendChild(b[f]);z.hasData(d)&&(z.data(c,z.data(d)),z(d).off("$destroy"));z.cleanData(a.querySelectorAll("*"));for(f=1;f<e;f++)delete b[f];b[0]=c;b.length=1}function ta(a,b){return O(function(){return a.apply(null,arguments)},a,b)}function va(a,b,d,e,g,f){try{a(b,d,e,g,f)}catch(h){c(h,za(d))}}function pa(a,b){if(s)throw ca("missingattr", + a,b);}function qa(a,c,d,e,g){function f(b,c,e){C(d.$onChanges)&&!cc(c,e)&&(ha||(a.$$postDigest(N),ha=[]),m||(m={},ha.push(h)),m[b]&&(e=m[b].previousValue),m[b]=new Jb(e,c))}function h(){d.$onChanges(m);m=void 0}var k=[],l={},m;r(e,function(e,h){var m=e.attrName,n=e.optional,t,G,s,F;switch(e.mode){case "@":n||ra.call(c,m)||(pa(m,g.name),d[h]=c[m]=void 0);n=c.$observe(m,function(a){if(E(a)||Na(a))f(h,a,d[h]),d[h]=a});c.$$observers[m].$$scope=a;t=c[m];E(t)?d[h]=b(t)(a):Na(t)&&(d[h]=t);l[h]=new Jb(sc, + d[h]);k.push(n);break;case "=":if(!ra.call(c,m)){if(n)break;pa(m,g.name);c[m]=void 0}if(n&&!c[m])break;G=p(c[m]);F=G.literal?sa:cc;s=G.assign||function(){t=d[h]=G(a);throw ca("nonassign",c[m],m,g.name);};t=d[h]=G(a);n=function(b){F(b,d[h])||(F(b,t)?s(a,b=d[h]):d[h]=b);return t=b};n.$stateful=!0;n=e.collection?a.$watchCollection(c[m],n):a.$watch(p(c[m],n),null,G.literal);k.push(n);break;case "<":if(!ra.call(c,m)){if(n)break;pa(m,g.name);c[m]=void 0}if(n&&!c[m])break;G=p(c[m]);var v=G.literal,y=d[h]= + G(a);l[h]=new Jb(sc,d[h]);n=a.$watch(G,function(a,b){if(b===a){if(b===y||v&&sa(b,y))return;b=y}f(h,a,b);d[h]=a},v);k.push(n);break;case "&":n||ra.call(c,m)||pa(m,g.name);G=c.hasOwnProperty(m)?p(c[m]):D;if(G===D&&n)break;d[h]=function(b){return G(a,b)}}});return{initialChanges:l,removeWatches:k.length&&function(){for(var a=0,b=k.length;a<b;++a)k[a]()}}}var Ja=/^\w/,Ba=w.document.createElement("div"),Ka=y,La=t,Fa=v,ha;qc.prototype={$normalize:Ea,$addClass:function(a){a&&0<a.length&&P.addClass(this.$$element, + a)},$removeClass:function(a){a&&0<a.length&&P.removeClass(this.$$element,a)},$updateClass:function(a,b){var c=rd(a,b);c&&c.length&&P.addClass(this.$$element,c);(c=rd(b,a))&&c.length&&P.removeClass(this.$$element,c)},$set:function(a,b,d,e){var g=kd(this.$$element[0],a),f=sd[a],h=a;g?(this.$$element.prop(a,b),e=g):f&&(this[f]=b,h=f);this[a]=b;e?this.$attr[a]=e:(e=this.$attr[a])||(this.$attr[a]=e=Vc(a,"-"));g=ya(this.$$element);if("a"===g&&("href"===a||"xlinkHref"===a)||"img"===g&&"src"===a)this[a]= + b=q(b,"src"===a);else if("img"===g&&"srcset"===a&&u(b)){for(var g="",f=Q(b),k=/(\s+\d+x\s*,|\s+\d+w\s*,|\s+,|,\s+)/,k=/\s/.test(f)?k:/(,)/,f=f.split(k),k=Math.floor(f.length/2),l=0;l<k;l++)var m=2*l,g=g+q(Q(f[m]),!0),g=g+(" "+Q(f[m+1]));f=Q(f[2*l]).split(/\s/);g+=q(Q(f[0]),!0);2===f.length&&(g+=" "+Q(f[1]));this[a]=b=g}!1!==d&&(null===b||x(b)?this.$$element.removeAttr(e):Ja.test(e)?this.$$element.attr(e,b):Ta(this.$$element[0],e,b));(a=this.$$observers)&&r(a[h],function(a){try{a(b)}catch(d){c(d)}})}, + $observe:function(a,b){var c=this,d=c.$$observers||(c.$$observers=S()),e=d[a]||(d[a]=[]);e.push(b);M.$evalAsync(function(){e.$$inter||!c.hasOwnProperty(a)||x(c[a])||b(c[a])});return function(){cb(e,b)}}};var Ga=b.startSymbol(),Ha=b.endSymbol(),Ia="{{"===Ga&&"}}"===Ha?ab:function(a){return a.replace(/\{\{/g,Ga).replace(/}}/g,Ha)},Pa=/^ngAttr[A-Z]/,Qa=/^(.+)Start$/;da.$$addBindingInfo=n?function(a,b){var c=a.data("$binding")||[];I(b)?c=c.concat(b):c.push(b);a.data("$binding",c)}:D;da.$$addBindingClass= + n?function(a){na(a,"ng-binding")}:D;da.$$addScopeInfo=n?function(a,b,c,d){a.data(c?d?"$isolateScopeNoTemplate":"$isolateScope":"$scope",b)}:D;da.$$addScopeClass=n?function(a,b){na(a,b?"ng-isolate-scope":"ng-scope")}:D;da.$$createComment=function(a,b){var c="";n&&(c=" "+(a||"")+": ",b&&(c+=b+" "));return w.document.createComment(c)};return da}]}function Jb(a,b){this.previousValue=a;this.currentValue=b}function Ea(a){return a.replace(od,"").replace(tg,function(a,d,c){return c?d.toUpperCase():d})}function rd(a, + b){var d="",c=a.split(/\s+/),e=b.split(/\s+/),f=0;a:for(;f<c.length;f++){for(var g=c[f],h=0;h<e.length;h++)if(g===e[h])continue a;d+=(0<d.length?" ":"")+g}return d}function qd(a){a=z(a);var b=a.length;if(1>=b)return a;for(;b--;){var d=a[b];(8===d.nodeType||d.nodeType===Oa&&""===d.nodeValue.trim())&&ug.call(a,b,1)}return a}function sg(a,b){if(b&&E(b))return b;if(E(a)){var d=td.exec(a);if(d)return d[3]}}function yf(){var a={},b=!1;this.has=function(b){return a.hasOwnProperty(b)};this.register=function(b, + c){Ia(b,"controller");B(b)?O(a,b):a[b]=c};this.allowGlobals=function(){b=!0};this.$get=["$injector","$window",function(d,c){function e(a,b,c,d){if(!a||!B(a.$scope))throw K("$controller")("noscp",d,b);a.$scope[b]=c}return function(f,g,h,k){var l,m,p;h=!0===h;k&&E(k)&&(p=k);if(E(f)){k=f.match(td);if(!k)throw ud("ctrlfmt",f);m=k[1];p=p||k[3];f=a.hasOwnProperty(m)?a[m]:Xc(g.$scope,m,!0)||(b?Xc(c,m,!0):void 0);if(!f)throw ud("ctrlreg",m);sb(f,m,!0)}if(h)return h=(I(f)?f[f.length-1]:f).prototype,l=Object.create(h|| + null),p&&e(g,p,l,m||f.name),O(function(){var a=d.invoke(f,l,g,m);a!==l&&(B(a)||C(a))&&(l=a,p&&e(g,p,l,m||f.name));return l},{instance:l,identifier:p});l=d.instantiate(f,g,m);p&&e(g,p,l,m||f.name);return l}}]}function zf(){this.$get=["$window",function(a){return z(a.document)}]}function Af(){this.$get=["$document","$rootScope",function(a,b){function d(){e=c.hidden}var c=a[0],e=c&&c.hidden;a.on("visibilitychange",d);b.$on("$destroy",function(){a.off("visibilitychange",d)});return function(){return e}}]} + function Bf(){this.$get=["$log",function(a){return function(b,d){a.error.apply(a,arguments)}}]}function tc(a){return B(a)?fa(a)?a.toISOString():eb(a):a}function Gf(){this.$get=function(){return function(a){if(!a)return"";var b=[];Oc(a,function(a,c){null===a||x(a)||C(a)||(I(a)?r(a,function(a){b.push(ja(c)+"="+ja(tc(a)))}):b.push(ja(c)+"="+ja(tc(a))))});return b.join("&")}}}function Hf(){this.$get=function(){return function(a){function b(a,e,f){null===a||x(a)||(I(a)?r(a,function(a,c){b(a,e+"["+(B(a)? + c:"")+"]")}):B(a)&&!fa(a)?Oc(a,function(a,c){b(a,e+(f?"":"[")+c+(f?"":"]"))}):d.push(ja(e)+"="+ja(tc(a))))}if(!a)return"";var d=[];b(a,"",!0);return d.join("&")}}}function uc(a,b){if(E(a)){var d=a.replace(vg,"").trim();if(d){var c=b("Content-Type"),c=c&&0===c.indexOf(vd),e;(e=c)||(e=(e=d.match(wg))&&xg[e[0]].test(d));if(e)try{a=Rc(d)}catch(f){if(!c)return a;throw Kb("baddata",a,f);}}}return a}function wd(a){var b=S(),d;E(a)?r(a.split("\n"),function(a){d=a.indexOf(":");var e=L(Q(a.substr(0,d)));a= + Q(a.substr(d+1));e&&(b[e]=b[e]?b[e]+", "+a:a)}):B(a)&&r(a,function(a,d){var f=L(d),g=Q(a);f&&(b[f]=b[f]?b[f]+", "+g:g)});return b}function xd(a){var b;return function(d){b||(b=wd(a));return d?(d=b[L(d)],void 0===d&&(d=null),d):b}}function yd(a,b,d,c){if(C(c))return c(a,b,d);r(c,function(c){a=c(a,b,d)});return a}function Ff(){var a=this.defaults={transformResponse:[uc],transformRequest:[function(a){return B(a)&&"[object File]"!==ia.call(a)&&"[object Blob]"!==ia.call(a)&&"[object FormData]"!==ia.call(a)? + eb(a):a}],headers:{common:{Accept:"application/json, text/plain, */*"},post:ka(vc),put:ka(vc),patch:ka(vc)},xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",paramSerializer:"$httpParamSerializer",jsonpCallbackParam:"callback"},b=!1;this.useApplyAsync=function(a){return u(a)?(b=!!a,this):b};var d=this.interceptors=[];this.$get=["$browser","$httpBackend","$$cookieReader","$cacheFactory","$rootScope","$q","$injector","$sce",function(c,e,f,g,h,k,l,m){function p(b){function d(a,b){for(var c=0, + e=b.length;c<e;){var g=b[c++],f=b[c++];a=a.then(g,f)}b.length=0;return a}function e(a,b){var c,d={};r(a,function(a,e){C(a)?(c=a(b),null!=c&&(d[e]=c)):d[e]=a});return d}function g(a){var b=O({},a);b.data=yd(a.data,a.headers,a.status,f.transformResponse);a=a.status;return 200<=a&&300>a?b:k.reject(b)}if(!B(b))throw K("$http")("badreq",b);if(!E(m.valueOf(b.url)))throw K("$http")("badreq",b.url);var f=O({method:"get",transformRequest:a.transformRequest,transformResponse:a.transformResponse,paramSerializer:a.paramSerializer, + jsonpCallbackParam:a.jsonpCallbackParam},b);f.headers=function(b){var c=a.headers,d=O({},b.headers),g,f,h,c=O({},c.common,c[L(b.method)]);a:for(g in c){f=L(g);for(h in d)if(L(h)===f)continue a;d[g]=c[g]}return e(d,ka(b))}(b);f.method=ub(f.method);f.paramSerializer=E(f.paramSerializer)?l.get(f.paramSerializer):f.paramSerializer;c.$$incOutstandingRequestCount();var h=[],p=[];b=k.resolve(f);r(y,function(a){(a.request||a.requestError)&&h.unshift(a.request,a.requestError);(a.response||a.responseError)&& + p.push(a.response,a.responseError)});b=d(b,h);b=b.then(function(b){var c=b.headers,d=yd(b.data,xd(c),void 0,b.transformRequest);x(d)&&r(c,function(a,b){"content-type"===L(b)&&delete c[b]});x(b.withCredentials)&&!x(a.withCredentials)&&(b.withCredentials=a.withCredentials);return n(b,d).then(g,g)});b=d(b,p);return b=b.finally(function(){c.$$completeOutstandingRequest(D)})}function n(c,d){function g(a){if(a){var c={};r(a,function(a,d){c[d]=function(c){function d(){a(c)}b?h.$applyAsync(d):h.$$phase?d(): + h.$apply(d)}});return c}}function l(a,c,d,e,g){function f(){n(c,a,d,e,g)}M&&(200<=a&&300>a?M.put(N,[a,c,wd(d),e,g]):M.remove(N));b?h.$applyAsync(f):(f(),h.$$phase||h.$apply())}function n(a,b,d,e,g){b=-1<=b?b:0;(200<=b&&300>b?J.resolve:J.reject)({data:a,status:b,headers:xd(d),config:c,statusText:e,xhrStatus:g})}function G(a){n(a.data,a.status,ka(a.headers()),a.statusText,a.xhrStatus)}function y(){var a=p.pendingRequests.indexOf(c);-1!==a&&p.pendingRequests.splice(a,1)}var J=k.defer(),R=J.promise,M, + T,P=c.headers,q="jsonp"===L(c.method),N=c.url;q?N=m.getTrustedResourceUrl(N):E(N)||(N=m.valueOf(N));N=F(N,c.paramSerializer(c.params));q&&(N=s(N,c.jsonpCallbackParam));p.pendingRequests.push(c);R.then(y,y);!c.cache&&!a.cache||!1===c.cache||"GET"!==c.method&&"JSONP"!==c.method||(M=B(c.cache)?c.cache:B(a.cache)?a.cache:v);M&&(T=M.get(N),u(T)?T&&C(T.then)?T.then(G,G):I(T)?n(T[1],T[0],ka(T[2]),T[3],T[4]):n(T,200,{},"OK","complete"):M.put(N,R));x(T)&&((T=zd(c.url)?f()[c.xsrfCookieName||a.xsrfCookieName]: + void 0)&&(P[c.xsrfHeaderName||a.xsrfHeaderName]=T),e(c.method,N,d,l,P,c.timeout,c.withCredentials,c.responseType,g(c.eventHandlers),g(c.uploadEventHandlers)));return R}function F(a,b){0<b.length&&(a+=(-1===a.indexOf("?")?"?":"&")+b);return a}function s(a,b){var c=a.split("?");if(2<c.length)throw Kb("badjsonp",a);c=ec(c[1]);r(c,function(c,d){if("JSON_CALLBACK"===c)throw Kb("badjsonp",a);if(d===b)throw Kb("badjsonp",b,a);});return a+=(-1===a.indexOf("?")?"?":"&")+b+"=JSON_CALLBACK"}var v=g("$http"); + a.paramSerializer=E(a.paramSerializer)?l.get(a.paramSerializer):a.paramSerializer;var y=[];r(d,function(a){y.unshift(E(a)?l.get(a):l.invoke(a))});p.pendingRequests=[];(function(a){r(arguments,function(a){p[a]=function(b,c){return p(O({},c||{},{method:a,url:b}))}})})("get","delete","head","jsonp");(function(a){r(arguments,function(a){p[a]=function(b,c,d){return p(O({},d||{},{method:a,url:b,data:c}))}})})("post","put","patch");p.defaults=a;return p}]}function Jf(){this.$get=function(){return function(){return new w.XMLHttpRequest}}} + function If(){this.$get=["$browser","$jsonpCallbacks","$document","$xhrFactory",function(a,b,d,c){return yg(a,c,a.defer,b,d[0])}]}function yg(a,b,d,c,e){function f(a,b,d){a=a.replace("JSON_CALLBACK",b);var f=e.createElement("script"),m=null;f.type="text/javascript";f.src=a;f.async=!0;m=function(a){f.removeEventListener("load",m);f.removeEventListener("error",m);e.body.removeChild(f);f=null;var g=-1,F="unknown";a&&("load"!==a.type||c.wasCalled(b)||(a={type:"error"}),F=a.type,g="error"===a.type?404: + 200);d&&d(g,F)};f.addEventListener("load",m);f.addEventListener("error",m);e.body.appendChild(f);return m}return function(e,h,k,l,m,p,n,F,s,v){function y(){q&&q();A&&A.abort()}function t(a,b,c,e,g,f){u(G)&&d.cancel(G);q=A=null;a(b,c,e,g,f)}h=h||a.url();if("jsonp"===L(e))var Aa=c.createCallback(h),q=f(h,Aa,function(a,b){var d=200===a&&c.getResponse(Aa);t(l,a,d,"",b,"complete");c.removeCallback(Aa)});else{var A=b(e,h);A.open(e,h,!0);r(m,function(a,b){u(a)&&A.setRequestHeader(b,a)});A.onload=function(){var a= + A.statusText||"",b="response"in A?A.response:A.responseText,c=1223===A.status?204:A.status;0===c&&(c=b?200:"file"===ta(h).protocol?404:0);t(l,c,b,A.getAllResponseHeaders(),a,"complete")};A.onerror=function(){t(l,-1,null,null,"","error")};A.onabort=function(){t(l,-1,null,null,"","abort")};A.ontimeout=function(){t(l,-1,null,null,"","timeout")};r(s,function(a,b){A.addEventListener(b,a)});r(v,function(a,b){A.upload.addEventListener(b,a)});n&&(A.withCredentials=!0);if(F)try{A.responseType=F}catch(H){if("json"!== + F)throw H;}A.send(x(k)?null:k)}if(0<p)var G=d(y,p);else p&&C(p.then)&&p.then(y)}}function Df(){var a="{{",b="}}";this.startSymbol=function(b){return b?(a=b,this):a};this.endSymbol=function(a){return a?(b=a,this):b};this.$get=["$parse","$exceptionHandler","$sce",function(d,c,e){function f(a){return"\\\\\\"+a}function g(c){return c.replace(p,a).replace(n,b)}function h(a,b,c,d){var e=a.$watch(function(a){e();return d(a)},b,c);return e}function k(f,k,p,n){function t(a){try{var b=a;a=p?e.getTrusted(p, + b):e.valueOf(b);return n&&!u(a)?a:gc(a)}catch(d){c(Fa.interr(f,d))}}if(!f.length||-1===f.indexOf(a)){var r;k||(k=g(f),r=la(k),r.exp=f,r.expressions=[],r.$$watchDelegate=h);return r}n=!!n;var q,A,H=0,G=[],ba=[];r=f.length;for(var J=[],R=[];H<r;)if(-1!==(q=f.indexOf(a,H))&&-1!==(A=f.indexOf(b,q+l)))H!==q&&J.push(g(f.substring(H,q))),H=f.substring(q+l,A),G.push(H),ba.push(d(H,t)),H=A+m,R.push(J.length),J.push("");else{H!==r&&J.push(g(f.substring(H)));break}p&&1<J.length&&Fa.throwNoconcat(f);if(!k||G.length){var M= + function(a){for(var b=0,c=G.length;b<c;b++){if(n&&x(a[b]))return;J[R[b]]=a[b]}return J.join("")};return O(function(a){var b=0,d=G.length,e=Array(d);try{for(;b<d;b++)e[b]=ba[b](a);return M(e)}catch(g){c(Fa.interr(f,g))}},{exp:f,expressions:G,$$watchDelegate:function(a,b){var c;return a.$watchGroup(ba,function(d,e){var g=M(d);b.call(this,g,d!==e?c:g,a);c=g})}})}}var l=a.length,m=b.length,p=new RegExp(a.replace(/./g,f),"g"),n=new RegExp(b.replace(/./g,f),"g");k.startSymbol=function(){return a};k.endSymbol= + function(){return b};return k}]}function Ef(){this.$get=["$rootScope","$window","$q","$$q","$browser",function(a,b,d,c,e){function f(f,k,l,m){function p(){n?f.apply(null,F):f(y)}var n=4<arguments.length,F=n?xa.call(arguments,4):[],s=b.setInterval,v=b.clearInterval,y=0,t=u(m)&&!m,r=(t?c:d).defer(),q=r.promise;l=u(l)?l:0;q.$$intervalId=s(function(){t?e.defer(p):a.$evalAsync(p);r.notify(y++);0<l&&y>=l&&(r.resolve(y),v(q.$$intervalId),delete g[q.$$intervalId]);t||a.$apply()},k);g[q.$$intervalId]=r;return q} + var g={};f.cancel=function(a){return a&&a.$$intervalId in g?(g[a.$$intervalId].promise.$$state.pur=!0,g[a.$$intervalId].reject("canceled"),b.clearInterval(a.$$intervalId),delete g[a.$$intervalId],!0):!1};return f}]}function wc(a){a=a.split("/");for(var b=a.length;b--;)a[b]=fb(a[b].replace(/%2F/g,"/"));return a.join("/")}function Ad(a,b){var d=ta(a);b.$$protocol=d.protocol;b.$$host=d.hostname;b.$$port=Z(d.port)||zg[d.protocol]||null}function Bd(a,b,d){if(Ag.test(a))throw kb("badpath",a);var c="/"!== + a.charAt(0);c&&(a="/"+a);a=ta(a);for(var c=(c&&"/"===a.pathname.charAt(0)?a.pathname.substring(1):a.pathname).split("/"),e=c.length;e--;)c[e]=decodeURIComponent(c[e]),d&&(c[e]=c[e].replace(/\//g,"%2F"));d=c.join("/");b.$$path=d;b.$$search=ec(a.search);b.$$hash=decodeURIComponent(a.hash);b.$$path&&"/"!==b.$$path.charAt(0)&&(b.$$path="/"+b.$$path)}function xc(a,b){return a.slice(0,b.length)===b}function ua(a,b){if(xc(b,a))return b.substr(a.length)}function La(a){var b=a.indexOf("#");return-1===b?a: + a.substr(0,b)}function lb(a){return a.replace(/(#.+)|#$/,"$1")}function yc(a,b,d){this.$$html5=!0;d=d||"";Ad(a,this);this.$$parse=function(a){var d=ua(b,a);if(!E(d))throw kb("ipthprfx",a,b);Bd(d,this,!0);this.$$path||(this.$$path="/");this.$$compose()};this.$$compose=function(){var a=fc(this.$$search),d=this.$$hash?"#"+fb(this.$$hash):"";this.$$url=wc(this.$$path)+(a?"?"+a:"")+d;this.$$absUrl=b+this.$$url.substr(1);this.$$urlUpdatedByLocation=!0};this.$$parseLinkUrl=function(c,e){if(e&&"#"===e[0])return this.hash(e.slice(1)), + !0;var f,g;u(f=ua(a,c))?(g=f,g=d&&u(f=ua(d,f))?b+(ua("/",f)||f):a+g):u(f=ua(b,c))?g=b+f:b===c+"/"&&(g=b);g&&this.$$parse(g);return!!g}}function zc(a,b,d){Ad(a,this);this.$$parse=function(c){var e=ua(a,c)||ua(b,c),f;x(e)||"#"!==e.charAt(0)?this.$$html5?f=e:(f="",x(e)&&(a=c,this.replace())):(f=ua(d,e),x(f)&&(f=e));Bd(f,this,!1);c=this.$$path;var e=a,g=/^\/[A-Z]:(\/.*)/;xc(f,e)&&(f=f.replace(e,""));g.exec(f)||(c=(f=g.exec(c))?f[1]:c);this.$$path=c;this.$$compose()};this.$$compose=function(){var b=fc(this.$$search), + e=this.$$hash?"#"+fb(this.$$hash):"";this.$$url=wc(this.$$path)+(b?"?"+b:"")+e;this.$$absUrl=a+(this.$$url?d+this.$$url:"");this.$$urlUpdatedByLocation=!0};this.$$parseLinkUrl=function(b,d){return La(a)===La(b)?(this.$$parse(b),!0):!1}}function Cd(a,b,d){this.$$html5=!0;zc.apply(this,arguments);this.$$parseLinkUrl=function(c,e){if(e&&"#"===e[0])return this.hash(e.slice(1)),!0;var f,g;a===La(c)?f=c:(g=ua(b,c))?f=a+d+g:b===c+"/"&&(f=b);f&&this.$$parse(f);return!!f};this.$$compose=function(){var b=fc(this.$$search), + e=this.$$hash?"#"+fb(this.$$hash):"";this.$$url=wc(this.$$path)+(b?"?"+b:"")+e;this.$$absUrl=a+d+this.$$url;this.$$urlUpdatedByLocation=!0}}function Lb(a){return function(){return this[a]}}function Dd(a,b){return function(d){if(x(d))return this[a];this[a]=b(d);this.$$compose();return this}}function Lf(){var a="!",b={enabled:!1,requireBase:!0,rewriteLinks:!0};this.hashPrefix=function(b){return u(b)?(a=b,this):a};this.html5Mode=function(a){if(Na(a))return b.enabled=a,this;if(B(a)){Na(a.enabled)&&(b.enabled= + a.enabled);Na(a.requireBase)&&(b.requireBase=a.requireBase);if(Na(a.rewriteLinks)||E(a.rewriteLinks))b.rewriteLinks=a.rewriteLinks;return this}return b};this.$get=["$rootScope","$browser","$sniffer","$rootElement","$window",function(d,c,e,f,g){function h(a,b,d){var e=l.url(),g=l.$$state;try{c.url(a,b,d),l.$$state=c.state()}catch(f){throw l.url(e),l.$$state=g,f;}}function k(a,b){d.$broadcast("$locationChangeSuccess",l.absUrl(),a,l.$$state,b)}var l,m;m=c.baseHref();var p=c.url(),n;if(b.enabled){if(!m&& + b.requireBase)throw kb("nobase");n=p.substring(0,p.indexOf("/",p.indexOf("//")+2))+(m||"/");m=e.history?yc:Cd}else n=La(p),m=zc;var F=n.substr(0,La(n).lastIndexOf("/")+1);l=new m(n,F,"#"+a);l.$$parseLinkUrl(p,p);l.$$state=c.state();var s=/^\s*(javascript|mailto):/i;f.on("click",function(a){var e=b.rewriteLinks;if(e&&!a.ctrlKey&&!a.metaKey&&!a.shiftKey&&2!==a.which&&2!==a.button){for(var h=z(a.target);"a"!==ya(h[0]);)if(h[0]===f[0]||!(h=h.parent())[0])return;if(!E(e)||!x(h.attr(e))){var e=h.prop("href"), + k=h.attr("href")||h.attr("xlink:href");B(e)&&"[object SVGAnimatedString]"===e.toString()&&(e=ta(e.animVal).href);s.test(e)||!e||h.attr("target")||a.isDefaultPrevented()||!l.$$parseLinkUrl(e,k)||(a.preventDefault(),l.absUrl()!==c.url()&&(d.$apply(),g.angular["ff-684208-preventDefault"]=!0))}}});lb(l.absUrl())!==lb(p)&&c.url(l.absUrl(),!0);var v=!0;c.onUrlChange(function(a,b){xc(a,F)?(d.$evalAsync(function(){var c=l.absUrl(),e=l.$$state,g;a=lb(a);l.$$parse(a);l.$$state=b;g=d.$broadcast("$locationChangeStart", + a,c,b,e).defaultPrevented;l.absUrl()===a&&(g?(l.$$parse(c),l.$$state=e,h(c,!1,e)):(v=!1,k(c,e)))}),d.$$phase||d.$digest()):g.location.href=a});d.$watch(function(){if(v||l.$$urlUpdatedByLocation){l.$$urlUpdatedByLocation=!1;var a=lb(c.url()),b=lb(l.absUrl()),g=c.state(),f=l.$$replace,m=a!==b||l.$$html5&&e.history&&g!==l.$$state;if(v||m)v=!1,d.$evalAsync(function(){var b=l.absUrl(),c=d.$broadcast("$locationChangeStart",b,a,l.$$state,g).defaultPrevented;l.absUrl()===b&&(c?(l.$$parse(a),l.$$state=g): + (m&&h(b,f,g===l.$$state?null:l.$$state),k(a,g)))})}l.$$replace=!1});return l}]}function Mf(){var a=!0,b=this;this.debugEnabled=function(b){return u(b)?(a=b,this):a};this.$get=["$window",function(d){function c(a){bc(a)&&(a.stack&&f?a=a.message&&-1===a.stack.indexOf(a.message)?"Error: "+a.message+"\n"+a.stack:a.stack:a.sourceURL&&(a=a.message+"\n"+a.sourceURL+":"+a.line));return a}function e(a){var b=d.console||{},e=b[a]||b.log||D;return function(){var a=[];r(arguments,function(b){a.push(c(b))});return Function.prototype.apply.call(e, + b,a)}}var f=Ca||/\bEdge\//.test(d.navigator&&d.navigator.userAgent);return{log:e("log"),info:e("info"),warn:e("warn"),error:e("error"),debug:function(){var c=e("debug");return function(){a&&c.apply(b,arguments)}}()}}]}function Bg(a){return a+""}function Cg(a,b){return"undefined"!==typeof a?a:b}function Ed(a,b){return"undefined"===typeof a?b:"undefined"===typeof b?a:a+b}function Dg(a,b){switch(a.type){case q.MemberExpression:if(a.computed)return!1;break;case q.UnaryExpression:return 1;case q.BinaryExpression:return"+"!== + a.operator?1:!1;case q.CallExpression:return!1}return void 0===b?Fd:b}function W(a,b,d){var c,e,f=a.isPure=Dg(a,d);switch(a.type){case q.Program:c=!0;r(a.body,function(a){W(a.expression,b,f);c=c&&a.expression.constant});a.constant=c;break;case q.Literal:a.constant=!0;a.toWatch=[];break;case q.UnaryExpression:W(a.argument,b,f);a.constant=a.argument.constant;a.toWatch=a.argument.toWatch;break;case q.BinaryExpression:W(a.left,b,f);W(a.right,b,f);a.constant=a.left.constant&&a.right.constant;a.toWatch= + a.left.toWatch.concat(a.right.toWatch);break;case q.LogicalExpression:W(a.left,b,f);W(a.right,b,f);a.constant=a.left.constant&&a.right.constant;a.toWatch=a.constant?[]:[a];break;case q.ConditionalExpression:W(a.test,b,f);W(a.alternate,b,f);W(a.consequent,b,f);a.constant=a.test.constant&&a.alternate.constant&&a.consequent.constant;a.toWatch=a.constant?[]:[a];break;case q.Identifier:a.constant=!1;a.toWatch=[a];break;case q.MemberExpression:W(a.object,b,f);a.computed&&W(a.property,b,f);a.constant=a.object.constant&& + (!a.computed||a.property.constant);a.toWatch=a.constant?[]:[a];break;case q.CallExpression:c=d=a.filter?!b(a.callee.name).$stateful:!1;e=[];r(a.arguments,function(a){W(a,b,f);c=c&&a.constant;e.push.apply(e,a.toWatch)});a.constant=c;a.toWatch=d?e:[a];break;case q.AssignmentExpression:W(a.left,b,f);W(a.right,b,f);a.constant=a.left.constant&&a.right.constant;a.toWatch=[a];break;case q.ArrayExpression:c=!0;e=[];r(a.elements,function(a){W(a,b,f);c=c&&a.constant;e.push.apply(e,a.toWatch)});a.constant=c; + a.toWatch=e;break;case q.ObjectExpression:c=!0;e=[];r(a.properties,function(a){W(a.value,b,f);c=c&&a.value.constant;e.push.apply(e,a.value.toWatch);a.computed&&(W(a.key,b,!1),c=c&&a.key.constant,e.push.apply(e,a.key.toWatch))});a.constant=c;a.toWatch=e;break;case q.ThisExpression:a.constant=!1;a.toWatch=[];break;case q.LocalsExpression:a.constant=!1,a.toWatch=[]}}function Gd(a){if(1===a.length){a=a[0].expression;var b=a.toWatch;return 1!==b.length?b:b[0]!==a?b:void 0}}function Hd(a){return a.type=== + q.Identifier||a.type===q.MemberExpression}function Id(a){if(1===a.body.length&&Hd(a.body[0].expression))return{type:q.AssignmentExpression,left:a.body[0].expression,right:{type:q.NGValueParameter},operator:"="}}function Jd(a){this.$filter=a}function Kd(a){this.$filter=a}function Mb(a,b,d){this.ast=new q(a,d);this.astCompiler=d.csp?new Kd(b):new Jd(b)}function Ac(a){return C(a.valueOf)?a.valueOf():Eg.call(a)}function Nf(){var a=S(),b={"true":!0,"false":!1,"null":null,undefined:void 0},d,c;this.addLiteral= + function(a,c){b[a]=c};this.setIdentifierFns=function(a,b){d=a;c=b;return this};this.$get=["$filter",function(e){function f(b,c){var d,g;switch(typeof b){case "string":return g=b=b.trim(),d=a[g],d||(d=new Nb(n),d=(new Mb(d,e,n)).parse(b),d.constant?d.$$watchDelegate=m:d.oneTime?d.$$watchDelegate=d.literal?l:k:d.inputs&&(d.$$watchDelegate=h),a[g]=d),p(d,c);case "function":return p(b,c);default:return p(D,c)}}function g(a,b,c){return null==a||null==b?a===b:"object"!==typeof a||(a=Ac(a),"object"!==typeof a|| + c)?a===b||a!==a&&b!==b:!1}function h(a,b,c,d,e){var f=d.inputs,h;if(1===f.length){var k=g,f=f[0];return a.$watch(function(a){var b=f(a);g(b,k,f.isPure)||(h=d(a,void 0,void 0,[b]),k=b&&Ac(b));return h},b,c,e)}for(var l=[],m=[],p=0,n=f.length;p<n;p++)l[p]=g,m[p]=null;return a.$watch(function(a){for(var b=!1,c=0,e=f.length;c<e;c++){var k=f[c](a);if(b||(b=!g(k,l[c],f[c].isPure)))m[c]=k,l[c]=k&&Ac(k)}b&&(h=d(a,void 0,void 0,m));return h},b,c,e)}function k(a,b,c,d,e){function g(a){return d(a)}function f(a, + c,d){l=a;C(b)&&b(a,c,d);u(a)&&d.$$postDigest(function(){u(l)&&k()})}var k,l;return k=d.inputs?h(a,f,c,d,e):a.$watch(g,f,c)}function l(a,b,c,d){function e(a){var b=!0;r(a,function(a){u(a)||(b=!1)});return b}var g,f;return g=a.$watch(function(a){return d(a)},function(a,c,d){f=a;C(b)&&b(a,c,d);e(a)&&d.$$postDigest(function(){e(f)&&g()})},c)}function m(a,b,c,d){var e=a.$watch(function(a){e();return d(a)},b,c);return e}function p(a,b){if(!b)return a;var c=a.$$watchDelegate,d=!1,e=c!==l&&c!==k?function(c, + e,g,f){g=d&&f?f[0]:a(c,e,g,f);return b(g,c,e)}:function(c,d,e,g){e=a(c,d,e,g);c=b(e,c,d);return u(e)?c:e},d=!a.inputs;c&&c!==h?(e.$$watchDelegate=c,e.inputs=a.inputs):b.$stateful||(e.$$watchDelegate=h,e.inputs=a.inputs?a.inputs:[a]);e.inputs&&(e.inputs=e.inputs.map(function(a){return a.isPure===Fd?function(b){return a(b)}:a}));return e}var n={csp:Ja().noUnsafeEval,literals:pa(b),isIdentifierStart:C(d)&&d,isIdentifierContinue:C(c)&&c};f.$$getAst=function(a){var b=new Nb(n);return(new Mb(b,e,n)).getAst(a).ast}; + return f}]}function Pf(){var a=!0;this.$get=["$rootScope","$exceptionHandler",function(b,d){return Ld(function(a){b.$evalAsync(a)},d,a)}];this.errorOnUnhandledRejections=function(b){return u(b)?(a=b,this):a}}function Qf(){var a=!0;this.$get=["$browser","$exceptionHandler",function(b,d){return Ld(function(a){b.defer(a)},d,a)}];this.errorOnUnhandledRejections=function(b){return u(b)?(a=b,this):a}}function Ld(a,b,d){function c(){return new e}function e(){var a=this.promise=new f;this.resolve=function(b){k(a, + b)};this.reject=function(b){m(a,b)};this.notify=function(b){n(a,b)}}function f(){this.$$state={status:0}}function g(){for(;!u&&w.length;){var a=w.shift();if(!a.pur){a.pur=!0;var c=a.value,c="Possibly unhandled rejection: "+("function"===typeof c?c.toString().replace(/ \{[\s\S]*$/,""):x(c)?"undefined":"string"!==typeof c?De(c,void 0):c);bc(a.value)?b(a.value,c):b(c)}}}function h(c){!d||c.pending||2!==c.status||c.pur||(0===u&&0===w.length&&a(g),w.push(c));!c.processScheduled&&c.pending&&(c.processScheduled= + !0,++u,a(function(){var e,f,h;h=c.pending;c.processScheduled=!1;c.pending=void 0;try{for(var l=0,p=h.length;l<p;++l){c.pur=!0;f=h[l][0];e=h[l][c.status];try{C(e)?k(f,e(c.value)):1===c.status?k(f,c.value):m(f,c.value)}catch(n){m(f,n),n&&!0===n.$$passToExceptionHandler&&b(n)}}}finally{--u,d&&0===u&&a(g)}}))}function k(a,b){a.$$state.status||(b===a?p(a,t("qcycle",b)):l(a,b))}function l(a,b){function c(b){g||(g=!0,l(a,b))}function d(b){g||(g=!0,p(a,b))}function e(b){n(a,b)}var f,g=!1;try{if(B(b)||C(b))f= + b.then;C(f)?(a.$$state.status=-1,f.call(b,c,d,e)):(a.$$state.value=b,a.$$state.status=1,h(a.$$state))}catch(k){d(k)}}function m(a,b){a.$$state.status||p(a,b)}function p(a,b){a.$$state.value=b;a.$$state.status=2;h(a.$$state)}function n(c,d){var e=c.$$state.pending;0>=c.$$state.status&&e&&e.length&&a(function(){for(var a,c,g=0,f=e.length;g<f;g++){c=e[g][0];a=e[g][3];try{n(c,C(a)?a(d):d)}catch(h){b(h)}}})}function F(a){var b=new f;m(b,a);return b}function s(a,b,c){var d=null;try{C(c)&&(d=c())}catch(e){return F(e)}return d&& + C(d.then)?d.then(function(){return b(a)},F):b(a)}function v(a,b,c,d){var e=new f;k(e,a);return e.then(b,c,d)}function q(a){if(!C(a))throw t("norslvr",a);var b=new f;a(function(a){k(b,a)},function(a){m(b,a)});return b}var t=K("$q",TypeError),u=0,w=[];O(f.prototype,{then:function(a,b,c){if(x(a)&&x(b)&&x(c))return this;var d=new f;this.$$state.pending=this.$$state.pending||[];this.$$state.pending.push([d,a,b,c]);0<this.$$state.status&&h(this.$$state);return d},"catch":function(a){return this.then(null, + a)},"finally":function(a,b){return this.then(function(b){return s(b,A,a)},function(b){return s(b,F,a)},b)}});var A=v;q.prototype=f.prototype;q.defer=c;q.reject=F;q.when=v;q.resolve=A;q.all=function(a){var b=new f,c=0,d=I(a)?[]:{};r(a,function(a,e){c++;v(a).then(function(a){d[e]=a;--c||k(b,d)},function(a){m(b,a)})});0===c&&k(b,d);return b};q.race=function(a){var b=c();r(a,function(a){v(a).then(b.resolve,b.reject)});return b.promise};return q}function Zf(){this.$get=["$window","$timeout",function(a, + b){var d=a.requestAnimationFrame||a.webkitRequestAnimationFrame,c=a.cancelAnimationFrame||a.webkitCancelAnimationFrame||a.webkitCancelRequestAnimationFrame,e=!!d,f=e?function(a){var b=d(a);return function(){c(b)}}:function(a){var c=b(a,16.66,!1);return function(){b.cancel(c)}};f.supported=e;return f}]}function Of(){function a(a){function b(){this.$$watchers=this.$$nextSibling=this.$$childHead=this.$$childTail=null;this.$$listeners={};this.$$listenerCount={};this.$$watchersCount=0;this.$id=++qb;this.$$ChildScope= + null}b.prototype=a;return b}var b=10,d=K("$rootScope"),c=null,e=null;this.digestTtl=function(a){arguments.length&&(b=a);return b};this.$get=["$exceptionHandler","$parse","$browser",function(f,g,h){function k(a){a.currentScope.$$destroyed=!0}function l(a){9===Ca&&(a.$$childHead&&l(a.$$childHead),a.$$nextSibling&&l(a.$$nextSibling));a.$parent=a.$$nextSibling=a.$$prevSibling=a.$$childHead=a.$$childTail=a.$root=a.$$watchers=null}function m(){this.$id=++qb;this.$$phase=this.$parent=this.$$watchers=this.$$nextSibling= + this.$$prevSibling=this.$$childHead=this.$$childTail=null;this.$root=this;this.$$destroyed=!1;this.$$listeners={};this.$$listenerCount={};this.$$watchersCount=0;this.$$isolateBindings=null}function p(a){if(t.$$phase)throw d("inprog",t.$$phase);t.$$phase=a}function n(a,b){do a.$$watchersCount+=b;while(a=a.$parent)}function F(a,b,c){do a.$$listenerCount[c]-=b,0===a.$$listenerCount[c]&&delete a.$$listenerCount[c];while(a=a.$parent)}function s(){}function v(){for(;A.length;)try{A.shift()()}catch(a){f(a)}e= + null}function q(){null===e&&(e=h.defer(function(){t.$apply(v)}))}m.prototype={constructor:m,$new:function(b,c){var d;c=c||this;b?(d=new m,d.$root=this.$root):(this.$$ChildScope||(this.$$ChildScope=a(this)),d=new this.$$ChildScope);d.$parent=c;d.$$prevSibling=c.$$childTail;c.$$childHead?(c.$$childTail.$$nextSibling=d,c.$$childTail=d):c.$$childHead=c.$$childTail=d;(b||c!==this)&&d.$on("$destroy",k);return d},$watch:function(a,b,d,e){var f=g(a);b=C(b)?b:D;if(f.$$watchDelegate)return f.$$watchDelegate(this, + b,d,f,a);var h=this,k=h.$$watchers,l={fn:b,last:s,get:f,exp:e||a,eq:!!d};c=null;k||(k=h.$$watchers=[],k.$$digestWatchIndex=-1);k.unshift(l);k.$$digestWatchIndex++;n(this,1);return function(){var a=cb(k,l);0<=a&&(n(h,-1),a<k.$$digestWatchIndex&&k.$$digestWatchIndex--);c=null}},$watchGroup:function(a,b){function c(){h=!1;k?(k=!1,b(e,e,g)):b(e,d,g)}var d=Array(a.length),e=Array(a.length),f=[],g=this,h=!1,k=!0;if(!a.length){var l=!0;g.$evalAsync(function(){l&&b(e,e,g)});return function(){l=!1}}if(1=== + a.length)return this.$watch(a[0],function(a,c,f){e[0]=a;d[0]=c;b(e,a===c?e:d,f)});r(a,function(a,b){var k=g.$watch(a,function(a,f){e[b]=a;d[b]=f;h||(h=!0,g.$evalAsync(c))});f.push(k)});return function(){for(;f.length;)f.shift()()}},$watchCollection:function(a,b){function c(a){e=a;var b,d,g,h;if(!x(e)){if(B(e))if(wa(e))for(f!==p&&(f=p,t=f.length=0,l++),a=e.length,t!==a&&(l++,f.length=t=a),b=0;b<a;b++)h=f[b],g=e[b],d=h!==h&&g!==g,d||h===g||(l++,f[b]=g);else{f!==n&&(f=n={},t=0,l++);a=0;for(b in e)ra.call(e, + b)&&(a++,g=e[b],h=f[b],b in f?(d=h!==h&&g!==g,d||h===g||(l++,f[b]=g)):(t++,f[b]=g,l++));if(t>a)for(b in l++,f)ra.call(e,b)||(t--,delete f[b])}else f!==e&&(f=e,l++);return l}}c.$stateful=!0;var d=this,e,f,h,k=1<b.length,l=0,m=g(a,c),p=[],n={},s=!0,t=0;return this.$watch(m,function(){s?(s=!1,b(e,e,d)):b(e,h,d);if(k)if(B(e))if(wa(e)){h=Array(e.length);for(var a=0;a<e.length;a++)h[a]=e[a]}else for(a in h={},e)ra.call(e,a)&&(h[a]=e[a]);else h=e})},$digest:function(){var a,g,k,l,m,n,r,F=b,q,A=[],y,x;p("$digest"); + h.$$checkUrlChange();this===t&&null!==e&&(h.defer.cancel(e),v());c=null;do{r=!1;q=this;for(n=0;n<u.length;n++){try{x=u[n],l=x.fn,l(x.scope,x.locals)}catch(z){f(z)}c=null}u.length=0;a:do{if(n=q.$$watchers)for(n.$$digestWatchIndex=n.length;n.$$digestWatchIndex--;)try{if(a=n[n.$$digestWatchIndex])if(m=a.get,(g=m(q))!==(k=a.last)&&!(a.eq?sa(g,k):U(g)&&U(k)))r=!0,c=a,a.last=a.eq?pa(g,null):g,l=a.fn,l(g,k===s?g:k,q),5>F&&(y=4-F,A[y]||(A[y]=[]),A[y].push({msg:C(a.exp)?"fn: "+(a.exp.name||a.exp.toString()): + a.exp,newVal:g,oldVal:k}));else if(a===c){r=!1;break a}}catch(D){f(D)}if(!(n=q.$$watchersCount&&q.$$childHead||q!==this&&q.$$nextSibling))for(;q!==this&&!(n=q.$$nextSibling);)q=q.$parent}while(q=n);if((r||u.length)&&!F--)throw t.$$phase=null,d("infdig",b,A);}while(r||u.length);for(t.$$phase=null;H<w.length;)try{w[H++]()}catch(B){f(B)}w.length=H=0;h.$$checkUrlChange()},$destroy:function(){if(!this.$$destroyed){var a=this.$parent;this.$broadcast("$destroy");this.$$destroyed=!0;this===t&&h.$$applicationDestroyed(); + n(this,-this.$$watchersCount);for(var b in this.$$listenerCount)F(this,this.$$listenerCount[b],b);a&&a.$$childHead===this&&(a.$$childHead=this.$$nextSibling);a&&a.$$childTail===this&&(a.$$childTail=this.$$prevSibling);this.$$prevSibling&&(this.$$prevSibling.$$nextSibling=this.$$nextSibling);this.$$nextSibling&&(this.$$nextSibling.$$prevSibling=this.$$prevSibling);this.$destroy=this.$digest=this.$apply=this.$evalAsync=this.$applyAsync=D;this.$on=this.$watch=this.$watchGroup=function(){return D};this.$$listeners= + {};this.$$nextSibling=null;l(this)}},$eval:function(a,b){return g(a)(this,b)},$evalAsync:function(a,b){t.$$phase||u.length||h.defer(function(){u.length&&t.$digest()});u.push({scope:this,fn:g(a),locals:b})},$$postDigest:function(a){w.push(a)},$apply:function(a){try{p("$apply");try{return this.$eval(a)}finally{t.$$phase=null}}catch(b){f(b)}finally{try{t.$digest()}catch(c){throw f(c),c;}}},$applyAsync:function(a){function b(){c.$eval(a)}var c=this;a&&A.push(b);a=g(a);q()},$on:function(a,b){var c=this.$$listeners[a]; + c||(this.$$listeners[a]=c=[]);c.push(b);var d=this;do d.$$listenerCount[a]||(d.$$listenerCount[a]=0),d.$$listenerCount[a]++;while(d=d.$parent);var e=this;return function(){var d=c.indexOf(b);-1!==d&&(delete c[d],F(e,1,a))}},$emit:function(a,b){var c=[],d,e=this,g=!1,h={name:a,targetScope:e,stopPropagation:function(){g=!0},preventDefault:function(){h.defaultPrevented=!0},defaultPrevented:!1},k=db([h],arguments,1),l,m;do{d=e.$$listeners[a]||c;h.currentScope=e;l=0;for(m=d.length;l<m;l++)if(d[l])try{d[l].apply(null, + k)}catch(n){f(n)}else d.splice(l,1),l--,m--;if(g)break;e=e.$parent}while(e);h.currentScope=null;return h},$broadcast:function(a,b){var c=this,d=this,e={name:a,targetScope:this,preventDefault:function(){e.defaultPrevented=!0},defaultPrevented:!1};if(!this.$$listenerCount[a])return e;for(var g=db([e],arguments,1),h,k;c=d;){e.currentScope=c;d=c.$$listeners[a]||[];h=0;for(k=d.length;h<k;h++)if(d[h])try{d[h].apply(null,g)}catch(l){f(l)}else d.splice(h,1),h--,k--;if(!(d=c.$$listenerCount[a]&&c.$$childHead|| + c!==this&&c.$$nextSibling))for(;c!==this&&!(d=c.$$nextSibling);)c=c.$parent}e.currentScope=null;return e}};var t=new m,u=t.$$asyncQueue=[],w=t.$$postDigestQueue=[],A=t.$$applyAsyncQueue=[],H=0;return t}]}function Ge(){var a=/^\s*(https?|s?ftp|mailto|tel|file):/,b=/^\s*((https?|ftp|file|blob):|data:image\/)/;this.aHrefSanitizationWhitelist=function(b){return u(b)?(a=b,this):a};this.imgSrcSanitizationWhitelist=function(a){return u(a)?(b=a,this):b};this.$get=function(){return function(d,c){var e=c?b: + a,f;f=ta(d&&d.trim()).href;return""===f||f.match(e)?d:"unsafe:"+f}}}function Fg(a){if("self"===a)return a;if(E(a)){if(-1<a.indexOf("***"))throw va("iwcard",a);a=Md(a).replace(/\\\*\\\*/g,".*").replace(/\\\*/g,"[^:/.?&;]*");return new RegExp("^"+a+"$")}if($a(a))return new RegExp("^"+a.source+"$");throw va("imatcher");}function Nd(a){var b=[];u(a)&&r(a,function(a){b.push(Fg(a))});return b}function Sf(){this.SCE_CONTEXTS=oa;var a=["self"],b=[];this.resourceUrlWhitelist=function(b){arguments.length&& + (a=Nd(b));return a};this.resourceUrlBlacklist=function(a){arguments.length&&(b=Nd(a));return b};this.$get=["$injector",function(d){function c(a,b){return"self"===a?zd(b):!!a.exec(b.href)}function e(a){var b=function(a){this.$$unwrapTrustedValue=function(){return a}};a&&(b.prototype=new a);b.prototype.valueOf=function(){return this.$$unwrapTrustedValue()};b.prototype.toString=function(){return this.$$unwrapTrustedValue().toString()};return b}var f=function(a){throw va("unsafe");};d.has("$sanitize")&& + (f=d.get("$sanitize"));var g=e(),h={};h[oa.HTML]=e(g);h[oa.CSS]=e(g);h[oa.URL]=e(g);h[oa.JS]=e(g);h[oa.RESOURCE_URL]=e(h[oa.URL]);return{trustAs:function(a,b){var c=h.hasOwnProperty(a)?h[a]:null;if(!c)throw va("icontext",a,b);if(null===b||x(b)||""===b)return b;if("string"!==typeof b)throw va("itype",a);return new c(b)},getTrusted:function(d,e){if(null===e||x(e)||""===e)return e;var g=h.hasOwnProperty(d)?h[d]:null;if(g&&e instanceof g)return e.$$unwrapTrustedValue();if(d===oa.RESOURCE_URL){var g=ta(e.toString()), + p,n,r=!1;p=0;for(n=a.length;p<n;p++)if(c(a[p],g)){r=!0;break}if(r)for(p=0,n=b.length;p<n;p++)if(c(b[p],g)){r=!1;break}if(r)return e;throw va("insecurl",e.toString());}if(d===oa.HTML)return f(e);throw va("unsafe");},valueOf:function(a){return a instanceof g?a.$$unwrapTrustedValue():a}}}]}function Rf(){var a=!0;this.enabled=function(b){arguments.length&&(a=!!b);return a};this.$get=["$parse","$sceDelegate",function(b,d){if(a&&8>Ca)throw va("iequirks");var c=ka(oa);c.isEnabled=function(){return a};c.trustAs= + d.trustAs;c.getTrusted=d.getTrusted;c.valueOf=d.valueOf;a||(c.trustAs=c.getTrusted=function(a,b){return b},c.valueOf=ab);c.parseAs=function(a,d){var e=b(d);return e.literal&&e.constant?e:b(d,function(b){return c.getTrusted(a,b)})};var e=c.parseAs,f=c.getTrusted,g=c.trustAs;r(oa,function(a,b){var d=L(b);c[("parse_as_"+d).replace(Bc,wb)]=function(b){return e(a,b)};c[("get_trusted_"+d).replace(Bc,wb)]=function(b){return f(a,b)};c[("trust_as_"+d).replace(Bc,wb)]=function(b){return g(a,b)}});return c}]} + function Tf(){this.$get=["$window","$document",function(a,b){var d={},c=!((!a.nw||!a.nw.process)&&a.chrome&&(a.chrome.app&&a.chrome.app.runtime||!a.chrome.app&&a.chrome.runtime&&a.chrome.runtime.id))&&a.history&&a.history.pushState,e=Z((/android (\d+)/.exec(L((a.navigator||{}).userAgent))||[])[1]),f=/Boxee/i.test((a.navigator||{}).userAgent),g=b[0]||{},h=g.body&&g.body.style,k=!1,l=!1;h&&(k=!!("transition"in h||"webkitTransition"in h),l=!!("animation"in h||"webkitAnimation"in h));return{history:!(!c|| + 4>e||f),hasEvent:function(a){if("input"===a&&Ca)return!1;if(x(d[a])){var b=g.createElement("div");d[a]="on"+a in b}return d[a]},csp:Ja(),transitions:k,animations:l,android:e}}]}function Vf(){var a;this.httpOptions=function(b){return b?(a=b,this):a};this.$get=["$exceptionHandler","$templateCache","$http","$q","$sce",function(b,d,c,e,f){function g(h,k){g.totalPendingRequests++;if(!E(h)||x(d.get(h)))h=f.getTrustedResourceUrl(h);var l=c.defaults&&c.defaults.transformResponse;I(l)?l=l.filter(function(a){return a!== + uc}):l===uc&&(l=null);return c.get(h,O({cache:d,transformResponse:l},a)).finally(function(){g.totalPendingRequests--}).then(function(a){d.put(h,a.data);return a.data},function(a){k||(a=Gg("tpload",h,a.status,a.statusText),b(a));return e.reject(a)})}g.totalPendingRequests=0;return g}]}function Wf(){this.$get=["$rootScope","$browser","$location",function(a,b,d){return{findBindings:function(a,b,d){a=a.getElementsByClassName("ng-binding");var g=[];r(a,function(a){var c=$.element(a).data("$binding");c&& + r(c,function(c){d?(new RegExp("(^|\\s)"+Md(b)+"(\\s|\\||$)")).test(c)&&g.push(a):-1!==c.indexOf(b)&&g.push(a)})});return g},findModels:function(a,b,d){for(var g=["ng-","data-ng-","ng\\:"],h=0;h<g.length;++h){var k=a.querySelectorAll("["+g[h]+"model"+(d?"=":"*=")+'"'+b+'"]');if(k.length)return k}},getLocation:function(){return d.url()},setLocation:function(b){b!==d.url()&&(d.url(b),a.$digest())},whenStable:function(a){b.notifyWhenNoOutstandingRequests(a)}}}]}function Xf(){this.$get=["$rootScope","$browser", + "$q","$$q","$exceptionHandler",function(a,b,d,c,e){function f(f,k,l){C(f)||(l=k,k=f,f=D);var m=xa.call(arguments,3),p=u(l)&&!l,n=(p?c:d).defer(),r=n.promise,s;s=b.defer(function(){try{n.resolve(f.apply(null,m))}catch(b){n.reject(b),e(b)}finally{delete g[r.$$timeoutId]}p||a.$apply()},k);r.$$timeoutId=s;g[s]=n;return r}var g={};f.cancel=function(a){return a&&a.$$timeoutId in g?(g[a.$$timeoutId].promise.$$state.pur=!0,g[a.$$timeoutId].reject("canceled"),delete g[a.$$timeoutId],b.defer.cancel(a.$$timeoutId)): + !1};return f}]}function ta(a){Ca&&(X.setAttribute("href",a),a=X.href);X.setAttribute("href",a);return{href:X.href,protocol:X.protocol?X.protocol.replace(/:$/,""):"",host:X.host,search:X.search?X.search.replace(/^\?/,""):"",hash:X.hash?X.hash.replace(/^#/,""):"",hostname:X.hostname,port:X.port,pathname:"/"===X.pathname.charAt(0)?X.pathname:"/"+X.pathname}}function zd(a){a=E(a)?ta(a):a;return a.protocol===Od.protocol&&a.host===Od.host}function Yf(){this.$get=la(w)}function Pd(a){function b(a){try{return decodeURIComponent(a)}catch(b){return a}} + var d=a[0]||{},c={},e="";return function(){var a,g,h,k,l;try{a=d.cookie||""}catch(m){a=""}if(a!==e)for(e=a,a=e.split("; "),c={},h=0;h<a.length;h++)g=a[h],k=g.indexOf("="),0<k&&(l=b(g.substring(0,k)),x(c[l])&&(c[l]=b(g.substring(k+1))));return c}}function bg(){this.$get=Pd}function ed(a){function b(d,c){if(B(d)){var e={};r(d,function(a,c){e[c]=b(c,a)});return e}return a.factory(d+"Filter",c)}this.register=b;this.$get=["$injector",function(a){return function(b){return a.get(b+"Filter")}}];b("currency", + Qd);b("date",Rd);b("filter",Hg);b("json",Ig);b("limitTo",Jg);b("lowercase",Kg);b("number",Sd);b("orderBy",Td);b("uppercase",Lg)}function Hg(){return function(a,b,d,c){if(!wa(a)){if(null==a)return a;throw K("filter")("notarray",a);}c=c||"$";var e;switch(Cc(b)){case "function":break;case "boolean":case "null":case "number":case "string":e=!0;case "object":b=Mg(b,d,c,e);break;default:return a}return Array.prototype.filter.call(a,b)}}function Mg(a,b,d,c){var e=B(a)&&d in a;!0===b?b=sa:C(b)||(b=function(a, + b){if(x(a))return!1;if(null===a||null===b)return a===b;if(B(b)||B(a)&&!ac(a))return!1;a=L(""+a);b=L(""+b);return-1!==a.indexOf(b)});return function(f){return e&&!B(f)?ha(f,a[d],b,d,!1):ha(f,a,b,d,c)}}function ha(a,b,d,c,e,f){var g=Cc(a),h=Cc(b);if("string"===h&&"!"===b.charAt(0))return!ha(a,b.substring(1),d,c,e);if(I(a))return a.some(function(a){return ha(a,b,d,c,e)});switch(g){case "object":var k;if(e){for(k in a)if(k.charAt&&"$"!==k.charAt(0)&&ha(a[k],b,d,c,!0))return!0;return f?!1:ha(a,b,d,c,!1)}if("object"=== + h){for(k in b)if(f=b[k],!C(f)&&!x(f)&&(g=k===c,!ha(g?a:a[k],f,d,c,g,g)))return!1;return!0}return d(a,b);case "function":return!1;default:return d(a,b)}}function Cc(a){return null===a?"null":typeof a}function Qd(a){var b=a.NUMBER_FORMATS;return function(a,c,e){x(c)&&(c=b.CURRENCY_SYM);x(e)&&(e=b.PATTERNS[1].maxFrac);var f=c?/\u00A4/g:/\s*\u00A4\s*/g;return null==a?a:Ud(a,b.PATTERNS[1],b.GROUP_SEP,b.DECIMAL_SEP,e).replace(f,c)}}function Sd(a){var b=a.NUMBER_FORMATS;return function(a,c){return null== + a?a:Ud(a,b.PATTERNS[0],b.GROUP_SEP,b.DECIMAL_SEP,c)}}function Ng(a){var b=0,d,c,e,f,g;-1<(c=a.indexOf(Vd))&&(a=a.replace(Vd,""));0<(e=a.search(/e/i))?(0>c&&(c=e),c+=+a.slice(e+1),a=a.substring(0,e)):0>c&&(c=a.length);for(e=0;a.charAt(e)===Dc;e++);if(e===(g=a.length))d=[0],c=1;else{for(g--;a.charAt(g)===Dc;)g--;c-=e;d=[];for(f=0;e<=g;e++,f++)d[f]=+a.charAt(e)}c>Wd&&(d=d.splice(0,Wd-1),b=c-1,c=1);return{d:d,e:b,i:c}}function Og(a,b,d,c){var e=a.d,f=e.length-a.i;b=x(b)?Math.min(Math.max(d,f),c):+b;d= + b+a.i;c=e[d];if(0<d){e.splice(Math.max(a.i,d));for(var g=d;g<e.length;g++)e[g]=0}else for(f=Math.max(0,f),a.i=1,e.length=Math.max(1,d=b+1),e[0]=0,g=1;g<d;g++)e[g]=0;if(5<=c)if(0>d-1){for(c=0;c>d;c--)e.unshift(0),a.i++;e.unshift(1);a.i++}else e[d-1]++;for(;f<Math.max(0,b);f++)e.push(0);if(b=e.reduceRight(function(a,b,c,d){b+=a;d[c]=b%10;return Math.floor(b/10)},0))e.unshift(b),a.i++}function Ud(a,b,d,c,e){if(!E(a)&&!Y(a)||isNaN(a))return"";var f=!isFinite(a),g=!1,h=Math.abs(a)+"",k="";if(f)k="\u221e"; + else{g=Ng(h);Og(g,e,b.minFrac,b.maxFrac);k=g.d;h=g.i;e=g.e;f=[];for(g=k.reduce(function(a,b){return a&&!b},!0);0>h;)k.unshift(0),h++;0<h?f=k.splice(h,k.length):(f=k,k=[0]);h=[];for(k.length>=b.lgSize&&h.unshift(k.splice(-b.lgSize,k.length).join(""));k.length>b.gSize;)h.unshift(k.splice(-b.gSize,k.length).join(""));k.length&&h.unshift(k.join(""));k=h.join(d);f.length&&(k+=c+f.join(""));e&&(k+="e+"+e)}return 0>a&&!g?b.negPre+k+b.negSuf:b.posPre+k+b.posSuf}function Ob(a,b,d,c){var e="";if(0>a||c&&0>= + a)c?a=-a+1:(a=-a,e="-");for(a=""+a;a.length<b;)a=Dc+a;d&&(a=a.substr(a.length-b));return e+a}function ea(a,b,d,c,e){d=d||0;return function(f){f=f["get"+a]();if(0<d||f>-d)f+=d;0===f&&-12===d&&(f=12);return Ob(f,b,c,e)}}function mb(a,b,d){return function(c,e){var f=c["get"+a](),g=ub((d?"STANDALONE":"")+(b?"SHORT":"")+a);return e[g][f]}}function Xd(a){var b=(new Date(a,0,1)).getDay();return new Date(a,0,(4>=b?5:12)-b)}function Yd(a){return function(b){var d=Xd(b.getFullYear());b=+new Date(b.getFullYear(), + b.getMonth(),b.getDate()+(4-b.getDay()))-+d;b=1+Math.round(b/6048E5);return Ob(b,a)}}function Ec(a,b){return 0>=a.getFullYear()?b.ERAS[0]:b.ERAS[1]}function Rd(a){function b(a){var b;if(b=a.match(d)){a=new Date(0);var f=0,g=0,h=b[8]?a.setUTCFullYear:a.setFullYear,k=b[8]?a.setUTCHours:a.setHours;b[9]&&(f=Z(b[9]+b[10]),g=Z(b[9]+b[11]));h.call(a,Z(b[1]),Z(b[2])-1,Z(b[3]));f=Z(b[4]||0)-f;g=Z(b[5]||0)-g;h=Z(b[6]||0);b=Math.round(1E3*parseFloat("0."+(b[7]||0)));k.call(a,f,g,h,b)}return a}var d=/^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(Z|([+-])(\d\d):?(\d\d))?)?$/; + return function(c,d,f){var g="",h=[],k,l;d=d||"mediumDate";d=a.DATETIME_FORMATS[d]||d;E(c)&&(c=Pg.test(c)?Z(c):b(c));Y(c)&&(c=new Date(c));if(!fa(c)||!isFinite(c.getTime()))return c;for(;d;)(l=Qg.exec(d))?(h=db(h,l,1),d=h.pop()):(h.push(d),d=null);var m=c.getTimezoneOffset();f&&(m=Sc(f,m),c=dc(c,f,!0));r(h,function(b){k=Rg[b];g+=k?k(c,a.DATETIME_FORMATS,m):"''"===b?"'":b.replace(/(^'|'$)/g,"").replace(/''/g,"'")});return g}}function Ig(){return function(a,b){x(b)&&(b=2);return eb(a,b)}}function Jg(){return function(a, + b,d){b=Infinity===Math.abs(Number(b))?Number(b):Z(b);if(U(b))return a;Y(a)&&(a=a.toString());if(!wa(a))return a;d=!d||isNaN(d)?0:Z(d);d=0>d?Math.max(0,a.length+d):d;return 0<=b?Fc(a,d,d+b):0===d?Fc(a,b,a.length):Fc(a,Math.max(0,d+b),d)}}function Fc(a,b,d){return E(a)?a.slice(b,d):xa.call(a,b,d)}function Td(a){function b(b){return b.map(function(b){var c=1,d=ab;if(C(b))d=b;else if(E(b)){if("+"===b.charAt(0)||"-"===b.charAt(0))c="-"===b.charAt(0)?-1:1,b=b.substring(1);if(""!==b&&(d=a(b),d.constant))var e= + d(),d=function(a){return a[e]}}return{get:d,descending:c}})}function d(a){switch(typeof a){case "number":case "boolean":case "string":return!0;default:return!1}}function c(a,b){var c=0,d=a.type,k=b.type;if(d===k){var k=a.value,l=b.value;"string"===d?(k=k.toLowerCase(),l=l.toLowerCase()):"object"===d&&(B(k)&&(k=a.index),B(l)&&(l=b.index));k!==l&&(c=k<l?-1:1)}else c=d<k?-1:1;return c}return function(a,f,g,h){if(null==a)return a;if(!wa(a))throw K("orderBy")("notarray",a);I(f)||(f=[f]);0===f.length&& + (f=["+"]);var k=b(f),l=g?-1:1,m=C(h)?h:c;a=Array.prototype.map.call(a,function(a,b){return{value:a,tieBreaker:{value:b,type:"number",index:b},predicateValues:k.map(function(c){var e=c.get(a);c=typeof e;if(null===e)c="string",e="null";else if("object"===c)a:{if(C(e.valueOf)&&(e=e.valueOf(),d(e)))break a;ac(e)&&(e=e.toString(),d(e))}return{value:e,type:c,index:b}})}});a.sort(function(a,b){for(var d=0,e=k.length;d<e;d++){var g=m(a.predicateValues[d],b.predicateValues[d]);if(g)return g*k[d].descending* + l}return(m(a.tieBreaker,b.tieBreaker)||c(a.tieBreaker,b.tieBreaker))*l});return a=a.map(function(a){return a.value})}}function Qa(a){C(a)&&(a={link:a});a.restrict=a.restrict||"AC";return la(a)}function Pb(a,b,d,c,e){this.$$controls=[];this.$error={};this.$$success={};this.$pending=void 0;this.$name=e(b.name||b.ngForm||"")(d);this.$dirty=!1;this.$valid=this.$pristine=!0;this.$submitted=this.$invalid=!1;this.$$parentForm=Qb;this.$$element=a;this.$$animate=c;Zd(this)}function Zd(a){a.$$classCache={}; + a.$$classCache[$d]=!(a.$$classCache[nb]=a.$$element.hasClass(nb))}function ae(a){function b(a,b,c){c&&!a.$$classCache[b]?(a.$$animate.addClass(a.$$element,b),a.$$classCache[b]=!0):!c&&a.$$classCache[b]&&(a.$$animate.removeClass(a.$$element,b),a.$$classCache[b]=!1)}function d(a,c,d){c=c?"-"+Vc(c,"-"):"";b(a,nb+c,!0===d);b(a,$d+c,!1===d)}var c=a.set,e=a.unset;a.clazz.prototype.$setValidity=function(a,g,h){x(g)?(this.$pending||(this.$pending={}),c(this.$pending,a,h)):(this.$pending&&e(this.$pending, + a,h),be(this.$pending)&&(this.$pending=void 0));Na(g)?g?(e(this.$error,a,h),c(this.$$success,a,h)):(c(this.$error,a,h),e(this.$$success,a,h)):(e(this.$error,a,h),e(this.$$success,a,h));this.$pending?(b(this,"ng-pending",!0),this.$valid=this.$invalid=void 0,d(this,"",null)):(b(this,"ng-pending",!1),this.$valid=be(this.$error),this.$invalid=!this.$valid,d(this,"",this.$valid));g=this.$pending&&this.$pending[a]?void 0:this.$error[a]?!1:this.$$success[a]?!0:null;d(this,a,g);this.$$parentForm.$setValidity(a, + g,this)}}function be(a){if(a)for(var b in a)if(a.hasOwnProperty(b))return!1;return!0}function Gc(a){a.$formatters.push(function(b){return a.$isEmpty(b)?b:b.toString()})}function Va(a,b,d,c,e,f){var g=L(b[0].type);if(!e.android){var h=!1;b.on("compositionstart",function(){h=!0});b.on("compositionend",function(){h=!1;l()})}var k,l=function(a){k&&(f.defer.cancel(k),k=null);if(!h){var e=b.val();a=a&&a.type;"password"===g||d.ngTrim&&"false"===d.ngTrim||(e=Q(e));(c.$viewValue!==e||""===e&&c.$$hasNativeValidators)&& + c.$setViewValue(e,a)}};if(e.hasEvent("input"))b.on("input",l);else{var m=function(a,b,c){k||(k=f.defer(function(){k=null;b&&b.value===c||l(a)}))};b.on("keydown",function(a){var b=a.keyCode;91===b||15<b&&19>b||37<=b&&40>=b||m(a,this,this.value)});if(e.hasEvent("paste"))b.on("paste cut drop",m)}b.on("change",l);if(ce[g]&&c.$$hasNativeValidators&&g===d.type)b.on("keydown wheel mousedown",function(a){if(!k){var b=this.validity,c=b.badInput,d=b.typeMismatch;k=f.defer(function(){k=null;b.badInput===c&& + b.typeMismatch===d||l(a)})}});c.$render=function(){var a=c.$isEmpty(c.$viewValue)?"":c.$viewValue;b.val()!==a&&b.val(a)}}function Rb(a,b){return function(d,c){var e,f;if(fa(d))return d;if(E(d)){'"'===d.charAt(0)&&'"'===d.charAt(d.length-1)&&(d=d.substring(1,d.length-1));if(Sg.test(d))return new Date(d);a.lastIndex=0;if(e=a.exec(d))return e.shift(),f=c?{yyyy:c.getFullYear(),MM:c.getMonth()+1,dd:c.getDate(),HH:c.getHours(),mm:c.getMinutes(),ss:c.getSeconds(),sss:c.getMilliseconds()/1E3}:{yyyy:1970, + MM:1,dd:1,HH:0,mm:0,ss:0,sss:0},r(e,function(a,c){c<b.length&&(f[b[c]]=+a)}),new Date(f.yyyy,f.MM-1,f.dd,f.HH,f.mm,f.ss||0,1E3*f.sss||0)}return NaN}}function ob(a,b,d,c){return function(e,f,g,h,k,l,m){function p(a){return a&&!(a.getTime&&a.getTime()!==a.getTime())}function n(a){return u(a)&&!fa(a)?d(a)||void 0:a}Hc(e,f,g,h);Va(e,f,g,h,k,l);var r=h&&h.$options.getOption("timezone"),s;h.$$parserName=a;h.$parsers.push(function(a){if(h.$isEmpty(a))return null;if(b.test(a))return a=d(a,s),r&&(a=dc(a,r)), + a});h.$formatters.push(function(a){if(a&&!fa(a))throw pb("datefmt",a);if(p(a))return(s=a)&&r&&(s=dc(s,r,!0)),m("date")(a,c,r);s=null;return""});if(u(g.min)||g.ngMin){var q;h.$validators.min=function(a){return!p(a)||x(q)||d(a)>=q};g.$observe("min",function(a){q=n(a);h.$validate()})}if(u(g.max)||g.ngMax){var y;h.$validators.max=function(a){return!p(a)||x(y)||d(a)<=y};g.$observe("max",function(a){y=n(a);h.$validate()})}}}function Hc(a,b,d,c){(c.$$hasNativeValidators=B(b[0].validity))&&c.$parsers.push(function(a){var c= + b.prop("validity")||{};return c.badInput||c.typeMismatch?void 0:a})}function de(a){a.$$parserName="number";a.$parsers.push(function(b){if(a.$isEmpty(b))return null;if(Tg.test(b))return parseFloat(b)});a.$formatters.push(function(b){if(!a.$isEmpty(b)){if(!Y(b))throw pb("numfmt",b);b=b.toString()}return b})}function Wa(a){u(a)&&!Y(a)&&(a=parseFloat(a));return U(a)?void 0:a}function Ic(a){var b=a.toString(),d=b.indexOf(".");return-1===d?-1<a&&1>a&&(a=/e-(\d+)$/.exec(b))?Number(a[1]):0:b.length-d-1}function ee(a, + b,d){a=Number(a);var c=(a|0)!==a,e=(b|0)!==b,f=(d|0)!==d;if(c||e||f){var g=c?Ic(a):0,h=e?Ic(b):0,k=f?Ic(d):0,g=Math.max(g,h,k),g=Math.pow(10,g);a*=g;b*=g;d*=g;c&&(a=Math.round(a));e&&(b=Math.round(b));f&&(d=Math.round(d))}return 0===(a-b)%d}function fe(a,b,d,c,e){if(u(c)){a=a(c);if(!a.constant)throw pb("constexpr",d,c);return a(b)}return e}function Jc(a,b){function d(a,b){if(!a||!a.length)return[];if(!b||!b.length)return a;var c=[],d=0;a:for(;d<a.length;d++){for(var e=a[d],f=0;f<b.length;f++)if(e=== + b[f])continue a;c.push(e)}return c}function c(a){var b=a;I(a)?b=a.map(c).join(" "):B(a)&&(b=Object.keys(a).filter(function(b){return a[b]}).join(" "));return b}function e(a){var b=a;if(I(a))b=a.map(e);else if(B(a)){var c=!1,b=Object.keys(a).filter(function(b){b=a[b];!c&&x(b)&&(c=!0);return b});c&&b.push(void 0)}return b}a="ngClass"+a;var f;return["$parse",function(g){return{restrict:"AC",link:function(h,k,l){function m(a,b){var c=[];r(a,function(a){if(0<b||t[a])t[a]=(t[a]||0)+b,t[a]===+(0<b)&&c.push(a)}); + return c.join(" ")}function p(a){if(a===b){var c=w,c=m(c&&c.split(" "),1);l.$addClass(c)}else c=w,c=m(c&&c.split(" "),-1),l.$removeClass(c);u=a}function n(a){a=c(a);a!==w&&q(a)}function q(a){if(u===b){var c=w&&w.split(" "),e=a&&a.split(" "),g=d(c,e),c=d(e,c),g=m(g,-1),c=m(c,1);l.$addClass(c);l.$removeClass(g)}w=a}var s=l[a].trim(),v=":"===s.charAt(0)&&":"===s.charAt(1),s=g(s,v?e:c),y=v?n:q,t=k.data("$classCounts"),u=!0,w;t||(t=S(),k.data("$classCounts",t));"ngClass"!==a&&(f||(f=g("$index",function(a){return a& + 1})),h.$watch(f,p));h.$watch(s,y,v)}}}]}function Sb(a,b,d,c,e,f,g,h,k){this.$modelValue=this.$viewValue=Number.NaN;this.$$rawModelValue=void 0;this.$validators={};this.$asyncValidators={};this.$parsers=[];this.$formatters=[];this.$viewChangeListeners=[];this.$untouched=!0;this.$touched=!1;this.$pristine=!0;this.$dirty=!1;this.$valid=!0;this.$invalid=!1;this.$error={};this.$$success={};this.$pending=void 0;this.$name=k(d.name||"",!1)(a);this.$$parentForm=Qb;this.$options=Tb;this.$$updateEvents=""; + this.$$updateEventHandler=this.$$updateEventHandler.bind(this);this.$$parsedNgModel=e(d.ngModel);this.$$parsedNgModelAssign=this.$$parsedNgModel.assign;this.$$ngModelGet=this.$$parsedNgModel;this.$$ngModelSet=this.$$parsedNgModelAssign;this.$$pendingDebounce=null;this.$$parserValid=void 0;this.$$currentValidationRunId=0;Object.defineProperty(this,"$$scope",{value:a});this.$$attr=d;this.$$element=c;this.$$animate=f;this.$$timeout=g;this.$$parse=e;this.$$q=h;this.$$exceptionHandler=b;Zd(this);Ug(this)} + function Ug(a){a.$$scope.$watch(function(b){b=a.$$ngModelGet(b);b===a.$modelValue||a.$modelValue!==a.$modelValue&&b!==b||a.$$setModelValue(b);return b})}function Kc(a){this.$$options=a}function ge(a,b){r(b,function(b,c){u(a[c])||(a[c]=b)})}function Ga(a,b){a.prop("selected",b);a.attr("selected",b)}var Mc={objectMaxDepth:5},Vg=/^\/(.+)\/([a-z]*)$/,ra=Object.prototype.hasOwnProperty,L=function(a){return E(a)?a.toLowerCase():a},ub=function(a){return E(a)?a.toUpperCase():a},Ca,z,ma,xa=[].slice,ug=[].splice, + Wg=[].push,ia=Object.prototype.toString,Pc=Object.getPrototypeOf,qa=K("ng"),$=w.angular||(w.angular={}),ic,qb=0;Ca=w.document.documentMode;var U=Number.isNaN||function(a){return a!==a};D.$inject=[];ab.$inject=[];var I=Array.isArray,se=/^\[object (?:Uint8|Uint8Clamped|Uint16|Uint32|Int8|Int16|Int32|Float32|Float64)Array]$/,Q=function(a){return E(a)?a.trim():a},Md=function(a){return a.replace(/([-()[\]{}+?*.$^|,:#<!\\])/g,"\\$1").replace(/\x08/g,"\\x08")},Ja=function(){if(!u(Ja.rules)){var a=w.document.querySelector("[ng-csp]")|| + w.document.querySelector("[data-ng-csp]");if(a){var b=a.getAttribute("ng-csp")||a.getAttribute("data-ng-csp");Ja.rules={noUnsafeEval:!b||-1!==b.indexOf("no-unsafe-eval"),noInlineStyle:!b||-1!==b.indexOf("no-inline-style")}}else{a=Ja;try{new Function(""),b=!1}catch(d){b=!0}a.rules={noUnsafeEval:b,noInlineStyle:!1}}}return Ja.rules},rb=function(){if(u(rb.name_))return rb.name_;var a,b,d=Ha.length,c,e;for(b=0;b<d;++b)if(c=Ha[b],a=w.document.querySelector("["+c.replace(":","\\:")+"jq]")){e=a.getAttribute(c+ + "jq");break}return rb.name_=e},ue=/:/g,Ha=["ng-","data-ng-","ng:","x-ng-"],xe=function(a){var b=a.currentScript;if(!b)return!0;if(!(b instanceof w.HTMLScriptElement||b instanceof w.SVGScriptElement))return!1;b=b.attributes;return[b.getNamedItem("src"),b.getNamedItem("href"),b.getNamedItem("xlink:href")].every(function(b){if(!b)return!0;if(!b.value)return!1;var c=a.createElement("a");c.href=b.value;if(a.location.origin===c.origin)return!0;switch(c.protocol){case "http:":case "https:":case "ftp:":case "blob:":case "file:":case "data:":return!0; + default:return!1}})}(w.document),Ae=/[A-Z]/g,Wc=!1,Oa=3,Fe={full:"1.6.9",major:1,minor:6,dot:9,codeName:"fiery-basilisk"};V.expando="ng339";var ib=V.cache={},gg=1;V._data=function(a){return this.cache[a[this.expando]]||{}};var cg=/-([a-z])/g,Xg=/^-ms-/,Ab={mouseleave:"mouseout",mouseenter:"mouseover"},lc=K("jqLite"),fg=/^<([\w-]+)\s*\/?>(?:<\/\1>|)$/,kc=/<|&#?\w+;/,dg=/<([\w:-]+)/,eg=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:-]+)[^>]*)\/>/gi,aa={option:[1,'<select multiple="multiple">', + "</select>"],thead:[1,"<table>","</table>"],col:[2,"<table><colgroup>","</colgroup></table>"],tr:[2,"<table><tbody>","</tbody></table>"],td:[3,"<table><tbody><tr>","</tr></tbody></table>"],_default:[0,"",""]};aa.optgroup=aa.option;aa.tbody=aa.tfoot=aa.colgroup=aa.caption=aa.thead;aa.th=aa.td;var lg=w.Node.prototype.contains||function(a){return!!(this.compareDocumentPosition(a)&16)},Sa=V.prototype={ready:gd,toString:function(){var a=[];r(this,function(b){a.push(""+b)});return"["+a.join(", ")+"]"}, + eq:function(a){return 0<=a?z(this[a]):z(this[this.length+a])},length:0,push:Wg,sort:[].sort,splice:[].splice},Gb={};r("multiple selected checked disabled readOnly required open".split(" "),function(a){Gb[L(a)]=a});var ld={};r("input select option textarea button form details".split(" "),function(a){ld[a]=!0});var sd={ngMinlength:"minlength",ngMaxlength:"maxlength",ngMin:"min",ngMax:"max",ngPattern:"pattern",ngStep:"step"};r({data:pc,removeData:oc,hasData:function(a){for(var b in ib[a.ng339])return!0; + return!1},cleanData:function(a){for(var b=0,d=a.length;b<d;b++)oc(a[b])}},function(a,b){V[b]=a});r({data:pc,inheritedData:Eb,scope:function(a){return z.data(a,"$scope")||Eb(a.parentNode||a,["$isolateScope","$scope"])},isolateScope:function(a){return z.data(a,"$isolateScope")||z.data(a,"$isolateScopeNoTemplate")},controller:id,injector:function(a){return Eb(a,"$injector")},removeAttr:function(a,b){a.removeAttribute(b)},hasClass:Bb,css:function(a,b,d){b=xb(b.replace(Xg,"ms-"));if(u(d))a.style[b]=d; + else return a.style[b]},attr:function(a,b,d){var c=a.nodeType;if(c!==Oa&&2!==c&&8!==c&&a.getAttribute){var c=L(b),e=Gb[c];if(u(d))null===d||!1===d&&e?a.removeAttribute(b):a.setAttribute(b,e?c:d);else return a=a.getAttribute(b),e&&null!==a&&(a=c),null===a?void 0:a}},prop:function(a,b,d){if(u(d))a[b]=d;else return a[b]},text:function(){function a(a,d){if(x(d)){var c=a.nodeType;return 1===c||c===Oa?a.textContent:""}a.textContent=d}a.$dv="";return a}(),val:function(a,b){if(x(b)){if(a.multiple&&"select"=== + ya(a)){var d=[];r(a.options,function(a){a.selected&&d.push(a.value||a.text)});return d}return a.value}a.value=b},html:function(a,b){if(x(b))return a.innerHTML;yb(a,!0);a.innerHTML=b},empty:jd},function(a,b){V.prototype[b]=function(b,c){var e,f,g=this.length;if(a!==jd&&x(2===a.length&&a!==Bb&&a!==id?b:c)){if(B(b)){for(e=0;e<g;e++)if(a===pc)a(this[e],b);else for(f in b)a(this[e],f,b[f]);return this}e=a.$dv;g=x(e)?Math.min(g,1):g;for(f=0;f<g;f++){var h=a(this[f],b,c);e=e?e+h:h}return e}for(e=0;e<g;e++)a(this[e], + b,c);return this}});r({removeData:oc,on:function(a,b,d,c){if(u(c))throw lc("onargs");if(jc(a)){c=zb(a,!0);var e=c.events,f=c.handle;f||(f=c.handle=ig(a,e));c=0<=b.indexOf(" ")?b.split(" "):[b];for(var g=c.length,h=function(b,c,g){var h=e[b];h||(h=e[b]=[],h.specialHandlerWrapper=c,"$destroy"===b||g||a.addEventListener(b,f));h.push(d)};g--;)b=c[g],Ab[b]?(h(Ab[b],kg),h(b,void 0,!0)):h(b)}},off:hd,one:function(a,b,d){a=z(a);a.on(b,function e(){a.off(b,d);a.off(b,e)});a.on(b,d)},replaceWith:function(a, + b){var d,c=a.parentNode;yb(a);r(new V(b),function(b){d?c.insertBefore(b,d.nextSibling):c.replaceChild(b,a);d=b})},children:function(a){var b=[];r(a.childNodes,function(a){1===a.nodeType&&b.push(a)});return b},contents:function(a){return a.contentDocument||a.childNodes||[]},append:function(a,b){var d=a.nodeType;if(1===d||11===d){b=new V(b);for(var d=0,c=b.length;d<c;d++)a.appendChild(b[d])}},prepend:function(a,b){if(1===a.nodeType){var d=a.firstChild;r(new V(b),function(b){a.insertBefore(b,d)})}}, + wrap:function(a,b){var d=z(b).eq(0).clone()[0],c=a.parentNode;c&&c.replaceChild(d,a);d.appendChild(a)},remove:Fb,detach:function(a){Fb(a,!0)},after:function(a,b){var d=a,c=a.parentNode;if(c){b=new V(b);for(var e=0,f=b.length;e<f;e++){var g=b[e];c.insertBefore(g,d.nextSibling);d=g}}},addClass:Db,removeClass:Cb,toggleClass:function(a,b,d){b&&r(b.split(" "),function(b){var e=d;x(e)&&(e=!Bb(a,b));(e?Db:Cb)(a,b)})},parent:function(a){return(a=a.parentNode)&&11!==a.nodeType?a:null},next:function(a){return a.nextElementSibling}, + find:function(a,b){return a.getElementsByTagName?a.getElementsByTagName(b):[]},clone:nc,triggerHandler:function(a,b,d){var c,e,f=b.type||b,g=zb(a);if(g=(g=g&&g.events)&&g[f])c={preventDefault:function(){this.defaultPrevented=!0},isDefaultPrevented:function(){return!0===this.defaultPrevented},stopImmediatePropagation:function(){this.immediatePropagationStopped=!0},isImmediatePropagationStopped:function(){return!0===this.immediatePropagationStopped},stopPropagation:D,type:f,target:a},b.type&&(c=O(c, + b)),b=ka(g),e=d?[c].concat(d):[c],r(b,function(b){c.isImmediatePropagationStopped()||b.apply(a,e)})}},function(a,b){V.prototype[b]=function(b,c,e){for(var f,g=0,h=this.length;g<h;g++)x(f)?(f=a(this[g],b,c,e),u(f)&&(f=z(f))):mc(f,a(this[g],b,c,e));return u(f)?f:this}});V.prototype.bind=V.prototype.on;V.prototype.unbind=V.prototype.off;var Yg=Object.create(null);md.prototype={_idx:function(a){if(a===this._lastKey)return this._lastIndex;this._lastKey=a;return this._lastIndex=this._keys.indexOf(a)},_transformKey:function(a){return U(a)? + Yg:a},get:function(a){a=this._transformKey(a);a=this._idx(a);if(-1!==a)return this._values[a]},set:function(a,b){a=this._transformKey(a);var d=this._idx(a);-1===d&&(d=this._lastIndex=this._keys.length);this._keys[d]=a;this._values[d]=b},delete:function(a){a=this._transformKey(a);a=this._idx(a);if(-1===a)return!1;this._keys.splice(a,1);this._values.splice(a,1);this._lastKey=NaN;this._lastIndex=-1;return!0}};var Hb=md,ag=[function(){this.$get=[function(){return Hb}]}],ng=/^([^(]+?)=>/,og=/^[^(]*\(\s*([^)]*)\)/m, + Zg=/,/,$g=/^\s*(_?)(\S+?)\1\s*$/,mg=/((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg,Ba=K("$injector");gb.$$annotate=function(a,b,d){var c;if("function"===typeof a){if(!(c=a.$inject)){c=[];if(a.length){if(b)throw E(d)&&d||(d=a.name||pg(a)),Ba("strictdi",d);b=nd(a);r(b[1].split(Zg),function(a){a.replace($g,function(a,b,d){c.push(d)})})}a.$inject=c}}else I(a)?(b=a.length-1,sb(a[b],"fn"),c=a.slice(0,b)):sb(a,"fn",!0);return c};var he=K("$animate"),sf=function(){this.$get=D},tf=function(){var a=new Hb,b=[];this.$get= + ["$$AnimateRunner","$rootScope",function(d,c){function e(a,b,c){var d=!1;b&&(b=E(b)?b.split(" "):I(b)?b:[],r(b,function(b){b&&(d=!0,a[b]=c)}));return d}function f(){r(b,function(b){var c=a.get(b);if(c){var d=qg(b.attr("class")),e="",f="";r(c,function(a,b){a!==!!d[b]&&(a?e+=(e.length?" ":"")+b:f+=(f.length?" ":"")+b)});r(b,function(a){e&&Db(a,e);f&&Cb(a,f)});a.delete(b)}});b.length=0}return{enabled:D,on:D,off:D,pin:D,push:function(g,h,k,l){l&&l();k=k||{};k.from&&g.css(k.from);k.to&&g.css(k.to);if(k.addClass|| + k.removeClass)if(h=k.addClass,l=k.removeClass,k=a.get(g)||{},h=e(k,h,!0),l=e(k,l,!1),h||l)a.set(g,k),b.push(g),1===b.length&&c.$$postDigest(f);g=new d;g.complete();return g}}}]},qf=["$provide",function(a){var b=this,d=null,c=null;this.$$registeredAnimations=Object.create(null);this.register=function(c,d){if(c&&"."!==c.charAt(0))throw he("notcsel",c);var g=c+"-animation";b.$$registeredAnimations[c.substr(1)]=g;a.factory(g,d)};this.customFilter=function(a){1===arguments.length&&(c=C(a)?a:null);return c}; + this.classNameFilter=function(a){if(1===arguments.length&&(d=a instanceof RegExp?a:null)&&/[(\s|\/)]ng-animate[(\s|\/)]/.test(d.toString()))throw d=null,he("nongcls","ng-animate");return d};this.$get=["$$animateQueue",function(a){function b(a,c,d){if(d){var e;a:{for(e=0;e<d.length;e++){var f=d[e];if(1===f.nodeType){e=f;break a}}e=void 0}!e||e.parentNode||e.previousElementSibling||(d=null)}d?d.after(a):c.prepend(a)}return{on:a.on,off:a.off,pin:a.pin,enabled:a.enabled,cancel:function(a){a.end&&a.end()}, + enter:function(c,d,k,l){d=d&&z(d);k=k&&z(k);d=d||k.parent();b(c,d,k);return a.push(c,"enter",Ka(l))},move:function(c,d,k,l){d=d&&z(d);k=k&&z(k);d=d||k.parent();b(c,d,k);return a.push(c,"move",Ka(l))},leave:function(b,c){return a.push(b,"leave",Ka(c),function(){b.remove()})},addClass:function(b,c,d){d=Ka(d);d.addClass=jb(d.addclass,c);return a.push(b,"addClass",d)},removeClass:function(b,c,d){d=Ka(d);d.removeClass=jb(d.removeClass,c);return a.push(b,"removeClass",d)},setClass:function(b,c,d,f){f=Ka(f); + f.addClass=jb(f.addClass,c);f.removeClass=jb(f.removeClass,d);return a.push(b,"setClass",f)},animate:function(b,c,d,f,m){m=Ka(m);m.from=m.from?O(m.from,c):c;m.to=m.to?O(m.to,d):d;m.tempClasses=jb(m.tempClasses,f||"ng-inline-animate");return a.push(b,"animate",m)}}}]}],vf=function(){this.$get=["$$rAF",function(a){function b(b){d.push(b);1<d.length||a(function(){for(var a=0;a<d.length;a++)d[a]();d=[]})}var d=[];return function(){var a=!1;b(function(){a=!0});return function(d){a?d():b(d)}}}]},uf=function(){this.$get= + ["$q","$sniffer","$$animateAsyncRun","$$isDocumentHidden","$timeout",function(a,b,d,c,e){function f(a){this.setHost(a);var b=d();this._doneCallbacks=[];this._tick=function(a){c()?e(a,0,!1):b(a)};this._state=0}f.chain=function(a,b){function c(){if(d===a.length)b(!0);else a[d](function(a){!1===a?b(!1):(d++,c())})}var d=0;c()};f.all=function(a,b){function c(f){e=e&&f;++d===a.length&&b(e)}var d=0,e=!0;r(a,function(a){a.done(c)})};f.prototype={setHost:function(a){this.host=a||{}},done:function(a){2=== + this._state?a():this._doneCallbacks.push(a)},progress:D,getPromise:function(){if(!this.promise){var b=this;this.promise=a(function(a,c){b.done(function(b){!1===b?c():a()})})}return this.promise},then:function(a,b){return this.getPromise().then(a,b)},"catch":function(a){return this.getPromise()["catch"](a)},"finally":function(a){return this.getPromise()["finally"](a)},pause:function(){this.host.pause&&this.host.pause()},resume:function(){this.host.resume&&this.host.resume()},end:function(){this.host.end&& + this.host.end();this._resolve(!0)},cancel:function(){this.host.cancel&&this.host.cancel();this._resolve(!1)},complete:function(a){var b=this;0===b._state&&(b._state=1,b._tick(function(){b._resolve(a)}))},_resolve:function(a){2!==this._state&&(r(this._doneCallbacks,function(b){b(a)}),this._doneCallbacks.length=0,this._state=2)}};return f}]},rf=function(){this.$get=["$$rAF","$q","$$AnimateRunner",function(a,b,d){return function(b,e){function f(){a(function(){g.addClass&&(b.addClass(g.addClass),g.addClass= + null);g.removeClass&&(b.removeClass(g.removeClass),g.removeClass=null);g.to&&(b.css(g.to),g.to=null);h||k.complete();h=!0});return k}var g=e||{};g.$$prepared||(g=pa(g));g.cleanupStyles&&(g.from=g.to=null);g.from&&(b.css(g.from),g.from=null);var h,k=new d;return{start:f,end:f}}}]},ca=K("$compile"),sc=new function(){};Yc.$inject=["$provide","$$sanitizeUriProvider"];Jb.prototype.isFirstChange=function(){return this.previousValue===sc};var od=/^((?:x|data)[:\-_])/i,tg=/[:\-_]+(.)/g,ud=K("$controller"), + td=/^(\S+)(\s+as\s+([\w$]+))?$/,Cf=function(){this.$get=["$document",function(a){return function(b){b?!b.nodeType&&b instanceof z&&(b=b[0]):b=a[0].body;return b.offsetWidth+1}}]},vd="application/json",vc={"Content-Type":vd+";charset=utf-8"},wg=/^\[|^\{(?!\{)/,xg={"[":/]$/,"{":/}$/},vg=/^\)]\}',?\n/,Kb=K("$http"),Fa=$.$interpolateMinErr=K("$interpolate");Fa.throwNoconcat=function(a){throw Fa("noconcat",a);};Fa.interr=function(a,b){return Fa("interr",a,b.toString())};var Kf=function(){this.$get=function(){function a(a){var b= + function(a){b.data=a;b.called=!0};b.id=a;return b}var b=$.callbacks,d={};return{createCallback:function(c){c="_"+(b.$$counter++).toString(36);var e="angular.callbacks."+c,f=a(c);d[e]=b[c]=f;return e},wasCalled:function(a){return d[a].called},getResponse:function(a){return d[a].data},removeCallback:function(a){delete b[d[a].id];delete d[a]}}}},ah=/^([^?#]*)(\?([^#]*))?(#(.*))?$/,zg={http:80,https:443,ftp:21},kb=K("$location"),Ag=/^\s*[\\/]{2,}/,bh={$$absUrl:"",$$html5:!1,$$replace:!1,absUrl:Lb("$$absUrl"), + url:function(a){if(x(a))return this.$$url;var b=ah.exec(a);(b[1]||""===a)&&this.path(decodeURIComponent(b[1]));(b[2]||b[1]||""===a)&&this.search(b[3]||"");this.hash(b[5]||"");return this},protocol:Lb("$$protocol"),host:Lb("$$host"),port:Lb("$$port"),path:Dd("$$path",function(a){a=null!==a?a.toString():"";return"/"===a.charAt(0)?a:"/"+a}),search:function(a,b){switch(arguments.length){case 0:return this.$$search;case 1:if(E(a)||Y(a))a=a.toString(),this.$$search=ec(a);else if(B(a))a=pa(a,{}),r(a,function(b, + c){null==b&&delete a[c]}),this.$$search=a;else throw kb("isrcharg");break;default:x(b)||null===b?delete this.$$search[a]:this.$$search[a]=b}this.$$compose();return this},hash:Dd("$$hash",function(a){return null!==a?a.toString():""}),replace:function(){this.$$replace=!0;return this}};r([Cd,zc,yc],function(a){a.prototype=Object.create(bh);a.prototype.state=function(b){if(!arguments.length)return this.$$state;if(a!==yc||!this.$$html5)throw kb("nostate");this.$$state=x(b)?null:b;this.$$urlUpdatedByLocation= + !0;return this}});var Xa=K("$parse"),Eg={}.constructor.prototype.valueOf,Ub=S();r("+ - * / % === !== == != < > <= >= && || ! = |".split(" "),function(a){Ub[a]=!0});var ch={n:"\n",f:"\f",r:"\r",t:"\t",v:"\v","'":"'",'"':'"'},Nb=function(a){this.options=a};Nb.prototype={constructor:Nb,lex:function(a){this.text=a;this.index=0;for(this.tokens=[];this.index<this.text.length;)if(a=this.text.charAt(this.index),'"'===a||"'"===a)this.readString(a);else if(this.isNumber(a)||"."===a&&this.isNumber(this.peek()))this.readNumber(); + else if(this.isIdentifierStart(this.peekMultichar()))this.readIdent();else if(this.is(a,"(){}[].,;:?"))this.tokens.push({index:this.index,text:a}),this.index++;else if(this.isWhitespace(a))this.index++;else{var b=a+this.peek(),d=b+this.peek(2),c=Ub[b],e=Ub[d];Ub[a]||c||e?(a=e?d:c?b:a,this.tokens.push({index:this.index,text:a,operator:!0}),this.index+=a.length):this.throwError("Unexpected next character ",this.index,this.index+1)}return this.tokens},is:function(a,b){return-1!==b.indexOf(a)},peek:function(a){a= + a||1;return this.index+a<this.text.length?this.text.charAt(this.index+a):!1},isNumber:function(a){return"0"<=a&&"9">=a&&"string"===typeof a},isWhitespace:function(a){return" "===a||"\r"===a||"\t"===a||"\n"===a||"\v"===a||"\u00a0"===a},isIdentifierStart:function(a){return this.options.isIdentifierStart?this.options.isIdentifierStart(a,this.codePointAt(a)):this.isValidIdentifierStart(a)},isValidIdentifierStart:function(a){return"a"<=a&&"z">=a||"A"<=a&&"Z">=a||"_"===a||"$"===a},isIdentifierContinue:function(a){return this.options.isIdentifierContinue? + this.options.isIdentifierContinue(a,this.codePointAt(a)):this.isValidIdentifierContinue(a)},isValidIdentifierContinue:function(a,b){return this.isValidIdentifierStart(a,b)||this.isNumber(a)},codePointAt:function(a){return 1===a.length?a.charCodeAt(0):(a.charCodeAt(0)<<10)+a.charCodeAt(1)-56613888},peekMultichar:function(){var a=this.text.charAt(this.index),b=this.peek();if(!b)return a;var d=a.charCodeAt(0),c=b.charCodeAt(0);return 55296<=d&&56319>=d&&56320<=c&&57343>=c?a+b:a},isExpOperator:function(a){return"-"=== + a||"+"===a||this.isNumber(a)},throwError:function(a,b,d){d=d||this.index;b=u(b)?"s "+b+"-"+this.index+" ["+this.text.substring(b,d)+"]":" "+d;throw Xa("lexerr",a,b,this.text);},readNumber:function(){for(var a="",b=this.index;this.index<this.text.length;){var d=L(this.text.charAt(this.index));if("."===d||this.isNumber(d))a+=d;else{var c=this.peek();if("e"===d&&this.isExpOperator(c))a+=d;else if(this.isExpOperator(d)&&c&&this.isNumber(c)&&"e"===a.charAt(a.length-1))a+=d;else if(!this.isExpOperator(d)|| + c&&this.isNumber(c)||"e"!==a.charAt(a.length-1))break;else this.throwError("Invalid exponent")}this.index++}this.tokens.push({index:b,text:a,constant:!0,value:Number(a)})},readIdent:function(){var a=this.index;for(this.index+=this.peekMultichar().length;this.index<this.text.length;){var b=this.peekMultichar();if(!this.isIdentifierContinue(b))break;this.index+=b.length}this.tokens.push({index:a,text:this.text.slice(a,this.index),identifier:!0})},readString:function(a){var b=this.index;this.index++; + for(var d="",c=a,e=!1;this.index<this.text.length;){var f=this.text.charAt(this.index),c=c+f;if(e)"u"===f?(e=this.text.substring(this.index+1,this.index+5),e.match(/[\da-f]{4}/i)||this.throwError("Invalid unicode escape [\\u"+e+"]"),this.index+=4,d+=String.fromCharCode(parseInt(e,16))):d+=ch[f]||f,e=!1;else if("\\"===f)e=!0;else{if(f===a){this.index++;this.tokens.push({index:b,text:c,constant:!0,value:d});return}d+=f}this.index++}this.throwError("Unterminated quote",b)}};var q=function(a,b){this.lexer= + a;this.options=b};q.Program="Program";q.ExpressionStatement="ExpressionStatement";q.AssignmentExpression="AssignmentExpression";q.ConditionalExpression="ConditionalExpression";q.LogicalExpression="LogicalExpression";q.BinaryExpression="BinaryExpression";q.UnaryExpression="UnaryExpression";q.CallExpression="CallExpression";q.MemberExpression="MemberExpression";q.Identifier="Identifier";q.Literal="Literal";q.ArrayExpression="ArrayExpression";q.Property="Property";q.ObjectExpression="ObjectExpression"; + q.ThisExpression="ThisExpression";q.LocalsExpression="LocalsExpression";q.NGValueParameter="NGValueParameter";q.prototype={ast:function(a){this.text=a;this.tokens=this.lexer.lex(a);a=this.program();0!==this.tokens.length&&this.throwError("is an unexpected token",this.tokens[0]);return a},program:function(){for(var a=[];;)if(0<this.tokens.length&&!this.peek("}",")",";","]")&&a.push(this.expressionStatement()),!this.expect(";"))return{type:q.Program,body:a}},expressionStatement:function(){return{type:q.ExpressionStatement, + expression:this.filterChain()}},filterChain:function(){for(var a=this.expression();this.expect("|");)a=this.filter(a);return a},expression:function(){return this.assignment()},assignment:function(){var a=this.ternary();if(this.expect("=")){if(!Hd(a))throw Xa("lval");a={type:q.AssignmentExpression,left:a,right:this.assignment(),operator:"="}}return a},ternary:function(){var a=this.logicalOR(),b,d;return this.expect("?")&&(b=this.expression(),this.consume(":"))?(d=this.expression(),{type:q.ConditionalExpression, + test:a,alternate:b,consequent:d}):a},logicalOR:function(){for(var a=this.logicalAND();this.expect("||");)a={type:q.LogicalExpression,operator:"||",left:a,right:this.logicalAND()};return a},logicalAND:function(){for(var a=this.equality();this.expect("&&");)a={type:q.LogicalExpression,operator:"&&",left:a,right:this.equality()};return a},equality:function(){for(var a=this.relational(),b;b=this.expect("==","!=","===","!==");)a={type:q.BinaryExpression,operator:b.text,left:a,right:this.relational()}; + return a},relational:function(){for(var a=this.additive(),b;b=this.expect("<",">","<=",">=");)a={type:q.BinaryExpression,operator:b.text,left:a,right:this.additive()};return a},additive:function(){for(var a=this.multiplicative(),b;b=this.expect("+","-");)a={type:q.BinaryExpression,operator:b.text,left:a,right:this.multiplicative()};return a},multiplicative:function(){for(var a=this.unary(),b;b=this.expect("*","/","%");)a={type:q.BinaryExpression,operator:b.text,left:a,right:this.unary()};return a}, + unary:function(){var a;return(a=this.expect("+","-","!"))?{type:q.UnaryExpression,operator:a.text,prefix:!0,argument:this.unary()}:this.primary()},primary:function(){var a;this.expect("(")?(a=this.filterChain(),this.consume(")")):this.expect("[")?a=this.arrayDeclaration():this.expect("{")?a=this.object():this.selfReferential.hasOwnProperty(this.peek().text)?a=pa(this.selfReferential[this.consume().text]):this.options.literals.hasOwnProperty(this.peek().text)?a={type:q.Literal,value:this.options.literals[this.consume().text]}: + this.peek().identifier?a=this.identifier():this.peek().constant?a=this.constant():this.throwError("not a primary expression",this.peek());for(var b;b=this.expect("(","[",".");)"("===b.text?(a={type:q.CallExpression,callee:a,arguments:this.parseArguments()},this.consume(")")):"["===b.text?(a={type:q.MemberExpression,object:a,property:this.expression(),computed:!0},this.consume("]")):"."===b.text?a={type:q.MemberExpression,object:a,property:this.identifier(),computed:!1}:this.throwError("IMPOSSIBLE"); + return a},filter:function(a){a=[a];for(var b={type:q.CallExpression,callee:this.identifier(),arguments:a,filter:!0};this.expect(":");)a.push(this.expression());return b},parseArguments:function(){var a=[];if(")"!==this.peekToken().text){do a.push(this.filterChain());while(this.expect(","))}return a},identifier:function(){var a=this.consume();a.identifier||this.throwError("is not a valid identifier",a);return{type:q.Identifier,name:a.text}},constant:function(){return{type:q.Literal,value:this.consume().value}}, + arrayDeclaration:function(){var a=[];if("]"!==this.peekToken().text){do{if(this.peek("]"))break;a.push(this.expression())}while(this.expect(","))}this.consume("]");return{type:q.ArrayExpression,elements:a}},object:function(){var a=[],b;if("}"!==this.peekToken().text){do{if(this.peek("}"))break;b={type:q.Property,kind:"init"};this.peek().constant?(b.key=this.constant(),b.computed=!1,this.consume(":"),b.value=this.expression()):this.peek().identifier?(b.key=this.identifier(),b.computed=!1,this.peek(":")? + (this.consume(":"),b.value=this.expression()):b.value=b.key):this.peek("[")?(this.consume("["),b.key=this.expression(),this.consume("]"),b.computed=!0,this.consume(":"),b.value=this.expression()):this.throwError("invalid key",this.peek());a.push(b)}while(this.expect(","))}this.consume("}");return{type:q.ObjectExpression,properties:a}},throwError:function(a,b){throw Xa("syntax",b.text,a,b.index+1,this.text,this.text.substring(b.index));},consume:function(a){if(0===this.tokens.length)throw Xa("ueoe", + this.text);var b=this.expect(a);b||this.throwError("is unexpected, expecting ["+a+"]",this.peek());return b},peekToken:function(){if(0===this.tokens.length)throw Xa("ueoe",this.text);return this.tokens[0]},peek:function(a,b,d,c){return this.peekAhead(0,a,b,d,c)},peekAhead:function(a,b,d,c,e){if(this.tokens.length>a){a=this.tokens[a];var f=a.text;if(f===b||f===d||f===c||f===e||!(b||d||c||e))return a}return!1},expect:function(a,b,d,c){return(a=this.peek(a,b,d,c))?(this.tokens.shift(),a):!1},selfReferential:{"this":{type:q.ThisExpression}, + $locals:{type:q.LocalsExpression}}};var Fd=2;Jd.prototype={compile:function(a){var b=this;this.state={nextId:0,filters:{},fn:{vars:[],body:[],own:{}},assign:{vars:[],body:[],own:{}},inputs:[]};W(a,b.$filter);var d="",c;this.stage="assign";if(c=Id(a))this.state.computing="assign",d=this.nextId(),this.recurse(c,d),this.return_(d),d="fn.assign="+this.generateFunction("assign","s,v,l");c=Gd(a.body);b.stage="inputs";r(c,function(a,c){var d="fn"+c;b.state[d]={vars:[],body:[],own:{}};b.state.computing=d; + var h=b.nextId();b.recurse(a,h);b.return_(h);b.state.inputs.push({name:d,isPure:a.isPure});a.watchId=c});this.state.computing="fn";this.stage="main";this.recurse(a);a='"'+this.USE+" "+this.STRICT+'";\n'+this.filterPrefix()+"var fn="+this.generateFunction("fn","s,l,a,i")+d+this.watchFns()+"return fn;";a=(new Function("$filter","getStringValue","ifDefined","plus",a))(this.$filter,Bg,Cg,Ed);this.state=this.stage=void 0;return a},USE:"use",STRICT:"strict",watchFns:function(){var a=[],b=this.state.inputs, + d=this;r(b,function(b){a.push("var "+b.name+"="+d.generateFunction(b.name,"s"));b.isPure&&a.push(b.name,".isPure="+JSON.stringify(b.isPure)+";")});b.length&&a.push("fn.inputs=["+b.map(function(a){return a.name}).join(",")+"];");return a.join("")},generateFunction:function(a,b){return"function("+b+"){"+this.varsPrefix(a)+this.body(a)+"};"},filterPrefix:function(){var a=[],b=this;r(this.state.filters,function(d,c){a.push(d+"=$filter("+b.escape(c)+")")});return a.length?"var "+a.join(",")+";":""},varsPrefix:function(a){return this.state[a].vars.length? + "var "+this.state[a].vars.join(",")+";":""},body:function(a){return this.state[a].body.join("")},recurse:function(a,b,d,c,e,f){var g,h,k=this,l,m,p;c=c||D;if(!f&&u(a.watchId))b=b||this.nextId(),this.if_("i",this.lazyAssign(b,this.computedMember("i",a.watchId)),this.lazyRecurse(a,b,d,c,e,!0));else switch(a.type){case q.Program:r(a.body,function(b,c){k.recurse(b.expression,void 0,void 0,function(a){h=a});c!==a.body.length-1?k.current().body.push(h,";"):k.return_(h)});break;case q.Literal:m=this.escape(a.value); + this.assign(b,m);c(b||m);break;case q.UnaryExpression:this.recurse(a.argument,void 0,void 0,function(a){h=a});m=a.operator+"("+this.ifDefined(h,0)+")";this.assign(b,m);c(m);break;case q.BinaryExpression:this.recurse(a.left,void 0,void 0,function(a){g=a});this.recurse(a.right,void 0,void 0,function(a){h=a});m="+"===a.operator?this.plus(g,h):"-"===a.operator?this.ifDefined(g,0)+a.operator+this.ifDefined(h,0):"("+g+")"+a.operator+"("+h+")";this.assign(b,m);c(m);break;case q.LogicalExpression:b=b||this.nextId(); + k.recurse(a.left,b);k.if_("&&"===a.operator?b:k.not(b),k.lazyRecurse(a.right,b));c(b);break;case q.ConditionalExpression:b=b||this.nextId();k.recurse(a.test,b);k.if_(b,k.lazyRecurse(a.alternate,b),k.lazyRecurse(a.consequent,b));c(b);break;case q.Identifier:b=b||this.nextId();d&&(d.context="inputs"===k.stage?"s":this.assign(this.nextId(),this.getHasOwnProperty("l",a.name)+"?l:s"),d.computed=!1,d.name=a.name);k.if_("inputs"===k.stage||k.not(k.getHasOwnProperty("l",a.name)),function(){k.if_("inputs"=== + k.stage||"s",function(){e&&1!==e&&k.if_(k.isNull(k.nonComputedMember("s",a.name)),k.lazyAssign(k.nonComputedMember("s",a.name),"{}"));k.assign(b,k.nonComputedMember("s",a.name))})},b&&k.lazyAssign(b,k.nonComputedMember("l",a.name)));c(b);break;case q.MemberExpression:g=d&&(d.context=this.nextId())||this.nextId();b=b||this.nextId();k.recurse(a.object,g,void 0,function(){k.if_(k.notNull(g),function(){a.computed?(h=k.nextId(),k.recurse(a.property,h),k.getStringValue(h),e&&1!==e&&k.if_(k.not(k.computedMember(g, + h)),k.lazyAssign(k.computedMember(g,h),"{}")),m=k.computedMember(g,h),k.assign(b,m),d&&(d.computed=!0,d.name=h)):(e&&1!==e&&k.if_(k.isNull(k.nonComputedMember(g,a.property.name)),k.lazyAssign(k.nonComputedMember(g,a.property.name),"{}")),m=k.nonComputedMember(g,a.property.name),k.assign(b,m),d&&(d.computed=!1,d.name=a.property.name))},function(){k.assign(b,"undefined")});c(b)},!!e);break;case q.CallExpression:b=b||this.nextId();a.filter?(h=k.filter(a.callee.name),l=[],r(a.arguments,function(a){var b= + k.nextId();k.recurse(a,b);l.push(b)}),m=h+"("+l.join(",")+")",k.assign(b,m),c(b)):(h=k.nextId(),g={},l=[],k.recurse(a.callee,h,g,function(){k.if_(k.notNull(h),function(){r(a.arguments,function(b){k.recurse(b,a.constant?void 0:k.nextId(),void 0,function(a){l.push(a)})});m=g.name?k.member(g.context,g.name,g.computed)+"("+l.join(",")+")":h+"("+l.join(",")+")";k.assign(b,m)},function(){k.assign(b,"undefined")});c(b)}));break;case q.AssignmentExpression:h=this.nextId();g={};this.recurse(a.left,void 0, + g,function(){k.if_(k.notNull(g.context),function(){k.recurse(a.right,h);m=k.member(g.context,g.name,g.computed)+a.operator+h;k.assign(b,m);c(b||m)})},1);break;case q.ArrayExpression:l=[];r(a.elements,function(b){k.recurse(b,a.constant?void 0:k.nextId(),void 0,function(a){l.push(a)})});m="["+l.join(",")+"]";this.assign(b,m);c(b||m);break;case q.ObjectExpression:l=[];p=!1;r(a.properties,function(a){a.computed&&(p=!0)});p?(b=b||this.nextId(),this.assign(b,"{}"),r(a.properties,function(a){a.computed? + (g=k.nextId(),k.recurse(a.key,g)):g=a.key.type===q.Identifier?a.key.name:""+a.key.value;h=k.nextId();k.recurse(a.value,h);k.assign(k.member(b,g,a.computed),h)})):(r(a.properties,function(b){k.recurse(b.value,a.constant?void 0:k.nextId(),void 0,function(a){l.push(k.escape(b.key.type===q.Identifier?b.key.name:""+b.key.value)+":"+a)})}),m="{"+l.join(",")+"}",this.assign(b,m));c(b||m);break;case q.ThisExpression:this.assign(b,"s");c(b||"s");break;case q.LocalsExpression:this.assign(b,"l");c(b||"l");break; + case q.NGValueParameter:this.assign(b,"v"),c(b||"v")}},getHasOwnProperty:function(a,b){var d=a+"."+b,c=this.current().own;c.hasOwnProperty(d)||(c[d]=this.nextId(!1,a+"&&("+this.escape(b)+" in "+a+")"));return c[d]},assign:function(a,b){if(a)return this.current().body.push(a,"=",b,";"),a},filter:function(a){this.state.filters.hasOwnProperty(a)||(this.state.filters[a]=this.nextId(!0));return this.state.filters[a]},ifDefined:function(a,b){return"ifDefined("+a+","+this.escape(b)+")"},plus:function(a, + b){return"plus("+a+","+b+")"},return_:function(a){this.current().body.push("return ",a,";")},if_:function(a,b,d){if(!0===a)b();else{var c=this.current().body;c.push("if(",a,"){");b();c.push("}");d&&(c.push("else{"),d(),c.push("}"))}},not:function(a){return"!("+a+")"},isNull:function(a){return a+"==null"},notNull:function(a){return a+"!=null"},nonComputedMember:function(a,b){var d=/[^$_a-zA-Z0-9]/g;return/^[$_a-zA-Z][$_a-zA-Z0-9]*$/.test(b)?a+"."+b:a+'["'+b.replace(d,this.stringEscapeFn)+'"]'},computedMember:function(a, + b){return a+"["+b+"]"},member:function(a,b,d){return d?this.computedMember(a,b):this.nonComputedMember(a,b)},getStringValue:function(a){this.assign(a,"getStringValue("+a+")")},lazyRecurse:function(a,b,d,c,e,f){var g=this;return function(){g.recurse(a,b,d,c,e,f)}},lazyAssign:function(a,b){var d=this;return function(){d.assign(a,b)}},stringEscapeRegex:/[^ a-zA-Z0-9]/g,stringEscapeFn:function(a){return"\\u"+("0000"+a.charCodeAt(0).toString(16)).slice(-4)},escape:function(a){if(E(a))return"'"+a.replace(this.stringEscapeRegex, + this.stringEscapeFn)+"'";if(Y(a))return a.toString();if(!0===a)return"true";if(!1===a)return"false";if(null===a)return"null";if("undefined"===typeof a)return"undefined";throw Xa("esc");},nextId:function(a,b){var d="v"+this.state.nextId++;a||this.current().vars.push(d+(b?"="+b:""));return d},current:function(){return this.state[this.state.computing]}};Kd.prototype={compile:function(a){var b=this;W(a,b.$filter);var d,c;if(d=Id(a))c=this.recurse(d);d=Gd(a.body);var e;d&&(e=[],r(d,function(a,c){var d= + b.recurse(a);d.isPure=a.isPure;a.input=d;e.push(d);a.watchId=c}));var f=[];r(a.body,function(a){f.push(b.recurse(a.expression))});a=0===a.body.length?D:1===a.body.length?f[0]:function(a,b){var c;r(f,function(d){c=d(a,b)});return c};c&&(a.assign=function(a,b,d){return c(a,d,b)});e&&(a.inputs=e);return a},recurse:function(a,b,d){var c,e,f=this,g;if(a.input)return this.inputs(a.input,a.watchId);switch(a.type){case q.Literal:return this.value(a.value,b);case q.UnaryExpression:return e=this.recurse(a.argument), + this["unary"+a.operator](e,b);case q.BinaryExpression:return c=this.recurse(a.left),e=this.recurse(a.right),this["binary"+a.operator](c,e,b);case q.LogicalExpression:return c=this.recurse(a.left),e=this.recurse(a.right),this["binary"+a.operator](c,e,b);case q.ConditionalExpression:return this["ternary?:"](this.recurse(a.test),this.recurse(a.alternate),this.recurse(a.consequent),b);case q.Identifier:return f.identifier(a.name,b,d);case q.MemberExpression:return c=this.recurse(a.object,!1,!!d),a.computed|| + (e=a.property.name),a.computed&&(e=this.recurse(a.property)),a.computed?this.computedMember(c,e,b,d):this.nonComputedMember(c,e,b,d);case q.CallExpression:return g=[],r(a.arguments,function(a){g.push(f.recurse(a))}),a.filter&&(e=this.$filter(a.callee.name)),a.filter||(e=this.recurse(a.callee,!0)),a.filter?function(a,c,d,f){for(var p=[],n=0;n<g.length;++n)p.push(g[n](a,c,d,f));a=e.apply(void 0,p,f);return b?{context:void 0,name:void 0,value:a}:a}:function(a,c,d,f){var p=e(a,c,d,f),n;if(null!=p.value){n= + [];for(var r=0;r<g.length;++r)n.push(g[r](a,c,d,f));n=p.value.apply(p.context,n)}return b?{value:n}:n};case q.AssignmentExpression:return c=this.recurse(a.left,!0,1),e=this.recurse(a.right),function(a,d,f,g){var p=c(a,d,f,g);a=e(a,d,f,g);p.context[p.name]=a;return b?{value:a}:a};case q.ArrayExpression:return g=[],r(a.elements,function(a){g.push(f.recurse(a))}),function(a,c,d,e){for(var f=[],n=0;n<g.length;++n)f.push(g[n](a,c,d,e));return b?{value:f}:f};case q.ObjectExpression:return g=[],r(a.properties, + function(a){a.computed?g.push({key:f.recurse(a.key),computed:!0,value:f.recurse(a.value)}):g.push({key:a.key.type===q.Identifier?a.key.name:""+a.key.value,computed:!1,value:f.recurse(a.value)})}),function(a,c,d,e){for(var f={},n=0;n<g.length;++n)g[n].computed?f[g[n].key(a,c,d,e)]=g[n].value(a,c,d,e):f[g[n].key]=g[n].value(a,c,d,e);return b?{value:f}:f};case q.ThisExpression:return function(a){return b?{value:a}:a};case q.LocalsExpression:return function(a,c){return b?{value:c}:c};case q.NGValueParameter:return function(a, + c,d){return b?{value:d}:d}}},"unary+":function(a,b){return function(d,c,e,f){d=a(d,c,e,f);d=u(d)?+d:0;return b?{value:d}:d}},"unary-":function(a,b){return function(d,c,e,f){d=a(d,c,e,f);d=u(d)?-d:-0;return b?{value:d}:d}},"unary!":function(a,b){return function(d,c,e,f){d=!a(d,c,e,f);return b?{value:d}:d}},"binary+":function(a,b,d){return function(c,e,f,g){var h=a(c,e,f,g);c=b(c,e,f,g);h=Ed(h,c);return d?{value:h}:h}},"binary-":function(a,b,d){return function(c,e,f,g){var h=a(c,e,f,g);c=b(c,e,f,g); + h=(u(h)?h:0)-(u(c)?c:0);return d?{value:h}:h}},"binary*":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)*b(c,e,f,g);return d?{value:c}:c}},"binary/":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)/b(c,e,f,g);return d?{value:c}:c}},"binary%":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)%b(c,e,f,g);return d?{value:c}:c}},"binary===":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)===b(c,e,f,g);return d?{value:c}:c}},"binary!==":function(a,b,d){return function(c,e,f,g){c=a(c, + e,f,g)!==b(c,e,f,g);return d?{value:c}:c}},"binary==":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)==b(c,e,f,g);return d?{value:c}:c}},"binary!=":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)!=b(c,e,f,g);return d?{value:c}:c}},"binary<":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)<b(c,e,f,g);return d?{value:c}:c}},"binary>":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)>b(c,e,f,g);return d?{value:c}:c}},"binary<=":function(a,b,d){return function(c,e,f,g){c=a(c,e,f, + g)<=b(c,e,f,g);return d?{value:c}:c}},"binary>=":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)>=b(c,e,f,g);return d?{value:c}:c}},"binary&&":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)&&b(c,e,f,g);return d?{value:c}:c}},"binary||":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)||b(c,e,f,g);return d?{value:c}:c}},"ternary?:":function(a,b,d,c){return function(e,f,g,h){e=a(e,f,g,h)?b(e,f,g,h):d(e,f,g,h);return c?{value:e}:e}},value:function(a,b){return function(){return b?{context:void 0, + name:void 0,value:a}:a}},identifier:function(a,b,d){return function(c,e,f,g){c=e&&a in e?e:c;d&&1!==d&&c&&null==c[a]&&(c[a]={});e=c?c[a]:void 0;return b?{context:c,name:a,value:e}:e}},computedMember:function(a,b,d,c){return function(e,f,g,h){var k=a(e,f,g,h),l,m;null!=k&&(l=b(e,f,g,h),l+="",c&&1!==c&&k&&!k[l]&&(k[l]={}),m=k[l]);return d?{context:k,name:l,value:m}:m}},nonComputedMember:function(a,b,d,c){return function(e,f,g,h){e=a(e,f,g,h);c&&1!==c&&e&&null==e[b]&&(e[b]={});f=null!=e?e[b]:void 0; + return d?{context:e,name:b,value:f}:f}},inputs:function(a,b){return function(d,c,e,f){return f?f[b]:a(d,c,e)}}};Mb.prototype={constructor:Mb,parse:function(a){a=this.getAst(a);var b=this.astCompiler.compile(a.ast),d=a.ast;b.literal=0===d.body.length||1===d.body.length&&(d.body[0].expression.type===q.Literal||d.body[0].expression.type===q.ArrayExpression||d.body[0].expression.type===q.ObjectExpression);b.constant=a.ast.constant;b.oneTime=a.oneTime;return b},getAst:function(a){var b=!1;a=a.trim();":"=== + a.charAt(0)&&":"===a.charAt(1)&&(b=!0,a=a.substring(2));return{ast:this.ast.ast(a),oneTime:b}}};var va=K("$sce"),oa={HTML:"html",CSS:"css",URL:"url",RESOURCE_URL:"resourceUrl",JS:"js"},Bc=/_([a-z])/g,Gg=K("$compile"),X=w.document.createElement("a"),Od=ta(w.location.href);Pd.$inject=["$document"];ed.$inject=["$provide"];var Wd=22,Vd=".",Dc="0";Qd.$inject=["$locale"];Sd.$inject=["$locale"];var Rg={yyyy:ea("FullYear",4,0,!1,!0),yy:ea("FullYear",2,0,!0,!0),y:ea("FullYear",1,0,!1,!0),MMMM:mb("Month"), + MMM:mb("Month",!0),MM:ea("Month",2,1),M:ea("Month",1,1),LLLL:mb("Month",!1,!0),dd:ea("Date",2),d:ea("Date",1),HH:ea("Hours",2),H:ea("Hours",1),hh:ea("Hours",2,-12),h:ea("Hours",1,-12),mm:ea("Minutes",2),m:ea("Minutes",1),ss:ea("Seconds",2),s:ea("Seconds",1),sss:ea("Milliseconds",3),EEEE:mb("Day"),EEE:mb("Day",!0),a:function(a,b){return 12>a.getHours()?b.AMPMS[0]:b.AMPMS[1]},Z:function(a,b,d){a=-1*d;return a=(0<=a?"+":"")+(Ob(Math[0<a?"floor":"ceil"](a/60),2)+Ob(Math.abs(a%60),2))},ww:Yd(2),w:Yd(1), + G:Ec,GG:Ec,GGG:Ec,GGGG:function(a,b){return 0>=a.getFullYear()?b.ERANAMES[0]:b.ERANAMES[1]}},Qg=/((?:[^yMLdHhmsaZEwG']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|L+|d+|H+|h+|m+|s+|a|Z|G+|w+))([\s\S]*)/,Pg=/^-?\d+$/;Rd.$inject=["$locale"];var Kg=la(L),Lg=la(ub);Td.$inject=["$parse"];var He=la({restrict:"E",compile:function(a,b){if(!b.href&&!b.xlinkHref)return function(a,b){if("a"===b[0].nodeName.toLowerCase()){var e="[object SVGAnimatedString]"===ia.call(b.prop("href"))?"xlink:href":"href";b.on("click",function(a){b.attr(e)|| + a.preventDefault()})}}}}),vb={};r(Gb,function(a,b){function d(a,d,e){a.$watch(e[c],function(a){e.$set(b,!!a)})}if("multiple"!==a){var c=Ea("ng-"+b),e=d;"checked"===a&&(e=function(a,b,e){e.ngModel!==e[c]&&d(a,b,e)});vb[c]=function(){return{restrict:"A",priority:100,link:e}}}});r(sd,function(a,b){vb[b]=function(){return{priority:100,link:function(a,c,e){if("ngPattern"===b&&"/"===e.ngPattern.charAt(0)&&(c=e.ngPattern.match(Vg))){e.$set("ngPattern",new RegExp(c[1],c[2]));return}a.$watch(e[b],function(a){e.$set(b, + a)})}}}});r(["src","srcset","href"],function(a){var b=Ea("ng-"+a);vb[b]=function(){return{priority:99,link:function(d,c,e){var f=a,g=a;"href"===a&&"[object SVGAnimatedString]"===ia.call(c.prop("href"))&&(g="xlinkHref",e.$attr[g]="xlink:href",f=null);e.$observe(b,function(b){b?(e.$set(g,b),Ca&&f&&c.prop(f,e[g])):"href"===a&&e.$set(g,null)})}}}});var Qb={$addControl:D,$$renameControl:function(a,b){a.$name=b},$removeControl:D,$setValidity:D,$setDirty:D,$setPristine:D,$setSubmitted:D};Pb.$inject=["$element", + "$attrs","$scope","$animate","$interpolate"];Pb.prototype={$rollbackViewValue:function(){r(this.$$controls,function(a){a.$rollbackViewValue()})},$commitViewValue:function(){r(this.$$controls,function(a){a.$commitViewValue()})},$addControl:function(a){Ia(a.$name,"input");this.$$controls.push(a);a.$name&&(this[a.$name]=a);a.$$parentForm=this},$$renameControl:function(a,b){var d=a.$name;this[d]===a&&delete this[d];this[b]=a;a.$name=b},$removeControl:function(a){a.$name&&this[a.$name]===a&&delete this[a.$name]; + r(this.$pending,function(b,d){this.$setValidity(d,null,a)},this);r(this.$error,function(b,d){this.$setValidity(d,null,a)},this);r(this.$$success,function(b,d){this.$setValidity(d,null,a)},this);cb(this.$$controls,a);a.$$parentForm=Qb},$setDirty:function(){this.$$animate.removeClass(this.$$element,Ya);this.$$animate.addClass(this.$$element,Vb);this.$dirty=!0;this.$pristine=!1;this.$$parentForm.$setDirty()},$setPristine:function(){this.$$animate.setClass(this.$$element,Ya,Vb+" ng-submitted");this.$dirty= + !1;this.$pristine=!0;this.$submitted=!1;r(this.$$controls,function(a){a.$setPristine()})},$setUntouched:function(){r(this.$$controls,function(a){a.$setUntouched()})},$setSubmitted:function(){this.$$animate.addClass(this.$$element,"ng-submitted");this.$submitted=!0;this.$$parentForm.$setSubmitted()}};ae({clazz:Pb,set:function(a,b,d){var c=a[b];c?-1===c.indexOf(d)&&c.push(d):a[b]=[d]},unset:function(a,b,d){var c=a[b];c&&(cb(c,d),0===c.length&&delete a[b])}});var ie=function(a){return["$timeout","$parse", + function(b,d){function c(a){return""===a?d('this[""]').assign:d(a).assign||D}return{name:"form",restrict:a?"EAC":"E",require:["form","^^?form"],controller:Pb,compile:function(d,f){d.addClass(Ya).addClass(nb);var g=f.name?"name":a&&f.ngForm?"ngForm":!1;return{pre:function(a,d,e,f){var p=f[0];if(!("action"in e)){var n=function(b){a.$apply(function(){p.$commitViewValue();p.$setSubmitted()});b.preventDefault()};d[0].addEventListener("submit",n);d.on("$destroy",function(){b(function(){d[0].removeEventListener("submit", + n)},0,!1)})}(f[1]||p.$$parentForm).$addControl(p);var r=g?c(p.$name):D;g&&(r(a,p),e.$observe(g,function(b){p.$name!==b&&(r(a,void 0),p.$$parentForm.$$renameControl(p,b),r=c(p.$name),r(a,p))}));d.on("$destroy",function(){p.$$parentForm.$removeControl(p);r(a,void 0);O(p,Qb)})}}}}}]},Ie=ie(),Ue=ie(!0),Sg=/^\d{4,}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+(?:[+-][0-2]\d:[0-5]\d|Z)$/,dh=/^[a-z][a-z\d.+-]*:\/*(?:[^:@]+(?::[^@]+)?@)?(?:[^\s:/?#]+|\[[a-f\d:]+])(?::\d+)?(?:\/[^?#]*)?(?:\?[^#]*)?(?:#.*)?$/i, + eh=/^(?=.{1,254}$)(?=.{1,64}@)[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/,Tg=/^\s*(-|\+)?(\d+|(\d*(\.\d*)))([eE][+-]?\d+)?\s*$/,je=/^(\d{4,})-(\d{2})-(\d{2})$/,ke=/^(\d{4,})-(\d\d)-(\d\d)T(\d\d):(\d\d)(?::(\d\d)(\.\d{1,3})?)?$/,Lc=/^(\d{4,})-W(\d\d)$/,le=/^(\d{4,})-(\d\d)$/,me=/^(\d\d):(\d\d)(?::(\d\d)(\.\d{1,3})?)?$/,ce=S();r(["date","datetime-local","month","time","week"],function(a){ce[a]= + !0});var ne={text:function(a,b,d,c,e,f){Va(a,b,d,c,e,f);Gc(c)},date:ob("date",je,Rb(je,["yyyy","MM","dd"]),"yyyy-MM-dd"),"datetime-local":ob("datetimelocal",ke,Rb(ke,"yyyy MM dd HH mm ss sss".split(" ")),"yyyy-MM-ddTHH:mm:ss.sss"),time:ob("time",me,Rb(me,["HH","mm","ss","sss"]),"HH:mm:ss.sss"),week:ob("week",Lc,function(a,b){if(fa(a))return a;if(E(a)){Lc.lastIndex=0;var d=Lc.exec(a);if(d){var c=+d[1],e=+d[2],f=d=0,g=0,h=0,k=Xd(c),e=7*(e-1);b&&(d=b.getHours(),f=b.getMinutes(),g=b.getSeconds(),h=b.getMilliseconds()); + return new Date(c,0,k.getDate()+e,d,f,g,h)}}return NaN},"yyyy-Www"),month:ob("month",le,Rb(le,["yyyy","MM"]),"yyyy-MM"),number:function(a,b,d,c,e,f){Hc(a,b,d,c);de(c);Va(a,b,d,c,e,f);var g,h;if(u(d.min)||d.ngMin)c.$validators.min=function(a){return c.$isEmpty(a)||x(g)||a>=g},d.$observe("min",function(a){g=Wa(a);c.$validate()});if(u(d.max)||d.ngMax)c.$validators.max=function(a){return c.$isEmpty(a)||x(h)||a<=h},d.$observe("max",function(a){h=Wa(a);c.$validate()});if(u(d.step)||d.ngStep){var k;c.$validators.step= + function(a,b){return c.$isEmpty(b)||x(k)||ee(b,g||0,k)};d.$observe("step",function(a){k=Wa(a);c.$validate()})}},url:function(a,b,d,c,e,f){Va(a,b,d,c,e,f);Gc(c);c.$$parserName="url";c.$validators.url=function(a,b){var d=a||b;return c.$isEmpty(d)||dh.test(d)}},email:function(a,b,d,c,e,f){Va(a,b,d,c,e,f);Gc(c);c.$$parserName="email";c.$validators.email=function(a,b){var d=a||b;return c.$isEmpty(d)||eh.test(d)}},radio:function(a,b,d,c){var e=!d.ngTrim||"false"!==Q(d.ngTrim);x(d.name)&&b.attr("name",++qb); + b.on("click",function(a){var g;b[0].checked&&(g=d.value,e&&(g=Q(g)),c.$setViewValue(g,a&&a.type))});c.$render=function(){var a=d.value;e&&(a=Q(a));b[0].checked=a===c.$viewValue};d.$observe("value",c.$render)},range:function(a,b,d,c,e,f){function g(a,c){b.attr(a,d[a]);d.$observe(a,c)}function h(a){p=Wa(a);U(c.$modelValue)||(m?(a=b.val(),p>a&&(a=p,b.val(a)),c.$setViewValue(a)):c.$validate())}function k(a){n=Wa(a);U(c.$modelValue)||(m?(a=b.val(),n<a&&(b.val(n),a=n<p?p:n),c.$setViewValue(a)):c.$validate())} + function l(a){r=Wa(a);U(c.$modelValue)||(m&&c.$viewValue!==b.val()?c.$setViewValue(b.val()):c.$validate())}Hc(a,b,d,c);de(c);Va(a,b,d,c,e,f);var m=c.$$hasNativeValidators&&"range"===b[0].type,p=m?0:void 0,n=m?100:void 0,r=m?1:void 0,q=b[0].validity;a=u(d.min);e=u(d.max);f=u(d.step);var v=c.$render;c.$render=m&&u(q.rangeUnderflow)&&u(q.rangeOverflow)?function(){v();c.$setViewValue(b.val())}:v;a&&(c.$validators.min=m?function(){return!0}:function(a,b){return c.$isEmpty(b)||x(p)||b>=p},g("min",h));e&& + (c.$validators.max=m?function(){return!0}:function(a,b){return c.$isEmpty(b)||x(n)||b<=n},g("max",k));f&&(c.$validators.step=m?function(){return!q.stepMismatch}:function(a,b){return c.$isEmpty(b)||x(r)||ee(b,p||0,r)},g("step",l))},checkbox:function(a,b,d,c,e,f,g,h){var k=fe(h,a,"ngTrueValue",d.ngTrueValue,!0),l=fe(h,a,"ngFalseValue",d.ngFalseValue,!1);b.on("click",function(a){c.$setViewValue(b[0].checked,a&&a.type)});c.$render=function(){b[0].checked=c.$viewValue};c.$isEmpty=function(a){return!1=== + a};c.$formatters.push(function(a){return sa(a,k)});c.$parsers.push(function(a){return a?k:l})},hidden:D,button:D,submit:D,reset:D,file:D},Zc=["$browser","$sniffer","$filter","$parse",function(a,b,d,c){return{restrict:"E",require:["?ngModel"],link:{pre:function(e,f,g,h){h[0]&&(ne[L(g.type)]||ne.text)(e,f,g,h[0],b,a,d,c)}}}}],fh=/^(true|false|\d+)$/,mf=function(){function a(a,d,c){var e=u(c)?c:9===Ca?"":null;a.prop("value",e);d.$set("value",c)}return{restrict:"A",priority:100,compile:function(b,d){return fh.test(d.ngValue)? + function(b,d,f){b=b.$eval(f.ngValue);a(d,f,b)}:function(b,d,f){b.$watch(f.ngValue,function(b){a(d,f,b)})}}}},Me=["$compile",function(a){return{restrict:"AC",compile:function(b){a.$$addBindingClass(b);return function(b,c,e){a.$$addBindingInfo(c,e.ngBind);c=c[0];b.$watch(e.ngBind,function(a){c.textContent=gc(a)})}}}}],Oe=["$interpolate","$compile",function(a,b){return{compile:function(d){b.$$addBindingClass(d);return function(c,d,f){c=a(d.attr(f.$attr.ngBindTemplate));b.$$addBindingInfo(d,c.expressions); + d=d[0];f.$observe("ngBindTemplate",function(a){d.textContent=x(a)?"":a})}}}}],Ne=["$sce","$parse","$compile",function(a,b,d){return{restrict:"A",compile:function(c,e){var f=b(e.ngBindHtml),g=b(e.ngBindHtml,function(b){return a.valueOf(b)});d.$$addBindingClass(c);return function(b,c,e){d.$$addBindingInfo(c,e.ngBindHtml);b.$watch(g,function(){var d=f(b);c.html(a.getTrustedHtml(d)||"")})}}}}],lf=la({restrict:"A",require:"ngModel",link:function(a,b,d,c){c.$viewChangeListeners.push(function(){a.$eval(d.ngChange)})}}), + Pe=Jc("",!0),Re=Jc("Odd",0),Qe=Jc("Even",1),Se=Qa({compile:function(a,b){b.$set("ngCloak",void 0);a.removeClass("ng-cloak")}}),Te=[function(){return{restrict:"A",scope:!0,controller:"@",priority:500}}],dd={},gh={blur:!0,focus:!0};r("click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste".split(" "),function(a){var b=Ea("ng-"+a);dd[b]=["$parse","$rootScope",function(d,c){return{restrict:"A",compile:function(e,f){var g= + d(f[b]);return function(b,d){d.on(a,function(d){var e=function(){g(b,{$event:d})};gh[a]&&c.$$phase?b.$evalAsync(e):b.$apply(e)})}}}}]});var We=["$animate","$compile",function(a,b){return{multiElement:!0,transclude:"element",priority:600,terminal:!0,restrict:"A",$$tlb:!0,link:function(d,c,e,f,g){var h,k,l;d.$watch(e.ngIf,function(d){d?k||g(function(d,f){k=f;d[d.length++]=b.$$createComment("end ngIf",e.ngIf);h={clone:d};a.enter(d,c.parent(),c)}):(l&&(l.remove(),l=null),k&&(k.$destroy(),k=null),h&&(l= + tb(h.clone),a.leave(l).done(function(a){!1!==a&&(l=null)}),h=null))})}}}],Xe=["$templateRequest","$anchorScroll","$animate",function(a,b,d){return{restrict:"ECA",priority:400,terminal:!0,transclude:"element",controller:$.noop,compile:function(c,e){var f=e.ngInclude||e.src,g=e.onload||"",h=e.autoscroll;return function(c,e,m,p,n){var r=0,q,v,y,t=function(){v&&(v.remove(),v=null);q&&(q.$destroy(),q=null);y&&(d.leave(y).done(function(a){!1!==a&&(v=null)}),v=y,y=null)};c.$watch(f,function(f){var m=function(a){!1=== + a||!u(h)||h&&!c.$eval(h)||b()},v=++r;f?(a(f,!0).then(function(a){if(!c.$$destroyed&&v===r){var b=c.$new();p.template=a;a=n(b,function(a){t();d.enter(a,null,e).done(m)});q=b;y=a;q.$emit("$includeContentLoaded",f);c.$eval(g)}},function(){c.$$destroyed||v!==r||(t(),c.$emit("$includeContentError",f))}),c.$emit("$includeContentRequested",f)):(t(),p.template=null)})}}}}],of=["$compile",function(a){return{restrict:"ECA",priority:-400,require:"ngInclude",link:function(b,d,c,e){ia.call(d[0]).match(/SVG/)? + (d.empty(),a(fd(e.template,w.document).childNodes)(b,function(a){d.append(a)},{futureParentElement:d})):(d.html(e.template),a(d.contents())(b))}}}],Ye=Qa({priority:450,compile:function(){return{pre:function(a,b,d){a.$eval(d.ngInit)}}}}),kf=function(){return{restrict:"A",priority:100,require:"ngModel",link:function(a,b,d,c){var e=d.ngList||", ",f="false"!==d.ngTrim,g=f?Q(e):e;c.$parsers.push(function(a){if(!x(a)){var b=[];a&&r(a.split(g),function(a){a&&b.push(f?Q(a):a)});return b}});c.$formatters.push(function(a){if(I(a))return a.join(e)}); + c.$isEmpty=function(a){return!a||!a.length}}}},nb="ng-valid",$d="ng-invalid",Ya="ng-pristine",Vb="ng-dirty",pb=K("ngModel");Sb.$inject="$scope $exceptionHandler $attrs $element $parse $animate $timeout $q $interpolate".split(" ");Sb.prototype={$$initGetterSetters:function(){if(this.$options.getOption("getterSetter")){var a=this.$$parse(this.$$attr.ngModel+"()"),b=this.$$parse(this.$$attr.ngModel+"($$$p)");this.$$ngModelGet=function(b){var c=this.$$parsedNgModel(b);C(c)&&(c=a(b));return c};this.$$ngModelSet= + function(a,c){C(this.$$parsedNgModel(a))?b(a,{$$$p:c}):this.$$parsedNgModelAssign(a,c)}}else if(!this.$$parsedNgModel.assign)throw pb("nonassign",this.$$attr.ngModel,za(this.$$element));},$render:D,$isEmpty:function(a){return x(a)||""===a||null===a||a!==a},$$updateEmptyClasses:function(a){this.$isEmpty(a)?(this.$$animate.removeClass(this.$$element,"ng-not-empty"),this.$$animate.addClass(this.$$element,"ng-empty")):(this.$$animate.removeClass(this.$$element,"ng-empty"),this.$$animate.addClass(this.$$element, + "ng-not-empty"))},$setPristine:function(){this.$dirty=!1;this.$pristine=!0;this.$$animate.removeClass(this.$$element,Vb);this.$$animate.addClass(this.$$element,Ya)},$setDirty:function(){this.$dirty=!0;this.$pristine=!1;this.$$animate.removeClass(this.$$element,Ya);this.$$animate.addClass(this.$$element,Vb);this.$$parentForm.$setDirty()},$setUntouched:function(){this.$touched=!1;this.$untouched=!0;this.$$animate.setClass(this.$$element,"ng-untouched","ng-touched")},$setTouched:function(){this.$touched= + !0;this.$untouched=!1;this.$$animate.setClass(this.$$element,"ng-touched","ng-untouched")},$rollbackViewValue:function(){this.$$timeout.cancel(this.$$pendingDebounce);this.$viewValue=this.$$lastCommittedViewValue;this.$render()},$validate:function(){if(!U(this.$modelValue)){var a=this.$$lastCommittedViewValue,b=this.$$rawModelValue,d=this.$valid,c=this.$modelValue,e=this.$options.getOption("allowInvalid"),f=this;this.$$runValidators(b,a,function(a){e||d===a||(f.$modelValue=a?b:void 0,f.$modelValue!== + c&&f.$$writeModelToScope())})}},$$runValidators:function(a,b,d){function c(){var c=!0;r(k.$validators,function(d,e){var g=Boolean(d(a,b));c=c&&g;f(e,g)});return c?!0:(r(k.$asyncValidators,function(a,b){f(b,null)}),!1)}function e(){var c=[],d=!0;r(k.$asyncValidators,function(e,g){var k=e(a,b);if(!k||!C(k.then))throw pb("nopromise",k);f(g,void 0);c.push(k.then(function(){f(g,!0)},function(){d=!1;f(g,!1)}))});c.length?k.$$q.all(c).then(function(){g(d)},D):g(!0)}function f(a,b){h===k.$$currentValidationRunId&& + k.$setValidity(a,b)}function g(a){h===k.$$currentValidationRunId&&d(a)}this.$$currentValidationRunId++;var h=this.$$currentValidationRunId,k=this;(function(){var a=k.$$parserName||"parse";if(x(k.$$parserValid))f(a,null);else return k.$$parserValid||(r(k.$validators,function(a,b){f(b,null)}),r(k.$asyncValidators,function(a,b){f(b,null)})),f(a,k.$$parserValid),k.$$parserValid;return!0})()?c()?e():g(!1):g(!1)},$commitViewValue:function(){var a=this.$viewValue;this.$$timeout.cancel(this.$$pendingDebounce); + if(this.$$lastCommittedViewValue!==a||""===a&&this.$$hasNativeValidators)this.$$updateEmptyClasses(a),this.$$lastCommittedViewValue=a,this.$pristine&&this.$setDirty(),this.$$parseAndValidate()},$$parseAndValidate:function(){var a=this.$$lastCommittedViewValue,b=this;if(this.$$parserValid=x(a)?void 0:!0)for(var d=0;d<this.$parsers.length;d++)if(a=this.$parsers[d](a),x(a)){this.$$parserValid=!1;break}U(this.$modelValue)&&(this.$modelValue=this.$$ngModelGet(this.$$scope));var c=this.$modelValue,e=this.$options.getOption("allowInvalid"); + this.$$rawModelValue=a;e&&(this.$modelValue=a,b.$modelValue!==c&&b.$$writeModelToScope());this.$$runValidators(a,this.$$lastCommittedViewValue,function(d){e||(b.$modelValue=d?a:void 0,b.$modelValue!==c&&b.$$writeModelToScope())})},$$writeModelToScope:function(){this.$$ngModelSet(this.$$scope,this.$modelValue);r(this.$viewChangeListeners,function(a){try{a()}catch(b){this.$$exceptionHandler(b)}},this)},$setViewValue:function(a,b){this.$viewValue=a;this.$options.getOption("updateOnDefault")&&this.$$debounceViewValueCommit(b)}, + $$debounceViewValueCommit:function(a){var b=this.$options.getOption("debounce");Y(b[a])?b=b[a]:Y(b["default"])&&(b=b["default"]);this.$$timeout.cancel(this.$$pendingDebounce);var d=this;0<b?this.$$pendingDebounce=this.$$timeout(function(){d.$commitViewValue()},b):this.$$scope.$root.$$phase?this.$commitViewValue():this.$$scope.$apply(function(){d.$commitViewValue()})},$overrideModelOptions:function(a){this.$options=this.$options.createChild(a);this.$$setUpdateOnEvents()},$processModelValue:function(){var a= + this.$$format();this.$viewValue!==a&&(this.$$updateEmptyClasses(a),this.$viewValue=this.$$lastCommittedViewValue=a,this.$render(),this.$$runValidators(this.$modelValue,this.$viewValue,D))},$$format:function(){for(var a=this.$formatters,b=a.length,d=this.$modelValue;b--;)d=a[b](d);return d},$$setModelValue:function(a){this.$modelValue=this.$$rawModelValue=a;this.$$parserValid=void 0;this.$processModelValue()},$$setUpdateOnEvents:function(){this.$$updateEvents&&this.$$element.off(this.$$updateEvents, + this.$$updateEventHandler);if(this.$$updateEvents=this.$options.getOption("updateOn"))this.$$element.on(this.$$updateEvents,this.$$updateEventHandler)},$$updateEventHandler:function(a){this.$$debounceViewValueCommit(a&&a.type)}};ae({clazz:Sb,set:function(a,b){a[b]=!0},unset:function(a,b){delete a[b]}});var jf=["$rootScope",function(a){return{restrict:"A",require:["ngModel","^?form","^?ngModelOptions"],controller:Sb,priority:1,compile:function(b){b.addClass(Ya).addClass("ng-untouched").addClass(nb); + return{pre:function(a,b,e,f){var g=f[0];b=f[1]||g.$$parentForm;if(f=f[2])g.$options=f.$options;g.$$initGetterSetters();b.$addControl(g);e.$observe("name",function(a){g.$name!==a&&g.$$parentForm.$$renameControl(g,a)});a.$on("$destroy",function(){g.$$parentForm.$removeControl(g)})},post:function(b,c,e,f){function g(){h.$setTouched()}var h=f[0];h.$$setUpdateOnEvents();c.on("blur",function(){h.$touched||(a.$$phase?b.$evalAsync(g):b.$apply(g))})}}}}}],Tb,hh=/(\s+|^)default(\s+|$)/;Kc.prototype={getOption:function(a){return this.$$options[a]}, + createChild:function(a){var b=!1;a=O({},a);r(a,function(d,c){"$inherit"===d?"*"===c?b=!0:(a[c]=this.$$options[c],"updateOn"===c&&(a.updateOnDefault=this.$$options.updateOnDefault)):"updateOn"===c&&(a.updateOnDefault=!1,a[c]=Q(d.replace(hh,function(){a.updateOnDefault=!0;return" "})))},this);b&&(delete a["*"],ge(a,this.$$options));ge(a,Tb.$$options);return new Kc(a)}};Tb=new Kc({updateOn:"",updateOnDefault:!0,debounce:0,getterSetter:!1,allowInvalid:!1,timezone:null});var nf=function(){function a(a, + d){this.$$attrs=a;this.$$scope=d}a.$inject=["$attrs","$scope"];a.prototype={$onInit:function(){var a=this.parentCtrl?this.parentCtrl.$options:Tb,d=this.$$scope.$eval(this.$$attrs.ngModelOptions);this.$options=a.createChild(d)}};return{restrict:"A",priority:10,require:{parentCtrl:"?^^ngModelOptions"},bindToController:!0,controller:a}},Ze=Qa({terminal:!0,priority:1E3}),ih=K("ngOptions"),jh=/^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+group\s+by\s+([\s\S]+?))?(?:\s+disable\s+when\s+([\s\S]+?))?\s+for\s+(?:([$\w][$\w]*)|(?:\(\s*([$\w][$\w]*)\s*,\s*([$\w][$\w]*)\s*\)))\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?$/, + gf=["$compile","$document","$parse",function(a,b,d){function c(a,b,c){function e(a,b,c,d,f){this.selectValue=a;this.viewValue=b;this.label=c;this.group=d;this.disabled=f}function f(a){var b;if(!r&&wa(a))b=a;else{b=[];for(var c in a)a.hasOwnProperty(c)&&"$"!==c.charAt(0)&&b.push(c)}return b}var p=a.match(jh);if(!p)throw ih("iexp",a,za(b));var n=p[5]||p[7],r=p[6];a=/ as /.test(p[0])&&p[1];var q=p[9];b=d(p[2]?p[1]:n);var v=a&&d(a)||b,u=q&&d(q),t=q?function(a,b){return u(c,b)}:function(a){return Pa(a)}, + w=function(a,b){return t(a,C(a,b))},x=d(p[2]||p[1]),A=d(p[3]||""),H=d(p[4]||""),G=d(p[8]),z={},C=r?function(a,b){z[r]=b;z[n]=a;return z}:function(a){z[n]=a;return z};return{trackBy:q,getTrackByValue:w,getWatchables:d(G,function(a){var b=[];a=a||[];for(var d=f(a),e=d.length,g=0;g<e;g++){var h=a===d?g:d[g],l=a[h],h=C(l,h),l=t(l,h);b.push(l);if(p[2]||p[1])l=x(c,h),b.push(l);p[4]&&(h=H(c,h),b.push(h))}return b}),getOptions:function(){for(var a=[],b={},d=G(c)||[],g=f(d),h=g.length,n=0;n<h;n++){var p=d=== + g?n:g[n],r=C(d[p],p),u=v(c,r),p=t(u,r),y=x(c,r),F=A(c,r),r=H(c,r),u=new e(p,u,y,F,r);a.push(u);b[p]=u}return{items:a,selectValueMap:b,getOptionFromViewValue:function(a){return b[w(a)]},getViewValueFromOption:function(a){return q?pa(a.viewValue):a.viewValue}}}}}var e=w.document.createElement("option"),f=w.document.createElement("optgroup");return{restrict:"A",terminal:!0,require:["select","ngModel"],link:{pre:function(a,b,c,d){d[0].registerOption=D},post:function(d,h,k,l){function m(a){var b=(a=t.getOptionFromViewValue(a))&& + a.element;b&&!b.selected&&(b.selected=!0);return a}function p(a,b){a.element=b;b.disabled=a.disabled;a.label!==b.label&&(b.label=a.label,b.textContent=a.label);b.value=a.selectValue}var n=l[0],q=l[1],s=k.multiple;l=0;for(var v=h.children(),y=v.length;l<y;l++)if(""===v[l].value){n.hasEmptyOption=!0;n.emptyOption=v.eq(l);break}h.empty();l=!!n.emptyOption;z(e.cloneNode(!1)).val("?");var t,w=c(k.ngOptions,h,d),x=b[0].createDocumentFragment();n.generateUnknownOptionValue=function(a){return"?"};s?(n.writeValue= + function(a){if(t){var b=a&&a.map(m)||[];t.items.forEach(function(a){a.element.selected&&-1===Array.prototype.indexOf.call(b,a)&&(a.element.selected=!1)})}},n.readValue=function(){var a=h.val()||[],b=[];r(a,function(a){(a=t.selectValueMap[a])&&!a.disabled&&b.push(t.getViewValueFromOption(a))});return b},w.trackBy&&d.$watchCollection(function(){if(I(q.$viewValue))return q.$viewValue.map(function(a){return w.getTrackByValue(a)})},function(){q.$render()})):(n.writeValue=function(a){if(t){var b=h[0].options[h[0].selectedIndex], + c=t.getOptionFromViewValue(a);b&&b.removeAttribute("selected");c?(h[0].value!==c.selectValue&&(n.removeUnknownOption(),h[0].value=c.selectValue,c.element.selected=!0),c.element.setAttribute("selected","selected")):n.selectUnknownOrEmptyOption(a)}},n.readValue=function(){var a=t.selectValueMap[h.val()];return a&&!a.disabled?(n.unselectEmptyOption(),n.removeUnknownOption(),t.getViewValueFromOption(a)):null},w.trackBy&&d.$watch(function(){return w.getTrackByValue(q.$viewValue)},function(){q.$render()})); + l&&(a(n.emptyOption)(d),h.prepend(n.emptyOption),8===n.emptyOption[0].nodeType?(n.hasEmptyOption=!1,n.registerOption=function(a,b){""===b.val()&&(n.hasEmptyOption=!0,n.emptyOption=b,n.emptyOption.removeClass("ng-scope"),q.$render(),b.on("$destroy",function(){var a=n.$isEmptyOptionSelected();n.hasEmptyOption=!1;n.emptyOption=void 0;a&&q.$render()}))}):n.emptyOption.removeClass("ng-scope"));d.$watchCollection(w.getWatchables,function(){var a=t&&n.readValue();if(t)for(var b=t.items.length-1;0<=b;b--){var c= + t.items[b];u(c.group)?Fb(c.element.parentNode):Fb(c.element)}t=w.getOptions();var d={};t.items.forEach(function(a){var b;if(u(a.group)){b=d[a.group];b||(b=f.cloneNode(!1),x.appendChild(b),b.label=null===a.group?"null":a.group,d[a.group]=b);var c=e.cloneNode(!1);b.appendChild(c);p(a,c)}else b=e.cloneNode(!1),x.appendChild(b),p(a,b)});h[0].appendChild(x);q.$render();q.$isEmpty(a)||(b=n.readValue(),(w.trackBy||s?sa(a,b):a===b)||(q.$setViewValue(b),q.$render()))})}}}}],$e=["$locale","$interpolate","$log", + function(a,b,d){var c=/{}/g,e=/^when(Minus)?(.+)$/;return{link:function(f,g,h){function k(a){g.text(a||"")}var l=h.count,m=h.$attr.when&&g.attr(h.$attr.when),p=h.offset||0,n=f.$eval(m)||{},q={},s=b.startSymbol(),v=b.endSymbol(),u=s+l+"-"+p+v,t=$.noop,w;r(h,function(a,b){var c=e.exec(b);c&&(c=(c[1]?"-":"")+L(c[2]),n[c]=g.attr(h.$attr[b]))});r(n,function(a,d){q[d]=b(a.replace(c,u))});f.$watch(l,function(b){var c=parseFloat(b),e=U(c);e||c in n||(c=a.pluralCat(c-p));c===w||e&&U(w)||(t(),e=q[c],x(e)?(null!= + b&&d.debug("ngPluralize: no rule defined for '"+c+"' in "+m),t=D,k()):t=f.$watch(e,k),w=c)})}}}],af=["$parse","$animate","$compile",function(a,b,d){var c=K("ngRepeat"),e=function(a,b,c,d,e,m,p){a[c]=d;e&&(a[e]=m);a.$index=b;a.$first=0===b;a.$last=b===p-1;a.$middle=!(a.$first||a.$last);a.$odd=!(a.$even=0===(b&1))};return{restrict:"A",multiElement:!0,transclude:"element",priority:1E3,terminal:!0,$$tlb:!0,compile:function(f,g){var h=g.ngRepeat,k=d.$$createComment("end ngRepeat",h),l=h.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+track\s+by\s+([\s\S]+?))?\s*$/); + if(!l)throw c("iexp",h);var m=l[1],p=l[2],n=l[3],q=l[4],l=m.match(/^(?:(\s*[$\w]+)|\(\s*([$\w]+)\s*,\s*([$\w]+)\s*\))$/);if(!l)throw c("iidexp",m);var s=l[3]||l[1],v=l[2];if(n&&(!/^[$a-zA-Z_][$a-zA-Z0-9_]*$/.test(n)||/^(null|undefined|this|\$index|\$first|\$middle|\$last|\$even|\$odd|\$parent|\$root|\$id)$/.test(n)))throw c("badident",n);var u,t,w,x,z={$id:Pa};q?u=a(q):(w=function(a,b){return Pa(b)},x=function(a){return a});return function(a,d,f,g,l){u&&(t=function(b,c,d){v&&(z[v]=b);z[s]=c;z.$index= + d;return u(a,z)});var m=S();a.$watchCollection(p,function(f){var g,p,q=d[0],u,y=S(),z,F,C,A,D,B,E;n&&(a[n]=f);if(wa(f))D=f,p=t||w;else for(E in p=t||x,D=[],f)ra.call(f,E)&&"$"!==E.charAt(0)&&D.push(E);z=D.length;E=Array(z);for(g=0;g<z;g++)if(F=f===D?g:D[g],C=f[F],A=p(F,C,g),m[A])B=m[A],delete m[A],y[A]=B,E[g]=B;else{if(y[A])throw r(E,function(a){a&&a.scope&&(m[a.id]=a)}),c("dupes",h,A,C);E[g]={id:A,scope:void 0,clone:void 0};y[A]=!0}for(u in m){B=m[u];A=tb(B.clone);b.leave(A);if(A[0].parentNode)for(g= + 0,p=A.length;g<p;g++)A[g].$$NG_REMOVED=!0;B.scope.$destroy()}for(g=0;g<z;g++)if(F=f===D?g:D[g],C=f[F],B=E[g],B.scope){u=q;do u=u.nextSibling;while(u&&u.$$NG_REMOVED);B.clone[0]!==u&&b.move(tb(B.clone),null,q);q=B.clone[B.clone.length-1];e(B.scope,g,s,C,v,F,z)}else l(function(a,c){B.scope=c;var d=k.cloneNode(!1);a[a.length++]=d;b.enter(a,null,q);q=d;B.clone=a;y[B.id]=B;e(B.scope,g,s,C,v,F,z)});m=y})}}}}],bf=["$animate",function(a){return{restrict:"A",multiElement:!0,link:function(b,d,c){b.$watch(c.ngShow, + function(b){a[b?"removeClass":"addClass"](d,"ng-hide",{tempClasses:"ng-hide-animate"})})}}}],Ve=["$animate",function(a){return{restrict:"A",multiElement:!0,link:function(b,d,c){b.$watch(c.ngHide,function(b){a[b?"addClass":"removeClass"](d,"ng-hide",{tempClasses:"ng-hide-animate"})})}}}],cf=Qa(function(a,b,d){a.$watch(d.ngStyle,function(a,d){d&&a!==d&&r(d,function(a,c){b.css(c,"")});a&&b.css(a)},!0)}),df=["$animate","$compile",function(a,b){return{require:"ngSwitch",controller:["$scope",function(){this.cases= + {}}],link:function(d,c,e,f){var g=[],h=[],k=[],l=[],m=function(a,b){return function(c){!1!==c&&a.splice(b,1)}};d.$watch(e.ngSwitch||e.on,function(c){for(var d,e;k.length;)a.cancel(k.pop());d=0;for(e=l.length;d<e;++d){var q=tb(h[d].clone);l[d].$destroy();(k[d]=a.leave(q)).done(m(k,d))}h.length=0;l.length=0;(g=f.cases["!"+c]||f.cases["?"])&&r(g,function(c){c.transclude(function(d,e){l.push(e);var f=c.element;d[d.length++]=b.$$createComment("end ngSwitchWhen");h.push({clone:d});a.enter(d,f.parent(), + f)})})})}}}],ef=Qa({transclude:"element",priority:1200,require:"^ngSwitch",multiElement:!0,link:function(a,b,d,c,e){a=d.ngSwitchWhen.split(d.ngSwitchWhenSeparator).sort().filter(function(a,b,c){return c[b-1]!==a});r(a,function(a){c.cases["!"+a]=c.cases["!"+a]||[];c.cases["!"+a].push({transclude:e,element:b})})}}),ff=Qa({transclude:"element",priority:1200,require:"^ngSwitch",multiElement:!0,link:function(a,b,d,c,e){c.cases["?"]=c.cases["?"]||[];c.cases["?"].push({transclude:e,element:b})}}),kh=K("ngTransclude"), + hf=["$compile",function(a){return{restrict:"EAC",compile:function(b){var d=a(b.contents());b.empty();return function(a,b,f,g,h){function k(){d(a,function(a){b.append(a)})}if(!h)throw kh("orphan",za(b));f.ngTransclude===f.$attr.ngTransclude&&(f.ngTransclude="");f=f.ngTransclude||f.ngTranscludeSlot;h(function(a,c){var d;if(d=a.length)a:{d=0;for(var f=a.length;d<f;d++){var g=a[d];if(g.nodeType!==Oa||g.nodeValue.trim()){d=!0;break a}}d=void 0}d?b.append(a):(k(),c.$destroy())},null,f);f&&!h.isSlotFilled(f)&& + k()}}}}],Je=["$templateCache",function(a){return{restrict:"E",terminal:!0,compile:function(b,d){"text/ng-template"===d.type&&a.put(d.id,b[0].text)}}}],lh={$setViewValue:D,$render:D},mh=["$element","$scope",function(a,b){function d(){g||(g=!0,b.$$postDigest(function(){g=!1;e.ngModelCtrl.$render()}))}function c(a){h||(h=!0,b.$$postDigest(function(){b.$$destroyed||(h=!1,e.ngModelCtrl.$setViewValue(e.readValue()),a&&e.ngModelCtrl.$render())}))}var e=this,f=new Hb;e.selectValueMap={};e.ngModelCtrl=lh; + e.multiple=!1;e.unknownOption=z(w.document.createElement("option"));e.hasEmptyOption=!1;e.emptyOption=void 0;e.renderUnknownOption=function(b){b=e.generateUnknownOptionValue(b);e.unknownOption.val(b);a.prepend(e.unknownOption);Ga(e.unknownOption,!0);a.val(b)};e.updateUnknownOption=function(b){b=e.generateUnknownOptionValue(b);e.unknownOption.val(b);Ga(e.unknownOption,!0);a.val(b)};e.generateUnknownOptionValue=function(a){return"? "+Pa(a)+" ?"};e.removeUnknownOption=function(){e.unknownOption.parent()&& + e.unknownOption.remove()};e.selectEmptyOption=function(){e.emptyOption&&(a.val(""),Ga(e.emptyOption,!0))};e.unselectEmptyOption=function(){e.hasEmptyOption&&Ga(e.emptyOption,!1)};b.$on("$destroy",function(){e.renderUnknownOption=D});e.readValue=function(){var b=a.val(),b=b in e.selectValueMap?e.selectValueMap[b]:b;return e.hasOption(b)?b:null};e.writeValue=function(b){var c=a[0].options[a[0].selectedIndex];c&&Ga(z(c),!1);e.hasOption(b)?(e.removeUnknownOption(),c=Pa(b),a.val(c in e.selectValueMap? + c:b),Ga(z(a[0].options[a[0].selectedIndex]),!0)):e.selectUnknownOrEmptyOption(b)};e.addOption=function(a,b){if(8!==b[0].nodeType){Ia(a,'"option value"');""===a&&(e.hasEmptyOption=!0,e.emptyOption=b);var c=f.get(a)||0;f.set(a,c+1);d()}};e.removeOption=function(a){var b=f.get(a);b&&(1===b?(f.delete(a),""===a&&(e.hasEmptyOption=!1,e.emptyOption=void 0)):f.set(a,b-1))};e.hasOption=function(a){return!!f.get(a)};e.$hasEmptyOption=function(){return e.hasEmptyOption};e.$isUnknownOptionSelected=function(){return a[0].options[0]=== + e.unknownOption[0]};e.$isEmptyOptionSelected=function(){return e.hasEmptyOption&&a[0].options[a[0].selectedIndex]===e.emptyOption[0]};e.selectUnknownOrEmptyOption=function(a){null==a&&e.emptyOption?(e.removeUnknownOption(),e.selectEmptyOption()):e.unknownOption.parent().length?e.updateUnknownOption(a):e.renderUnknownOption(a)};var g=!1,h=!1;e.registerOption=function(a,b,f,g,h){if(f.$attr.ngValue){var q,r=NaN;f.$observe("value",function(a){var d,f=b.prop("selected");u(r)&&(e.removeOption(q),delete e.selectValueMap[r], + d=!0);r=Pa(a);q=a;e.selectValueMap[r]=a;e.addOption(a,b);b.attr("value",r);d&&f&&c()})}else g?f.$observe("value",function(a){e.readValue();var d,f=b.prop("selected");u(q)&&(e.removeOption(q),d=!0);q=a;e.addOption(a,b);d&&f&&c()}):h?a.$watch(h,function(a,d){f.$set("value",a);var g=b.prop("selected");d!==a&&e.removeOption(d);e.addOption(a,b);d&&g&&c()}):e.addOption(f.value,b);f.$observe("disabled",function(a){if("true"===a||a&&b.prop("selected"))e.multiple?c(!0):(e.ngModelCtrl.$setViewValue(null),e.ngModelCtrl.$render())}); + b.on("$destroy",function(){var a=e.readValue(),b=f.value;e.removeOption(b);d();(e.multiple&&a&&-1!==a.indexOf(b)||a===b)&&c(!0)})}}],Ke=function(){return{restrict:"E",require:["select","?ngModel"],controller:mh,priority:1,link:{pre:function(a,b,d,c){var e=c[0],f=c[1];if(f){if(e.ngModelCtrl=f,b.on("change",function(){e.removeUnknownOption();a.$apply(function(){f.$setViewValue(e.readValue())})}),d.multiple){e.multiple=!0;e.readValue=function(){var a=[];r(b.find("option"),function(b){b.selected&&!b.disabled&& + (b=b.value,a.push(b in e.selectValueMap?e.selectValueMap[b]:b))});return a};e.writeValue=function(a){r(b.find("option"),function(b){var c=!!a&&(-1!==Array.prototype.indexOf.call(a,b.value)||-1!==Array.prototype.indexOf.call(a,e.selectValueMap[b.value]));c!==b.selected&&Ga(z(b),c)})};var g,h=NaN;a.$watch(function(){h!==f.$viewValue||sa(g,f.$viewValue)||(g=ka(f.$viewValue),f.$render());h=f.$viewValue});f.$isEmpty=function(a){return!a||0===a.length}}}else e.registerOption=D},post:function(a,b,d,c){var e= + c[1];if(e){var f=c[0];e.$render=function(){f.writeValue(e.$viewValue)}}}}}},Le=["$interpolate",function(a){return{restrict:"E",priority:100,compile:function(b,d){var c,e;u(d.ngValue)||(u(d.value)?c=a(d.value,!0):(e=a(b.text(),!0))||d.$set("value",b.text()));return function(a,b,d){var k=b.parent();(k=k.data("$selectController")||k.parent().data("$selectController"))&&k.registerOption(a,b,d,c,e)}}}}],ad=function(){return{restrict:"A",require:"?ngModel",link:function(a,b,d,c){c&&(d.required=!0,c.$validators.required= + function(a,b){return!d.required||!c.$isEmpty(b)},d.$observe("required",function(){c.$validate()}))}}},$c=function(){return{restrict:"A",require:"?ngModel",link:function(a,b,d,c){if(c){var e,f=d.ngPattern||d.pattern;d.$observe("pattern",function(a){E(a)&&0<a.length&&(a=new RegExp("^"+a+"$"));if(a&&!a.test)throw K("ngPattern")("noregexp",f,a,za(b));e=a||void 0;c.$validate()});c.$validators.pattern=function(a,b){return c.$isEmpty(b)||x(e)||e.test(b)}}}}},cd=function(){return{restrict:"A",require:"?ngModel", + link:function(a,b,d,c){if(c){var e=-1;d.$observe("maxlength",function(a){a=Z(a);e=U(a)?-1:a;c.$validate()});c.$validators.maxlength=function(a,b){return 0>e||c.$isEmpty(b)||b.length<=e}}}}},bd=function(){return{restrict:"A",require:"?ngModel",link:function(a,b,d,c){if(c){var e=0;d.$observe("minlength",function(a){e=Z(a)||0;c.$validate()});c.$validators.minlength=function(a,b){return c.$isEmpty(b)||b.length>=e}}}}};w.angular.bootstrap?w.console&&console.log("WARNING: Tried to load AngularJS more than once."): + (Be(),Ee($),$.module("ngLocale",[],["$provide",function(a){function b(a){a+="";var b=a.indexOf(".");return-1==b?0:a.length-b-1}a.value("$locale",{DATETIME_FORMATS:{AMPMS:["AM","PM"],DAY:"Sunday Monday Tuesday Wednesday Thursday Friday Saturday".split(" "),ERANAMES:["Before Christ","Anno Domini"],ERAS:["BC","AD"],FIRSTDAYOFWEEK:6,MONTH:"January February March April May June July August September October November December".split(" "),SHORTDAY:"Sun Mon Tue Wed Thu Fri Sat".split(" "),SHORTMONTH:"Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" "), + STANDALONEMONTH:"January February March April May June July August September October November December".split(" "),WEEKENDRANGE:[5,6],fullDate:"EEEE, MMMM d, y",longDate:"MMMM d, y",medium:"MMM d, y h:mm:ss a",mediumDate:"MMM d, y",mediumTime:"h:mm:ss a","short":"M/d/yy h:mm a",shortDate:"M/d/yy",shortTime:"h:mm a"},NUMBER_FORMATS:{CURRENCY_SYM:"$",DECIMAL_SEP:".",GROUP_SEP:",",PATTERNS:[{gSize:3,lgSize:3,maxFrac:3,minFrac:0,minInt:1,negPre:"-",negSuf:"",posPre:"",posSuf:""},{gSize:3,lgSize:3,maxFrac:2, + minFrac:2,minInt:1,negPre:"-\u00a4",negSuf:"",posPre:"\u00a4",posSuf:""}]},id:"en-us",localeID:"en_US",pluralCat:function(a,c){var e=a|0,f=c;void 0===f&&(f=Math.min(b(a),3));Math.pow(10,f);return 1==e&&0==f?"one":"other"}})}]),z(function(){we(w.document,Uc)}))})(window);!window.angular.$$csp().noInlineStyle&&window.angular.element(document.head).prepend('<style type="text/css">@charset "UTF-8";[ng\\:cloak],[ng-cloak],[data-ng-cloak],[x-ng-cloak],.ng-cloak,.x-ng-cloak,.ng-hide:not(.ng-hide-animate){display:none !important;}ng\\:form{display:block;}.ng-animate-shim{visibility:hidden;}.ng-anchor{position:absolute;}</style>'); +//# sourceMappingURL=angular.min.js.map \ No newline at end of file diff --git a/setup/pub/images/magento-logo.svg b/setup/pub/images/magento-logo.svg index 6dcc79d33b294..e4f627809b627 100644 --- a/setup/pub/images/magento-logo.svg +++ b/setup/pub/images/magento-logo.svg @@ -1,18 +1 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<!-- Generator: Adobe Illustrator 16.0.4, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> -<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1 Tiny//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-tiny.dtd'> -<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" width="214px" xml:space="preserve" height="62px" viewBox="0 0 214 62" baseProfile="tiny" version="1.1" y="0px" x="0px" xmlns:xlink="http://www.w3.org/1999/xlink"> - <path d="m93.166 44.96l-1.809-23.096-9.17 23.221h-2.988l-9.17-23.221-1.767 23.096h-3.702l2.314-29.026h4.88l9.045 23.809 9.045-23.809h4.836l2.271 29.026h-3.785z" fill="#131108"/> - <path d="m112.94 44.96l-0.421-2.692c-1.597 1.639-3.785 3.112-7.066 3.112-3.619 0-5.889-2.188-5.889-5.596 0-5.006 4.29-6.981 12.663-7.867v-0.841c0-2.523-1.515-3.407-3.83-3.407-2.439 0-4.754 0.757-6.94 1.725l-0.505-3.238c2.398-0.969 4.67-1.682 7.783-1.682 4.88 0 7.236 1.976 7.236 6.435v14.051h-3.02zm-0.72-10.182c-7.406 0.715-8.963 2.735-8.963 4.796 0 1.642 1.095 2.693 2.989 2.693 2.187 0 4.291-1.095 5.974-2.82v-4.669z" fill="#131108"/> - <path d="m137.46 24.599l0.546 3.364-3.826 0.378c0.546 0.926 0.799 1.979 0.799 3.113 0 4.292-3.618 6.899-7.699 6.899-0.504 0-1.011-0.042-1.514-0.126-0.589 0.38-1.01 0.844-1.01 1.22 0 0.716 0.714 0.886 4.248 1.517l1.432 0.252c4.249 0.757 6.898 2.102 6.898 5.216 0 4.206-4.586 6.183-9.802 6.183s-9.381-1.64-9.381-5.173c0-2.062 1.431-3.66 4.248-5.174-0.882-0.631-1.26-1.348-1.26-2.104 0-0.969 0.756-1.936 2.103-2.734-2.229-1.095-3.744-3.238-3.744-5.974 0-4.332 3.616-6.981 7.697-6.981 2.019 0 3.786 0.587 5.175 1.682l5.08-1.558zm-15.73 22.547c0 1.599 2.06 2.775 5.972 2.775 3.913 0 6.099-1.345 6.099-3.027 0-1.222-0.924-2.061-3.784-2.566l-2.397-0.422c-1.095-0.208-1.682-0.336-2.481-0.502-2.36 1.177-3.41 2.356-3.41 3.742zm5.47-19.939c-2.522 0-4.081 1.936-4.081 4.375 0 2.313 1.6 4.12 4.081 4.12 2.566 0 4.165-1.892 4.165-4.29 0-2.397-1.68-4.205-4.16-4.205z" fill="#131108"/> - <path d="m155.3 35.325h-13.631c0.125 4.669 2.354 6.856 5.847 6.856 2.904 0 5.007-1.135 7.193-2.86l0.546 3.367c-2.144 1.682-4.709 2.691-8.031 2.691-5.219 0-9.299-3.155-9.299-10.519 0-6.435 3.787-10.388 8.835-10.388 5.846 0 8.54 4.5 8.54 10.052v0.801zm-8.58-7.908c-2.313 0-4.291 1.641-4.879 5.09h9.675c-0.47-3.239-1.9-5.09-4.8-5.09z" fill="#131108"/> - <path d="m171.07 44.96v-13.673c0-2.06-0.883-3.449-3.07-3.449-1.977 0-3.996 1.305-5.807 3.239v13.883h-3.743v-20.067h2.986l0.463 2.903c1.893-1.724 4.251-3.323 7.108-3.323 3.786 0 5.808 2.271 5.808 5.888v14.599h-3.75z" fill="#131108"/> - <path d="m185.88 45.298c-3.532 0-5.846-1.265-5.846-5.304v-11.946h-3.03v-3.156h3.03v-6.688l3.66-0.546v7.234h4.332l0.505 3.156h-4.837v11.273c0 1.643 0.675 2.651 2.776 2.651 0.673 0 1.262-0.041 1.724-0.127l0.506 3.196c-0.63 0.128-1.51 0.257-2.81 0.257z" fill="#131108"/> - <path d="m198.29 45.38c-5.342 0-9.213-3.827-9.213-10.434 0-6.605 3.871-10.473 9.213-10.473 5.383 0 9.339 3.868 9.339 10.473 0 6.607-3.96 10.434-9.34 10.434zm0-17.753c-3.617 0-5.426 3.113-5.426 7.319 0 4.125 1.892 7.321 5.426 7.321 3.702 0 5.553-3.114 5.553-7.321 0-4.122-1.93-7.319-5.55-7.319z" fill="#131108"/> - <path d="m210.28 27.897c-1.505 0-2.551-1.045-2.551-2.606 0-1.55 1.067-2.618 2.551-2.618 1.505 0 2.55 1.056 2.55 2.618 0 1.55-1.07 2.606-2.55 2.606zm0-4.92c-1.214 0-2.18 0.831-2.18 2.314 0 1.472 0.966 2.303 2.18 2.303 1.225 0 2.191-0.832 2.191-2.303 0-1.483-0.98-2.314-2.19-2.314zm0.75 3.708l-0.863-1.237h-0.281v1.191h-0.495v-2.888h0.878c0.606 0 1.01 0.303 1.01 0.843 0 0.416-0.225 0.686-0.585 0.798l0.833 1.18-0.5 0.113zm-0.76-2.484h-0.383v0.854h0.359c0.325 0 0.53-0.135 0.53-0.427 0-0.281-0.18-0.427-0.51-0.427z" fill="#131108"/> - <g fill="#E85D22"> - <path d="m26.845 8.857"/> - <polygon points="53.692 15.5 53.692 46.5 46.021 50.929 46.021 19.929 26.845 8.857 7.67 19.928 7.67 50.929 0 46.5 0 15.5 26.845 0"/> - <polygon points="26.847 62 15.341 55.356 15.341 24.357 23.011 19.928 23.011 50.929 26.845 53.257 30.682 50.929 30.682 19.929 38.353 24.357 38.353 55.356"/> - </g> -</svg> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 179.07329 60.13148"><defs><style>.a{fill:#f26322;}.b{fill:#4d4d4d;}</style></defs><title>Magento-an-Adobe-Company-logo-horizontal diff --git a/setup/pub/magento/setup/add-database.js b/setup/pub/magento/setup/add-database.js index 125b67f950f1c..229c13d11e279 100644 --- a/setup/pub/magento/setup/add-database.js +++ b/setup/pub/magento/setup/add-database.js @@ -20,14 +20,14 @@ angular.module('add-database', ['ngStorage']) $scope.testConnection = function () { $http.post('index.php/database-check', $scope.db) - .success(function (data) { - $scope.testConnection.result = data; + .then(function successCallback(resp) { + $scope.testConnection.result = resp.data; + if ($scope.testConnection.result.success) { $scope.nextState(); } - }) - .error(function (data) { - $scope.testConnection.failed = data; + }, function errorCallback(resp) { + $scope.testConnection.failed = resp.data; }); }; diff --git a/setup/pub/magento/setup/app.js b/setup/pub/magento/setup/app.js index e6514ea41c0bd..f0abd15d106c2 100644 --- a/setup/pub/magento/setup/app.js +++ b/setup/pub/magento/setup/app.js @@ -31,7 +31,8 @@ var app = angular.module( 'home', 'auth-dialog', 'system-config', - 'marketplace-credentials' + 'marketplace-credentials', + 'ngSanitize' ]); app.config(['$httpProvider', '$stateProvider', function ($httpProvider, $stateProvider) { @@ -55,6 +56,9 @@ app.config(['$httpProvider', '$stateProvider', function ($httpProvider, $statePr return $delegate; }); }) + .config(['$locationProvider', function($locationProvider) { + $locationProvider.hashPrefix(''); + }]) .run(function ($rootScope, $state) { $rootScope.$state = $state; }); diff --git a/setup/pub/magento/setup/complete-backup.js b/setup/pub/magento/setup/complete-backup.js index 9c3dd09798dac..db7f6fdf8a176 100644 --- a/setup/pub/magento/setup/complete-backup.js +++ b/setup/pub/magento/setup/complete-backup.js @@ -121,8 +121,7 @@ angular.module('complete-backup', ['ngStorage']) }; $scope.disableMeintenanceMode = function() { - $http.post('index.php/maintenance/index', {'disable' : true}).success(function(data) { - }); + $http.post('index.php/maintenance/index', {'disable' : true}); }; $scope.isCompleted = function() { @@ -149,8 +148,9 @@ angular.module('complete-backup', ['ngStorage']) $scope.query = function(item) { if (!$rootScope.hasErrors) { return $http.post(item.url, $scope.backupInfoPassed, {timeout: 3000000}) - .success(function(data) { item.process(data) }) - .error(function(data, status) { + .then(function successCallback(resp) { + item.process(resp.data); + }, function errorCallback() { item.fail(); }); } else { diff --git a/setup/pub/magento/setup/create-admin-account.js b/setup/pub/magento/setup/create-admin-account.js index f6cbd154edfec..6e8807bb7a2ae 100644 --- a/setup/pub/magento/setup/create-admin-account.js +++ b/setup/pub/magento/setup/create-admin-account.js @@ -51,14 +51,14 @@ angular.module('create-admin-account', ['ngStorage']) $scope.validate(); if ($scope.valid) { $http.post('index.php/validate-admin-credentials', data) - .success(function (data) { - $scope.validateCredentials.result = data; + .then(function successCallback(resp) { + $scope.validateCredentials.result = resp.data; + if ($scope.validateCredentials.result.success) { $scope.nextState(); } - }) - .error(function (data) { - $scope.validateCredentials.failed = data; + }, function errorCallback(resp) { + $scope.validateCredentials.failed = resp.data; }); } }; diff --git a/setup/pub/magento/setup/customize-your-store.js b/setup/pub/magento/setup/customize-your-store.js index 7404ef67765e8..f14e8c1a88af1 100644 --- a/setup/pub/magento/setup/customize-your-store.js +++ b/setup/pub/magento/setup/customize-your-store.js @@ -31,10 +31,9 @@ angular.module('customize-your-store', ['ngStorage', 'ngSanitize']) if (!$localStorage.store) { $http.get('index.php/customize-your-store/default-time-zone',{'responseType' : 'json'}) - .success(function (data) { - $scope.store.timezone = data.defaultTimeZone; - }) - .error(function (data) { + .then(function successCallback(resp) { + $scope.store.timezone = resp.data.defaultTimeZone; + }, function errorCallback() { $scope.store.timezone = 'UTC'; }); } @@ -48,9 +47,13 @@ angular.module('customize-your-store', ['ngStorage', 'ngSanitize']) $localStorage.store = $scope.store; $scope.loading = true; $http.post('index.php/modules/all-modules-valid', $scope.store) - .success(function (data) { - $scope.checkModuleConstraints.result = data; - if (($scope.checkModuleConstraints.result !== undefined) && ($scope.checkModuleConstraints.result.success)) { + .then(function successCallback(resp) { + $scope.checkModuleConstraints.result = resp.data; + + if ( + $scope.checkModuleConstraints.result !== undefined && + $scope.checkModuleConstraints.result.success + ) { $scope.loading = false; $scope.nextState(); } else { @@ -61,17 +64,18 @@ angular.module('customize-your-store', ['ngStorage', 'ngSanitize']) }; if (!$scope.store.loadedAllModules) { - $http.get('index.php/modules').success(function (data) { - $state.loadedModules = data; + $http.get('index.php/modules').then(function successCallback(resp) { + $state.loadedModules = resp.data; $scope.store.showModulesControl = true; - if (data.error) { + + if (resp.data.error) { $scope.updateOnExpand($scope.store.advanced); - $scope.store.errorMessage = $sce.trustAsHtml(data.error); + $scope.store.errorMessage = $sce.trustAsHtml(resp.data.error); } }); } - $state.loadModules = function(){ + $state.loadModules = function () { if(!$scope.store.loadedAllModules) { var allModules = $scope.$state.loadedModules.modules; for (var eachModule in allModules) { @@ -120,8 +124,9 @@ angular.module('customize-your-store', ['ngStorage', 'ngSanitize']) var allParameters = {'allModules' : $scope.store.allModules, 'selectedModules' : $scope.store.selectedModules, 'module' : module, 'status' : moduleStatus}; $http.post('index.php/modules/validate', allParameters) - .success(function (data) { - $scope.checkModuleConstraints.result = data; + .then(function successCallback(resp) { + $scope.checkModuleConstraints.result = resp.data; + if ((($scope.checkModuleConstraints.result.error !== undefined) && (!$scope.checkModuleConstraints.result.success))) { $scope.store.errorMessage = $sce.trustAsHtml($scope.checkModuleConstraints.result.error); if (moduleStatus) { @@ -130,7 +135,7 @@ angular.module('customize-your-store', ['ngStorage', 'ngSanitize']) $scope.store.selectedModules.push(module); } } else { - $state.loadedModules = data; + $state.loadedModules = resp.data; $scope.store.errorMessage = false; $scope.store.showError = false; $scope.store.errorFlag = false; diff --git a/setup/pub/magento/setup/data-option.js b/setup/pub/magento/setup/data-option.js index 5eed528ec0993..16bde5d32fdea 100644 --- a/setup/pub/magento/setup/data-option.js +++ b/setup/pub/magento/setup/data-option.js @@ -13,9 +13,9 @@ angular.module('data-option', ['ngStorage']) if ($localStorage.componentType === 'magento2-module') { $http.post('index.php/data-option/hasUninstall', {'moduleName' : $localStorage.moduleName}) - .success(function(data) { - $scope.component.hasUninstall = data.hasUninstall; - }); + .then(function successCallback(resp) { + $scope.component.hasUninstall = resp.data.hasUninstall; + }); } if ($localStorage.dataOption) { diff --git a/setup/pub/magento/setup/extension-grid.js b/setup/pub/magento/setup/extension-grid.js index 4f73304282bb0..416a767a483fa 100644 --- a/setup/pub/magento/setup/extension-grid.js +++ b/setup/pub/magento/setup/extension-grid.js @@ -14,7 +14,9 @@ angular.module('extension-grid', ['ngStorage']) $scope.syncError = false; $scope.currentPage = 1; - $http.get('index.php/extensionGrid/extensions').success(function (data) { + $http.get('index.php/extensionGrid/extensions').then(function successCallback(resp) { + var data = resp.data; + $scope.extensions = data.extensions; $scope.total = data.total; @@ -37,7 +39,7 @@ angular.module('extension-grid', ['ngStorage']) } $scope.availableUpdatePackages = data.lastSyncData.packages; $scope.currentPage = 1; - $scope.rowLimit = 20; + $scope.rowLimit = '20'; $scope.numberOfPages = Math.ceil($scope.total / $scope.rowLimit); $rootScope.extensionsProcessed = true; }); @@ -78,7 +80,9 @@ angular.module('extension-grid', ['ngStorage']) $scope.sync = function() { $scope.isHiddenSpinner = false; - $http.get('index.php/extensionGrid/sync').success(function(data) { + $http.get('index.php/extensionGrid/sync').then(function successCallback(resp) { + var data = resp.data; + if (typeof data.lastSyncData.lastSyncDate !== 'undefined') { $scope.lastSyncDate = data.lastSyncData.lastSyncDate.date; $scope.lastSyncTime = data.lastSyncData.lastSyncDate.time; diff --git a/setup/pub/magento/setup/install-extension-grid.js b/setup/pub/magento/setup/install-extension-grid.js index 46feae60aeb26..6a94d99df372d 100644 --- a/setup/pub/magento/setup/install-extension-grid.js +++ b/setup/pub/magento/setup/install-extension-grid.js @@ -8,7 +8,9 @@ angular.module('install-extension-grid', ['ngStorage', 'clickOut']) .controller('installExtensionGridController', ['$scope', '$http', '$localStorage', 'authService', 'paginationService', 'multipleChoiceService', function ($scope, $http, $localStorage, authService, paginationService, multipleChoiceService) { - $http.get('index.php/installExtensionGrid/extensions').success(function(data) { + $http.get('index.php/installExtensionGrid/extensions').then(function successCallback(resp) { + var data = resp.data; + $scope.error = false; $scope.errorMessage = ''; $scope.multipleChoiceService = multipleChoiceService; @@ -19,7 +21,7 @@ angular.module('install-extension-grid', ['ngStorage', 'clickOut']) $scope.extensions = data.extensions; $scope.total = data.total; $scope.currentPage = 1; - $scope.rowLimit = 20; + $scope.rowLimit = '20'; $scope.numberOfPages = Math.ceil($scope.total / $scope.rowLimit); }); diff --git a/setup/pub/magento/setup/install.js b/setup/pub/magento/setup/install.js index 942e0a1d25443..a9be01db23f47 100644 --- a/setup/pub/magento/setup/install.js +++ b/setup/pub/magento/setup/install.js @@ -75,13 +75,17 @@ angular.module('install', ['ngStorage']) $scope.isStarted = true; $scope.isInProgress = true; progress.post(data, function (response) { + response = response.data; + $scope.isInProgress = false; + if (response.success) { $localStorage.config.encrypt.key = response.key; $localStorage.messages = response.messages; $scope.nextState(); } else { $scope.displayFailure(); + if (response.isSampleDataError) { $scope.isSampleDataError = true; } @@ -105,10 +109,10 @@ angular.module('install', ['ngStorage']) .service('progress', ['$http', function ($http) { return { get: function (callback) { - $http.post('index.php/install/progress').then(callback); + $http.post('index.php/install/progress').then(callback, function errorCallback() {}); }, post: function (data, callback) { - $http.post('index.php/install/start', data).success(callback); + $http.post('index.php/install/start', data).then(callback, function errorCallback() {}); } }; }]); diff --git a/setup/pub/magento/setup/main.js b/setup/pub/magento/setup/main.js index d9d8b5145665a..d4fb0069fea33 100644 --- a/setup/pub/magento/setup/main.js +++ b/setup/pub/magento/setup/main.js @@ -40,11 +40,10 @@ main.controller('navigationController', function ($scope, $state, navigationService, $localStorage, $interval, $http) { $interval( function () { - $http.post('index.php/session/prolong') - .success(function (result) { - }) - .error(function (result) { - }); + $http.post('index.php/session/prolong').then( + function successCallback() {}, + function errorCallback() {} + ) }, 25000 ); @@ -117,9 +116,11 @@ main.controller('navigationController', isLoadedStates: false, load: function () { var self = this; - return $http.get('index.php/navigation').success(function (data) { - var currentState = $location.path().replace('/', ''); - var isCurrentStateFound = false; + return $http.get('index.php/navigation').then(function successCallback(resp) { + var data = resp.data, + currentState = $location.path().replace('/', ''), + isCurrentStateFound = false; + self.states = data.nav; $localStorage.menu = data.menu; self.titlesWithModuleName.forEach(function (value) { @@ -184,34 +185,35 @@ main.controller('navigationController', }, reset: function (context) { return $http.post('index.php/marketplace/remove-credentials', []) - .success(function (response) { - if (response.success) { + .then(function successCallback(response) { + if (response.data.success) { $localStorage.isMarketplaceAuthorized = $rootScope.isMarketplaceAuthorized = false; context.success(); } - }) - .error(function (data) { - }); + }, function errorCallback() {}); }, checkAuth: function(context) { return $http.post('index.php/marketplace/check-auth', []) - .success(function (response) { - if (response.success) { + .then(function successCallback(response) { + var data = response.data; + + if (data.success) { $rootScope.isMarketplaceAuthorized = $localStorage.isMarketplaceAuthorized = true; - $localStorage.marketplaceUsername = response.data.username; - context.success(response); + $localStorage.marketplaceUsername = data.username; + context.success(data); } else { $rootScope.isMarketplaceAuthorized = $localStorage.isMarketplaceAuthorized = false; - context.fail(response); + context.fail(data); } - }) - .error(function() { + }, function errorCallback() { $rootScope.isMarketplaceAuthorized = $localStorage.isMarketplaceAuthorized = false; context.error(); }); }, openAuthDialog: function(scope) { - return $http.get('index.php/marketplace/popup-auth').success(function (data) { + return $http.get('index.php/marketplace/popup-auth').then(function successCallback(response) { + var data = response.data; + scope.isHiddenSpinner = true; ngDialog.open({ scope: scope, @@ -227,18 +229,19 @@ main.controller('navigationController', }, saveAuthJson: function (context) { return $http.post('index.php/marketplace/save-auth-json', context.user) - .success(function (response) { - $rootScope.isMarketplaceAuthorized = $localStorage.isMarketplaceAuthorized = response.success; + .then(function successCallback(response) { + var data = response.data; + + $rootScope.isMarketplaceAuthorized = $localStorage.isMarketplaceAuthorized = data.success; $localStorage.marketplaceUsername = context.user.username; - if (response.success) { - context.success(response); + if (data.success) { + context.success(data); } else { - context.fail(response); + context.fail(data); } - }) - .error(function (data) { + }, function errorCallback(resp) { $rootScope.isMarketplaceAuthorized = $localStorage.isMarketplaceAuthorized = false; - context.error(data); + context.error(resp.data); }); } }; diff --git a/setup/pub/magento/setup/marketplace-credentials.js b/setup/pub/magento/setup/marketplace-credentials.js index c94fec1c563c9..a0d17c14d2a2a 100644 --- a/setup/pub/magento/setup/marketplace-credentials.js +++ b/setup/pub/magento/setup/marketplace-credentials.js @@ -41,16 +41,21 @@ angular.module('marketplace-credentials', ['ngStorage']) $scope.upgradeProcessError = false; if ($state.current.type == 'upgrade') { + $scope.isHiddenSpinner = false; $http.get('index.php/select-version/installedSystemPackage', {'responseType' : 'json'}) - .success(function (data) { + .then(function successCallback(resp) { + var data = resp.data; + + $scope.isHiddenSpinner = true; + if (data.responseType == 'error') { $scope.upgradeProcessError = true; $scope.upgradeProcessErrorMessage = $sce.trustAsHtml(data.error); } else { $scope.checkAuth(); } - }) - .error(function (data) { + }, function errorCallback() { + $scope.isHiddenSpinner = true; $scope.upgradeProcessError = true; }); } else { diff --git a/setup/pub/magento/setup/module-grid.js b/setup/pub/magento/setup/module-grid.js index 694781a303303..3866c41716aee 100644 --- a/setup/pub/magento/setup/module-grid.js +++ b/setup/pub/magento/setup/module-grid.js @@ -8,11 +8,13 @@ angular.module('module-grid', ['ngStorage']) .controller('moduleGridController', ['$rootScope', '$scope', '$http', '$localStorage', '$state', 'titleService', 'paginationService', function ($rootScope, $scope, $http, $localStorage, $state, titleService, paginationService) { $rootScope.modulesProcessed = false; - $http.get('index.php/moduleGrid/modules').success(function(data) { + $http.get('index.php/moduleGrid/modules').then(function successCallback(resp) { + var data = resp.data; + $scope.modules = data.modules; $scope.total = data.total; $scope.currentPage = 1; - $scope.rowLimit = 20; + $scope.rowLimit = '20'; $scope.numberOfPages = Math.ceil($scope.total/$scope.rowLimit); $rootScope.modulesProcessed = true; }); diff --git a/setup/pub/magento/setup/readiness-check.js b/setup/pub/magento/setup/readiness-check.js index 23b5dac650a9a..fa9db13b24b88 100644 --- a/setup/pub/magento/setup/readiness-check.js +++ b/setup/pub/magento/setup/readiness-check.js @@ -313,18 +313,18 @@ angular.module('readiness-check', ['remove-dialog']) item.url = item.url + '?type=' + item.params; } else { return $http.post(item.url, item.params) - .success(function (data) { - item.process(data) - }) - .error(function (data, status) { + .then(function successCallback(resp) { + item.process(resp.data); + }, function errorCallback() { item.fail(); }); } } // setting 1 minute timeout to prevent system from timing out return $http.get(item.url, {timeout: 60000}) - .success(function(data) { item.process(data) }) - .error(function(data, status) { + .then(function successCallback(resp) { + item.process(resp.data); + }, function errorCallback() { item.fail(); }); }; diff --git a/setup/pub/magento/setup/select-version.js b/setup/pub/magento/setup/select-version.js index 32210d29dcbfe..aa4df0079b919 100644 --- a/setup/pub/magento/setup/select-version.js +++ b/setup/pub/magento/setup/select-version.js @@ -28,7 +28,9 @@ angular.module('select-version', ['ngStorage']) }; $http.get('index.php/select-version/systemPackage', {'responseType' : 'json'}) - .success(function (data) { + .then(function successCallback(resp) { + var data = resp.data; + if (data.responseType != 'error') { $scope.upgradeProcessError = true; @@ -70,8 +72,7 @@ angular.module('select-version', ['ngStorage']) $scope.upgradeProcessErrorMessage = $sce.trustAsHtml(data.error); } $scope.upgradeProcessed = true; - }) - .error(function (data) { + }, function errorCallback() { $scope.upgradeProcessError = true; }); @@ -103,15 +104,17 @@ angular.module('select-version', ['ngStorage']) $scope.updateComponents.no = false; if (!$scope.componentsProcessed && !$scope.componentsProcessError) { $scope.componentsReadyForNext = false; - $http.get('index.php/other-components-grid/components', {'responseType': 'json'}). - success(function (data) { + $http.get('index.php/other-components-grid/components', {'responseType': 'json'}) + .then(function successCallback(resp) { + var data = resp.data; + if (data.responseType != 'error') { $scope.components = data.components; $scope.displayComponents = data.components; $scope.totalForGrid = data.total; $scope.total = data.total; $scope.currentPage = 1; - $scope.rowLimit = 20; + $scope.rowLimit = '20'; $scope.numberOfPages = Math.ceil(data.total/$scope.rowLimit); for (var i = 0; i < $scope.totalForGrid; i++) { $scope.packages.push({ @@ -124,8 +127,7 @@ angular.module('select-version', ['ngStorage']) $scope.componentsProcessError = true; } $scope.componentsProcessed = true; - }) - .error(function (data) { + }, function errorCallback() { $scope.componentsProcessError = true; }); } diff --git a/setup/pub/magento/setup/start-updater.js b/setup/pub/magento/setup/start-updater.js index 3633da066187d..1b6e2e515bf72 100644 --- a/setup/pub/magento/setup/start-updater.js +++ b/setup/pub/magento/setup/start-updater.js @@ -35,14 +35,15 @@ angular.module('start-updater', ['ngStorage']) 'dataOption': $localStorage.dataOption }; $http.post('index.php/start-updater/update', payLoad) - .success(function (data) { - if (data['success']) { + .then(function successCallback(resp) { + var data = resp.data; + + if (data.success) { $window.location.href = '../update/index.php'; } else { - $scope.errorMessage = data['message']; + $scope.errorMessage = data.message; } - }) - .error(function (data) { + }, function errorCallback() { $scope.errorMessage = 'Something went wrong. Please try again.'; }); }; diff --git a/setup/pub/magento/setup/system-config.js b/setup/pub/magento/setup/system-config.js index 2956c837ee543..40b155076bc8f 100644 --- a/setup/pub/magento/setup/system-config.js +++ b/setup/pub/magento/setup/system-config.js @@ -7,6 +7,7 @@ angular.module('system-config', ['ngStorage']) .controller('systemConfigController', ['$scope', '$state', '$http', '$localStorage', '$rootScope', 'authService', function ($scope, $state, $http, $localStorage, $rootScope, authService) { + $scope.isHiddenSpinner = false; $scope.user = { username : $localStorage.marketplaceUsername ? $localStorage.marketplaceUsername : '', password : '', @@ -27,6 +28,8 @@ angular.module('system-config', ['ngStorage']) $scope.isHiddenSpinner = true; } }); + } else { + $scope.isHiddenSpinner = true; } $scope.saveAuthJson = function () { diff --git a/setup/pub/magento/setup/update-extension-grid.js b/setup/pub/magento/setup/update-extension-grid.js index 71d8fbad5de3e..78af0d7faf31d 100644 --- a/setup/pub/magento/setup/update-extension-grid.js +++ b/setup/pub/magento/setup/update-extension-grid.js @@ -9,7 +9,9 @@ angular.module('update-extension-grid', ['ngStorage', 'clickOut']) function ($scope, $http, $localStorage, titleService, authService, paginationService, multipleChoiceService) { $scope.isHiddenSpinner = false; - $http.get('index.php/updateExtensionGrid/extensions').success(function(data) { + $http.get('index.php/updateExtensionGrid/extensions').then(function successCallback(resp) { + var data = resp.data; + $scope.error = false; $scope.errorMessage = ''; $scope.extensionsVersions = {}; @@ -26,7 +28,7 @@ angular.module('update-extension-grid', ['ngStorage', 'clickOut']) $scope.extensions = data.extensions; $scope.total = data.total; $scope.currentPage = 1; - $scope.rowLimit = 20; + $scope.rowLimit = '20'; $scope.numberOfPages = Math.ceil($scope.total / $scope.rowLimit); $scope.isHiddenSpinner = true; $localStorage.extensionsVersions = $scope.extensionsVersions; @@ -36,7 +38,7 @@ angular.module('update-extension-grid', ['ngStorage', 'clickOut']) $scope.predicate = 'name'; $scope.reverse = false; - $scope.order = function(predicate) { + $scope.order = function (predicate) { $scope.reverse = ($scope.predicate === predicate) ? !$scope.reverse : false; $scope.predicate = predicate; }; diff --git a/setup/pub/magento/setup/web-configuration.js b/setup/pub/magento/setup/web-configuration.js index 1ad09ea986216..261cad9fe8404 100644 --- a/setup/pub/magento/setup/web-configuration.js +++ b/setup/pub/magento/setup/web-configuration.js @@ -124,22 +124,23 @@ angular.module('web-configuration', ['ngStorage']) $scope.validateUrl = function () { if (!$scope.webconfig.submitted) { $http.post('index.php/url-check', $scope.config) - .success(function (data) { - $scope.validateUrl.result = data; + .then(function successCallback(resp) { + $scope.validateUrl.result = resp.data; if ($scope.validateUrl.result.successUrl && $scope.validateUrl.result.successSecureUrl) { $scope.nextState(); } + if (!$scope.validateUrl.result.successUrl) { $scope.webconfig.submitted = true; $scope.webconfig.base_url.$setValidity('url', false); } + if (!$scope.validateUrl.result.successSecureUrl) { $scope.webconfig.submitted = true; $scope.webconfig.https.$setValidity('url', false); } - }) - .error(function (data) { - $scope.validateUrl.failed = data; + }, function errorCallback(resp) { + $scope.validateUrl.failed = resp.data; }); } }; diff --git a/setup/src/Magento/Setup/Module/Di/App/Task/Operation/ApplicationCodeGenerator.php b/setup/src/Magento/Setup/Module/Di/App/Task/Operation/ApplicationCodeGenerator.php index 76cd6a20bf669..07b9a7110e643 100644 --- a/setup/src/Magento/Setup/Module/Di/App/Task/Operation/ApplicationCodeGenerator.php +++ b/setup/src/Magento/Setup/Module/Di/App/Task/Operation/ApplicationCodeGenerator.php @@ -74,7 +74,7 @@ public function doOperation() $this->directoryScanner->scan($path, $this->data['filePatterns'], $this->data['excludePatterns']) ); } - $entities = $this->phpScanner->collectEntities($files['php']); + $entities = isset($files['php']) ? $this->phpScanner->collectEntities($files['php']) : []; foreach ($entities as $entityName) { class_exists($entityName); } diff --git a/setup/view/magento/setup/customize-your-store.phtml b/setup/view/magento/setup/customize-your-store.phtml index 15cd53ca26cfd..97ecf38d5f490 100644 --- a/setup/view/magento/setup/customize-your-store.phtml +++ b/setup/view/magento/setup/customize-your-store.phtml @@ -178,7 +178,7 @@

{{$state.current.header}}

@@ -66,8 +69,8 @@
Marketplace - for purchasing extensions.
+ You haven't purchased any extensions yet. Visit Marketplace + for purchasing extensions.
diff --git a/setup/view/magento/setup/module-grid.phtml b/setup/view/magento/setup/module-grid.phtml index 5fe0447b7c791..3bf9377104df1 100644 --- a/setup/view/magento/setup/module-grid.phtml +++ b/setup/view/magento/setup/module-grid.phtml @@ -8,7 +8,7 @@
diff --git a/setup/view/magento/setup/popupauth.phtml b/setup/view/magento/setup/popupauth.phtml index 8b074abaa5f12..3ffca7ba49bab 100644 --- a/setup/view/magento/setup/popupauth.phtml +++ b/setup/view/magento/setup/popupauth.phtml @@ -17,7 +17,9 @@