├── .gitignore ├── CONTRIBUTING ├── LICENSE ├── README.md ├── composer.json ├── docs ├── Aliases.md ├── Attributes.md └── Props.md ├── phpstan.neon ├── src ├── Concern │ └── IsArrayAccessibleAndCountable.php ├── Exception │ ├── BrickException.php │ ├── PropExpectedTypeStringInvalidException.php │ ├── PropHasUnexpectedTypeException.php │ └── PropIsMissingException.php ├── Model │ ├── BrickAttributesBag.php │ ├── BrickPropsBag.php │ └── Mason.php ├── Plugin │ └── TemplateEngine │ │ └── PhpPlugin.php ├── etc │ ├── di.xml │ ├── frontend │ │ └── di.xml │ └── module.xml ├── registration.php └── view │ └── base │ └── templates │ ├── cms │ └── block.phtml │ └── elements │ └── link │ └── external.phtml └── tests └── Unit ├── BrickAttributesBagTest.php ├── BrickPropsBagTest.php └── MasonTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/magento2,phpstorm,macos,linux 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=magento2,phpstorm,macos,linux 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### macOS ### 20 | # General 21 | .DS_Store 22 | .AppleDouble 23 | .LSOverride 24 | 25 | # Icon must end with two \r 26 | Icon 27 | 28 | 29 | # Thumbnails 30 | ._* 31 | 32 | # Files that might appear in the root of a volume 33 | .DocumentRevisions-V100 34 | .fseventsd 35 | .Spotlight-V100 36 | .TemporaryItems 37 | .Trashes 38 | .VolumeIcon.icns 39 | .com.apple.timemachine.donotpresent 40 | 41 | # Directories potentially created on remote AFP share 42 | .AppleDB 43 | .AppleDesktop 44 | Network Trash Folder 45 | Temporary Items 46 | .apdisk 47 | 48 | ### macOS Patch ### 49 | # iCloud generated files 50 | *.icloud 51 | 52 | ### Magento2 ### 53 | /.buildpath 54 | /.cache 55 | /.metadata 56 | /.project 57 | /.settings 58 | /.vscode 59 | atlassian* 60 | /nbproject 61 | /robots.txt 62 | /pub/robots.txt 63 | /sitemap 64 | /sitemap.xml 65 | /pub/sitemap 66 | /pub/sitemap.xml 67 | /.idea 68 | /.gitattributes 69 | /app/config_sandbox 70 | /app/etc/config.php 71 | /app/etc/env.php 72 | /app/code/Magento/TestModule* 73 | /lib/internal/flex/uploader/.actionScriptProperties 74 | /lib/internal/flex/uploader/.flexProperties 75 | /lib/internal/flex/uploader/.project 76 | /lib/internal/flex/uploader/.settings 77 | /lib/internal/flex/varien/.actionScriptProperties 78 | /lib/internal/flex/varien/.flexLibProperties 79 | /lib/internal/flex/varien/.project 80 | /lib/internal/flex/varien/.settings 81 | /node_modules 82 | /.grunt 83 | /Gruntfile.js 84 | /package.json 85 | /.php_cs 86 | /.php_cs.cache 87 | /grunt-config.json 88 | /pub/media/*.* 89 | !/pub/media/.htaccess 90 | /pub/media/attribute/* 91 | !/pub/media/attribute/.htaccess 92 | /pub/media/analytics/* 93 | /pub/media/catalog/* 94 | !/pub/media/catalog/.htaccess 95 | /pub/media/customer/* 96 | !/pub/media/customer/.htaccess 97 | /pub/media/downloadable/* 98 | !/pub/media/downloadable/.htaccess 99 | /pub/media/favicon/* 100 | /pub/media/import/* 101 | !/pub/media/import/.htaccess 102 | /pub/media/logo/* 103 | /pub/media/custom_options/* 104 | !/pub/media/custom_options/.htaccess 105 | /pub/media/theme/* 106 | /pub/media/theme_customization/* 107 | !/pub/media/theme_customization/.htaccess 108 | /pub/media/wysiwyg/* 109 | !/pub/media/wysiwyg/.htaccess 110 | /pub/media/tmp/* 111 | !/pub/media/tmp/.htaccess 112 | /pub/media/captcha/* 113 | /pub/static/* 114 | !/pub/static/.htaccess 115 | 116 | /var/* 117 | !/var/.htaccess 118 | /vendor/* 119 | !/vendor/.htaccess 120 | /generated/* 121 | !/generated/.htaccess 122 | 123 | ### PhpStorm ### 124 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 125 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 126 | 127 | # User-specific stuff 128 | .idea/**/workspace.xml 129 | .idea/**/tasks.xml 130 | .idea/**/usage.statistics.xml 131 | .idea/**/dictionaries 132 | .idea/**/shelf 133 | 134 | # AWS User-specific 135 | .idea/**/aws.xml 136 | 137 | # Generated files 138 | .idea/**/contentModel.xml 139 | 140 | # Sensitive or high-churn files 141 | .idea/**/dataSources/ 142 | .idea/**/dataSources.ids 143 | .idea/**/dataSources.local.xml 144 | .idea/**/sqlDataSources.xml 145 | .idea/**/dynamic.xml 146 | .idea/**/uiDesigner.xml 147 | .idea/**/dbnavigator.xml 148 | 149 | # Gradle 150 | .idea/**/gradle.xml 151 | .idea/**/libraries 152 | 153 | # Gradle and Maven with auto-import 154 | # When using Gradle or Maven with auto-import, you should exclude module files, 155 | # since they will be recreated, and may cause churn. Uncomment if using 156 | # auto-import. 157 | # .idea/artifacts 158 | # .idea/compiler.xml 159 | # .idea/jarRepositories.xml 160 | # .idea/modules.xml 161 | # .idea/*.iml 162 | # .idea/modules 163 | # *.iml 164 | # *.ipr 165 | 166 | # CMake 167 | cmake-build-*/ 168 | 169 | # Mongo Explorer plugin 170 | .idea/**/mongoSettings.xml 171 | 172 | # File-based project format 173 | *.iws 174 | 175 | # IntelliJ 176 | out/ 177 | 178 | # mpeltonen/sbt-idea plugin 179 | .idea_modules/ 180 | 181 | # JIRA plugin 182 | atlassian-ide-plugin.xml 183 | 184 | # Cursive Clojure plugin 185 | .idea/replstate.xml 186 | 187 | # SonarLint plugin 188 | .idea/sonarlint/ 189 | 190 | # Crashlytics plugin (for Android Studio and IntelliJ) 191 | com_crashlytics_export_strings.xml 192 | crashlytics.properties 193 | crashlytics-build.properties 194 | fabric.properties 195 | 196 | # Editor-based Rest Client 197 | .idea/httpRequests 198 | 199 | # Android studio 3.1+ serialized cache file 200 | .idea/caches/build_file_checksums.ser 201 | 202 | ### PhpStorm Patch ### 203 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 204 | 205 | # *.iml 206 | # modules.xml 207 | # .idea/misc.xml 208 | # *.ipr 209 | 210 | # Sonarlint plugin 211 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 212 | .idea/**/sonarlint/ 213 | 214 | # SonarQube Plugin 215 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 216 | .idea/**/sonarIssues.xml 217 | 218 | # Markdown Navigator plugin 219 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 220 | .idea/**/markdown-navigator.xml 221 | .idea/**/markdown-navigator-enh.xml 222 | .idea/**/markdown-navigator/ 223 | 224 | # Cache file creation bug 225 | # See https://youtrack.jetbrains.com/issue/JBR-2257 226 | .idea/$CACHE_FILE$ 227 | 228 | # CodeStream plugin 229 | # https://plugins.jetbrains.com/plugin/12206-codestream 230 | .idea/codestream.xml 231 | 232 | # Azure Toolkit for IntelliJ plugin 233 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij 234 | .idea/**/azureSettings.xml 235 | 236 | # End of https://www.toptal.com/developers/gitignore/api/magento2,phpstorm,macos,linux 237 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | Contributions are appreciated. You can reach out to me directly, or open an issue or pull request. 2 | 3 | You can help by: 4 | - Raising an issue or concern, with steps how to reproduce 5 | - Requesting useful and viable features 6 | - Writing a (failing) test 7 | - Submitting a pull request 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 LBannenberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Layout Bricks for Magento 2 | 3 | 4 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/corrivate/magento2-layout-bricks?color=blue)](https://packagist.org/packages/corrivate/magento2-layout-bricks) 5 | [![MIT Licensed](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE.md) 6 | 7 | *All in all you're just another brick in the layout* 8 | 9 | ```bash 10 | composer require corrivate/magento2-layout-bricks 11 | ``` 12 | 13 | Modern frontend frameworks embrace reusable components, like buttons, input fields, and cards. And they style them with utility CSS like Tailwind. It's fine to pile a dozen classes on that primary button, because you only have to build it once. 14 | 15 | Magento doesn't come with this out of the box. Many templates are *huge* and if you talk about UI components people make the sign of the ~~cross~~ XML at you. 16 | 17 | This package is a way to make things better. To use small anonymous components without hassle. It's heavily inspired by Laravel's anonymous blade components. In Magento, our unit of frontend template is a block. An anonymous block is a **brick**. 18 | 19 | ## An example phtml template 20 | ```php 21 | 26 | 27 |
28 | 'newsletter-explanation']) ?> 29 | 30 | 'rounded-md text-stone-800 bg-stone-100', 33 | 'placeholder' => 'joe@examplecorp.com' 34 | ]) ?> 35 | 36 | 'submit'], props: ['label' => __('Save')]) ?> 37 |
38 | ``` 39 | 40 | ## How does it work? 41 | 42 | The `$mason` object is globally injected into every `.phtml` template. It has just one method, `__invoke()`, to cause it to output as a string a fully rendered child block. So it's essentially a compact, ergonomic way of doing this: 43 | 44 | ```php 45 | getLayout() 47 | ->createBlock(\Magento\Framework\View\Element\Template::class) 48 | ->setTemplate($template) 49 | ?> 50 | ``` 51 | 52 | This is already nice, because we are now still using Magento's templating engine: 53 | * We can call small templates without using a ton of boilerplate. It's now realistic to make a template for something as small as a single button. So we can re-use the same button look and feel throughout the entire website. This is really helpful if the button actually has a LOT of utility CSS classes. Hi Tailwind. 54 | * We can make a library of base components as a reusable module. 55 | * We still have the opportunity to use Magento's theme overrides. We can change the way buttons look in a website or single store. But because we're re-using the button template everywhere, we can change it in one place and have the change happen everywhere. 56 | 57 | But there's more: 58 | 59 | * You can set default HTML attributes (such as classes) on a component, and inject additional ones based on the context. They will be merged, with new properties overriding default ones. 60 | * You can inject props into a component, supplying them with data. 61 | 62 | For example, consider the `cms.block` brick: 63 | ```php 64 | 'border-2 border-stone-400 rounded-lg'], 66 | props: ['block_id' => 'text-block']) 67 | ?> 68 | ``` 69 | 70 | This will render the CMS block with ID 'text-block', but surround it in a div with a gray round border. 71 | 72 | ## Aliases 73 | 74 | [Aliases in detail](docs/Aliases.md) 75 | 76 | You can place bricks in two ways: 77 | * Fully cite the Magento template path: 78 | 79 | ```php 80 | 'test-block']) ?> 81 | ``` 82 | 83 | * Create an **alias** for it, so you can refer to it more shortly: 84 | 85 | ```php 86 | 'test-block']) ?> 87 | ``` 88 | 89 | [Aliases in detail](docs/Aliases.md) 90 | 91 | ## Attributes 92 | 93 | [Attributes in detail](docs/Attributes.md) 94 | 95 | The `$mason` objects invoke method accepts an array of attributes. For example: 96 | 97 | ```php 98 | false, 101 | 'class' => 'text-black', 102 | 'placeholder' => 'your input please', 103 | 'name' => 'user_comment' 104 | ]) ?> 105 | ``` 106 | 107 | In the brick template, this will be available as a BrickAttributesBag which could for example have the following default attributes/values: 108 | 109 | ```php 110 | default([ 111 | 'class' => 112 | 'bg-white', 113 | 'disabled' => true, 114 | 'type' => 'text' 115 | ]) ?> /> 116 | ``` 117 | 118 | This would result in the following HTML after the defaults and your custom input is merged: 119 | 120 | ```html 121 | 126 | ``` 127 | 128 | [Attributes in detail](docs/Attributes.md) 129 | 130 | ## Props 131 | 132 | [Props in detail](docs/Props.md) 133 | 134 | Props are used to pass data to the brick. For example, if you were making a brick to render a "product card", you'd pass the product that needs to be displayed: 135 | 136 | ```php 137 | $product]) ?> 138 | ``` 139 | 140 | In the brick template, the props are available through the `$props` variable, which is automatically present: 141 | 142 | ```php 143 | 149 | 150 |
151 | getSku() ?> 152 |
153 | 154 | ``` 155 | 156 | The `$props` variable is not an array, but it implements `ArrayAccess` to give access to its contents. 157 | 158 | The `$props` variable also has a `$props->default([])` method so you can supply default (scalar) props. You can always override those default from the parent template. 159 | 160 | The `$props` object also has a `$props->expect([])` method which allows you to specify expected props and their data types so you can opt into greater type safety. 161 | 162 | [Props in detail](docs/Props.md) 163 | 164 | 165 | ## Escaper 166 | Using `$escaper` to filter raw output of data from the DB or user input is important to protect against various attacks. [Official documentation](https://developer.adobe.com/commerce/php/development/security/cross-site-scripting/#phtml-templates) about this. 167 | 168 | However, the output from `$mason()` is the output of another block that already produces HTML. So the call to `$mason()` should NOT be escaped, but inside the PHTML template implementing your brick, you should use it normally. 169 | 170 | ## Empty PHTML brick template 171 | 172 | Just copy paste this into a file and start designing: 173 | 174 | ```php 175 | default([ 185 | // you can supply default scalar values for your props 186 | ])->expect([ 187 | // specify propName => type for your props 188 | ]); 189 | ?> 190 | 191 | 192 |
default(['class' => '']) ?>> 193 | 194 |
195 | 196 | ``` 197 | 198 | 199 | ## Corrivate 200 | (en.wiktionary.org) 201 | 202 | Etymology 203 | 204 | From Latin *corrivatus*, past participle of *corrivare* ("to corrivate"). 205 | 206 | ### Verb 207 | 208 | **corrivate** (*third-person singular simple present* **corrivates**, *present participle* **corrivating**, *simple past and past participle* **corrivated**) 209 | 210 | (*obsolete*) To cause to flow together, as water drawn from several streams. 211 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "corrivate/magento2-layout-bricks", 3 | "description": "Use layout bricks in Magento, inspired by Laravel anonymous Blade components", 4 | "type": "magento2-module", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Lau Bannenberg", 9 | "email": "lau.bannenberg@gmail.com" 10 | } 11 | ], 12 | "minimum-stability": "stable", 13 | "require": { 14 | "php": "~7.4.0||~8.0.0||~8.1.0||~8.2.0||~8.3.0", 15 | "magento/framework": "*", 16 | "magento/module-cms": "*" 17 | }, 18 | "require-dev": { 19 | "bitexpert/phpstan-magento": "^0.32.0", 20 | "phpstan/extension-installer": "^1.4", 21 | "phpstan/phpstan": "^1.12" 22 | }, 23 | "autoload": { 24 | "files": [ 25 | "src/registration.php" 26 | ], 27 | "psr-4": { 28 | "Corrivate\\LayoutBricks\\": "src/" 29 | } 30 | }, 31 | "autoload-dev": { 32 | "psr-4": { 33 | "Corrivate\\LayoutBricks\\": "tests/" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /docs/Aliases.md: -------------------------------------------------------------------------------- 1 | # Aliases 2 | 3 | Aliases are created by injecting the with `frontend/di.xml` into the `\Corrivate\LayoutBricks\Model\Mason` constructor's `aliases` array: 4 | 5 | ```xml 6 | 7 | 9 | 10 | 11 | 12 | Corrivate_LayoutBricks::cms/block.phtml 13 | 14 | 15 | 16 | 17 | ``` 18 | 19 | It's important to target the `frontend/di.xml` (or `adminhtml/di.xml`) files, not the base `etc/di.xml` file, because the frontend injected dependencies will cause any deps you try to inject in the base area to be ignored. 20 | -------------------------------------------------------------------------------- /docs/Attributes.md: -------------------------------------------------------------------------------- 1 | # Attributes 2 | 3 | ## Merging default and injected attributes 4 | 5 | The module provides a (Laravel-inspired) way to combine default and customized HTML attributes. From the outside, this looks like this: 6 | 7 | ```PHP 8 | 9 |
10 | 'text-bold text-blue-800']) ?> 11 |
12 | ``` 13 | 14 | And inside the component it would look like: 15 | 16 | ```PHP 17 | 23 | 24 | 27 | ``` 28 | 29 | The resulting HTML would be: 30 | 31 | ```html 32 |
33 | 34 |
35 | ``` 36 | 37 | As you can see, we get both the default attributes (type=button, text-black) as the injected ones (text-blue-800). Because the injected CSS classes are placed last, and assuming no weird specificity problems, they will have the last word. 38 | 39 | **NOTE** The `default()` method merges in-place, so if you call: 40 | 41 | ```php 42 | $attributes->default(['style' => 'display: none;']); 43 | echo $attributes; 44 | ``` 45 | 46 | You'll get a `style="display: none;"`, because the `$attributes` object has been modified. 47 | 48 | ## Accessing a specific attribute 49 | 50 | The attributes are supplied by the BrickAttributesBag class, which is a data carrier that presents an ArrayAccess interface so you can also for example reach into it to grab a specific attribute: 51 | 52 | ```php 53 | $attributes['style'] = 'display: none;' 54 | echo $attributes['style']; 55 | ``` 56 | 57 | ## Using `only` or `without` some attributes 58 | 59 | If you need a handful of attributes which may or may not have been set, you can use the `only` method (and it's counterpart, the `without` method): 60 | 61 | ```php 62 | echo $attributes->only('required', 'disabled', 'readonly'); 63 | echo $attributes->without('required', 'disabled', 'readonly'); 64 | ``` 65 | 66 | These methods return a **new copy** of the `$attributes` with only the requested keys. 67 | 68 | ## Using `whereStartsWith` and `WhereDoesntStartWith` 69 | 70 | When working with Magewire and AlpineJS it may also be useful to filter attributes based on prefix: 71 | 72 | ```php 73 | 74 | 75 |
whereStartsWith('wire:') ?>> 76 | ... 77 |
78 | 79 |
whereDoesntStartWith('x-') ?> > 80 | ... 81 |
82 | ``` 83 | 84 | These methods return a **new copy** of the `$attributes` with only the requested keys. 85 | 86 | ## Boolean HTML attributes 87 | 88 | Attributes like `required` are Boolean: either they're present on a HTML element and true, or they're completely absent. But we often want to set them based on conditions. We can do this: 89 | 90 | ```php 91 | $session->isLoggedIn()]) ?> 92 | ``` 93 | 94 | Depending on whether `isLoggedIn()` turns out to be true, we would get either of these: 95 | 96 | ```html 97 | 98 | 99 | ``` 100 | 101 | This follows the standard that Boolean HTML attributes should be simply present when true and absent when false. 102 | 103 | Note that it's also possible to just pass in straight strings, if you don't need extra logic: 104 | 105 | ```php 106 | 107 | ``` 108 | Will result in: 109 | ```html 110 | 111 | ``` 112 | -------------------------------------------------------------------------------- /docs/Props.md: -------------------------------------------------------------------------------- 1 | # Props 2 | 3 | Props are used to pass data to the brick component. In the template, it could look something like this: 4 | 5 | ```php 6 | default([ 'title' => 'My Product Card' ]) 13 | ->expect([ 14 | 'title' => 'string', 15 | 'special_price' => '?float', 16 | 'initial_quantity' => 'int|float|null' 17 | 'product' => \Magento\Catalog\Api\Data\ProductInterface::class 18 | ]); 19 | 20 | $product = $props['product']; 21 | ?> 22 | 23 |
default(['class' => 'border-2 border-color-stone-600 rounded-md']) ?>> 24 |

