├── .github ├── banner.png └── workflows │ └── tests.yml ├── .gitignore ├── LICENSE ├── Plugin.php ├── README.md ├── assets ├── dist │ └── js │ │ ├── blocks.js │ │ └── blocks.js.LICENSE.txt └── src │ └── js │ ├── blocks.js │ └── utilities │ └── Actions.js ├── blocks ├── button.block ├── button_group.block ├── cards.block ├── code.block ├── columns_two.block ├── divider.block ├── image.block ├── plaintext.block ├── richtext.block ├── title.block ├── video.block ├── vimeo.block └── youtube.block ├── classes ├── Block.php ├── BlockBuilder.php ├── BlockCode.php ├── BlockManager.php ├── BlockParser.php ├── BlockProcessor.php └── BlocksDatasource.php ├── codecov.yml ├── composer.json ├── formwidgets ├── Block.php ├── Blocks.php └── blocks │ ├── assets │ ├── css │ │ └── blocks.css │ ├── js │ │ └── blocks.js │ └── less │ │ └── blocks.less │ └── partials │ ├── _block.php │ ├── _block_add_item.php │ └── _block_item.php ├── lang ├── en │ └── lang.php └── fr │ └── lang.php ├── meta └── actions.yaml ├── phpunit.xml ├── tests ├── PluginTest.php ├── classes │ └── BlockManagerTest.php ├── fixtures │ ├── blocks │ │ ├── container.block │ │ ├── richtext.block │ │ └── title.block │ ├── models │ │ └── Page.php │ └── themes │ │ └── blocktest │ │ └── blocks │ │ └── title.block └── formwidgets │ └── BlocksTest.php ├── updates └── version.yaml └── winter.mix.js /.github/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wintercms/wn-blocks-plugin/a3817d5f8027982690dfca0cc690c37b07653afb/.github/banner.png -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | phpUnitTests: 11 | name: ${{ matrix.operatingSystem }} / PHP ${{ matrix.phpVersion }} 12 | runs-on: ${{ matrix.operatingSystem }} 13 | strategy: 14 | max-parallel: 4 15 | matrix: 16 | operatingSystem: [ubuntu-latest, windows-latest] 17 | phpVersion: ['8.1', '8.2', '8.3', '8.4'] 18 | steps: 19 | - name: Setup Winter 20 | uses: wintercms/setup-winter-action@v1 21 | with: 22 | php-version: ${{ matrix.phpVersion }} 23 | plugin-author: winter 24 | plugin-name: blocks 25 | 26 | - name: Run tests 27 | if: matrix.phpVersion != '8.1' || matrix.operatingSystem != 'ubuntu-latest' 28 | run: php artisan winter:test -p Winter.Blocks -- --testdox 29 | 30 | - name: Run tests (and generate coverage report) 31 | if: matrix.phpVersion == '8.1' && matrix.operatingSystem == 'ubuntu-latest' 32 | env: 33 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 34 | run: | 35 | XDEBUG_MODE=coverage php artisan winter:test -p Winter.Blocks -- --testdox --coverage-clover coverage.xml 36 | bash <(curl -s https://codecov.io/bash) 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .phpunit.result.cache 2 | mix.webpack.js 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Winter CMS 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 | -------------------------------------------------------------------------------- /Plugin.php: -------------------------------------------------------------------------------- 1 | 'winter.blocks::lang.plugin.name', 31 | 'description' => 'winter.blocks::lang.plugin.description', 32 | 'author' => 'Winter CMS', 33 | 'icon' => 'icon-cubes', 34 | ]; 35 | } 36 | 37 | /** 38 | * Registers the custom Blocks provided by this plugin 39 | */ 40 | public function registerBlocks(): array 41 | { 42 | return [ 43 | 'button' => '$/winter/blocks/blocks/button.block', 44 | 'button_group' => '$/winter/blocks/blocks/button_group.block', 45 | 'cards' => '$/winter/blocks/blocks/cards.block', 46 | 'code' => '$/winter/blocks/blocks/code.block', 47 | 'columns_two' => '$/winter/blocks/blocks/columns_two.block', 48 | 'divider' => '$/winter/blocks/blocks/divider.block', 49 | 'image' => '$/winter/blocks/blocks/image.block', 50 | 'plaintext' => '$/winter/blocks/blocks/plaintext.block', 51 | 'richtext' => '$/winter/blocks/blocks/richtext.block', 52 | 'title' => '$/winter/blocks/blocks/title.block', 53 | 'video' => '$/winter/blocks/blocks/video.block', 54 | 'vimeo' => '$/winter/blocks/blocks/vimeo.block', 55 | 'youtube' => '$/winter/blocks/blocks/youtube.block', 56 | ]; 57 | } 58 | 59 | /** 60 | * Registers the custom FormWidgets provided by this plugin 61 | */ 62 | public function registerFormWidgets(): array 63 | { 64 | return [ 65 | \Winter\Blocks\FormWidgets\Blocks::class => 'blocks' 66 | ]; 67 | } 68 | 69 | /** 70 | * Registers the custom twig markups provided by this plugin 71 | */ 72 | public function registerMarkupTags() 73 | { 74 | return [ 75 | 'functions' => [ 76 | 'renderBlock' => [ 77 | function (array $context, string|array $block, array $data = []) { 78 | return BlockModel::render( 79 | $block, 80 | $data, 81 | $context['this']['controller'] ?? null 82 | ); 83 | }, 84 | 'options' => ['needs_context' => true] 85 | ], 86 | 'renderBlocks' => [ 87 | function (array $context, array $blocks) { 88 | return BlockModel::renderAll( 89 | $blocks, 90 | $context['this']['controller'] ?? null 91 | ); 92 | }, 93 | 'options' => ['needs_context' => true] 94 | ], 95 | ], 96 | ]; 97 | } 98 | 99 | /** 100 | * Boot method, called right before the request route. 101 | */ 102 | public function boot(): void 103 | { 104 | $this->extendThemeDatasource(); 105 | $this->extendControlLibraryBlocks(); 106 | } 107 | 108 | /** 109 | * Extend the theme's datasource to include the BlocksDatasource for loading blocks from 110 | */ 111 | protected function extendThemeDatasource(): void 112 | { 113 | // Register the block manager instance 114 | BlockManager::instance(); 115 | Event::listen('cms.theme.registerHalcyonDatasource', function (Theme $theme, $resolver) { 116 | $source = $theme->getDatasource(); 117 | if ($source instanceof AutoDatasource) { 118 | /* @var AutoDatasource $source */ 119 | $source->appendDatasource('blocks', new BlocksDatasource()); 120 | return; 121 | } else { 122 | $resolver->addDatasource($theme->getDirName(), new AutoDatasource([ 123 | 'theme' => $source, 124 | 'blocks' => new BlocksDatasource(), 125 | ], 'blocks-autodatasource')); 126 | } 127 | }); 128 | } 129 | 130 | /** 131 | * Extend the ControlLibrary provided by Winter.Builder to register blocks as Form Controls 132 | */ 133 | protected function extendControlLibraryBlocks(): void 134 | { 135 | // Register blocks as custom controls 136 | Event::listen('pages.builder.registerControls', function (\Winter\Builder\Classes\ControlLibrary $controlLibrary) { 137 | foreach (BlockManager::instance()->getConfigs('forms') as $key => $config) { 138 | // Map custom fields into standard properties, while ignoring irrelevant properties 139 | $properties = $controlLibrary->getStandardProperties([ 140 | 'label', 'required', 'comment', 'placeholder', 'default', 'defaultFrom', 'stretch' 141 | ], array_combine( 142 | array_map( 143 | fn($field) => sprintf('data[%s]', $field), 144 | array_keys($config['fields'] ?? []) 145 | ), 146 | array_values( 147 | array_map( 148 | fn ($field) => array_merge($field, [ 149 | 'title' => $field['label'] ?? '', 150 | 'tab' => 'Field Options' 151 | ]), 152 | $config['fields'] ?? [] 153 | ) 154 | ) 155 | )); 156 | 157 | // Sort custom fields to the top 158 | uksort($properties, fn ($a, $b) => str_contains($key, 'data[') ? 1 : $a <=> $b); 159 | 160 | $controlLibrary->registerControl( 161 | Block::TYPE_PREFIX . $key, 162 | $config['name'], 163 | $config['description'], 164 | Block::GROUP_BLOCKS, 165 | $config['icon'], 166 | $properties, 167 | null 168 | ); 169 | } 170 | }, PHP_INT_MIN); 171 | 172 | // Register a Winter\Blocks\FormWidgets\Block FormWidget under each block's key 173 | WidgetManager::instance()->registerFormWidgets(function ($manager) { 174 | foreach (BlockManager::instance()->getConfigs() as $key => $config) { 175 | $manager->registerFormWidget(Block::class, Block::TYPE_PREFIX . $key); 176 | } 177 | }); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blocks Plugin 2 | 3 | ![Blocks Plugin](https://github.com/wintercms/wn-blocks-plugin/blob/main/.github/banner.png?raw=true) 4 | 5 | [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/wintercms/wn-blocks-plugin/blob/main/LICENSE) 6 | 7 | Provides a "block based" content management experience in Winter CMS 8 | 9 | >**NOTE:** This plugin is still in development and is likely to undergo changes. Do not use in production environments without using a version constraint in your composer.json file and carefully monitoring for breaking changes. 10 | 11 | ## Installation 12 | 13 | This plugin is available for installation via [Composer](http://getcomposer.org/). 14 | 15 | ```bash 16 | composer require winter/wn-blocks-plugin 17 | ``` 18 | 19 | After installing the plugin you will need to run the migrations and (if you are using a [public folder](https://wintercms.com/docs/develop/docs/setup/configuration#using-a-public-folder)) [republish your public directory](https://wintercms.com/docs/develop/docs/console/setup-maintenance#mirror-public-files). 20 | 21 | ```bash 22 | php artisan migrate 23 | ``` 24 | 25 | >**NOTE:** In order to have the `actions` support function correctly, you need to load `/plugins/winter/blocks/assets/dist/js/blocks.js` after the Snowboard framework has been loaded. 26 | 27 | ## Core Concepts 28 | 29 | ### Blocks 30 | 31 | This plugin manages the concept of "blocks" in Winter CMS. Blocks are self contained pieces of structured content that can be managed and rendered in a variety of ways. 32 | 33 | Blocks can be provided by both plugins and themes and can be overridden by themes. 34 | 35 | ### Actions 36 | 37 | This plugin also introduces the concepts of "actions"; a way to define and execute client side actions that can be triggered by various events. Currently, actions are only defined in the `$/winter/blocks/meta/actions.yaml` file and must exist as a function on the `window.actions` object in the frontend keyed by the action's identifier that receives the `data` object as the first argument and (optionally) the `event` object that triggered the action as the second argument. 38 | 39 | >**NOTE:** This is very much a WIP API and is subject to change. Feedback very much welcome here for ideas around how to register, manage, extend, and provide actions to the frontend. 40 | 41 | ### Tags 42 | 43 | Blocks may have one or more tags, which is a way of defining and grouping blocks. For example, you may have a Gallery block which allows only "image" tagged blocks to be used, or a container block which allows all "content" tagged blocks but does not allow another "container" tagged block within. 44 | 45 | Tags are defined in the blocks, and can be used to filter the available blocks in the Blocks form widget. 46 | 47 | 48 | ## Registering Blocks 49 | 50 | Themes can have their blocks automatically registered by placing `.block` files in the `/blocks` folder and subfolders. 51 | 52 | Plugins can register blocks by providing a `registerBlocks()` method in their Plugin.php file. The method should return an array of block definitions in the following format: 53 | 54 | ```php 55 | public function registerBlocks(): array 56 | { 57 | return [ 58 | 'example' => '$/myauthor/myplugin/blocks/example.block', 59 | ]; 60 | } 61 | ``` 62 | 63 | 68 | 69 | 70 | ## Block Definition 71 | 72 | Blocks are defined as `.block` files that consist of 2 to 3 parts: 73 | 74 | - A YAML configuration section that defines the block's name, description, and other metadata as well as the block's properties and the form used to edit those properties. 75 | - A PHP code section that allows for basic code to be executed when the block is rendered, similar to a partial. 76 | - A Twig template section that defines the HTML markup template of the block. 77 | 78 | When there are two parts, they are the Settings (YAML) & Markup (Twig) sections. 79 | 80 | The following property values (name, description, etc) can be defined in the Settings (YAML) section of the `.block` files: 81 | 82 | ```yaml 83 | name: Example 84 | description: Example Block Description 85 | icon: icon-name 86 | tags: [] # Defines the tags that this block is associated with 87 | permissions: [] # List of permissions required to interact with the block 88 | fields: # The form fields used to populate the block's content 89 | config: # The block configuration options 90 | ``` 91 | 92 | Blocks can use components in them, although they may face lifecycle limitations with complex AJAX handlers similar to component support in partials. 93 | 94 | ### Fields and Configuration 95 | 96 | Blocks may define both `fields` as well as a `config` property in the Settings. Both of these parameters accept a [form schema](https://wintercms.com/docs/backend/forms#form-fields), but serve different purposes. In general, `fields` should contain the fields that actually fill in the content of the block, whereas the `config` should contain the fields that define the appearance or structure of the block itself. Fields are displayed within the block in the `blocks` form widget and configuration is displayed in an Inspector which can be shown by clicking on the "cogwheel" icon of a block in the `blocks` form widget. 97 | 98 | For example, let's say you have a **Title** block which can display a heading tag in your content. You may optionally want to align it to left, center or right, and define which heading tag to use. The best practice would be to have a `content` field in the `fields` definition, because it's the actual content being displayed. The `alignment` and `tag` would become part of the `config` configuration. 99 | 100 | **Example:** 101 | 102 | ``` 103 | name: Title 104 | description: Adds a title 105 | icon: icon-heading 106 | tags: ["content"] 107 | fields: 108 | content: 109 | label: false 110 | span: full 111 | type: text 112 | config: 113 | size: 114 | label: Size 115 | span: auto 116 | type: dropdown 117 | default: h2 118 | options: 119 | h1: H1 120 | h2: H2 121 | h3: H3 122 | h4: H4 123 | h5: H5 124 | alignment_x: 125 | label: Alignment 126 | span: auto 127 | type: dropdown 128 | default: center 129 | options: 130 | left: Left 131 | center: Centre 132 | right: Right 133 | == 134 | {% if config.alignment_x == 'left' %} 135 | {% set alignment = 'text-left' %} 136 | {% elseif config.alignment_x == 'center' or not config.alignment_x %} 137 | {% set alignment = 'text-center' %} 138 | {% elseif config.alignment_x == 'right' %} 139 | {% set alignment = 'text-right' %} 140 | {% endif %} 141 | 142 | <{{ config.size }} class="{{ alignment }}"> 143 | {{ content }} 144 | 145 | ``` 146 | 147 | ## Using the `blocks` FormWidget 148 | 149 | In order to provide an interface for managing block-based content, this plugin provides the `blocks` FormWidget. This widget can be used in the backend as a form field to manage blocks. 150 | 151 | The `blocks` FormWidget supports the following additional properties: 152 | 153 | - `allow`: An array of block types that are allowed to be added to the widget. If specified, only those block types listed will be available to add to the current instance of the field. You can define either a straight array of individual blocks to allow, or define an object with `tags` and/or `blocks` to allow whole tags or individual blocks. 154 | - `ignore`: A list of block types that are not allowed to be added to the widget. If not specified, all block types will be available to add to the current instance of the field. You can define either a straight array of individual blocks to ignore, or define an object with `tags` and/or `blocks` to ignore whole tags or individual blocks. 155 | - `tags`: A list of block tags that are allowed to be added to the widget. If specified, only block types that have at least one of the listed tags will be available to add to the current instance of the field. 156 | 157 | Those properties allow you to limit the block types that can be added to a specific instance of the widget, which can be very helpful when building "container" type blocks that need to avoid including themselves or only support a specific set of blocks as "children". 158 | 159 | ### Examples 160 | 161 | The `button_group` block type only allows a `button` block to be added to it: 162 | 163 | ```yaml 164 | buttons: 165 | label: Buttons 166 | span: full 167 | type: blocks 168 | allow: 169 | - button 170 | ``` 171 | 172 | The `container` block type allows any block called `title`, or has a tag of `content`, to be added to it: 173 | 174 | ```yaml 175 | container: 176 | label: Container 177 | span: full 178 | type: blocks 179 | allow: 180 | blocks: 181 | - title 182 | tags: 183 | - content 184 | ``` 185 | 186 | The `columns_two` block type allows every block except for itself to be added to it: 187 | 188 | ```yaml 189 | left: 190 | label: Left Column 191 | span: left 192 | type: blocks 193 | ignore: 194 | - columns_two 195 | right: 196 | label: Right Column 197 | span: right 198 | type: blocks 199 | ignore: 200 | - columns_two 201 | ``` 202 | 203 | ### Integration with the Winter.Pages plugin: 204 | 205 | Include the following line in your layout file to include the blocks FormWidget on a Winter.Pages page: 206 | 207 | ```twig 208 | {variable type="blocks" name="blocks" tags="pages" tab="winter.pages::lang.editor.content"}{/variable} 209 | ``` 210 | 211 | 212 | ## Rendering Blocks 213 | 214 | ### Using Twig 215 | 216 | Twig functions are provided by this plugin for rendering blocks. 217 | You can then use the following Twig snippet to render the blocks data in your layout: 218 | 219 | ```twig 220 | {{ renderBlocks(blocks) }} 221 | ``` 222 | 223 | You can use it anywhere an expression is accepted: 224 | 225 | ```twig 226 | {{ ('

Some text

' ~ renderBlocks(blocks) ~ '

Some more text

') | raw }} 227 | 228 | {% set myContent = renderBlocks(blocks) %} 229 | ``` 230 | 231 | If you need to render a single block, you can use the `renderBlock` function: 232 | 233 | ```twig 234 | {{ renderBlock({ 235 | '_group':'title', 236 | 'content':'Lorem ipsum dolor sit amet.', 237 | 'alignment_x':'left', 238 | 'size':'h1', 239 | }) }} 240 | 241 | {{ renderBlock('title', { 242 | 'content':'Lorem ipsum dolor sit amet.', 243 | 'alignment_x':'left', 244 | 'size':'h1', 245 | }) }} 246 | ``` 247 | 248 | ### Using a partial 249 | 250 | If you need to customize the rendering of blocks according to their group, you can use a special `blocks.htm` partial in your theme: 251 | 252 | ```twig 253 | {% for blockIndex, block in blocks %} 254 | {# Adding blocks to the following array allows them to implement their own containers #} 255 | {% if block._group in ["hero", "section"] %} 256 | {{ renderBlock(block) }} 257 | {% else %} 258 |

259 |
260 | {{ renderBlock(block) }} 261 |
262 |
263 | {% endif %} 264 | {% endfor %} 265 | ``` 266 | 267 | You can then use the following Twig snippet to render the block data in your layout: 268 | 269 | ```twig 270 | {% partial 'blocks' blocks=blocks %} 271 | ``` 272 | 273 | ### Using PHP 274 | 275 | ```php 276 | use Winter\Blocks\Classes\Block; 277 | 278 | // Render a single block from stored data 279 | Block::render($model->blocks[0]); 280 | 281 | // Render an array of blocks from stored data 282 | Block::renderAll($model->blocks); 283 | 284 | // Render a single block manually 285 | Block::render('title', [ 286 | 'content' => 'Lorem ipsum dolor sit amet.', 287 | 'alignment_x' => 'left', 288 | 'size' => 'h1', 289 | ]); 290 | 291 | // Render a single block manually using only array data 292 | Block::render([ 293 | '_group' => 'title', 294 | 'content' => 'Lorem ipsum dolor sit amet.', 295 | 'alignment_x' => 'left', 296 | 'size' => 'h1', 297 | ]); 298 | ``` 299 | 300 | 301 | ## Integrating with TailwindCSS / CSS Purging 302 | 303 | If your theme uses CSS class purging (i.e. Tailwind), it can be useful to add the following paths to your build configuration to include the styles for any blocks defined by the theme or plugins. 304 | 305 | ```js 306 | // tailwind.config.js 307 | module.exports = { 308 | content: [ 309 | // Winter.Pages static page content 310 | './content/**/*.htm', 311 | './layouts/**/*.htm', 312 | './pages/**/*.htm', 313 | './partials/**/*.htm', 314 | './blocks/**/*.block', 315 | 316 | // Blocks provided by plugins 317 | '../../plugins/*/*/blocks/*.block', 318 | ], 319 | }; 320 | ``` 321 | 322 | 323 | ## Feedback 324 | 325 | > The Winter.Blocks is perfect for my block-based themes. I've been looking for something like this for a long time 326 | -------------------------------------------------------------------------------- /assets/dist/js/blocks.js: -------------------------------------------------------------------------------- 1 | /*! For license information please see blocks.js.LICENSE.txt */ 2 | (()=>{"use strict";var t,r={331:()=>{function t(r){return t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},t(r)}function r(){r=function(){return e};var e={},n=Object.prototype,o=n.hasOwnProperty,i=Object.defineProperty||function(t,r,e){t[r]=e.value},a="function"==typeof Symbol?Symbol:{},c=a.iterator||"@@iterator",u=a.asyncIterator||"@@asyncIterator",f=a.toStringTag||"@@toStringTag";function l(t,r,e){return Object.defineProperty(t,r,{value:e,enumerable:!0,configurable:!0,writable:!0}),t[r]}try{l({},"")}catch(t){l=function(t,r,e){return t[r]=e}}function s(t,r,e,n){var o=r&&r.prototype instanceof y?r:y,a=Object.create(o.prototype),c=new _(n||[]);return i(a,"_invoke",{value:x(t,e,c)}),a}function p(t,r,e){try{return{type:"normal",arg:t.call(r,e)}}catch(t){return{type:"throw",arg:t}}}e.wrap=s;var h={};function y(){}function v(){}function d(){}var w={};l(w,c,(function(){return this}));var b=Object.getPrototypeOf,g=b&&b(b(S([])));g&&g!==n&&o.call(g,c)&&(w=g);var m=d.prototype=y.prototype=Object.create(w);function O(t){["next","throw","return"].forEach((function(r){l(t,r,(function(t){return this._invoke(r,t)}))}))}function j(r,e){function n(i,a,c,u){var f=p(r[i],r,a);if("throw"!==f.type){var l=f.arg,s=l.value;return s&&"object"==t(s)&&o.call(s,"__await")?e.resolve(s.__await).then((function(t){n("next",t,c,u)}),(function(t){n("throw",t,c,u)})):e.resolve(s).then((function(t){l.value=t,c(l)}),(function(t){return n("throw",t,c,u)}))}u(f.arg)}var a;i(this,"_invoke",{value:function(t,r){function o(){return new e((function(e,o){n(t,r,e,o)}))}return a=a?a.then(o,o):o()}})}function x(t,r,e){var n="suspendedStart";return function(o,i){if("executing"===n)throw new Error("Generator is already running");if("completed"===n){if("throw"===o)throw i;return k()}for(e.method=o,e.arg=i;;){var a=e.delegate;if(a){var c=E(a,e);if(c){if(c===h)continue;return c}}if("next"===e.method)e.sent=e._sent=e.arg;else if("throw"===e.method){if("suspendedStart"===n)throw n="completed",e.arg;e.dispatchException(e.arg)}else"return"===e.method&&e.abrupt("return",e.arg);n="executing";var u=p(t,r,e);if("normal"===u.type){if(n=e.done?"completed":"suspendedYield",u.arg===h)continue;return{value:u.arg,done:e.done}}"throw"===u.type&&(n="completed",e.method="throw",e.arg=u.arg)}}}function E(t,r){var e=r.method,n=t.iterator[e];if(void 0===n)return r.delegate=null,"throw"===e&&t.iterator.return&&(r.method="return",r.arg=void 0,E(t,r),"throw"===r.method)||"return"!==e&&(r.method="throw",r.arg=new TypeError("The iterator does not provide a '"+e+"' method")),h;var o=p(n,t.iterator,r.arg);if("throw"===o.type)return r.method="throw",r.arg=o.arg,r.delegate=null,h;var i=o.arg;return i?i.done?(r[t.resultName]=i.value,r.next=t.nextLoc,"return"!==r.method&&(r.method="next",r.arg=void 0),r.delegate=null,h):i:(r.method="throw",r.arg=new TypeError("iterator result is not an object"),r.delegate=null,h)}function P(t){var r={tryLoc:t[0]};1 in t&&(r.catchLoc=t[1]),2 in t&&(r.finallyLoc=t[2],r.afterLoc=t[3]),this.tryEntries.push(r)}function L(t){var r=t.completion||{};r.type="normal",delete r.arg,t.completion=r}function _(t){this.tryEntries=[{tryLoc:"root"}],t.forEach(P,this),this.reset(!0)}function S(t){if(t){var r=t[c];if(r)return r.call(t);if("function"==typeof t.next)return t;if(!isNaN(t.length)){var e=-1,n=function r(){for(;++e=0;--n){var i=this.tryEntries[n],a=i.completion;if("root"===i.tryLoc)return e("end");if(i.tryLoc<=this.prev){var c=o.call(i,"catchLoc"),u=o.call(i,"finallyLoc");if(c&&u){if(this.prev=0;--e){var n=this.tryEntries[e];if(n.tryLoc<=this.prev&&o.call(n,"finallyLoc")&&this.prev=0;--r){var e=this.tryEntries[r];if(e.finallyLoc===t)return this.complete(e.completion,e.afterLoc),L(e),h}},catch:function(t){for(var r=this.tryEntries.length-1;r>=0;--r){var e=this.tryEntries[r];if(e.tryLoc===t){var n=e.completion;if("throw"===n.type){var o=n.arg;L(e)}return o}}throw new Error("illegal catch attempt")},delegateYield:function(t,r,e){return this.delegate={iterator:S(t),resultName:r,nextLoc:e},"next"===this.method&&(this.arg=void 0),h}},e}function e(t,r,e,n,o,i,a){try{var c=t[i](a),u=c.value}catch(t){return void e(t)}c.done?r(u):Promise.resolve(u).then(n,o)}function n(t,r){var e=Object.keys(t);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(t);r&&(n=n.filter((function(r){return Object.getOwnPropertyDescriptor(t,r).enumerable}))),e.push.apply(e,n)}return e}function o(t){for(var r=1;r{}},e={};function n(t){var o=e[t];if(void 0!==o)return o.exports;var i=e[t]={exports:{}};return r[t](i,i.exports,n),i.exports}n.m=r,t=[],n.O=(r,e,o,i)=>{if(!e){var a=1/0;for(l=0;l=i)&&Object.keys(n.O).every((t=>n.O[t](e[u])))?e.splice(u--,1):(c=!1,i0&&t[l-1][2]>i;l--)t[l]=t[l-1];t[l]=[e,o,i]},n.o=(t,r)=>Object.prototype.hasOwnProperty.call(t,r),(()=>{var t={983:0,488:0};n.O.j=r=>0===t[r];var r=(r,e)=>{var o,i,[a,c,u]=e,f=0;if(a.some((r=>0!==t[r]))){for(o in c)n.o(c,o)&&(n.m[o]=c[o]);if(u)var l=u(n)}for(r&&r(e);fn(331)));var o=n.O(void 0,[488],(()=>n(60)));o=n.O(o)})(); -------------------------------------------------------------------------------- /assets/dist/js/blocks.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */ 2 | -------------------------------------------------------------------------------- /assets/src/js/blocks.js: -------------------------------------------------------------------------------- 1 | import Actions from './utilities/Actions'; 2 | 3 | if (window.Snowboard === undefined) { 4 | throw new Error('Snowboard must be loaded in order to register the Blocks functionality.'); 5 | } 6 | 7 | ((Snowboard) => { 8 | Snowboard.addPlugin('actions', Actions); 9 | })(window.Snowboard); 10 | -------------------------------------------------------------------------------- /assets/src/js/utilities/Actions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Action Processor 3 | * 4 | * You can process actions manually by calling the following: 5 | * 6 | * ```js 7 | * Snowboard.addPlugin('actions', Actions); 8 | * Snowboard.actions().doActions(actions, event); 9 | * ``` 10 | * 11 | * @copyright 2023 Winter. 12 | * @author Luke Towers 13 | */ 14 | export default class Actions extends window.Snowboard.Singleton { 15 | /** 16 | * @TODO: This is terrible, find a better way to manage the available actions 17 | */ 18 | construct() { 19 | window.actions = window.actions || {}; 20 | window.actions = { 21 | ...(window.actions || {}), 22 | 23 | open_url: (data, event) => { 24 | if (typeof data.target === "undefined") { 25 | data.target = "_self"; 26 | } 27 | 28 | window.open(data.href, data.target); 29 | } 30 | }; 31 | } 32 | /** 33 | * Run the provided actions. 34 | * 35 | * @param {array} actions 36 | * @param {Object} event 37 | */ 38 | async doActions(actions, event) { 39 | if (event) { 40 | event.stopPropagation(); 41 | } 42 | 43 | if (!Array.isArray(actions)) { 44 | console.error(`Actions is not an array`); 45 | return; 46 | } 47 | 48 | actions.forEach((action) => { 49 | // @TODO: Terrible, find a better way to handle dynamically registering available actions 50 | if (typeof window.actions[action.action] !== 'function') { 51 | console.error(`Action ${action.action} does not exist on the window object`); 52 | return; 53 | } 54 | 55 | window.actions[action.action](action.data, event); 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /blocks/button.block: -------------------------------------------------------------------------------- 1 | name: winter.blocks::lang.blocks.button.name 2 | description: winter.blocks::lang.blocks.button.description 3 | icon: icon-caret-square-o-right 4 | tags: ["pages"] 5 | fields: 6 | config: 7 | type: nestedform 8 | usePanelStyles: false 9 | form: 10 | fields: 11 | label: 12 | label: winter.blocks::lang.fields.label 13 | span: full 14 | type: text 15 | tabs: 16 | icons: 17 | winter.blocks::lang.fields.actions: 'icon-arrow-pointer' 18 | winter.blocks::lang.tabs.display: 'icon-brush' 19 | 20 | fields: 21 | actions: 22 | type: repeater 23 | tab: winter.blocks::lang.fields.actions 24 | prompt: winter.blocks::lang.fields.actions_prompt 25 | groups: $/winter/blocks/meta/actions.yaml 26 | color: 27 | label: winter.blocks::lang.fields.color 28 | tab: winter.blocks::lang.tabs.display 29 | span: auto 30 | type: colorpicker 31 | icon: 32 | label: winter.blocks::lang.fields.icon 33 | tab: winter.blocks::lang.tabs.display 34 | span: auto 35 | type: iconpicker 36 | == 37 | controller->addJs(Url::asset('/plugins/winter/blocks/assets/dist/js/blocks.js'), 'Winter.Blocks'); 43 | 44 | $data = $this['data']['config']; 45 | 46 | // Ensure actions are 0 indexed 47 | $data['actions'] = array_values($data['actions'] ?? []); 48 | 49 | if (!empty($data['actions'])) { 50 | foreach ($data['actions'] as &$config) { 51 | $action = $config['_group'] ?? ''; 52 | unset($config['_group']); 53 | 54 | switch ($action) { 55 | case 'open_media': 56 | $config['href'] = MediaLibrary::url($config['media_file']); 57 | $action = 'open_url'; 58 | break; 59 | } 60 | 61 | $config = [ 62 | 'data' => $config, 63 | 'action' => $action, 64 | ]; 65 | } 66 | } 67 | 68 | $this['data'] = array_merge($this['data'], [ 69 | 'config' => $data 70 | ]); 71 | } 72 | ?> 73 | == 74 | 89 | -------------------------------------------------------------------------------- /blocks/button_group.block: -------------------------------------------------------------------------------- 1 | name: winter.blocks::lang.blocks.button_group.name 2 | description: winter.blocks::lang.blocks.button_group.description 3 | icon: icon-object-group 4 | tags: ["pages"] 5 | fields: 6 | buttons: 7 | label: winter.blocks::lang.blocks.button_group.buttons 8 | span: full 9 | type: blocks 10 | allow: 11 | - button 12 | config: 13 | position: 14 | label: winter.blocks::lang.blocks.button_group.position 15 | span: left 16 | type: balloon-selector 17 | default: "justify-center" 18 | options: 19 | "justify-start": winter.blocks::lang.blocks.button_group.position_left 20 | "justify-center": winter.blocks::lang.blocks.button_group.position_center 21 | "justify-end": winter.blocks::lang.blocks.button_group.position_right 22 | width: 23 | label: winter.blocks::lang.blocks.button_group.width 24 | span: right 25 | type: balloon-selector 26 | default: "w-full" 27 | options: 28 | "w-full": winter.blocks::lang.blocks.button_group.width_full 29 | "w-auto": winter.blocks::lang.blocks.button_group.width_auto 30 | == 31 |
32 | {% for button in buttons %} 33 | {% set button = button | merge({'width': config.width, _group: 'button'}) %} 34 | {{ renderBlock(button) }} 35 | {% endfor %} 36 |
37 | -------------------------------------------------------------------------------- /blocks/cards.block: -------------------------------------------------------------------------------- 1 | name: winter.blocks::lang.blocks.cards.name 2 | description: winter.blocks::lang.blocks.cards.description 3 | icon: icon-grip 4 | tags: ["pages"] 5 | fields: 6 | cards: 7 | label: winter.blocks::lang.blocks.cards.name 8 | span: full 9 | type: repeater 10 | form: 11 | fields: 12 | blocks: 13 | label: winter.blocks::lang.fields.content 14 | span: full 15 | type: blocks 16 | allow: 17 | - image 18 | - richtext 19 | - button 20 | == 21 |
22 | {% for card in cards %} 23 |
24 | {% for block in card.blocks %} 25 | {% if loop.first and block._group == "image" and block.size == "w-full" %} 26 | {% set block = block | merge({'size': block.size ~ " rounded-t-lg"}) %} 27 | {% endif %} 28 | {% if block._group != "image" %} 29 |
30 | {{ renderBlock(block) }} 31 |
32 | {% else %} 33 | {{ renderBlock(block) }} 34 | {% endif %} 35 | {% endfor %} 36 |
37 | {% endfor %} 38 |
39 | -------------------------------------------------------------------------------- /blocks/code.block: -------------------------------------------------------------------------------- 1 | name: winter.blocks::lang.blocks.code.name 2 | description: winter.blocks::lang.blocks.code.description 3 | icon: icon-code 4 | tags: ["pages"] 5 | fields: 6 | content: 7 | span: full 8 | type: codeeditor 9 | language: html 10 | == 11 | {{ content | raw }} 12 | -------------------------------------------------------------------------------- /blocks/columns_two.block: -------------------------------------------------------------------------------- 1 | name: winter.blocks::lang.blocks.columns_two.name 2 | description: winter.blocks::lang.blocks.columns_two.description 3 | icon: icon-table-columns 4 | tags: ["pages"] 5 | fields: 6 | left: 7 | label: winter.blocks::lang.blocks.columns_two.left 8 | span: left 9 | type: blocks 10 | ignore: 11 | - columns_two 12 | right: 13 | label: winter.blocks::lang.blocks.columns_two.right 14 | span: right 15 | type: blocks 16 | ignore: 17 | - columns_two 18 | == 19 |
20 |
21 | {{ renderBlocks(left | default([])) }} 22 |
23 |
24 | {{ renderBlocks(right | default([])) }} 25 |
26 |
27 | -------------------------------------------------------------------------------- /blocks/divider.block: -------------------------------------------------------------------------------- 1 | name: winter.blocks::lang.blocks.divider.name 2 | description: winter.blocks::lang.blocks.divider.description 3 | icon: icon-ruler-horizontal 4 | tags: ["pages"] 5 | == 6 |
7 | -------------------------------------------------------------------------------- /blocks/image.block: -------------------------------------------------------------------------------- 1 | name: winter.blocks::lang.blocks.image.name 2 | description: winter.blocks::lang.blocks.image.description 3 | icon: icon-picture-o 4 | tags: ["pages"] 5 | fields: 6 | image: 7 | type: mediafinder 8 | span: full 9 | mode: image 10 | alt_text: 11 | label: winter.blocks::lang.blocks.image.alt_text 12 | span: full 13 | type: text 14 | config: 15 | size: 16 | label: winter.blocks::lang.fields.size 17 | span: right 18 | type: balloon-selector 19 | default: "w-full" 20 | options: 21 | "w-full": 'winter.blocks::lang.blocks.image.size.w-full' 22 | "w-2/3": 'winter.blocks::lang.blocks.image.size.w-2/3' 23 | "w-1/2": 'winter.blocks::lang.blocks.image.size.w-1/2' 24 | "w-1/3": 'winter.blocks::lang.blocks.image.size.w-1/3' 25 | "w-1/4": 'winter.blocks::lang.blocks.image.size.w-1/4' 26 | == 27 |
28 | {{ alt_text }} 29 |
30 | -------------------------------------------------------------------------------- /blocks/plaintext.block: -------------------------------------------------------------------------------- 1 | name: winter.blocks::lang.blocks.plaintext.name 2 | description: winter.blocks::lang.blocks.plaintext.description 3 | icon: icon-font 4 | tags: ["pages"] 5 | fields: 6 | content: 7 | placeholder: winter.blocks::lang.fields.content 8 | span: full 9 | type: textarea 10 | size: small 11 | == 12 |

13 | {{ content }} 14 |

15 | -------------------------------------------------------------------------------- /blocks/richtext.block: -------------------------------------------------------------------------------- 1 | name: winter.blocks::lang.blocks.richtext.name 2 | description: winter.blocks::lang.blocks.richtext.description 3 | icon: icon-pencil-square-o 4 | tags: ["pages"] 5 | fields: 6 | content: 7 | placeholder: winter.blocks::lang.fields.content 8 | span: full 9 | type: richeditor 10 | == 11 |
12 | {{ content | raw }} 13 |
14 | -------------------------------------------------------------------------------- /blocks/title.block: -------------------------------------------------------------------------------- 1 | name: winter.blocks::lang.blocks.title.name 2 | description: winter.blocks::lang.blocks.title.description 3 | icon: icon-heading 4 | tags: ["pages"] 5 | fields: 6 | content: 7 | placeholder: winter.blocks::lang.blocks.title.name 8 | span: full 9 | type: text 10 | config: 11 | size: 12 | label: winter.blocks::lang.fields.size 13 | span: auto 14 | type: balloon-selector 15 | default: h2 16 | options: 17 | h2: winter.blocks::lang.blocks.title.size.h2 18 | h3: winter.blocks::lang.blocks.title.size.h3 19 | h4: winter.blocks::lang.blocks.title.size.h4 20 | alignment_x: 21 | label: winter.blocks::lang.fields.alignment_x.label 22 | span: auto 23 | type: balloon-selector 24 | default: inherit 25 | options: 26 | inherit: winter.blocks::lang.fields.default 27 | left: winter.blocks::lang.fields.alignment_x.left 28 | center: winter.blocks::lang.fields.alignment_x.center 29 | right: winter.blocks::lang.fields.alignment_x.right 30 | == 31 | <{{ config.size }} style="text-align: {{ config.alignment_x }};">{{ content }} 32 | -------------------------------------------------------------------------------- /blocks/video.block: -------------------------------------------------------------------------------- 1 | name: winter.blocks::lang.blocks.video.name 2 | description: winter.blocks::lang.blocks.video.description 3 | icon: icon-video 4 | tags: ["pages"] 5 | fields: 6 | video: 7 | label: winter.blocks::lang.blocks.video.name 8 | span: full 9 | type: mediafinder 10 | mode: video 11 | == 12 | {% if video %} 13 | 14 | {% endif %} 15 | -------------------------------------------------------------------------------- /blocks/vimeo.block: -------------------------------------------------------------------------------- 1 | name: winter.blocks::lang.blocks.vimeo.name 2 | description: winter.blocks::lang.blocks.vimeo.description 3 | icon: icon-vimeo 4 | tags: ["pages"] 5 | fields: 6 | vimeo_id: 7 | label: winter.blocks::lang.blocks.vimeo.vimeo_id 8 | type: text 9 | == 10 | {% if vimeo_id %} 11 | 19 | {% endif %} 20 | -------------------------------------------------------------------------------- /blocks/youtube.block: -------------------------------------------------------------------------------- 1 | name: winter.blocks::lang.blocks.youtube.name 2 | description: winter.blocks::lang.blocks.youtube.description 3 | icon: icon-youtube 4 | tags: ["pages"] 5 | fields: 6 | youtube_id: 7 | label: winter.blocks::lang.blocks.youtube.youtube_id 8 | type: text 9 | == 10 | {% if youtube_id %} 11 | 19 | {% endif %} 20 | -------------------------------------------------------------------------------- /classes/Block.php: -------------------------------------------------------------------------------- 1 | partialStack = new PartialStack(); 36 | parent::__construct($attributes); 37 | } 38 | 39 | /** 40 | * Renders the provided block 41 | */ 42 | public static function render(string|array $block, array $data = [], ?Controller $controller = null): string 43 | { 44 | if (!$controller) { 45 | $controller = new Controller(); 46 | } 47 | 48 | if (is_array($block)) { 49 | $data = $block; 50 | $block = $data['_group'] ?? false; 51 | } 52 | 53 | if (empty($block)) { 54 | throw new SystemException("The block name was not provided"); 55 | } 56 | 57 | $partialData = []; 58 | 59 | foreach ($data as $key => $value) { 60 | if (in_array($key, ['_group', '_config'])) { 61 | continue; 62 | } 63 | 64 | $partialData[$key] = $value; 65 | } 66 | 67 | // Allow data to be accessed via "data" key, for backwards compatibility. 68 | $partialData['data'] = $partialData; 69 | 70 | if (!empty($data['_config'])) { 71 | $partialData['config'] = json_decode($data['_config']); 72 | } else { 73 | $partialData['config'] = static::getDefaultConfig($block); 74 | } 75 | 76 | return $controller->renderPartial($block . '.block', $partialData); 77 | } 78 | 79 | /** 80 | * Renders the provided blocks 81 | */ 82 | public static function renderAll(array $blocks, ?Controller $controller = null): string 83 | { 84 | $content = ''; 85 | $controller ??= (new Controller()); 86 | 87 | foreach ($blocks as $i => $block) { 88 | if (!array_key_exists('_group', $block)) { 89 | throw new SystemException("The block definition at index $i must contain a `_group` key."); 90 | } 91 | 92 | $partialData = []; 93 | 94 | foreach ($block as $key => $value) { 95 | if (in_array($key, ['_group', '_config'])) { 96 | continue; 97 | } 98 | 99 | $partialData[$key] = $value; 100 | } 101 | 102 | // Allow data to be accessed via "data" key, for backwards compatibility. 103 | $partialData['data'] = $partialData; 104 | 105 | if (!empty($block['_config'])) { 106 | $config = json_decode($block['_config']); 107 | } else { 108 | $config = static::getDefaultConfig($block['_group']); 109 | } 110 | 111 | $partialData['config'] = json_decode(json_encode($config), true); 112 | 113 | $content .= $controller->renderPartial($block['_group'] . '.block', $partialData); 114 | } 115 | 116 | return $content; 117 | } 118 | 119 | /** 120 | * Returns name of a PHP class to us a parent for the PHP class created for the object's PHP section. 121 | */ 122 | public function getCodeClassParent(): string 123 | { 124 | return BlockCode::class; 125 | } 126 | 127 | /** 128 | * Get a new query builder for the object 129 | * @return \Winter\Storm\Halcyon\Builder 130 | */ 131 | public function newQuery() 132 | { 133 | $datasource = $this->getDatasource(); 134 | 135 | $query = new BlockBuilder($datasource, new BlockProcessor()); 136 | 137 | return $query->setModel($this); 138 | } 139 | 140 | /** 141 | * Execute the lifecycle of the partial manually. Usually this would only happen for cms partials (i.e. component 142 | * partials), but this method enables this functionality for blocks 143 | */ 144 | public function executeLifecycle(Controller $controller): static 145 | { 146 | $this->partialStack->stackPartial(); 147 | 148 | $manager = ComponentManager::instance(); 149 | 150 | foreach ($this->components as $component => $properties) { 151 | // Do not inject the viewBag component to the environment. 152 | // Not sure if they're needed there by the requirements, 153 | // but there were problems with array-typed properties used by Static Pages 154 | // snippets and setComponentPropertiesFromParams(). --ab 155 | if ($component == 'viewBag') { 156 | continue; 157 | } 158 | 159 | list($name, $alias) = strpos($component, ' ') 160 | ? explode(' ', $component) 161 | : [$component, $component]; 162 | 163 | if (!$componentObj = $manager->makeComponent($name, $this, $properties)) { 164 | throw new SystemException(Lang::get('cms::lang.component.not_found', ['name'=>$name])); 165 | } 166 | 167 | $componentObj->alias = $alias; 168 | $parameters[$alias] = $this->components[$alias] = $componentObj; 169 | 170 | $this->partialStack->addComponent($alias, $componentObj); 171 | 172 | $this->setComponentPropertiesFromParams($componentObj, $parameters); 173 | $componentObj->init(); 174 | } 175 | 176 | CmsException::mask($this->page, 300); 177 | $parser = new CodeParser($this); 178 | $partialObj = $parser->source( 179 | $controller->getPage() ?: new Page(), 180 | $controller->getLayout() ?: new Layout(), 181 | $controller 182 | ); 183 | CmsException::unmask(); 184 | 185 | CmsException::mask($this, 300); 186 | $partialObj->onStart(); 187 | $this->runComponents(); 188 | $partialObj->onEnd(); 189 | CmsException::unmask(); 190 | 191 | return $this; 192 | } 193 | 194 | /** 195 | * Gets the default config for the provided block, if no user-defined config is available. 196 | */ 197 | private static function getDefaultConfig(string $block): ?array 198 | { 199 | $config = BlockManager::instance()->getConfig($block); 200 | 201 | if (!array_key_exists('config', $config)) { 202 | return null; 203 | } 204 | 205 | $defaults = []; 206 | 207 | foreach ($config['config'] as $configKey => $configData) { 208 | $defaults[$configKey] = $configData['default'] ?? null; 209 | } 210 | 211 | return $defaults; 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /classes/BlockBuilder.php: -------------------------------------------------------------------------------- 1 | executeLifecycle($controller); 42 | } else { 43 | throw new SystemException("The block '$partialName' can not found."); 44 | } 45 | } 46 | }); 47 | 48 | foreach (PluginManager::instance()->getRegistrationMethodValues('registerBlocks') as $plugin => $blocks) { 49 | foreach ($blocks as $key => $path) { 50 | $this->registerBlock($key, $path); 51 | } 52 | } 53 | } 54 | 55 | /** 56 | * Get the list of registered blocks in the form of ['key' => '$/path/to/block.block'] 57 | */ 58 | public function getRegisteredBlocks(): array 59 | { 60 | return $this->blocks; 61 | } 62 | 63 | /** 64 | * Register the provided key & path as a block 65 | */ 66 | public function registerBlock(string $key, string $path): void 67 | { 68 | $realPath = File::symbolizePath($path); 69 | 70 | if (!File::exists($realPath)) { 71 | return; 72 | } 73 | 74 | $this->blocks[$key] = PathResolver::standardize($realPath); 75 | } 76 | 77 | /** 78 | * Get a collection of Block instances using the active theme 79 | */ 80 | public function getBlocks(): CmsObjectCollection 81 | { 82 | return Block::listInTheme(Theme::getActiveTheme()); 83 | } 84 | 85 | /** 86 | * Get an array of blocks and their configuration details in the form of ['key' => $config] 87 | */ 88 | public function getConfigs(string|array|null $tags = null): array 89 | { 90 | $configs = []; 91 | foreach ($this->getBlocks() as $block) { 92 | if (isset($tags)) { 93 | $tags = (is_array($tags)) ? $tags : [$tags]; 94 | $blockTags = (isset($block->tags) && is_array($block->tags)) ? $block->tags : []; 95 | 96 | if (count(array_intersect($tags, $blockTags)) === 0) { 97 | continue; 98 | } 99 | } 100 | 101 | $configs[pathinfo($block['fileName'])['filename']] = array_except( 102 | $block->getAttributes(), 103 | [ 104 | 'fileName', 105 | 'content', 106 | 'mtime', 107 | 'markup', 108 | 'code', 109 | ] 110 | ); 111 | } 112 | 113 | return $configs; 114 | } 115 | 116 | /** 117 | * Get the configuration of the provided block type 118 | */ 119 | public function getConfig(string $type): ?array 120 | { 121 | return $this->getConfigs()[$type] ?? null; 122 | } 123 | 124 | /** 125 | * Check if the provided string is a valid block type 126 | */ 127 | public function isBlock(string $type): bool 128 | { 129 | return !!$this->getConfig($type); 130 | } 131 | 132 | /** 133 | * Remove a block by key 134 | */ 135 | public function removeBlock(string|array $key): void 136 | { 137 | if (is_array($key)) { 138 | foreach ($key as $k) { 139 | $this->removeBlock($k); 140 | } 141 | 142 | return; 143 | } 144 | 145 | unset($this->blocks[$key]); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /classes/BlockParser.php: -------------------------------------------------------------------------------- 1 | $query->getModel()->isCompoundObject() 24 | ]; 25 | 26 | $content = array_get($result, 'content', ''); 27 | 28 | $processed = BlockParser::parse($content, $options); 29 | 30 | return [ 31 | 'fileName' => $fileName, 32 | 'content' => $content, 33 | 'mtime' => array_get($result, 'mtime'), 34 | 'markup' => $processed['markup'], 35 | 'code' => $processed['code'] 36 | ] + $processed['settings']; 37 | } 38 | 39 | /** 40 | * Process the data in to an insert action. 41 | * 42 | * @param \Winter\Storm\Halcyon\Builder $query 43 | * @param array $data 44 | * @return string 45 | */ 46 | public function processInsert(Builder $query, $data) 47 | { 48 | $options = [ 49 | 'wrapCodeInPhpTags' => $query->getModel()->getWrapCode(), 50 | 'isCompoundObject' => $query->getModel()->isCompoundObject() 51 | ]; 52 | 53 | return BlockParser::render($data, $options); 54 | } 55 | 56 | /** 57 | * Process the data in to an update action. 58 | * 59 | * @param \Winter\Storm\Halcyon\Builder $query 60 | * @param array $data 61 | * @return string 62 | */ 63 | public function processUpdate(Builder $query, $data) 64 | { 65 | $options = [ 66 | 'wrapCodeInPhpTags' => $query->getModel()->getWrapCode(), 67 | 'isCompoundObject' => $query->getModel()->isCompoundObject() 68 | ]; 69 | 70 | $existingData = $query->getModel()->attributesToArray(); 71 | 72 | return BlockParser::render($data + $existingData, $options); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /classes/BlocksDatasource.php: -------------------------------------------------------------------------------- 1 | path] List of blocks managed by the BlockManager 12 | */ 13 | protected array $blocks; 14 | 15 | public function __construct() 16 | { 17 | $this->processor = new BlockProcessor(); 18 | $this->blocks = array_merge( 19 | // Get blocks registered via plugins 20 | BlockManager::instance()->getRegisteredBlocks(), 21 | // Get blocks existing in the autodatasource 22 | BlockManager::instance()->getBlocks()->map(function ($block) { 23 | return ['name' => $block->id, 'path' => $block->getFilePath()]; 24 | })->pluck('path', 'name')->toArray() 25 | ); 26 | } 27 | 28 | /** 29 | * @inheritDoc 30 | */ 31 | public function selectOne(string $dirName, string $fileName, string $extension): ?array 32 | { 33 | if ($dirName !== 'blocks' || $extension !== 'block' || !isset($this->blocks[$fileName])) { 34 | return null; 35 | } 36 | 37 | return [ 38 | 'fileName' => $fileName . '.' . $extension, 39 | 'content' => file_get_contents($this->blocks[$fileName]), 40 | 'mtime' => filemtime($this->blocks[$fileName]), 41 | ]; 42 | } 43 | 44 | /** 45 | * @inheritDoc 46 | */ 47 | public function select(string $dirName, array $options = []): array 48 | { 49 | // Prepare query options 50 | $queryOptions = array_merge([ 51 | 'columns' => null, // Only return specific columns (fileName, mtime, content) 52 | 'extensions' => null, // Match specified extensions 53 | 'fileMatch' => null, // Match the file name using fnmatch() 54 | 'orders' => null, // @todo 55 | 'limit' => null, // @todo 56 | 'offset' => null // @todo 57 | ], $options); 58 | extract($queryOptions); 59 | 60 | if (isset($columns)) { 61 | if ($columns === ['*'] || !is_array($columns)) { 62 | $columns = null; 63 | } else { 64 | $columns = array_flip($columns); 65 | } 66 | } 67 | 68 | if ($dirName !== 'blocks' || (isset($extensions) && !in_array('block', $extensions))) { 69 | return []; 70 | } 71 | 72 | $result = []; 73 | foreach ($this->blocks as $fileName => $path) { 74 | $item = [ 75 | 'fileName' => $fileName . '.block', 76 | ]; 77 | 78 | if (!isset($columns) || array_key_exists('content', $columns)) { 79 | $item['content'] = file_get_contents($path); 80 | } 81 | 82 | if (!isset($columns) || array_key_exists('mtime', $columns)) { 83 | $item['mtime'] = filemtime($path); 84 | } 85 | 86 | $result[] = $item; 87 | } 88 | 89 | return $result; 90 | } 91 | 92 | /** 93 | * @inheritDoc 94 | */ 95 | public function insert(string $dirName, string $fileName, string $extension, string $content): int 96 | { 97 | throw new SystemException('insert() is not implemented on the BlocksDatasource'); 98 | } 99 | 100 | /** 101 | * @inheritDoc 102 | */ 103 | public function update(string $dirName, string $fileName, string $extension, string $content, ?string $oldFileName = null, ?string $oldExtension = null): int 104 | { 105 | throw new SystemException('update() is not implemented on the BlocksDatasource'); 106 | } 107 | 108 | /** 109 | * @inheritDoc 110 | */ 111 | public function delete(string $dirName, string $fileName, string $extension): bool 112 | { 113 | throw new SystemException('delete() is not implemented on the BlocksDatasource'); 114 | } 115 | 116 | /** 117 | * @inheritDoc 118 | */ 119 | public function lastModified(string $dirName, string $fileName, string $extension): ?int 120 | { 121 | return $this->selectOne($dirName, $fileName, $extension)['mtime'] ?? null; 122 | } 123 | 124 | /** 125 | * @inheritDoc 126 | */ 127 | public function makeCacheKey(string $name = ''): string 128 | { 129 | return hash('crc32b', $name); 130 | } 131 | 132 | /** 133 | * @inheritDoc 134 | */ 135 | public function getPathsCacheKey(): string 136 | { 137 | return 'halcyon-datastore-blocks-' . md5(json_encode($this->getAvailablePaths())); 138 | } 139 | 140 | /** 141 | * @inheritDoc 142 | */ 143 | public function getAvailablePaths(): array 144 | { 145 | $paths = []; 146 | foreach ($this->blocks as $block => $path) { 147 | $paths["blocks/$block.block"] = true; 148 | } 149 | return $paths; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | github_checks: false 3 | fixes: 4 | - "plugins/winter/blocks/::" 5 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "winter/wn-blocks-plugin", 3 | "type": "winter-plugin", 4 | "description": "Block based content management plugin for Winter CMS.", 5 | "homepage": "https://github.com/wintercms/wn-blocks-plugin", 6 | "keywords": ["winter", "wintercms", "laravel", "blocks", "block", "builder"], 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Luke Towers", 11 | "email": "wintercms@luketowers.ca" 12 | }, 13 | { 14 | "name": "Winter CMS Maintainers", 15 | "homepage": "https://wintercms.com", 16 | "role": "Maintainer" 17 | } 18 | ], 19 | "support": { 20 | "issues": "https://github.com/wintercms/wn-blocks-plugin/issues", 21 | "discord": "https://discord.gg/D5MFSPH6Ux", 22 | "source": "https://github.com/wintercms/wn-blocks-plugin" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /formwidgets/Block.php: -------------------------------------------------------------------------------- 1 | renderPartial( 22 | Str::after($this->config->type, static::TYPE_PREFIX) . '.' . BlockManager::BLOCK_EXTENSION, 23 | ['data' => $this->formField->config['data'] ?? []] 24 | ); 25 | } 26 | 27 | public function getSaveValue($value) 28 | { 29 | return FormField::NO_SAVE_DATA; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /formwidgets/Blocks.php: -------------------------------------------------------------------------------- 1 | fillFromConfig([ 41 | 'ignore', 42 | 'allow', 43 | 'tags', 44 | ]); 45 | 46 | parent::init(); 47 | } 48 | 49 | /** 50 | * {@inheritDoc} 51 | */ 52 | protected function loadAssets() 53 | { 54 | $this->addCss('css/blocks.css', 'Winter.Blocks'); 55 | $this->addJs('js/blocks.js', 'Winter.Blocks'); 56 | } 57 | 58 | /** 59 | * {@inheritDoc} 60 | */ 61 | public function render() 62 | { 63 | $this->prepareVars(); 64 | return $this->makePartial('block'); 65 | } 66 | 67 | /** 68 | * Splices in some meta data (group and index values) to the dataset. 69 | * @param array|mixed $value 70 | * @return array|mixed 71 | */ 72 | protected function processSaveValue($value) 73 | { 74 | if (!is_array($value) || !$value) { 75 | return null; 76 | } 77 | 78 | $count = count($value); 79 | 80 | if ($this->minItems && $count < $this->minItems) { 81 | throw new ApplicationException(Lang::get('backend::lang.repeater.min_items_failed', [ 82 | 'name' => $this->fieldName, 83 | 'min' => $this->minItems, 84 | 'items' => $count, 85 | ])); 86 | } 87 | if ($this->maxItems && $count > $this->maxItems) { 88 | throw new ApplicationException(Lang::get('backend::lang.repeater.max_items_failed', [ 89 | 'name' => $this->fieldName, 90 | 'max' => $this->maxItems, 91 | 'items' => $count, 92 | ])); 93 | } 94 | 95 | /* 96 | * Give repeated form field widgets an opportunity to process the data. 97 | */ 98 | foreach ($value as $index => $data) { 99 | if (isset($this->formWidgets[$index])) { 100 | $value[$index] = array_merge($this->formWidgets[$index]->getSaveData(), [ 101 | '_group' => $data['_group'], 102 | '_config' => (!empty($data['_config'])) ? $data['_config'] : null, 103 | ]); 104 | } 105 | } 106 | 107 | return array_values($value); 108 | } 109 | 110 | /** 111 | * {@inheritDoc} 112 | */ 113 | protected function processItems() 114 | { 115 | $currentValue = ($this->loaded === true) 116 | ? post($this->formField->getName()) 117 | : $this->getLoadValue(); 118 | 119 | // Detect when a child widget is trying to run an AJAX handler 120 | // outside of the form element that contains all the repeater 121 | // fields that would normally be used to identify that case 122 | $handler = $this->controller->getAjaxHandler(); 123 | if (!$this->loaded && starts_with($handler, $this->alias . 'Form')) { 124 | // Attempt to get the index of the repeater 125 | $handler = str_after($handler, $this->alias . 'Form'); 126 | preg_match("~^(\d+)~", $handler, $matches); 127 | 128 | if (isset($matches[1])) { 129 | $index = $matches[1]; 130 | $this->makeItemFormWidget($index); 131 | } 132 | } 133 | 134 | // Ensure that the minimum number of items are preinitialized 135 | // ONLY DONE WHEN NOT IN GROUP MODE 136 | if (!$this->useGroups && $this->minItems > 0) { 137 | if (!is_array($currentValue)) { 138 | $currentValue = []; 139 | for ($i = 0; $i < $this->minItems; $i++) { 140 | $currentValue[$i] = []; 141 | } 142 | } elseif (count($currentValue) < $this->minItems) { 143 | for ($i = 0; $i < ($this->minItems - count($currentValue)); $i++) { 144 | $currentValue[] = []; 145 | } 146 | } 147 | } 148 | 149 | if (!$this->childAddItemCalled && $currentValue === null) { 150 | $this->formWidgets = []; 151 | return; 152 | } 153 | 154 | if ($this->childAddItemCalled && !isset($currentValue[$this->childIndexCalled])) { 155 | // If no value is available but a child repeater has added an item, add a "stub" repeater item 156 | $this->makeItemFormWidget($this->childIndexCalled); 157 | } 158 | 159 | if (!is_array($currentValue)) { 160 | return; 161 | } 162 | 163 | collect($currentValue)->each(function ($value, $index) { 164 | $this->makeItemFormWidget($index, array_get($value, '_group', null)); 165 | $this->indexConfigMeta[$index] = array_get($value, '_config', null); 166 | }); 167 | } 168 | 169 | /** 170 | * {@inheritDoc} 171 | */ 172 | public function onAddItem() 173 | { 174 | $groupCode = post('_repeater_group'); 175 | 176 | $index = $this->getNextIndex(); 177 | 178 | $this->prepareVars(); 179 | $this->vars['widget'] = $this->makeItemFormWidget($index, $groupCode); 180 | $this->vars['indexValue'] = $index; 181 | 182 | $itemContainer = '@#' . $this->getId('items'); 183 | $addItemContainer = '#' . $this->getId('add-item'); 184 | 185 | return [ 186 | $addItemContainer => '', 187 | $itemContainer => $this->makePartial('block_item') . $this->makePartial('block_add_item') 188 | ]; 189 | } 190 | 191 | /** 192 | * {@inheritDoc} 193 | * 194 | * This method overrides the base repeater processGroupMode to implement block functionality without pre-defining a 195 | * group. 196 | */ 197 | protected function processGroupMode(): void 198 | { 199 | $definitions = []; 200 | foreach (BlockManager::instance()->getConfigs($this->tags) as $code => $config) { 201 | if (!empty($config['tags']) && !$this->isBlockAllowed($code, $config['tags'])) { 202 | continue; 203 | } 204 | 205 | $definitions[$code] = [ 206 | 'code' => $code, 207 | 'name' => array_get($config, 'name'), 208 | 'icon' => array_get($config, 'icon', 'icon-square-o'), 209 | 'description' => array_get($config, 'description'), 210 | 'fields' => array_get($config, 'fields'), 211 | 'config' => array_get($config, 'config', null), 212 | ]; 213 | } 214 | 215 | // Sort the builder blocks by translated name label 216 | uasort($definitions, fn ($a, $b) => trans($a['name']) <=> trans($b['name'])); 217 | 218 | $this->groupDefinitions = $definitions; 219 | $this->useGroups = true; 220 | } 221 | 222 | /** 223 | * Determines if a block is allowed according to the widget's ignore/allow list. 224 | */ 225 | protected function isBlockAllowed(string $code, array|string $blockTags): bool 226 | { 227 | $blockTags = is_array($blockTags) ? $blockTags : [$blockTags]; 228 | 229 | if (isset($this->ignore['blocks']) || isset($this->ignore['tags'])) { 230 | $ignoredBlocks = isset($this->ignore['blocks']) ? $this->ignore['blocks'] : []; 231 | $ignoredTags = isset($this->ignore['tags']) ? $this->ignore['tags'] : []; 232 | } else { 233 | $ignoredBlocks = $this->ignore; 234 | $ignoredTags = []; 235 | } 236 | if (isset($this->allow['blocks']) || isset($this->allow['tags'])) { 237 | $allowedBlocks = isset($this->allow['blocks']) ? $this->allow['blocks'] : []; 238 | $allowedTags = isset($this->allow['tags']) ? $this->allow['tags'] : []; 239 | } else { 240 | $allowedBlocks = $this->allow; 241 | $allowedTags = []; 242 | } 243 | 244 | // Reject explicitly ignored blocks 245 | if (count($ignoredBlocks) && in_array($code, $ignoredBlocks)) { 246 | return false; 247 | } 248 | 249 | // Reject blocks that have any ignored tags 250 | if (count($ignoredTags) && array_intersect($blockTags, $ignoredTags)) { 251 | return false; 252 | } 253 | 254 | // Reject blocks that are not explicitly allowed 255 | if (count($allowedBlocks) && !in_array($code, $allowedBlocks)) { 256 | return false; 257 | } 258 | 259 | // Reject blocks that do not have any allowed tags 260 | if (count($allowedTags) && !array_intersect($blockTags, $allowedTags)) { 261 | return false; 262 | } 263 | 264 | return true; 265 | } 266 | 267 | /** 268 | * Gets the configuration of a block. 269 | */ 270 | public function getGroupConfigFromIndex(int $index) 271 | { 272 | return $this->indexConfigMeta[$index] ?? null; 273 | } 274 | 275 | /** 276 | * Returns the group description from its unique code. 277 | */ 278 | public function getGroupDescription(string $groupCode): ?string 279 | { 280 | return array_get($this->groupDefinitions, $groupCode . '.description'); 281 | } 282 | 283 | /** 284 | * Returns the group icon from its unique code. 285 | */ 286 | public function getGroupIcon(string $groupCode): ?string 287 | { 288 | return array_get($this->groupDefinitions, $groupCode . '.icon'); 289 | } 290 | 291 | /** 292 | * Determines if the given block has an Inspector config. 293 | */ 294 | public function hasInspectorConfig(string $groupCode): bool 295 | { 296 | return isset($this->groupDefinitions[$groupCode]['config']); 297 | } 298 | 299 | /** 300 | * Returns the Inspector config, as a JSON string, for the given group code. 301 | */ 302 | public function getInspectorConfig(string $groupCode): string 303 | { 304 | return json_encode($this->processInspectorConfig(array_get($this->groupDefinitions, $groupCode . '.config', []))); 305 | } 306 | 307 | /** 308 | * Converts a Form widget configuration into an Inspector configuration. 309 | */ 310 | protected function processInspectorConfig(array $config): array 311 | { 312 | $properties = []; 313 | 314 | foreach ($config as $property => $schema) { 315 | $defined = [ 316 | 'property' => $property, 317 | 'title' => Lang::get(array_get($schema, 'title', array_get($schema, 'label'))), 318 | 'description' => Lang::get(array_get($schema, 'description', array_get($schema, 'comment', array_get($schema, 'commentAbove')))), 319 | 'type' => $this->getBestInspectorField(array_get($schema, 'type', 'string')), 320 | 'group' => array_get($schema, 'group', array_get($schema, 'tab')), 321 | ]; 322 | 323 | $defined = array_merge($defined, array_except($schema, [ 324 | 'title', 325 | 'label', 326 | 'description', 327 | 'comment', 328 | 'commentAbove', 329 | 'type', 330 | 'group', 331 | 'span', 332 | ])); 333 | 334 | if (isset($defined['options']) && is_array($defined['options'])) { 335 | foreach ($defined['options'] as $key => &$value) { 336 | $value = Lang::get($value); 337 | } 338 | } 339 | 340 | $properties[] = array_filter($defined); 341 | } 342 | 343 | return $properties; 344 | } 345 | 346 | /** 347 | * Converts a Form widget field type into the best Inspector field type. 348 | * 349 | * If it cannot convert the type, it is returned as-is. 350 | */ 351 | protected function getBestInspectorField(string $type): string 352 | { 353 | switch ($type) { 354 | case 'text': 355 | return 'string'; 356 | case 'textarea': 357 | return 'text'; 358 | case 'checkboxlist': 359 | return 'set'; 360 | case 'balloon-selector': 361 | case 'radio': 362 | return 'dropdown'; 363 | } 364 | 365 | return $type; 366 | } 367 | } 368 | -------------------------------------------------------------------------------- /formwidgets/blocks/assets/css/blocks.css: -------------------------------------------------------------------------------- 1 | .field-blocks{padding-top:5px}.field-blocks .field-repeater-items{counter-reset:repeater-index-counter}.field-blocks li.field-repeater-item,.field-blocks ul.field-repeater-items{list-style:none;margin:0;padding:0}.field-blocks ul.field-repeater-items>li.dragged{background-color:#f9f9f9;border:1px dashed #dbdee0;opacity:.7;padding-right:15px;padding-top:15px;position:absolute;z-index:2000}.field-blocks ul.field-repeater-items>li.dragged .repeater-item-remove{opacity:0}.field-blocks ul.field-repeater-items>li.dragged .repeater-item-collapsed-title{top:5px}.field-blocks ul.field-repeater-items>li.placeholder{display:block;height:25px;margin-bottom:5px;position:relative}.field-blocks ul.field-repeater-items>li.placeholder:before{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;color:#d35714;content:"\f054";display:block;font-family:Font Awesome\ 6 Free;font-style:normal;font-variant:normal;font-weight:900;left:-10px;position:absolute;text-rendering:auto;top:8px;z-index:2000}.field-blocks li.field-repeater-item{background:#f9f9f9;border:1px solid #d1d6d9;border-radius:3px;margin:0 0 1em!important;min-height:30px;padding:3.5em 1.25em 0!important;position:relative}.field-blocks li.field-repeater-item.collapsed,.field-blocks li.field-repeater-item.empty{padding:0!important}.field-blocks li.field-repeater-item.collapsed .field-repeater-form,.field-blocks li.field-repeater-item.empty .field-repeater-form{display:none}.field-blocks li.field-repeater-item.collapsed .repeater-item-collapse .repeater-item-collapse-one,.field-blocks li.field-repeater-item.empty .repeater-item-collapse .repeater-item-collapse-one{transform:rotate(180deg)}.field-blocks li.field-repeater-item.collapsed .repeater-item-title,.field-blocks li.field-repeater-item.empty .repeater-item-title{border-bottom:none;border-bottom-left-radius:3px;border-bottom-right-radius:0;display:inline-block;height:100%}.field-blocks li.field-repeater-item.collapsed>.repeater-item-collapse,.field-blocks li.field-repeater-item.collapsed>.repeater-item-remove,.field-blocks li.field-repeater-item.empty>.repeater-item-collapse,.field-blocks li.field-repeater-item.empty>.repeater-item-remove{opacity:1}.field-blocks li.field-repeater-item .repeater-item-collapse{opacity:0;position:absolute;right:30px;top:5px;transition:opacity .5s;z-index:90}.field-blocks li.field-repeater-item .repeater-item-collapse a,.field-blocks li.field-repeater-item .repeater-item-collapse button{color:#bdc3c7;display:block;font-size:12px;line-height:20px;transition:transform .3s}.field-blocks li.field-repeater-item .repeater-item-collapse a:focus,.field-blocks li.field-repeater-item .repeater-item-collapse a:hover,.field-blocks li.field-repeater-item .repeater-item-collapse button:focus,.field-blocks li.field-repeater-item .repeater-item-collapse button:hover{color:#999;text-decoration:none}.field-blocks li.field-repeater-item .repeater-item-remove{opacity:0;position:absolute;right:5px;top:4px;transition:opacity .5s;z-index:90}.field-blocks li.field-repeater-item .repeater-item-remove.disabled{display:none}.field-blocks li.field-repeater-item .repeater-item-remove.disabled+.repeater-item-collapse{right:7px}.field-blocks li.field-repeater-item .repeater-item-remove .close{display:inline-block;float:none}.field-blocks li.field-repeater-item .block-config{color:#bdc3c7;display:block;font-size:12px;line-height:20px;opacity:0;position:absolute;right:60px;top:4px;transition:opacity .5s;z-index:90}.field-blocks li.field-repeater-item .block-config.inspector-open,.field-blocks li.field-repeater-item .block-config:focus,.field-blocks li.field-repeater-item .block-config:hover{color:#999;text-decoration:none}.field-blocks li.field-repeater-item .repeater-item-collapse,.field-blocks li.field-repeater-item .repeater-item-remove{height:20px;text-align:center;width:20px}.field-blocks li.field-repeater-item .repeater-item-collapse>a,.field-blocks li.field-repeater-item .repeater-item-collapse>button,.field-blocks li.field-repeater-item .repeater-item-remove>a,.field-blocks li.field-repeater-item .repeater-item-remove>button{outline:none}.field-blocks li.field-repeater-item .repeater-item-collapsed-handle{position:absolute;top:0;inset-inline:0}.field-blocks li.field-repeater-item .repeater-item-title{background:#fff;border-bottom:1px solid #d1d6d9;border-bottom-right-radius:3px;border-right:1px solid #d1d6d9;border-top-left-radius:3px;color:rgba(56,84,135,.5);font-size:13px;left:0;padding:4px 8px;position:absolute;top:0}.field-blocks li.field-repeater-item .repeater-item-title>.icon{margin-right:4px}.field-blocks li.field-repeater-item .repeater-item-handle{cursor:move}.field-blocks li.field-repeater-item.hover{border:1px solid #999}.field-blocks li.field-repeater-item.hover>.repeater-item-title{border-color:#999;color:#999}.field-blocks li.field-repeater-item.focus{border:1px solid #4ea5e0!important}.field-blocks li.field-repeater-item.focus>.repeater-item-title{border-color:#4ea5e0!important;color:#4ea5e0!important}.field-blocks li.field-repeater-item.focus>.block-config,.field-blocks li.field-repeater-item.focus>.repeater-item-collapse,.field-blocks li.field-repeater-item.focus>.repeater-item-handle,.field-blocks li.field-repeater-item.focus>.repeater-item-remove,.field-blocks li.field-repeater-item.hover>.block-config,.field-blocks li.field-repeater-item.hover>.repeater-item-collapse,.field-blocks li.field-repeater-item.hover>.repeater-item-handle,.field-blocks li.field-repeater-item.hover>.repeater-item-remove{opacity:1}@media (hover:none){.field-blocks li.field-repeater-item>.block-config,.field-blocks li.field-repeater-item>.repeater-item-collapse,.field-blocks li.field-repeater-item>.repeater-item-handle,.field-blocks li.field-repeater-item>.repeater-item-remove{opacity:1!important}}.field-blocks li.field-repeater-item .field-repeater-form{position:relative;top:-7px}.field-blocks li.field-repeater-item .field-repeater-form:after,.field-blocks li.field-repeater-item .field-repeater-form:before{content:" ";display:table}.field-blocks li.field-repeater-item .field-repeater-form:after{clear:both}.field-blocks li.field-repeater-item .field-repeater-form .form-group.span-left,.field-blocks li.field-repeater-item .field-repeater-form .form-group.span-right{width:49.5%}.field-blocks .field-repeater-add-item{margin-top:10px;position:relative}.field-blocks .field-repeater-add-item>a{border:1px dashed #bdc3c7;border-radius:5px;color:#bdc3c7;display:block;font-size:12px;font-weight:600;outline:none;padding:13px 15px;text-align:center;text-decoration:none;text-transform:uppercase;transition:border-color .5s,color .5s}.field-blocks .field-repeater-add-item>a:focus,.field-blocks .field-repeater-add-item>a:hover{border-color:#4ea5e0;color:#4ea5e0}.field-blocks .field-repeater-add-item>a:active{border-color:#3498db;color:#3498db}.field-blocks .field-repeater-add-item.in-progress>a{background:transparent!important;border-color:#e0e0e0!important}.field-blocks[data-mode=grid]{container-type:inline-size}.field-blocks[data-mode=grid] ul.field-repeater-items{display:grid;gap:20px}.field-blocks[data-mode=grid] ul.field-repeater-items .field-repeater-item{margin-bottom:0!important}.field-blocks[data-mode=grid] ul.field-repeater-items .field-repeater-add-item{margin-top:0}.field-blocks[data-mode=grid] ul.field-repeater-items .field-repeater-add-item a{display:flex;flex-direction:column;height:100%;justify-content:center}.field-blocks[data-mode=grid] ul.field-repeater-items .field-repeater-add-item:before{display:none}.field-blocks[data-mode=grid] ul.field-repeater-items .block-config{right:30px}.field-blocks[data-mode=grid][data-columns="2"] ul.field-repeater-items{grid-template-columns:repeat(2,1fr)}.field-blocks[data-mode=grid][data-columns="3"] ul.field-repeater-items{grid-template-columns:repeat(3,1fr)}.field-blocks[data-mode=grid][data-columns="4"] ul.field-repeater-items{grid-template-columns:repeat(4,1fr)}@media (max-width:1600px){.field-blocks[data-mode=grid][data-columns="4"] ul.field-repeater-items{grid-template-columns:repeat(3,1fr)}}.field-blocks[data-mode=grid][data-columns="5"] ul.field-repeater-items{grid-template-columns:repeat(5,1fr)}@media (max-width:1600px){.field-blocks[data-mode=grid][data-columns="5"] ul.field-repeater-items{grid-template-columns:repeat(4,1fr)}}.field-blocks[data-mode=grid][data-columns="6"] ul.field-repeater-items{grid-template-columns:repeat(6,1fr)}@media (max-width:1600px){.field-blocks[data-mode=grid][data-columns="6"] ul.field-repeater-items{grid-template-columns:repeat(4,1fr)}}@media (min-width:768px) and (max-width:1199px){.field-blocks[data-mode=grid][data-columns="3"] ul.field-repeater-items,.field-blocks[data-mode=grid][data-columns="4"] ul.field-repeater-items,.field-blocks[data-mode=grid][data-columns="5"] ul.field-repeater-items,.field-blocks[data-mode=grid][data-columns="6"] ul.field-repeater-items{grid-template-columns:repeat(2,1fr)}}@media (max-width:767px){.field-blocks[data-mode=grid] ul.field-repeater-items{grid-template-columns:1fr!important}.field-blocks[data-mode=grid] ul.field-repeater-items .field-repeater-add-item,.field-blocks[data-mode=grid] ul.field-repeater-items .field-repeater-item{min-height:0!important}.field-blocks[data-mode=grid] ul.field-repeater-items .field-repeater-add-item{margin-top:10px}.field-blocks[data-mode=grid] ul.field-repeater-items .field-repeater-add-item:before{display:block}}@container (width < 800px){[data-columns="2"] ul.field-repeater-items,[data-columns="3"] ul.field-repeater-items,[data-columns="4"] ul.field-repeater-items,[data-columns="5"] ul.field-repeater-items,[data-columns="6"] ul.field-repeater-items{grid-template-columns:1fr!important}}@container (width > 800px) and (width < 1200px){[data-columns="3"] ul.field-repeater-items,[data-columns="4"] ul.field-repeater-items,[data-columns="5"] ul.field-repeater-items,[data-columns="6"] ul.field-repeater-items{grid-template-columns:repeat(2,1fr)!important}}@container (width >= 1200px) and (width < 1600px){[data-columns="4"] ul.field-repeater-items,[data-columns="5"] ul.field-repeater-items,[data-columns="6"] ul.field-repeater-items{grid-template-columns:repeat(3,1fr)!important}} 2 | -------------------------------------------------------------------------------- /formwidgets/blocks/assets/js/blocks.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Blocks FormWidget plugin 3 | * 4 | * @TODO: 5 | * - Remove functionality not used by the Blocks FormWidget 6 | * - Potentially switch to Editor.js? 7 | * 8 | * Data attributes: 9 | * - data-control="fieldblocks" - enables the plugin on an element 10 | * - data-option="value" - an option with a value 11 | * 12 | * JavaScript API: 13 | * $('a#someElement').fieldBlocks({...}) 14 | */ 15 | 16 | +function ($) { "use strict"; 17 | 18 | var Base = $.wn.foundation.base, 19 | BaseProto = Base.prototype 20 | 21 | // FIELD REPEATER CLASS DEFINITION 22 | // ============================ 23 | 24 | var Blocks = function(element, options) { 25 | this.options = options 26 | this.$el = $(element) 27 | if (this.options.sortable) { 28 | this.$sortable = $(options.sortableContainer, this.$el) 29 | } 30 | 31 | $.wn.foundation.controlUtils.markDisposable(element) 32 | Base.call(this) 33 | this.init() 34 | } 35 | 36 | Blocks.prototype = Object.create(BaseProto) 37 | Blocks.prototype.constructor = Blocks 38 | 39 | Blocks.DEFAULTS = { 40 | sortableHandle: '.repeater-item-handle', 41 | sortableContainer: 'ul.field-repeater-items', 42 | titleFrom: null, 43 | minItems: null, 44 | maxItems: null, 45 | sortable: false, 46 | mode: 'list', 47 | style: 'default', 48 | } 49 | 50 | Blocks.prototype.init = function() { 51 | if (this.options.sortable) { 52 | this.bindSorting() 53 | } 54 | 55 | this.$el.on('ajaxDone', '> .field-repeater-items > .field-repeater-item > .repeater-item-remove > [data-repeater-remove]', this.proxy(this.onRemoveItemSuccess)) 56 | this.$el.on('ajaxDone', '> .field-repeater-items > .field-repeater-add-item > [data-repeater-add]', this.proxy(this.onAddItemSuccess)) 57 | this.$el.on('click', '> ul > li > .repeater-item-collapse .repeater-item-collapse-one', this.proxy(this.toggleCollapse)) 58 | this.$el.on('click', '> .field-repeater-items > .field-repeater-add-item > [data-repeater-add-group]', this.proxy(this.clickAddGroupButton)) 59 | this.$el.on('mouseover', '> .field-repeater-items > .field-repeater-item', this.proxy(this.onItemMouseOver)) 60 | this.$el.on('mouseout', '> .field-repeater-items > .field-repeater-item', this.proxy(this.onItemMouseOut)) 61 | this.$el.on('focus', '> .field-repeater-items > .field-repeater-item', this.proxy(this.onItemFocus)) 62 | this.$el.on('blur', '> .field-repeater-items > .field-repeater-item', this.proxy(this.onItemBlur)) 63 | 64 | this.$el.find('> ul > li > .repeater-item-collapsed-handle') 65 | .css({'cursor': 'pointer', 'width': '100%'}) 66 | .on('click', this.proxy(this.toggleCollapse)) 67 | 68 | this.$el.find('> ul > li > .field-repeater-form > .form-group > label') 69 | .css({'cursor': 'pointer', 'width': '100%'}) 70 | .on('click', this.proxy(this.toggleCollapse)) 71 | 72 | this.$el.one('dispose-control', this.proxy(this.dispose)) 73 | 74 | this.togglePrompt() 75 | this.applyStyle() 76 | } 77 | 78 | Blocks.prototype.dispose = function() { 79 | if (this.options.sortable) { 80 | this.$sortable.sortable('destroy') 81 | } 82 | 83 | this.$el.off('ajaxDone', '> .field-repeater-items > .field-repeater-item > .repeater-item-remove > [data-repeater-remove]', this.proxy(this.onRemoveItemSuccess)) 84 | this.$el.off('ajaxDone', '> .field-repeater-items > .field-repeater-add-item > [data-repeater-add]', this.proxy(this.onAddItemSuccess)) 85 | this.$el.off('click', '> ul > li > .repeater-item-collapse .repeater-item-collapse-one', this.proxy(this.toggleCollapse)) 86 | this.$el.off('click', '> .field-repeater-items > .field-repeater-add-item > [data-repeater-add-group]', this.proxy(this.clickAddGroupButton)) 87 | this.$el.off('mouseover', '> .field-repeater-items > .field-repeater-item', this.proxy(this.onItemMouseOver)) 88 | this.$el.off('mouseout', '> .field-repeater-items > .field-repeater-item', this.proxy(this.onItemMouseOut)) 89 | this.$el.off('focus', '> .field-repeater-items > .field-repeater-item', this.proxy(this.onItemFocus)) 90 | this.$el.off('blur', '> .field-repeater-items > .field-repeater-item', this.proxy(this.onItemBlur)) 91 | 92 | this.$el.off('dispose-control', this.proxy(this.dispose)) 93 | this.$el.removeData('wn.blocks') 94 | 95 | this.$el = null 96 | this.$sortable = null 97 | this.options = null 98 | 99 | BaseProto.dispose.call(this) 100 | } 101 | 102 | // Deprecated 103 | Blocks.prototype.unbind = function() { 104 | this.dispose() 105 | } 106 | 107 | Blocks.prototype.bindSorting = function() { 108 | var sortableOptions = { 109 | handle: this.options.sortableHandle, 110 | nested: false, 111 | vertical: this.options.mode === 'list', 112 | } 113 | 114 | this.$sortable.sortable(sortableOptions) 115 | } 116 | 117 | Blocks.prototype.clickAddGroupButton = function(ev) { 118 | var $self = this 119 | var templateHtml = $('> [data-group-palette-template]', this.$el).html(), 120 | $target = $(ev.target), 121 | $form = this.$el.closest('form'), 122 | $loadContainer = $target.closest('.loading-indicator-container') 123 | 124 | $target.ocPopover({ 125 | content: templateHtml 126 | }) 127 | 128 | var $container = $target.data('oc.popover').$container 129 | 130 | // Initialize the scrollpad control in the popup 131 | $container.trigger('render') 132 | 133 | $container 134 | .on('click', 'a', function (ev) { 135 | setTimeout(function() { $(ev.target).trigger('close.oc.popover') }, 1) 136 | }) 137 | .on('ajaxPromise', '[data-repeater-add]', function(ev, context) { 138 | $loadContainer.loadIndicator() 139 | 140 | $(window).one('ajaxUpdateComplete', function() { 141 | $loadContainer.loadIndicator('hide') 142 | $self.togglePrompt() 143 | $($self.$el).find('.field-repeater-items > .field-repeater-add-item').each(function () { 144 | if ($(this).children().length === 0) { 145 | $(this).remove() 146 | } 147 | }) 148 | }) 149 | }) 150 | 151 | $('[data-repeater-add]', $container).data('request-form', $form) 152 | } 153 | 154 | Blocks.prototype.onRemoveItemSuccess = function(ev) { 155 | var $target = $(ev.target) 156 | 157 | // Allow any widgets inside a deleted item to be disposed 158 | $target.closest('.field-repeater-item').find('[data-disposable]').each(function () { 159 | var $elem = $(this), 160 | control = $elem.data('control'), 161 | widget = $elem.data('oc.' + control) 162 | 163 | if (widget && typeof widget['dispose'] === 'function') { 164 | widget.dispose() 165 | } 166 | }) 167 | 168 | $target.closest('[data-field-name]').trigger('change.oc.formwidget') 169 | $target.closest('.field-repeater-item').remove() 170 | this.togglePrompt() 171 | } 172 | 173 | Blocks.prototype.onAddItemSuccess = function(ev) { 174 | window.requestAnimationFrame(() => { 175 | this.togglePrompt() 176 | $(ev.target).closest('[data-field-name]').trigger('change.oc.formwidget') 177 | $(this.$el).find('.field-repeater-items > .field-repeater-add-item').each(function () { 178 | if ($(this).children().length === 0) { 179 | $(this).remove() 180 | } 181 | }) 182 | }) 183 | } 184 | 185 | Blocks.prototype.togglePrompt = function () { 186 | if (this.options.minItems && this.options.minItems > 0) { 187 | var repeatedItems = this.$el.find('> .field-repeater-items > .field-repeater-item').length, 188 | $removeItemBtn = this.$el.find('> .field-repeater-items > .field-repeater-item > .repeater-item-remove') 189 | 190 | $removeItemBtn.toggleClass('disabled', !(repeatedItems > this.options.minItems)) 191 | } 192 | 193 | if (this.options.maxItems && this.options.maxItems > 0) { 194 | var repeatedItems = this.$el.find('> .field-repeater-items > .field-repeater-item').length, 195 | $addItemBtn = this.$el.find('> .field-repeater-items > .field-repeater-add-item') 196 | 197 | $addItemBtn.toggle(repeatedItems < this.options.maxItems) 198 | } 199 | } 200 | 201 | Blocks.prototype.toggleCollapse = function(ev) { 202 | var $item = $(ev.target).closest('.field-repeater-item'), 203 | isCollapsed = $item.hasClass('collapsed') 204 | 205 | ev.preventDefault() 206 | 207 | if (this.getStyle() === 'accordion') { 208 | if (isCollapsed) { 209 | this.expand($item) 210 | } 211 | return 212 | } 213 | 214 | if (ev.ctrlKey || ev.metaKey) { 215 | isCollapsed ? this.expandAll() : this.collapseAll() 216 | } 217 | else { 218 | isCollapsed ? this.expand($item) : this.collapse($item) 219 | } 220 | } 221 | 222 | Blocks.prototype.collapseAll = function() { 223 | var self = this, 224 | items = $(this.$el).children('.field-repeater-items').children('.field-repeater-item') 225 | 226 | $.each(items, function(key, item){ 227 | self.collapse($(item)) 228 | }) 229 | } 230 | 231 | Blocks.prototype.expandAll = function() { 232 | var self = this, 233 | items = $(this.$el).children('.field-repeater-items').children('.field-repeater-item') 234 | 235 | $.each(items, function(key, item){ 236 | self.expand($(item)) 237 | }) 238 | } 239 | 240 | Blocks.prototype.collapse = function($item) { 241 | $item.addClass('collapsed') 242 | $('.repeater-item-collapsed-title', $item).text(this.getCollapseTitle($item)) 243 | } 244 | 245 | Blocks.prototype.expand = function($item) { 246 | if (this.getStyle() === 'accordion') { 247 | this.collapseAll() 248 | } 249 | $item.removeClass('collapsed') 250 | } 251 | 252 | Blocks.prototype.getCollapseTitle = function($item) { 253 | var $target, 254 | defaultText = '', 255 | explicitText = $item.data('collapse-title') 256 | 257 | if (explicitText) { 258 | return explicitText 259 | } 260 | 261 | if (this.options.titleFrom) { 262 | $target = $('[data-field-name="'+this.options.titleFrom+'"]', $item) 263 | if (!$target.length) { 264 | $target = $item 265 | } 266 | } 267 | else { 268 | $target = $item 269 | } 270 | 271 | var $textInput = $('input[type=text]:first, select:first', $target).first() 272 | if ($textInput.length) { 273 | switch($textInput.prop("tagName")) { 274 | case 'SELECT': 275 | return $textInput.find('option:selected').text() 276 | default: 277 | return $textInput.val() 278 | } 279 | } else { 280 | var $disabledTextInput = $('.text-field:first > .form-control', $target) 281 | if ($disabledTextInput.length) { 282 | return $disabledTextInput.text() 283 | } 284 | } 285 | 286 | return defaultText 287 | } 288 | 289 | Blocks.prototype.getStyle = function() { 290 | var style = 'default' 291 | 292 | // Validate style 293 | if (this.options.style && ['collapsed', 'accordion'].indexOf(this.options.style) !== -1) { 294 | style = this.options.style 295 | } 296 | 297 | return style 298 | } 299 | 300 | Blocks.prototype.applyStyle = function() { 301 | if (this.options.mode === 'grid') { 302 | return 303 | } 304 | 305 | var style = this.getStyle(), 306 | self = this, 307 | items = $(this.$el).children('.field-repeater-items').children('.field-repeater-item') 308 | 309 | $.each(items, function(key, item) { 310 | switch (style) { 311 | case 'collapsed': 312 | self.collapse($(item)) 313 | break 314 | case 'accordion': 315 | if (key !== 0) { 316 | self.collapse($(item)) 317 | } 318 | break 319 | } 320 | }) 321 | } 322 | 323 | Blocks.prototype.onItemMouseOver = function(event) { 324 | event.stopPropagation() 325 | 326 | $(this.$el).find('.field-repeater-item').removeClass('hover') 327 | $(event.currentTarget).closest('.field-repeater-item').addClass('hover') 328 | } 329 | 330 | Blocks.prototype.onItemMouseOut = function(event) { 331 | event.stopPropagation() 332 | 333 | if ($(event.currentTarget).closest('.field-repeater-item').find('.inspector-open').length) { 334 | return 335 | } 336 | 337 | $(event.currentTarget).closest('.field-repeater-item').removeClass('hover') 338 | } 339 | 340 | Blocks.prototype.onItemFocus = function(event) { 341 | event.stopPropagation() 342 | 343 | $(event.currentTarget).closest('.field-repeater-item').addClass('focus') 344 | } 345 | 346 | Blocks.prototype.onItemBlur = function(event) { 347 | event.stopPropagation() 348 | 349 | $(event.currentTarget).closest('.field-repeater-item').removeClass('focus') 350 | } 351 | 352 | // FIELD REPEATER PLUGIN DEFINITION 353 | // ============================ 354 | 355 | var old = $.fn.fieldBlocks 356 | 357 | $.fn.fieldBlocks = function (option) { 358 | var args = Array.prototype.slice.call(arguments, 1), result 359 | this.each(function () { 360 | var $this = $(this) 361 | var data = $this.data('wn.blocks') 362 | var options = $.extend({}, Blocks.DEFAULTS, $this.data(), typeof option == 'object' && option) 363 | if (!data) $this.data('wn.blocks', (data = new Blocks(this, options))) 364 | if (typeof option == 'string') result = data[option].apply(data, args) 365 | if (typeof result != 'undefined') return false 366 | }) 367 | 368 | return result ? result : this 369 | } 370 | 371 | $.fn.fieldBlocks.Constructor = Blocks 372 | 373 | // FIELD REPEATER NO CONFLICT 374 | // ================= 375 | 376 | $.fn.fieldBlocks.noConflict = function () { 377 | $.fn.fieldBlocks = old 378 | return this 379 | } 380 | 381 | // FIELD REPEATER DATA-API 382 | // =============== 383 | 384 | $(document).render(function() { 385 | $('[data-control="fieldblocks"]').fieldBlocks() 386 | }) 387 | 388 | }(window.jQuery); 389 | -------------------------------------------------------------------------------- /formwidgets/blocks/assets/less/blocks.less: -------------------------------------------------------------------------------- 1 | // out: false 2 | 3 | @import "../../../../../../../modules/backend/assets/less/core/boot.less"; 4 | 5 | .field-blocks { 6 | padding-top: 5px; 7 | 8 | .field-repeater-items { 9 | counter-reset: repeater-index-counter; 10 | } 11 | 12 | ul.field-repeater-items, 13 | li.field-repeater-item { 14 | padding: 0; 15 | margin: 0; 16 | list-style: none; 17 | } 18 | 19 | ul.field-repeater-items > li { 20 | &.dragged { 21 | opacity: .7; 22 | position: absolute; 23 | padding-top: 15px; 24 | padding-right: 15px; 25 | z-index: 2000; 26 | background-color: @body-bg; 27 | border: 1px dashed #dbdee0; 28 | 29 | .repeater-item-remove { 30 | opacity: 0; 31 | } 32 | 33 | .repeater-item-collapsed-title { 34 | top: 5px; 35 | } 36 | } 37 | 38 | &.placeholder { 39 | display: block; 40 | position: relative; 41 | height: 25px; 42 | margin-bottom: 5px; 43 | &:before { 44 | display: block; 45 | position: absolute; 46 | .icon(@chevron-right); 47 | color: #d35714; 48 | left: -10px; 49 | top: 8px; 50 | z-index: 2000; 51 | } 52 | } 53 | } 54 | 55 | li.field-repeater-item { 56 | position: relative; 57 | margin: 0 0 1em !important; 58 | padding: 3.5em 1.25em 0 1.25em !important; 59 | border: 1px solid @input-border; 60 | border-radius: @border-radius-base; 61 | background: @body-bg; 62 | min-height: 30px; 63 | 64 | &.collapsed, 65 | &.empty { 66 | padding: 0 !important; 67 | 68 | .field-repeater-form { 69 | display:none; 70 | } 71 | 72 | .repeater-item-collapse { 73 | .repeater-item-collapse-one { 74 | .transform(rotate(180deg)); 75 | } 76 | } 77 | 78 | .repeater-item-title { 79 | display: inline-block; 80 | border-bottom: none; 81 | border-bottom-right-radius: 0; 82 | border-bottom-left-radius: @border-radius-base; 83 | height: 100%; 84 | } 85 | 86 | > .repeater-item-collapse, 87 | > .repeater-item-remove { 88 | opacity: 1; 89 | } 90 | } 91 | 92 | .repeater-item-collapse { 93 | position: absolute; 94 | top: 5px; 95 | right: 30px; 96 | z-index: 90; 97 | opacity: 0; 98 | .transition(~'opacity 0.5s'); 99 | 100 | a, button { 101 | .transition(~'transform 0.3s'); 102 | color: #bdc3c7; 103 | line-height: 20px; 104 | display: block; 105 | font-size: 12px; 106 | 107 | &:hover, 108 | &:focus { 109 | color: #999; 110 | text-decoration: none; 111 | } 112 | } 113 | } 114 | 115 | .repeater-item-remove { 116 | position: absolute; 117 | top: 4px; 118 | right: 5px; 119 | z-index: 90; 120 | opacity: 0; 121 | .transition(~'opacity 0.5s'); 122 | 123 | &.disabled { 124 | display: none; 125 | 126 | + .repeater-item-collapse { 127 | right: 7px; 128 | } 129 | } 130 | 131 | .close { 132 | float: none; 133 | display: inline-block; 134 | } 135 | } 136 | 137 | .block-config { 138 | position: absolute; 139 | top: 4px; 140 | right: 60px; 141 | z-index: 90; 142 | opacity: 0; 143 | .transition(~'opacity 0.5s'); 144 | 145 | color: #bdc3c7; 146 | line-height: 20px; 147 | display: block; 148 | font-size: 12px; 149 | 150 | &:hover, 151 | &:focus, 152 | &.inspector-open { 153 | color: #999; 154 | text-decoration: none; 155 | } 156 | } 157 | 158 | .repeater-item-collapse, 159 | .repeater-item-remove { 160 | width: 20px; 161 | height: 20px; 162 | text-align: center; 163 | 164 | > button, 165 | > a { 166 | outline: none; 167 | } 168 | } 169 | 170 | .repeater-item-collapsed-handle { 171 | position: absolute; 172 | top: 0; 173 | inset-inline: 0; 174 | } 175 | 176 | .repeater-item-title { 177 | position: absolute; 178 | font-size: 13px; 179 | top: 0px; 180 | left: 0px; 181 | padding: 4px 8px; 182 | border-bottom: 1px solid @input-border; 183 | border-right: 1px solid @input-border; 184 | border-top-left-radius: @border-radius-base; 185 | border-bottom-right-radius: @border-radius-base; 186 | background: #fff; 187 | color: fadeout(@input-color, 50); 188 | 189 | > .icon { 190 | margin-right: 4px; 191 | } 192 | } 193 | 194 | .repeater-item-handle { 195 | cursor: move; 196 | } 197 | 198 | &.hover { 199 | border: 1px solid #999; 200 | 201 | > .repeater-item-title { 202 | color: #999; 203 | border-color: #999; 204 | } 205 | } 206 | 207 | &.focus { 208 | border: 1px solid @highlight-hover-bg !important; 209 | 210 | > .repeater-item-title { 211 | color: @highlight-hover-bg !important; 212 | border-color: @highlight-hover-bg !important; 213 | } 214 | } 215 | 216 | &.hover, 217 | &.focus { 218 | > .block-config, 219 | > .repeater-item-collapse, 220 | > .repeater-item-handle, 221 | > .repeater-item-remove { 222 | opacity: 1; 223 | } 224 | } 225 | 226 | @media (hover: none) { 227 | > .block-config, 228 | > .repeater-item-collapse, 229 | > .repeater-item-handle, 230 | > .repeater-item-remove { 231 | opacity: 1 !important; 232 | } 233 | } 234 | 235 | .field-repeater-form { 236 | position: relative; 237 | top: -7px; 238 | .clearfix; 239 | 240 | .form-group.span-left, 241 | .form-group.span-right { 242 | width: 49.5%; 243 | } 244 | } 245 | } 246 | 247 | .field-repeater-add-item { 248 | position: relative; 249 | margin-top: 10px; 250 | 251 | > a { 252 | border: 1px dashed #bdc3c7; 253 | border-radius: 5px; 254 | color: #bdc3c7; 255 | text-align: center; 256 | display: block; 257 | text-decoration: none; 258 | padding: 13px 15px; 259 | text-transform: uppercase; 260 | font-weight: 600; 261 | font-size: @font-size-base - 2; 262 | outline: none; 263 | .transition(~'border-color 0.5s, color 0.5s'); 264 | 265 | &:hover, &:focus { 266 | border-color: @highlight-hover-bg; 267 | color: @highlight-hover-bg; 268 | } 269 | 270 | &:active { 271 | border-color: @highlight-active-bg; 272 | color: @highlight-active-bg; 273 | } 274 | } 275 | 276 | &.in-progress > a { 277 | border-color: #e0e0e0 !important; 278 | background: transparent !important; 279 | } 280 | } 281 | 282 | &[data-mode="grid"] { 283 | container-type: inline-size; 284 | 285 | ul.field-repeater-items { 286 | display: grid; 287 | gap: 20px; 288 | 289 | .field-repeater-item { 290 | margin-bottom: 0 !important; 291 | } 292 | 293 | .field-repeater-add-item { 294 | margin-top: 0; 295 | 296 | a { 297 | display: flex; 298 | flex-direction: column; 299 | justify-content: center; 300 | height: 100%; 301 | } 302 | 303 | &:before { 304 | display: none; 305 | } 306 | } 307 | 308 | .block-config { 309 | right: 30px; 310 | } 311 | } 312 | 313 | &[data-columns="2"] ul.field-repeater-items { 314 | grid-template-columns: repeat(2, 1fr); 315 | } 316 | &[data-columns="3"] ul.field-repeater-items { 317 | grid-template-columns: repeat(3, 1fr); 318 | } 319 | &[data-columns="4"] ul.field-repeater-items { 320 | grid-template-columns: repeat(4, 1fr); 321 | 322 | @media (max-width: 1600px) { 323 | grid-template-columns: repeat(3, 1fr); 324 | } 325 | } 326 | &[data-columns="5"] ul.field-repeater-items { 327 | grid-template-columns: repeat(5, 1fr); 328 | 329 | @media (max-width: 1600px) { 330 | grid-template-columns: repeat(4, 1fr); 331 | } 332 | } 333 | &[data-columns="6"] ul.field-repeater-items { 334 | grid-template-columns: repeat(6, 1fr); 335 | 336 | @media (max-width: 1600px) { 337 | grid-template-columns: repeat(4, 1fr); 338 | } 339 | } 340 | 341 | @media (min-width: @screen-sm-min) and (max-width: @screen-md-max) { 342 | &[data-columns="3"] ul.field-repeater-items, 343 | &[data-columns="4"] ul.field-repeater-items, 344 | &[data-columns="5"] ul.field-repeater-items, 345 | &[data-columns="6"] ul.field-repeater-items { 346 | grid-template-columns: repeat(2, 1fr); 347 | } 348 | } 349 | 350 | @media (max-width: @screen-xs-max) { 351 | ul.field-repeater-items { 352 | grid-template-columns: 1fr !important; 353 | 354 | .field-repeater-item, 355 | .field-repeater-add-item { 356 | min-height: 0 !important; 357 | } 358 | 359 | .field-repeater-add-item { 360 | margin-top: 10px; 361 | 362 | &::before { 363 | display: block; 364 | } 365 | } 366 | } 367 | } 368 | } 369 | } 370 | 371 | @container (width < 800px) { 372 | &[data-columns="2"] ul.field-repeater-items, 373 | &[data-columns="3"] ul.field-repeater-items, 374 | &[data-columns="4"] ul.field-repeater-items, 375 | &[data-columns="5"] ul.field-repeater-items, 376 | &[data-columns="6"] ul.field-repeater-items { 377 | grid-template-columns: 1fr !important; 378 | } 379 | } 380 | 381 | @container (width > 800px) and (width < 1200px) { 382 | &[data-columns="3"] ul.field-repeater-items, 383 | &[data-columns="4"] ul.field-repeater-items, 384 | &[data-columns="5"] ul.field-repeater-items, 385 | &[data-columns="6"] ul.field-repeater-items { 386 | grid-template-columns: repeat(2, 1fr) !important; 387 | } 388 | } 389 | 390 | @container (width >= 1200px) and (width < 1600px) { 391 | &[data-columns="4"] ul.field-repeater-items, 392 | &[data-columns="5"] ul.field-repeater-items, 393 | &[data-columns="6"] ul.field-repeater-items { 394 | grid-template-columns: repeat(3, 1fr) !important; 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /formwidgets/blocks/partials/_block.php: -------------------------------------------------------------------------------- 1 |
4 | 5 | 6 | 7 | data-mode="" 8 | data-columns="" 9 | 10 | data-sortable="true" 11 | data-sortable-container="#getId('items') ?>" 12 | data-sortable-handle=".getId('items') ?>-handle" 13 | 14 | > 15 | previewMode): ?> 16 | 17 | 18 | 19 |
    20 | $widget) : ?> 21 | makePartial('block_item', [ 22 | 'widget' => $widget, 23 | 'indexValue' => $index, 24 | 'height' => ($mode === 'grid') ? $rowHeight : null, 25 | ]) ?> 26 | 27 | 28 | makePartial('block_add_item', [ 29 | 'useGroups' => $useGroups, 30 | 'height' => ($mode === 'grid') ? $rowHeight : null, 31 | ]) ?> 32 |
33 | 34 | previewMode) : ?> 35 | 36 | 37 | 38 | 70 |
71 | -------------------------------------------------------------------------------- /formwidgets/blocks/partials/_block_add_item.php: -------------------------------------------------------------------------------- 1 | previewMode): ?> 2 |
  • style="min-height: px" 6 | > 7 | 12 | 13 | 14 |
  • 15 | 16 | -------------------------------------------------------------------------------- /formwidgets/blocks/partials/_block_item.php: -------------------------------------------------------------------------------- 1 | getGroupCodeFromIndex($indexValue); 3 | $groupConfig = $this->getGroupConfigFromIndex($indexValue); 4 | $itemTitle = $this->getGroupTitle($groupCode); 5 | $itemDescription = $this->getGroupDescription($groupCode); 6 | $itemIcon = $this->getGroupIcon($groupCode); 7 | ?> 8 |
  • style="min-height: px" 11 | > 12 | 13 | previewMode) : ?> 14 |
    15 | 25 |
    26 | 27 | 28 | getFields()) && $mode !== 'grid'): ?> 29 |
    30 | 31 | 32 | 33 |
    34 | 35 | 36 |
     
    37 | 38 |
    > 39 | 40 | 41 | 42 | 43 | 44 | 45 |
    46 | 47 | hasInspectorConfig($groupCode)): ?> 48 | 58 | 59 | 60 | 61 | 62 | 63 |
    67 | getFields() as $field) : ?> 68 | renderField($field) ?> 69 | 70 | 71 | 72 |
    73 | 74 |
  • 75 | -------------------------------------------------------------------------------- /lang/en/lang.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'name' => 'Blocks', 6 | 'description' => 'Block based content management plugin for Winter CMS.', 7 | ], 8 | 'actions' => [ 9 | 'open_url' => [ 10 | 'name' => 'Open URL', 11 | 'description' => 'Open the provided URL', 12 | 'href' => 'URL', 13 | 'target' => 'Open URL in', 14 | 'target_self' => 'Same tab', 15 | 'target_blank' => 'New tab', 16 | ], 17 | 'open_media' => [ 18 | 'name' => 'Download Media File', 19 | 'description' => 'Download the provided file from the Media Library', 20 | 'media_file' => 'File', 21 | ], 22 | ], 23 | 'tabs' => [ 24 | 'display' => 'Display', 25 | ], 26 | 'blocks' => [ 27 | 'button' => [ 28 | 'name' => 'Button', 29 | 'description' => 'A clickable button', 30 | ], 31 | 'button_group' => [ 32 | 'name' => 'Button Group', 33 | 'description' => 'Group of clickable buttons', 34 | 'buttons' => 'Buttons', 35 | 'position_center' => 'Center', 36 | 'position_left' => 'Left', 37 | 'position_right' => 'Right', 38 | 'position' => 'Position', 39 | 'width_auto' => 'Auto', 40 | 'width_full' => 'Full', 41 | 'width' => 'Width', 42 | ], 43 | 'cards' => [ 44 | 'name' => 'Cards', 45 | 'description' => 'Content in card format', 46 | ], 47 | 'code' => [ 48 | 'name' => 'Code', 49 | 'description' => 'Custom HTML content', 50 | ], 51 | 'columns_two' => [ 52 | 'name' => 'Two Columns', 53 | 'description' => 'Two columns of content', 54 | 'left' => 'Left Column', 55 | 'right' => 'Right Column', 56 | ], 57 | 'divider' => [ 58 | 'name' => 'Divider', 59 | 'description' => 'Horizontal dividing line', 60 | ], 61 | 'image' => [ 62 | 'name' => 'Image', 63 | 'description' => 'Single image from Media Library', 64 | 'alt_text' => 'Description (for screen readers)', 65 | 'size' => [ 66 | 'w-full' => 'Full', 67 | 'w-2/3' => 'Two Thirds', 68 | 'w-1/2' => 'Half', 69 | 'w-1/3' => 'Third', 70 | 'w-1/4' => 'Quarter', 71 | ], 72 | ], 73 | 'plaintext' => [ 74 | 'name' => 'Plain text', 75 | 'description' => 'Content with no formatting', 76 | ], 77 | 'richtext' => [ 78 | 'name' => 'Rich Text', 79 | 'description' => 'Content with basic formatting', 80 | ], 81 | 'title' => [ 82 | 'name' => 'Title', 83 | 'description' => 'Large text with size options', 84 | 'size' => [ 85 | 'h4' => 'Small', 86 | 'h3' => 'Medium', 87 | 'h2' => 'Large', 88 | ], 89 | ], 90 | 'video' => [ 91 | 'name' => 'Video', 92 | 'description' => 'Embed a Media Library video', 93 | ], 94 | 'vimeo' => [ 95 | 'name' => 'Vimeo', 96 | 'description' => 'Embed a Vimeo video', 97 | 'vimeo_id' => 'Vimeo Video ID', 98 | ], 99 | 'youtube' => [ 100 | 'name' => 'YouTube', 101 | 'description' => 'Embed a YouTube video', 102 | 'youtube_id' => 'YouTube Video ID', 103 | ], 104 | ], 105 | 'fields' => [ 106 | 'actions_prompt' => 'Add action', 107 | 'actions' => 'Actions', 108 | 'blocks_prompt' => 'Add block', 109 | 'blocks' => 'Blocks', 110 | 'color' => 'Color', 111 | 'content' => 'Content', 112 | 'icon' => 'Icon', 113 | 'label' => 'Label', 114 | 'size' => 'Size', 115 | 'default' => 'Default', 116 | 'alignment_x' => [ 117 | 'label' => 'Horizontal Alignment', 118 | 'left' => 'Left', 119 | 'center' => 'Center', 120 | 'right' => 'Right', 121 | ], 122 | ], 123 | ]; 124 | -------------------------------------------------------------------------------- /lang/fr/lang.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'name' => 'Blocks', 6 | 'description' => 'Plugin de gestion de contenu basé sur des blocs pour Winter CMS.', 7 | ], 8 | 'actions' => [ 9 | 'open_url' => [ 10 | 'name' => 'Ouvrir l\'URL', 11 | 'description' => 'Ouvrez l\'URL fournie', 12 | 'href' => 'URL', 13 | 'target' => 'Ouvrir l\'URL dans', 14 | 'target_self' => 'Même onglet', 15 | 'target_blank' => 'Nouvel onglet', 16 | ], 17 | 'open_media' => [ 18 | 'name' => 'Télécharger un fichier média', 19 | 'description' => 'Télécharger le fichier spécifié à partir de la médiathèque', 20 | 'media_file' => 'Fichier', 21 | ], 22 | ], 23 | 'tabs' => [ 24 | 'display' => 'Apparence', 25 | ], 26 | 'blocks' => [ 27 | 'button' => [ 28 | 'name' => 'Bouton', 29 | 'description' => 'Un bouton cliquable', 30 | ], 31 | 'button_group' => [ 32 | 'name' => 'Groupe de boutons', 33 | 'description' => 'Groupe de boutons cliquables', 34 | 'buttons' => 'Boutons', 35 | 'position_center' => 'Centré', 36 | 'position_left' => 'A gauche', 37 | 'position_right' => 'A droite', 38 | 'position' => 'Position', 39 | 'width_auto' => 'Auto', 40 | 'width_full' => 'complète', 41 | 'width' => 'Largeur', 42 | ], 43 | 'cards' => [ 44 | 'name' => 'Cartes', 45 | 'description' => 'Contenu au format carte', 46 | ], 47 | 'code' => [ 48 | 'name' => 'Code', 49 | 'description' => 'Contenu HTML personnalisé', 50 | ], 51 | 'columns_two' => [ 52 | 'name' => 'Deux colonnes', 53 | 'description' => 'Deux colonnes de contenu', 54 | 'left' => 'Colonne de gauche', 55 | 'right' => 'Colonne de droite', 56 | ], 57 | 'divider' => [ 58 | 'name' => 'Séparateur', 59 | 'description' => 'Ligne de séparation horizontale', 60 | ], 61 | 'image' => [ 62 | 'name' => 'Image', 63 | 'description' => 'Image unique de la médiathèque', 64 | 'alt_text' => 'Description (pour les lecteurs d\'écran) ', 65 | 'size' => [ 66 | 'w-full' => 'Complète', 67 | 'w-2/3' => '2/3', 68 | 'w-1/2' => 'Moitié', 69 | 'w-1/3' => '1/3', 70 | 'w-1/4' => '1/4', 71 | ], 72 | ], 73 | 'plaintext' => [ 74 | 'name' => 'Texte simple', 75 | 'description' => 'Contenu sans mise en forme', 76 | ], 77 | 'richtext' => [ 78 | 'name' => 'Contenu enrichi', 79 | 'description' => 'Contenu avec mise en forme de base', 80 | ], 81 | 'title' => [ 82 | 'name' => 'Titre', 83 | 'description' => 'Titre de page avec options de taille', 84 | 'size' => [ 85 | 'h4' => 'Petit', 86 | 'h3' => 'Moyen', 87 | 'h2' => 'Grand', 88 | ], 89 | ], 90 | 'video' => [ 91 | 'name' => 'Vidéo', 92 | 'description' => 'Intégrer une vidéo de la médiathèque', 93 | ], 94 | 'vimeo' => [ 95 | 'name' => 'Vimeo', 96 | 'description' => 'Intégrer une vidéo Vimeo', 97 | 'vimeo_id' => 'ID de la vidéo Vimeo', 98 | ], 99 | 'youtube' => [ 100 | 'name' => 'YouTube', 101 | 'description' => 'Intégrer une vidéo YouTube', 102 | 'youtube_id' => 'ID de la vidéo YouTube', 103 | ], 104 | ], 105 | 'fields' => [ 106 | 'actions_prompt' => 'Ajouter une action', 107 | 'actions' => 'Actions', 108 | 'blocks_prompt' => 'Ajouter un bloc', 109 | 'blocks' => 'Blocs', 110 | 'color' => 'Couleur', 111 | 'content' => 'Contenu', 112 | 'icon' => 'Icône', 113 | 'label' => 'Label/texte', 114 | 'size' => 'Taille', 115 | 'default' => 'Défaut', 116 | 'alignment_x' => [ 117 | 'label' => 'Alignement horizontal', 118 | 'left' => 'À gauche', 119 | 'center' => 'Centré', 120 | 'right' => 'À droite', 121 | ], 122 | ], 123 | ]; 124 | -------------------------------------------------------------------------------- /meta/actions.yaml: -------------------------------------------------------------------------------- 1 | open_url: 2 | name: winter.blocks::lang.actions.open_url.name 3 | description: winter.blocks::lang.actions.open_url.description 4 | icon: icon-link 5 | fields: 6 | _title: 7 | label: winter.blocks::lang.actions.open_url.name 8 | type: section 9 | href: 10 | label: winter.blocks::lang.actions.open_url.href 11 | type: text 12 | target: 13 | label: winter.blocks::lang.actions.open_url.target 14 | type: dropdown 15 | options: 16 | _self: winter.blocks::lang.actions.open_url.target_self 17 | _blank: winter.blocks::lang.actions.open_url.target_blank 18 | open_media: 19 | name: winter.blocks::lang.actions.open_media.name 20 | description: winter.blocks::lang.actions.open_media.description 21 | icon: icon-file 22 | fields: 23 | _title: 24 | label: winter.blocks::lang.actions.open_media.name 25 | type: section 26 | media_file: 27 | label: winter.blocks::lang.actions.open_media.media_file 28 | type: mediafinder 29 | span: left 30 | mode: file 31 | target: 32 | label: winter.blocks::lang.actions.open_url.target 33 | type: balloon-selector 34 | span: right 35 | options: 36 | _self: winter.blocks::lang.actions.open_url.target_self 37 | _blank: winter.blocks::lang.actions.open_url.target_blank 38 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./tests 14 | 15 | 16 | 17 | 18 | classes 19 | formwidgets 20 | Plugin.php 21 | 22 | 23 | formwidgets/*/partials 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /tests/PluginTest.php: -------------------------------------------------------------------------------- 1 | plugin = new Plugin($this->createApplication()); 22 | } 23 | 24 | public function testSetsCorrectPluginDetails() 25 | { 26 | $details = $this->plugin->pluginDetails(); 27 | 28 | $this->assertIsArray($details); 29 | $this->assertArrayHasKey('name', $details); 30 | $this->assertArrayHasKey('description', $details); 31 | $this->assertArrayHasKey('icon', $details); 32 | $this->assertArrayHasKey('author', $details); 33 | 34 | $this->assertEquals('Winter CMS', $details['author']); 35 | } 36 | 37 | public function testRegistersPermissions() 38 | { 39 | $this->markTestSkipped('Permissions have not been implemented yet.'); 40 | 41 | $permissions = $this->plugin->registerPermissions(); 42 | 43 | $this->assertIsArray($permissions); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/classes/BlockManagerTest.php: -------------------------------------------------------------------------------- 1 | manager = BlockManager::instance(); 34 | $this->fixturePath = dirname(__DIR__) . '/fixtures/blocks/'; 35 | $this->pluginPath = dirname(dirname(__DIR__)) . '/blocks/'; 36 | } 37 | 38 | public function testCanRegisterBlocksDirectly() 39 | { 40 | $this->manager->registerBlock('container', $this->fixturePath . 'container.block'); 41 | 42 | $this->assertIsArray($this->manager->getRegisteredBlocks()); 43 | $this->assertEquals([ 44 | 'container' => PathResolver::standardize($this->fixturePath . 'container.block'), 45 | 'button_group' => PathResolver::standardize($this->pluginPath . 'button_group.block'), 46 | 'button' => PathResolver::standardize($this->pluginPath . 'button.block'), 47 | 'cards' => PathResolver::standardize($this->pluginPath . 'cards.block'), 48 | 'code' => PathResolver::standardize($this->pluginPath . 'code.block'), 49 | 'columns_two' => PathResolver::standardize($this->pluginPath . 'columns_two.block'), 50 | 'divider' => PathResolver::standardize($this->pluginPath . 'divider.block'), 51 | 'image' => PathResolver::standardize($this->pluginPath . 'image.block'), 52 | 'plaintext' => PathResolver::standardize($this->pluginPath . 'plaintext.block'), 53 | 'richtext' => PathResolver::standardize($this->pluginPath . 'richtext.block'), 54 | 'title' => PathResolver::standardize($this->pluginPath . 'title.block'), 55 | 'video' => PathResolver::standardize($this->pluginPath . 'video.block'), 56 | 'vimeo' => PathResolver::standardize($this->pluginPath . 'vimeo.block'), 57 | 'youtube' => PathResolver::standardize($this->pluginPath . 'youtube.block'), 58 | ], $this->manager->getRegisteredBlocks()); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/fixtures/blocks/container.block: -------------------------------------------------------------------------------- 1 | name: Container 2 | description: Container block 3 | icon: icon-square 4 | tags: ["containers"] 5 | fields: 6 | contents: 7 | label: false 8 | type: blocks 9 | span: full 10 | allow: 11 | - contents 12 | == 13 | -------------------------------------------------------------------------------- /tests/fixtures/blocks/richtext.block: -------------------------------------------------------------------------------- 1 | name: Rich text 2 | description: title block 3 | icon: icon-text 4 | tags: ["content"] 5 | fields: 6 | content: 7 | label: false 8 | type: richeditor 9 | size: small 10 | == 11 | -------------------------------------------------------------------------------- /tests/fixtures/blocks/title.block: -------------------------------------------------------------------------------- 1 | name: Title 2 | description: Title block 3 | icon: icon-text-width 4 | tags: ["content"] 5 | fields: 6 | content: 7 | label: false 8 | type: text 9 | == 10 | -------------------------------------------------------------------------------- /tests/fixtures/models/Page.php: -------------------------------------------------------------------------------- 1 | fixturePath = dirname(__DIR__) . '/fixtures/blocks/'; 29 | 30 | Config::set('cms.activeTheme', 'blocktest'); 31 | Config::set('cms.themesPath', '/plugins/winter/blocks/tests/fixtures/themes'); 32 | 33 | Event::flush('cms.theme.getActiveTheme'); 34 | Theme::resetCache(); 35 | } 36 | 37 | protected function createTestFormWidget(array $config = []): Blocks 38 | { 39 | Theme::load('blocktest'); 40 | $controller = new Controller(); 41 | $model = new Page(); 42 | $form = new Form($controller, [ 43 | 'model' => $model, 44 | 'fields' => [], 45 | ]); 46 | $form->bindToController(); 47 | 48 | $widget = new Blocks( 49 | $controller, 50 | new FormField('content', 'Content'), 51 | array_merge($config, [ 52 | 'parentForm' => $form, 53 | 'model' => $model, 54 | ]), 55 | ); 56 | 57 | $widget->init(); 58 | return $widget; 59 | } 60 | 61 | public function testCanCreateFormWidget() 62 | { 63 | $this->assertInstanceOf(Blocks::class, $this->createTestFormWidget()); 64 | } 65 | 66 | public function testCanLimitAvailableBlocksByTag() 67 | { 68 | BlockManager::instance()->registerBlock('container', $this->fixturePath . 'container.block'); 69 | BlockManager::instance()->registerBlock('richtext', $this->fixturePath . 'richtext.block'); 70 | BlockManager::instance()->registerBlock('title', $this->fixturePath . 'title.block'); 71 | 72 | $widget = $this->createTestFormWidget([ 73 | 'tags' => 'content', 74 | ]); 75 | 76 | // Only way we can see if the block is available through the public API is through getting the title of 77 | // the block. If the title is missing, the block isn't available. 78 | $this->assertEquals('Rich text', $widget->getGroupTitle('richtext')); 79 | $this->assertEquals('Title', $widget->getGroupTitle('title')); 80 | $this->assertNull($widget->getGroupTitle('container')); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /updates/version.yaml: -------------------------------------------------------------------------------- 1 | '1.0.0': 2 | - 'First version of Winter.Blocks' 3 | -------------------------------------------------------------------------------- /winter.mix.js: -------------------------------------------------------------------------------- 1 | const mix = require('laravel-mix'); 2 | 3 | mix 4 | .setPublicPath(__dirname) 5 | .js('assets/src/js/blocks.js', 'assets/dist/js/blocks.js') 6 | .less('formwidgets/blocks/assets/less/blocks.less', 'formwidgets/blocks/assets/css/blocks.css'); 7 | --------------------------------------------------------------------------------