25 |

getSku() ?>

26 |

getPrice() ?>

27 | " step="0.1"/> 28 |
29 | ``` 30 | 31 | The `$props` object has two public methods: 32 | * With `default()` you can specify default (scalar) values for props. If the parent template that's using this brick injects its own values, those will override the default values. 33 | * You can supply scalar values here and a few adjacent ones like Phrase. 34 | * It would be a bad practice to start DI-ing more complex default types here, because then you're starting to put a lot of logic in your view templates. 35 | * With `expect()` you can specify which props you're expecting, and if they're mandatory. 36 | * A prop expectation starting with '?' is optional/nullable. If it's not supplied at all, it will be set with a `$propName = null` assignment. If it's present, it will be validated against expectations. 37 | * If you allow multiple types, such as `int|float|null` you cannot use the `?` prefix syntax (consistent with regular PHP function signatures). 38 | * You can specify interfaces and parent types, and concrete & child types will be accepted as well (we use `instanceof` to check). For example, if you're expecting a ProductInterface, you can supply a concrete Product. 39 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | # phpVersion: 70400 # also supported 3 | # phpVersion: 80100 # also supported 4 | phpVersion: 80200 # also supported 5 | # phpVersion: 80300 # also supported 6 | level: 8 7 | paths: 8 | # expecting you to run this from project root, 9 | # epecting the package to located in {project root}/packages/corrivate/magento2-layout-bricks 10 | # expecting you to be using composer "path" repository for local development 11 | - src 12 | excludePaths: 13 | - tests/* 14 | -------------------------------------------------------------------------------- /src/Concern/IsArrayAccessibleAndCountable.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | public array $container = []; 11 | 12 | public function offsetSet($offset, $value): void 13 | { 14 | if($offset) { 15 | $this->container[$offset] = $value; 16 | } 17 | } 18 | 19 | public function offsetExists($offset): bool 20 | { 21 | return isset($this->container[$offset]); 22 | } 23 | 24 | public function offsetUnset($offset): void 25 | { 26 | unset($this->container[$offset]); 27 | } 28 | 29 | /** 30 | * @param $offset 31 | * @return mixed 32 | */ 33 | #[\ReturnTypeWillChange] // Needed to placate PHPstan @ 8.2 and maintain 7.4 compatibility at the same time 34 | public function offsetGet($offset) 35 | { 36 | return $this->container[$offset]; 37 | } 38 | 39 | public function count(): int 40 | { 41 | return count($this->container); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Exception/BrickException.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class BrickAttributesBag implements \ArrayAccess, \Countable 12 | { 13 | use IsArrayAccessibleAndCountable; 14 | 15 | public const HTML_BOOLEAN_ATTRIBUTES = [ // sourced from https://meiert.com/en/blog/boolean-attributes-of-html/ 16 | 'allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked', 'controls', 'default', 'defer', 'disabled', 17 | 'formnovalidate', 'inert', 'ismap', 'itemscope', 'loop', 'multiple', 'muted', 'nomodule', 'novalidate', 'open', 18 | 'readonly', 'required', 'reversed', 'selected' 19 | ]; 20 | 21 | 22 | /** 23 | * @param array $attributes 24 | */ 25 | public function __construct( 26 | array $attributes = [] 27 | ) { 28 | $attributes = $this->sanitizeBooleanAttributes($attributes); 29 | $this->container = $attributes; 30 | } 31 | 32 | /** 33 | * @param array $defaults 34 | * @return $this 35 | */ 36 | public function default(array $defaults = []): BrickAttributesBag 37 | { 38 | $defaults = $this->sanitizeBooleanAttributes($defaults); 39 | $result = $defaults; 40 | foreach ($this->container as $key => $value) { 41 | if ($key === 'class') { 42 | $result['class'] = isset($result['class']) 43 | ? $result['class'].' '.$value // Specific classes added after default, so that they can override 44 | : $value; 45 | } elseif ($key === 'style') { 46 | if (empty($result['style'])) { 47 | $result['style'] = $this->endWith($value, ';'); 48 | continue; 49 | } 50 | $result['style'] = $this->endWith($result['style'], ';').' '.$this->endWith($value, ';'); 51 | } else { 52 | $result[$key] = $value; 53 | } 54 | } 55 | $this->container = $result; 56 | return $this; 57 | } 58 | 59 | 60 | public function whereStartsWith(string $startsWith): BrickAttributesBag 61 | { 62 | $result = []; 63 | foreach ($this->container as $key => $value) { 64 | if (substr($key, 0, strlen($startsWith)) === $startsWith) { 65 | $result[$key] = $value; 66 | } 67 | } 68 | return new BrickAttributesBag($result); 69 | } 70 | 71 | 72 | public function whereDoesntStartWith(string $startsWith): BrickAttributesBag 73 | { 74 | $result = []; 75 | foreach ($this->container as $key => $value) { 76 | if (substr($key, 0, strlen($startsWith)) !== $startsWith) { 77 | $result[$key] = $value; 78 | } 79 | } 80 | return new BrickAttributesBag($result); 81 | } 82 | 83 | 84 | public function only(string ...$keys): BrickAttributesBag 85 | { 86 | $only = []; 87 | foreach($keys as $key) { 88 | if(isset($this->container[$key])) { 89 | $only[$key] = $this->container[$key]; 90 | } 91 | } 92 | return new BrickAttributesBag($only); 93 | } 94 | 95 | 96 | public function without(string ...$withoutKeys): BrickAttributesBag 97 | { 98 | $keep = []; 99 | foreach($this->container as $key => $value) { 100 | if(!in_array($key, $withoutKeys)) { 101 | $keep[$key] = $value; 102 | } 103 | } 104 | return new BrickAttributesBag($keep); 105 | } 106 | 107 | 108 | public function toHtml(): string 109 | { 110 | return (string) $this; 111 | } 112 | 113 | 114 | public function __toString() 115 | { 116 | $output = []; 117 | foreach ($this->container as $key => $value) { 118 | // Render only truthy boolean attributes 119 | if (in_array($key, self::HTML_BOOLEAN_ATTRIBUTES)) { 120 | if ($value) { 121 | $output[] = $key; // We map for example ['checked' => true] to checked 122 | } 123 | continue; 124 | } 125 | 126 | // Non-Boolean attributes 127 | $output[] = $key.'="'.$value.'"'; 128 | } 129 | return implode(' ', $output); 130 | } 131 | 132 | 133 | private function endWith(string $value, string $end): string 134 | { 135 | $value = trim($value); 136 | if (strlen($value) == 0) { 137 | return ''; 138 | } 139 | 140 | return substr($value, -1) == $end 141 | ? $value 142 | : $value.$end; 143 | } 144 | 145 | 146 | /** 147 | * @param array $attributes 148 | * @return array 149 | */ 150 | private function sanitizeBooleanAttributes(array $attributes): array 151 | { 152 | $result = []; 153 | foreach ($attributes as $key => $value) { 154 | if (is_int($key) && in_array($value, self::HTML_BOOLEAN_ATTRIBUTES)) { 155 | $result[(string) $value] = true; 156 | continue; 157 | } 158 | if (in_array($key, self::HTML_BOOLEAN_ATTRIBUTES)) { 159 | $result[(string) $key] = (bool) $value; 160 | continue; 161 | } 162 | $result[(string) $key] = $value; 163 | } 164 | return $result; 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/Model/BrickPropsBag.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class BrickPropsBag implements \ArrayAccess, \Countable 15 | { 16 | use IsArrayAccessibleAndCountable; 17 | 18 | /** 19 | * @param array $props 20 | */ 21 | public function __construct( 22 | array $props = [] 23 | ) { 24 | $this->container = $props; 25 | } 26 | 27 | /** 28 | * @param array $defaults 29 | */ 30 | public function default(array $defaults = []): BrickPropsBag 31 | { 32 | $result = $defaults; 33 | foreach ($this->container as $key => $value) { 34 | $result[$key] = $value; 35 | } 36 | $this->container = $result; 37 | return $this; 38 | } 39 | 40 | 41 | /** 42 | * @param array $expectations 43 | * @throws PropExpectedTypeStringInvalidException 44 | * @throws PropIsMissingException 45 | * @throws PropHasUnexpectedTypeException 46 | */ 47 | public function expect(array $expectations = []): self 48 | { 49 | foreach ($expectations as $propName => $acceptedTypesString) { 50 | if (substr($acceptedTypesString, 0, 1) === '?' && strpos($acceptedTypesString, '|') !== false) { 51 | throw new PropExpectedTypeStringInvalidException( 52 | "Cannot use '?' to start a prop's type-string AND use |; use |null instead." 53 | ); 54 | } 55 | 56 | $nullable = false; 57 | if(substr($acceptedTypesString, 0, 1) === '?') { 58 | $nullable = true; 59 | $acceptedTypesString = substr($acceptedTypesString, 1); 60 | } 61 | $acceptedTypes = explode('|', $acceptedTypesString); 62 | $nullable = $nullable || in_array('null', $acceptedTypes); 63 | 64 | if ($nullable 65 | && (!in_array($propName, array_keys($this->container)) 66 | || $this->container[$propName] === null) 67 | ) { 68 | $this->container[$propName] = null; 69 | continue; 70 | } 71 | 72 | if(!isset($this->container[$propName])) { 73 | throw new PropIsMissingException( 74 | "Expected prop '$propName' with type(s) '$acceptedTypesString' but did not receive it." 75 | ); 76 | } 77 | 78 | $subject = $this->container[$propName]; 79 | 80 | // See if we can match to any accept type; 81 | // We need continue 3 because the switch statement also counts as a loop context; see 82 | // https://www.php.net/manual/en/control-structures.continue.php 83 | /** @var string[] $acceptedTypes */ 84 | foreach ($acceptedTypes as $acceptedType) { 85 | switch ($acceptedType) { 86 | case 'string': 87 | if (is_string($subject) || $subject instanceof \Magento\Framework\Phrase) { 88 | $this->container[$propName] = (string) $subject; 89 | continue 3; 90 | } 91 | break; 92 | case 'int': 93 | if (is_int($subject)) { 94 | continue 3; 95 | } 96 | break; 97 | case 'float': 98 | if (is_float($subject) || gettype($subject) == 'double') { 99 | $this->container[$propName] = (float)$subject; 100 | continue 3; 101 | } 102 | break; 103 | case 'bool': 104 | if (is_bool($subject)) { 105 | continue 3; 106 | } 107 | break; 108 | case 'array': 109 | if (is_array($subject)) { 110 | continue 3; 111 | } 112 | break; 113 | default: 114 | if ($subject instanceof $acceptedType) { 115 | continue 3; 116 | } 117 | } 118 | } 119 | 120 | // Could not match to any accepted type 121 | $actualType = gettype($subject) == 'object' 122 | ? get_class($subject) 123 | : gettype($subject); 124 | // Undo removing '?' prefix, if needed 125 | $acceptedTypesString = $nullable && count($acceptedTypes) == 1 126 | ? '?'.$acceptedTypesString 127 | : $acceptedTypesString; 128 | throw new PropHasUnexpectedTypeException( 129 | "Prop '$propName' has unexpected type '$actualType', expected '$acceptedTypesString'" 130 | ); 131 | } 132 | return $this; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Model/Mason.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | private array $aliases; 14 | private Layout $layout; 15 | 16 | /** 17 | * @param array $aliases 18 | */ 19 | public function __construct( 20 | Layout $layout, 21 | array $aliases = [ 22 | // 'alias' => 'Vendor_Module::path/to/template.phtml' ; inject through frontend/di.xml or adminhtml/di.xml 23 | ] 24 | ){ 25 | $this->aliases = $aliases; 26 | $this->layout = $layout; 27 | } 28 | 29 | 30 | /** 31 | * @param array> $attributes 32 | * @param array $props 33 | */ 34 | public function __invoke( 35 | string $template = '', // Alias or Magento path 36 | array $attributes = [], 37 | array $props = [], 38 | string $block = Template::class 39 | ): string 40 | { 41 | // Is it a Vendor_Module::path/to/template.phtml Magento path? 42 | if(!preg_match('/^\w+_\w+::[\w\/]+\.phtml$/', $template)) { 43 | $template = $this->decodeAlias($template); 44 | } 45 | 46 | return $this->layout 47 | ->createBlock($block) 48 | ->setTemplate($template) 49 | ->setData('is_brick', true) 50 | ->setData('brick_attributes', $attributes) 51 | ->setData('brick_props', $props) 52 | ->toHtml(); 53 | } 54 | 55 | 56 | private function decodeAlias(string $alias): string 57 | { 58 | if(isset($this->aliases[$alias])) { 59 | return $this->aliases[$alias]; 60 | } 61 | throw new \InvalidArgumentException("\$mason alias [ $alias ] not configured."); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Plugin/TemplateEngine/PhpPlugin.php: -------------------------------------------------------------------------------- 1 | brickAttributesBagFactory = $brickAttributesBagFactory; 23 | $this->mason = $mason; 24 | $this->brickPropsBagFactory = $brickPropsBagFactory; 25 | } 26 | 27 | /** 28 | * @param string $fileName 29 | * @param array $dictionary 30 | * @return array|\Magento\Framework\View\Element\BlockInterface|string> 31 | */ 32 | public function beforeRender(Php $subject, BlockInterface $block, $fileName, array $dictionary = []): array 33 | { 34 | $dictionary['mason'] = $this->mason; 35 | 36 | if ($block->getData('is_brick')) { // @phpstan-ignore method.notFound 37 | $dictionary['attributes'] = $this->brickAttributesBagFactory->create( 38 | ['attributes' => $block->getData('brick_attributes')] // @phpstan-ignore method.notFound 39 | ); 40 | $dictionary['props'] = $this->brickPropsBagFactory->create( 41 | ['props' => $block->getData('brick_props')] // @phpstan-ignore method.notFound 42 | ); 43 | } 44 | 45 | return [$block, $fileName, $dictionary]; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/etc/di.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/etc/frontend/di.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | Corrivate_LayoutBricks::cms/block.phtml 8 | Corrivate_LayoutBricks::elements/link/external.phtml 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/etc/module.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/registration.php: -------------------------------------------------------------------------------- 1 | expect(['block_id' => 'string']); 8 | 9 | $cmsBlock = $this->getLayout() 10 | ->createBlock('Magento\Cms\Block\Block') 11 | ->setBlockId($props['block_id']) 12 | ->toHtml(); 13 | ?> 14 | 15 |
default(['class' => '']) ?>> 16 | 17 |
18 | -------------------------------------------------------------------------------- /src/view/base/templates/elements/link/external.phtml: -------------------------------------------------------------------------------- 1 | expect(['label' => 'string']) 9 | 10 | ?> 11 | 12 | default(['_target' => 'blank']) ?> > 13 | 14 | 16 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/Unit/BrickAttributesBagTest.php: -------------------------------------------------------------------------------- 1 | toHtml(); 19 | 20 | // ASSERT 21 | $this->assertSame(0, count($bag)); 22 | $this->assertSame('', $output); 23 | } 24 | 25 | 26 | public function testThatInitialAttributesArePrintedSeparatedBySpaces() 27 | { 28 | // ARRANGE 29 | $bag = new BrickAttributesBag(['class' => 'bg-black', 'foo' => 'bar']); 30 | 31 | // ACT 32 | // nothing to do here 33 | 34 | // ASSERT 35 | $this->assertSame(2, count($bag)); 36 | $this->assertSame('class="bg-black" foo="bar"', $bag->toHtml()); 37 | } 38 | 39 | 40 | public function testThatDefaultAttributesCanBeSet() 41 | { 42 | // ARRANGE 43 | $bag = new BrickAttributesBag(); 44 | 45 | // ACT 46 | $bag->default(['foo' => 'bar']); 47 | 48 | // ASSERT 49 | $this->assertSame(1, count($bag)); 50 | $this->assertSame('foo="bar"', $bag->toHtml()); 51 | } 52 | 53 | 54 | public function testThatDefaultClassesArePrependedBeforeInjectedClasses() 55 | { 56 | // ARRANGE 57 | $bag = new BrickAttributesBag(['class' => 'bg-black']); 58 | 59 | // ACT 60 | $bag->default(['class' => 'text-white']); 61 | 62 | // ASSERT 63 | $this->assertSame(1, count($bag)); 64 | $this->assertSame('class="text-white bg-black"', $bag->toHtml()); 65 | } 66 | 67 | 68 | public function testThatBooleanAttributesAreRenderedOnlyIfTruthy() 69 | { 70 | // ARRANGE 71 | $bag = new BrickAttributesBag([ 72 | 'required', 73 | 'checked' => true, 74 | 'autoplay', 75 | 'disabled' => false, 76 | 'selected' => 'false' // haha! string is not falsey! 77 | ]); 78 | 79 | // ACT 80 | // nothing to do here 81 | 82 | // ASSERT 83 | $this->assertSame('required checked autoplay selected', $bag->toHtml()); 84 | } 85 | 86 | 87 | public function testThatInjectedBooleanAttributesTrumpDefaultValues() 88 | { 89 | // ARRANGE 90 | $bag = new BrickAttributesBag([ 91 | 'checked' => true, 92 | 'autoplay', 93 | 'disabled' => false, 94 | 'selected' => 'false' // haha! string is not falsey! 95 | ]); 96 | 97 | // ACT 98 | $bag->default(['required', 'checked' => false, 'disabled' => true]); 99 | 100 | // ASSERT 101 | $this->assertSame('required checked autoplay selected', $bag->toHtml()); 102 | } 103 | 104 | 105 | public function testThatInjectedStylesAreAppendedAfterDefaults(){ 106 | // ARRANGE 107 | $bag = new BrickAttributesBag([ 108 | 'style' => 'height:12px' // intentionally forgetting closing ';' 109 | ]); 110 | 111 | // ACT 112 | $bag->default(['style' => 'width:12px;']); 113 | 114 | // ASSERT 115 | $this->assertSame('style="width:12px; height:12px;"', $bag->toHtml()); 116 | } 117 | 118 | 119 | public function testThatWhereStartsWithReturnsANewBagWithOnlyThoseAttributes(){ 120 | // ARRANGE 121 | $bag = new BrickAttributesBag([ 122 | 'required', 123 | 'disabled', 124 | 'checked', 125 | 'selected', 126 | 'readonly' 127 | ]); 128 | 129 | // ACT 130 | $only = $bag->whereStartsWith('re'); 131 | 132 | // ASSERT 133 | $this->assertSame('required disabled checked selected readonly', $bag->toHtml()); 134 | $this->assertSame('required readonly', $only->toHtml()); 135 | } 136 | 137 | 138 | public function testThatWhereDoesntStartWithReturnsANewBagWithoutThoseAttributes(){ 139 | // ARRANGE 140 | $bag = new BrickAttributesBag([ 141 | 'required', 142 | 'disabled', 143 | 'checked', 144 | 'selected', 145 | 'readonly' 146 | ]); 147 | 148 | // ACT 149 | $without = $bag->whereDoesntStartWith('re'); 150 | 151 | // ASSERT 152 | $this->assertSame('required disabled checked selected readonly', $bag->toHtml()); 153 | $this->assertSame('disabled checked selected', $without->toHtml()); 154 | } 155 | 156 | 157 | public function testThatOnlyReturnsABagWithOnlyThoseAttributes(){ 158 | // ARRANGE 159 | $bag = new BrickAttributesBag([ 160 | 'required', 161 | 'disabled', 162 | 'checked', 163 | 'selected', 164 | 'readonly' 165 | ]); 166 | 167 | // ACT 168 | $without = $bag->only('disabled', 'checked', 'selected'); 169 | 170 | // ASSERT 171 | $this->assertSame('required disabled checked selected readonly', $bag->toHtml()); 172 | $this->assertSame('disabled checked selected', $without->toHtml()); 173 | } 174 | 175 | 176 | public function testThatWithoutReturnsABagWithoutThoseAttributes(){ 177 | // ARRANGE 178 | $bag = new BrickAttributesBag([ 179 | 'required', 180 | 'disabled', 181 | 'checked', 182 | 'selected', 183 | 'readonly' 184 | ]); 185 | 186 | // ACT 187 | $without = $bag->without('disabled', 'checked', 'selected'); 188 | 189 | // ASSERT 190 | $this->assertSame('required disabled checked selected readonly', $bag->toHtml()); 191 | $this->assertSame('required readonly', $without->toHtml()); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /tests/Unit/BrickPropsBagTest.php: -------------------------------------------------------------------------------- 1 | assertSame(0, count($bag)); 25 | } 26 | 27 | public function testThatInitialPropsCanBeAccessed() 28 | { 29 | // ARRANGE 30 | $bag = new BrickPropsBag(['foo' => 'bar']); 31 | 32 | // ACT 33 | // nothing to do here 34 | 35 | // ASSERT 36 | $this->assertSame('bar', $bag['foo']); 37 | } 38 | 39 | public function testThatDefaultPropsCanBeMergedAndAccessed() 40 | { 41 | // ARRANGE 42 | $bag = new BrickPropsBag(); 43 | 44 | // ACT 45 | $bag->default(['foo' => 'bar']); 46 | 47 | // ASSERT 48 | $this->assertSame('bar', $bag['foo']); 49 | } 50 | 51 | public function testThatInjectedPropsReplaceDefaultProps() 52 | { 53 | // ARRANGE 54 | $bag = new BrickPropsBag(['foo' => 'baz']); 55 | 56 | // ACT 57 | $bag->default(['foo' => 'bar']); 58 | 59 | // ASSERT 60 | $this->assertSame('baz', $bag['foo']); 61 | } 62 | 63 | 64 | public function testThatBothDefaultAndInjectedPropsCanBeUsedTogether() 65 | { 66 | // ARRANGE 67 | $bag = new BrickPropsBag(['foo' => 'baz']); 68 | 69 | // ACT 70 | $bag->default(['bar' => 'bar']); 71 | 72 | // ASSERT 73 | $this->assertSame('baz', $bag['foo']); 74 | $this->assertSame('bar', $bag['bar']); 75 | } 76 | 77 | 78 | public function testThatPropBagAcceptsMetExpectations() 79 | { 80 | // ARRANGE 81 | $bag = new BrickPropsBag([ 82 | 'block_id' => 'test_block', 83 | 'label' => __('OK'), 84 | 'count' => 3, 85 | 'price' => 3.14 86 | ]); 87 | 88 | // EXPECT 89 | $this->expectNotToPerformAssertions(); 90 | 91 | // ACT 92 | $bag->expect([ 93 | 'block_id' => 'string', 94 | 'label' => 'string', 95 | 'count' => 'int', 96 | 'price' => 'float', 97 | 'option' => '?string' 98 | ]); 99 | } 100 | 101 | 102 | public function testThatMissedExpectedPropsAreDetected() 103 | { 104 | // ARRANGE 105 | $bag = new BrickPropsBag(); 106 | 107 | // EXPECT 108 | $this->expectException(PropIsMissingException::class); 109 | $this->expectExceptionMessage("Expected prop 'price' with type(s) 'float' but did not receive it."); 110 | 111 | // ACT 112 | $bag->expect(['price' => 'float']); 113 | } 114 | 115 | 116 | public function testThatIncorrectlyTypedPropsAreDetected() 117 | { 118 | // ARRANGE 119 | $bag = new BrickPropsBag(['price' => '1.5']); 120 | 121 | // EXPECT 122 | $this->expectException(PropHasUnexpectedTypeException::class); 123 | $this->expectExceptionMessage("Prop 'price' has unexpected type 'string', expected 'float'"); 124 | 125 | // ACT 126 | $bag->expect(['price' => 'float']); 127 | } 128 | 129 | public function testThatNullablePropsAreStillCheckedIfPresent() 130 | { 131 | // ARRANGE 132 | $bag = new BrickPropsBag(['price' => '1.5']); // string, not float! 133 | 134 | // EXPECT 135 | $this->expectException(PropHasUnexpectedTypeException::class); 136 | $this->expectExceptionMessage("Prop 'price' has unexpected type 'string', expected '?float'"); 137 | 138 | // ACT 139 | $bag->expect(['price' => '?float']); 140 | } 141 | 142 | 143 | public function testThatNullablePropsAreNotCheckedIfAbsent() 144 | { 145 | // ARRANGE 146 | $bag = new BrickPropsBag([]); 147 | 148 | // ACT 149 | $bag->expect(['price' => '?float']); 150 | 151 | $this->assertSame(null, $bag['price']); 152 | } 153 | 154 | public function testThatNullablePropsAreAcceptedIfPresentAndCorrect() 155 | { 156 | // ARRANGE 157 | $bag = new BrickPropsBag(['price' => 1.5]); 158 | 159 | // ACT 160 | $bag->expect(['price' => '?float']); 161 | 162 | $this->assertSame(1.5, $bag['price']); 163 | } 164 | 165 | 166 | public function testThatPropExpectationsAcceptChildClassesAndImplementation() 167 | { 168 | // ARRANGE 169 | $bag = new BrickPropsBag([ 170 | 'exception' => new PropIsMissingException(), 171 | 'array' => new BrickAttributesBag() 172 | ]); 173 | 174 | // EXPECT 175 | $this->expectNotToPerformAssertions(); 176 | 177 | // ACT 178 | $bag->expect([ 179 | 'exception' => BrickException::class, // parent class 180 | 'array' => \ArrayAccess::class // interface 181 | ]); 182 | } 183 | 184 | 185 | public function testThatPropStringExpectationsAcceptPhrases() 186 | { 187 | // ARRANGE 188 | $bag = new BrickPropsBag(['label' => __('OK')]); 189 | 190 | // EXPECT 191 | $this->expectNotToPerformAssertions(); 192 | 193 | // ACT 194 | $bag->expect(['label' => 'string']); 195 | } 196 | 197 | 198 | public function testThatPropExpectationsCanTestCombinedTypes() 199 | { 200 | // ARRANGE 201 | $bag = new BrickPropsBag([ 202 | 'qty_ordered' => 5.5, 203 | 'qty_shipped' => 4 204 | ]); 205 | 206 | // EXPECT 207 | $this->expectNotToPerformAssertions(); 208 | 209 | // ACT 210 | $bag->expect(['qty_ordered' => 'int|float', 'qty_shipped' => 'int|float']); 211 | } 212 | 213 | public function testThatPropExpectationsRejectInvalidNullableDefinitions() 214 | { 215 | // ARRANGE 216 | $bag = new BrickPropsBag([ 217 | 'qty_ordered' => 5.5, 218 | 'qty_shipped' => 4 219 | ]); 220 | 221 | // EXPECT 222 | $this->expectException(PropExpectedTypeStringInvalidException::class); 223 | $this->expectExceptionMessage("Cannot use '?' to start a prop's type-string AND use |; use |null instead."); 224 | 225 | // ACT 226 | $bag->expect(['qty_ordered' => '?int|float', 'qty_shipped' => 'int|float']); 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /tests/Unit/MasonTest.php: -------------------------------------------------------------------------------- 1 | createMock(Layout::class); 15 | $block = $this->createMock(\Magento\Framework\View\Element\Template::class); 16 | 17 | $layout->expects($this->once())->method('createBlock')->with(\Magento\Framework\View\Element\Template::class)->willReturn($block); 18 | $block->expects($this->once())->method('setTemplate')->with('Corrivate_LayoutBricks::elements/link/external.phtml')->willReturn($block); 19 | $block->expects($this->atLeastOnce())->method('setData')->willReturn($block); 20 | 21 | /** @var Layout $layout */ 22 | $mason = new Mason( 23 | $layout, 24 | ['elements.link.external' => 'Corrivate_LayoutBricks::elements/link/external.phtml'] 25 | ); 26 | 27 | // ACT 28 | try { 29 | $mason('elements.link.external'); 30 | } catch (\TypeError $e) { 31 | // Type error is to be expected, because we didn't give $mason a real Layout or Block to work with. 32 | } 33 | 34 | // ASSERT 35 | // Our mocks have received expected method calls 36 | } 37 | 38 | 39 | public function testThatItThrowsWhenGivenAnUnknownAlias(){ 40 | // ARRANGE 41 | $layout = $this->createMock(Layout::class); 42 | 43 | 44 | /** @var Layout $layout */ 45 | $mason = new Mason( 46 | $layout, 47 | [] // No aliases configured 48 | ); 49 | 50 | $this->expectException(\InvalidArgumentException::class); 51 | 52 | // ACT 53 | $mason('elements.link.external'); 54 | 55 | // ASSERT 56 | // The exception should have been thrown 57 | } 58 | 59 | 60 | public function testItDoesNotTreatAMagentoPathAsAlias(){ 61 | // ARRANGE 62 | $layout = $this->createMock(Layout::class); 63 | $block = $this->createMock(\Magento\Framework\View\Element\Template::class); 64 | 65 | $layout->expects($this->once())->method('createBlock')->with(\Magento\Framework\View\Element\Template::class)->willReturn($block); 66 | $block->expects($this->once())->method('setTemplate')->with('Corrivate_LayoutBricks::elements/link/external.phtml')->willReturn($block); 67 | $block->expects($this->atLeastOnce())->method('setData')->willReturn($block); 68 | $block->expects($this->atLeastOnce())->method('toHtml')->willReturn('html'); 69 | 70 | 71 | /** @var Layout $layout */ 72 | $mason = new Mason( 73 | $layout, 74 | [] // No aliases configured 75 | ); 76 | 77 | // ACT 78 | $mason('Corrivate_LayoutBricks::elements/link/external.phtml'); 79 | 80 | // ASSERT 81 | // The exception should NOT have been thrown 82 | } 83 | 84 | } 85 | --------------------------------------------------------------------------------