├── src ├── web │ ├── assets │ │ ├── input │ │ │ ├── dist │ │ │ │ └── styles │ │ │ │ │ └── input.js │ │ │ └── src │ │ │ │ └── scripts │ │ │ │ ├── plugins │ │ │ │ └── cpfieldinspect │ │ │ │ │ └── main.js │ │ │ │ ├── main.js │ │ │ │ ├── Group.js │ │ │ │ ├── Buttons.js │ │ │ │ ├── namespace.js │ │ │ │ ├── ButtonsList.js │ │ │ │ ├── ButtonsGrid.js │ │ │ │ └── BlockType.js │ │ ├── configurator │ │ │ ├── dist │ │ │ │ └── styles │ │ │ │ │ ├── configurator.js │ │ │ │ │ └── configurator.css │ │ │ └── src │ │ │ │ └── scripts │ │ │ │ ├── jquery-extensions.js │ │ │ │ ├── main.js │ │ │ │ ├── Item.js │ │ │ │ ├── Settings.js │ │ │ │ ├── namespace.js │ │ │ │ ├── GroupSettings.js │ │ │ │ ├── BlockTypeIconSelect.js │ │ │ │ ├── BlockTypeFieldLayout.js │ │ │ │ └── Group.js │ │ └── converter │ │ │ ├── dist │ │ │ └── scripts │ │ │ │ └── converter.js │ │ │ └── src │ │ │ └── scripts │ │ │ └── main.js │ └── twig │ │ ├── Extension.php │ │ └── Variable.php ├── templates │ ├── macros.twig │ ├── child-blocks.twig │ ├── _block-type-icon-input.twig │ ├── input.twig │ ├── block-type-group-settings.twig │ ├── _tabs.twig │ ├── plugin-settings.twig │ └── feed-me.twig ├── enums │ ├── BlockTypeIconSelectMode.php │ ├── NewBlockMenuStyle.php │ └── BlockTypeGroupDropdown.php ├── events │ ├── SetConditionElementTypesEvent.php │ ├── BlockTypeEvent.php │ └── FilterBlockTypesEvent.php ├── elements │ └── conditions │ │ ├── fields │ │ ├── ParentDateFieldConditionRule.php │ │ ├── ParentTextFieldConditionRule.php │ │ ├── ParentNumberFieldConditionRule.php │ │ ├── ParentOptionsFieldConditionRule.php │ │ ├── ParentRelationalFieldConditionRule.php │ │ └── ParentLightswitchFieldConditionRule.php │ │ ├── OwnerUriConditionRule.php │ │ ├── OwnerSlugConditionRule.php │ │ ├── OwnerLevelConditionRule.php │ │ ├── OwnerTitleConditionRule.php │ │ ├── OwnerHasUrlConditionRule.php │ │ ├── OwnerDateCreatedConditionRule.php │ │ ├── OwnerDateUpdatedConditionRule.php │ │ ├── OwnerTagGroupConditionRule.php │ │ ├── OwnerVolumeConditionRule.php │ │ ├── OwnerSectionConditionRule.php │ │ ├── OwnerUserGroupConditionRule.php │ │ ├── OwnerEntryTypeConditionRule.php │ │ ├── OwnerCategoryGroupConditionRule.php │ │ ├── OwnerConditionRuleTrait.php │ │ └── BlockCondition.php ├── errors │ └── BlockTypeNotFoundException.php ├── migrations │ ├── m240311_044007_add_block_type_color.php │ ├── m231005_132818_add_block_type_icon_filename_property.php │ ├── m240711_024245_block_type_entry_type.php │ ├── m240224_024030_migrate_owners_table.php │ ├── m240403_061537_content_refactor.php │ ├── m240722_092833_revert_index_change.php │ ├── m240212_040753_move_project_config_data.php │ ├── m240226_032156_migrate_deleted_with_owner.php │ └── m231027_012155_project_config_sort_orders.php ├── helpers │ └── Memoize.php ├── records │ ├── BlockTypeGroup.php │ ├── Block.php │ ├── BlockStructure.php │ └── BlockType.php ├── controllers │ └── Conversion.php ├── fieldlayoutelements │ └── ChildBlocksUiElement.php ├── jobs │ ├── DeleteBlocks.php │ ├── DeleteBlock.php │ ├── SaveBlockStructures.php │ └── ResaveFieldBlockStructures.php ├── gql │ ├── arguments │ │ └── elements │ │ │ └── Block.php │ ├── types │ │ ├── elements │ │ │ └── Block.php │ │ ├── generators │ │ │ └── BlockType.php │ │ └── input │ │ │ └── Block.php │ ├── resolvers │ │ └── elements │ │ │ └── Block.php │ └── interfaces │ │ └── elements │ │ └── Block.php ├── models │ ├── BlockStructure.php │ ├── BlockTypeGroup.php │ └── Settings.php ├── icon.svg ├── translations │ └── de │ │ └── neo.php └── console │ └── controllers │ └── BlockTypeGroupsController.php ├── docs ├── assets │ ├── icon.png │ ├── feature1-1.png │ ├── feature3-1.png │ ├── feature3-2.png │ ├── feature4-1.png │ ├── feature4-2.png │ ├── feature5-1.png │ ├── feature5-2.png │ ├── feature6-1.png │ ├── feature6-2.png │ ├── feature6-3.png │ ├── feed-me-block-level.png │ └── neo-block-type-settings.png ├── installation.md ├── upgrade-guides │ ├── neo-2.7-craft-3.4.md │ ├── neo-5.md │ └── neo-4.md ├── feed-me.md ├── resources.md ├── plugin-compatibility.md ├── faq.md ├── content-migration-guides │ └── populating-neo-fields.md ├── events.md ├── templating.md ├── settings.md ├── creating-neo-fields.md └── graphql.md ├── composer.json ├── LICENSE └── README.md /src/web/assets/input/dist/styles/input.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/web/assets/configurator/dist/styles/configurator.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicywebau/craft-neo/HEAD/docs/assets/icon.png -------------------------------------------------------------------------------- /docs/assets/feature1-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicywebau/craft-neo/HEAD/docs/assets/feature1-1.png -------------------------------------------------------------------------------- /docs/assets/feature3-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicywebau/craft-neo/HEAD/docs/assets/feature3-1.png -------------------------------------------------------------------------------- /docs/assets/feature3-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicywebau/craft-neo/HEAD/docs/assets/feature3-2.png -------------------------------------------------------------------------------- /docs/assets/feature4-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicywebau/craft-neo/HEAD/docs/assets/feature4-1.png -------------------------------------------------------------------------------- /docs/assets/feature4-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicywebau/craft-neo/HEAD/docs/assets/feature4-2.png -------------------------------------------------------------------------------- /docs/assets/feature5-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicywebau/craft-neo/HEAD/docs/assets/feature5-1.png -------------------------------------------------------------------------------- /docs/assets/feature5-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicywebau/craft-neo/HEAD/docs/assets/feature5-2.png -------------------------------------------------------------------------------- /docs/assets/feature6-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicywebau/craft-neo/HEAD/docs/assets/feature6-1.png -------------------------------------------------------------------------------- /docs/assets/feature6-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicywebau/craft-neo/HEAD/docs/assets/feature6-2.png -------------------------------------------------------------------------------- /docs/assets/feature6-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicywebau/craft-neo/HEAD/docs/assets/feature6-3.png -------------------------------------------------------------------------------- /docs/assets/feed-me-block-level.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicywebau/craft-neo/HEAD/docs/assets/feed-me-block-level.png -------------------------------------------------------------------------------- /src/templates/macros.twig: -------------------------------------------------------------------------------- 1 | {% macro getBlockId(block) %} 2 | {{- block.id ?? 'new' ~ block.unsavedId -}} 3 | {%- endmacro %} 4 | -------------------------------------------------------------------------------- /docs/assets/neo-block-type-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicywebau/craft-neo/HEAD/docs/assets/neo-block-type-settings.png -------------------------------------------------------------------------------- /src/web/assets/input/src/scripts/plugins/cpfieldinspect/main.js: -------------------------------------------------------------------------------- 1 | // import Craft from 'craft' 2 | 3 | export function addFieldLinks ($element) { 4 | if (window.Craft.CpFieldInspectPlugin) { 5 | window.Craft.CpFieldInspectPlugin.addFieldLinks() 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/web/assets/configurator/src/scripts/jquery-extensions.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | 3 | // @see http://stackoverflow.com/a/12903503/556609 4 | $.fn.insertAt = function (index, $parent) { 5 | return this.each(function () { 6 | if (index === 0) { 7 | $parent.prepend(this) 8 | } else { 9 | $parent.children().eq(index - 1).after(this) 10 | } 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /src/enums/BlockTypeIconSelectMode.php: -------------------------------------------------------------------------------- 1 | 10 | * @since 4.0.0 11 | */ 12 | abstract class BlockTypeIconSelectMode 13 | { 14 | public const Path = 'path'; 15 | public const Sources = 'sources'; 16 | } 17 | -------------------------------------------------------------------------------- /src/events/SetConditionElementTypesEvent.php: -------------------------------------------------------------------------------- 1 | 11 | * @since 3.2.0 12 | */ 13 | class SetConditionElementTypesEvent extends Event 14 | { 15 | /** 16 | * @var string[] 17 | */ 18 | public array $elementTypes; 19 | } 20 | -------------------------------------------------------------------------------- /src/web/assets/configurator/src/scripts/main.js: -------------------------------------------------------------------------------- 1 | import Configurator from './Configurator' 2 | 3 | const context = window ?? this 4 | const configurators = [] 5 | 6 | context.Neo = { 7 | Configurator, 8 | configurators, 9 | 10 | createConfigurator (settings = {}) { 11 | const configurator = new Configurator(settings) 12 | configurators.push(configurator) 13 | 14 | return configurator 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/enums/NewBlockMenuStyle.php: -------------------------------------------------------------------------------- 1 | 10 | * @since 3.6.0 11 | */ 12 | abstract class NewBlockMenuStyle 13 | { 14 | public const Classic = 'classic'; 15 | public const Grid = 'grid'; 16 | public const List = 'list'; 17 | } 18 | -------------------------------------------------------------------------------- /src/enums/BlockTypeGroupDropdown.php: -------------------------------------------------------------------------------- 1 | 10 | * @since 3.1.0 11 | */ 12 | abstract class BlockTypeGroupDropdown 13 | { 14 | public const Show = 'show'; 15 | public const Hide = 'hide'; 16 | public const Global = 'global'; 17 | } 18 | -------------------------------------------------------------------------------- /src/events/BlockTypeEvent.php: -------------------------------------------------------------------------------- 1 | 12 | * @since 2.3.0 13 | */ 14 | class BlockTypeEvent extends Event 15 | { 16 | /** 17 | * @var BlockType 18 | */ 19 | public BlockType $blockType; 20 | 21 | /** 22 | * @var bool 23 | */ 24 | public bool $isNew = false; 25 | } 26 | -------------------------------------------------------------------------------- /src/elements/conditions/fields/ParentDateFieldConditionRule.php: -------------------------------------------------------------------------------- 1 | 12 | * @since 3.7.0 13 | */ 14 | class ParentDateFieldConditionRule extends DateFieldConditionRule 15 | { 16 | use ParentFieldConditionRuleTrait; 17 | } 18 | -------------------------------------------------------------------------------- /src/elements/conditions/fields/ParentTextFieldConditionRule.php: -------------------------------------------------------------------------------- 1 | 12 | * @since 3.7.0 13 | */ 14 | class ParentTextFieldConditionRule extends TextFieldConditionRule 15 | { 16 | use ParentFieldConditionRuleTrait; 17 | } 18 | -------------------------------------------------------------------------------- /src/web/assets/input/src/scripts/main.js: -------------------------------------------------------------------------------- 1 | import Input from './Input' 2 | 3 | const context = window ?? this 4 | const inputs = [] 5 | 6 | context.Neo = { 7 | Input, 8 | inputs, 9 | 10 | createInput (settings = {}) { 11 | const input = new Input(settings) 12 | inputs.push(input) 13 | input.on('destroy', () => { 14 | for (const i in inputs) { 15 | if (inputs[i] === input) { 16 | inputs.splice(i, 1) 17 | } 18 | } 19 | }) 20 | 21 | return input 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/elements/conditions/fields/ParentNumberFieldConditionRule.php: -------------------------------------------------------------------------------- 1 | 12 | * @since 3.7.0 13 | */ 14 | class ParentNumberFieldConditionRule extends NumberFieldConditionRule 15 | { 16 | use ParentFieldConditionRuleTrait; 17 | } 18 | -------------------------------------------------------------------------------- /src/elements/conditions/fields/ParentOptionsFieldConditionRule.php: -------------------------------------------------------------------------------- 1 | 12 | * @since 3.7.0 13 | */ 14 | class ParentOptionsFieldConditionRule extends OptionsFieldConditionRule 15 | { 16 | use ParentFieldConditionRuleTrait; 17 | } 18 | -------------------------------------------------------------------------------- /src/elements/conditions/fields/ParentRelationalFieldConditionRule.php: -------------------------------------------------------------------------------- 1 | 12 | * @since 3.7.0 13 | */ 14 | class ParentRelationalFieldConditionRule extends RelationalFieldConditionRule 15 | { 16 | use ParentFieldConditionRuleTrait; 17 | } 18 | -------------------------------------------------------------------------------- /src/errors/BlockTypeNotFoundException.php: -------------------------------------------------------------------------------- 1 | 12 | * @author Benjamin Fleming 13 | * @since 2.0.0 14 | */ 15 | class BlockTypeNotFoundException extends Exception 16 | { 17 | /** 18 | * @inheritdoc 19 | */ 20 | public function getName(): string 21 | { 22 | return "Neo block type not found"; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/elements/conditions/fields/ParentLightswitchFieldConditionRule.php: -------------------------------------------------------------------------------- 1 | 12 | * @since 3.7.0 13 | */ 14 | class ParentLightswitchFieldConditionRule extends LightswitchFieldConditionRule 15 | { 16 | use ParentFieldConditionRuleTrait; 17 | } 18 | -------------------------------------------------------------------------------- /src/events/FilterBlockTypesEvent.php: -------------------------------------------------------------------------------- 1 | addColumn('{{%neoblocktypes}}', 'color', $this->string()->after('iconId')); 18 | return true; 19 | } 20 | 21 | /** 22 | * @inheritdoc 23 | */ 24 | public function safeDown(): bool 25 | { 26 | echo "m240311_044007_add_block_type_color cannot be reverted.\n"; 27 | return false; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/helpers/Memoize.php: -------------------------------------------------------------------------------- 1 | 10 | * @author Benjamin Fleming 11 | * @since 2.0.0 12 | */ 13 | class Memoize 14 | { 15 | public static array $blockTypeRecordsById = []; 16 | public static array $blockTypesById = []; 17 | public static array $blockTypesByHandle = []; 18 | public static array $blockTypesByFieldId = []; 19 | public static array $blockTypeGroupsById = []; 20 | public static array $blockTypeGroupsByFieldId = []; 21 | 22 | /** 23 | * @since 5.2.12 24 | */ 25 | public static array $parentFieldInstancesByLayoutElementUuid = []; 26 | } 27 | -------------------------------------------------------------------------------- /src/web/assets/input/src/scripts/Group.js: -------------------------------------------------------------------------------- 1 | import Garnish from 'garnish' 2 | 3 | const _defaults = { 4 | id: -1, 5 | sortOrder: 0, 6 | alwaysShowDropdown: null, 7 | name: '' 8 | } 9 | 10 | export default Garnish.Base.extend({ 11 | 12 | init (settings = {}) { 13 | settings = Object.assign({}, _defaults, settings) 14 | 15 | this._id = settings.id | 0 16 | this._sortOrder = settings.sortOrder | 0 17 | this._alwaysShowDropdown = settings.alwaysShowDropdown 18 | this._name = settings.name 19 | }, 20 | 21 | getType () { return 'group' }, 22 | getId () { return this._id }, 23 | getSortOrder () { return this._sortOrder }, 24 | getName () { return this._name }, 25 | getAlwaysShowDropdown () { return this._alwaysShowDropdown }, 26 | isBlank () { return !this._name } 27 | }) 28 | -------------------------------------------------------------------------------- /src/migrations/m231005_132818_add_block_type_icon_filename_property.php: -------------------------------------------------------------------------------- 1 | addColumn('{{%neoblocktypes}}', 'iconFilename', $this->string()->after('description')); 18 | return true; 19 | } 20 | 21 | /** 22 | * @inheritdoc 23 | */ 24 | public function safeDown(): bool 25 | { 26 | echo "m231005_132818_add_block_type_icon_filename_property cannot be reverted.\n"; 27 | return false; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/records/BlockTypeGroup.php: -------------------------------------------------------------------------------- 1 | 13 | * @author Benjamin Fleming 14 | * @since 2.0.0 15 | */ 16 | class BlockTypeGroup extends ActiveRecord 17 | { 18 | /** 19 | * @inheritdoc 20 | */ 21 | public static function tableName(): string 22 | { 23 | return '{{%neoblocktypegroups}}'; 24 | } 25 | 26 | /** 27 | * Returns the block type group's field. 28 | * 29 | * @return ActiveQueryInterface 30 | */ 31 | public function getField(): ActiveQueryInterface 32 | { 33 | return $this->hasOne(Field::class, [ 'id' => 'fieldId' ]); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/elements/conditions/OwnerUriConditionRule.php: -------------------------------------------------------------------------------- 1 | 14 | * @since 5.5.0 15 | */ 16 | class OwnerUriConditionRule extends UriConditionRule 17 | { 18 | use OwnerConditionRuleTrait; 19 | 20 | /** 21 | * @inheritdoc 22 | */ 23 | public function getLabel(): string 24 | { 25 | return Craft::t('neo', 'Owner URI'); 26 | } 27 | 28 | /** 29 | * @inheritdoc 30 | */ 31 | public function matchElement(ElementInterface $element): bool 32 | { 33 | return $this->_matchElement($element); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/elements/conditions/OwnerSlugConditionRule.php: -------------------------------------------------------------------------------- 1 | 14 | * @since 5.5.0 15 | */ 16 | class OwnerSlugConditionRule extends SlugConditionRule 17 | { 18 | use OwnerConditionRuleTrait; 19 | 20 | /** 21 | * @inheritdoc 22 | */ 23 | public function getLabel(): string 24 | { 25 | return Craft::t('neo', 'Owner Slug'); 26 | } 27 | 28 | /** 29 | * @inheritdoc 30 | */ 31 | public function matchElement(ElementInterface $element): bool 32 | { 33 | return $this->_matchElement($element); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/elements/conditions/OwnerLevelConditionRule.php: -------------------------------------------------------------------------------- 1 | 14 | * @since 5.5.0 15 | */ 16 | class OwnerLevelConditionRule extends LevelConditionRule 17 | { 18 | use OwnerConditionRuleTrait; 19 | 20 | /** 21 | * @inheritdoc 22 | */ 23 | public function getLabel(): string 24 | { 25 | return Craft::t('neo', 'Owner Level'); 26 | } 27 | 28 | /** 29 | * @inheritdoc 30 | */ 31 | public function matchElement(ElementInterface $element): bool 32 | { 33 | return $this->_matchElement($element); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/elements/conditions/OwnerTitleConditionRule.php: -------------------------------------------------------------------------------- 1 | 14 | * @since 5.5.0 15 | */ 16 | class OwnerTitleConditionRule extends TitleConditionRule 17 | { 18 | use OwnerConditionRuleTrait; 19 | 20 | /** 21 | * @inheritdoc 22 | */ 23 | public function getLabel(): string 24 | { 25 | return Craft::t('neo', 'Owner Title'); 26 | } 27 | 28 | /** 29 | * @inheritdoc 30 | */ 31 | public function matchElement(ElementInterface $element): bool 32 | { 33 | return $this->_matchElement($element); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/elements/conditions/OwnerHasUrlConditionRule.php: -------------------------------------------------------------------------------- 1 | 14 | * @since 5.5.0 15 | */ 16 | class OwnerHasUrlConditionRule extends HasUrlConditionRule 17 | { 18 | use OwnerConditionRuleTrait; 19 | 20 | /** 21 | * @inheritdoc 22 | */ 23 | public function getLabel(): string 24 | { 25 | return Craft::t('neo', 'Owner Has URL'); 26 | } 27 | 28 | /** 29 | * @inheritdoc 30 | */ 31 | public function matchElement(ElementInterface $element): bool 32 | { 33 | return $this->_matchElement($element); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/migrations/m240711_024245_block_type_entry_type.php: -------------------------------------------------------------------------------- 1 | addColumn('{{%neoblocktypes}}', 'entryTypeId', $this->integer()->after('groupId')); 19 | $this->addForeignKey(null, '{{%neoblocktypes}}', ['entryTypeId'], Table::ENTRYTYPES, ['id'], 'SET NULL', null); 20 | 21 | return true; 22 | } 23 | 24 | /** 25 | * @inheritdoc 26 | */ 27 | public function safeDown(): bool 28 | { 29 | echo "m240711_024245_block_type_entry_type cannot be reverted.\n"; 30 | return false; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/web/twig/Extension.php: -------------------------------------------------------------------------------- 1 | 14 | * @author Benjamin Fleming 15 | * @since 5.0.0 16 | */ 17 | class Extension extends AbstractExtension 18 | { 19 | /** 20 | * @return TwigTest[] 21 | */ 22 | public function getTests(): array 23 | { 24 | return [ 25 | new TwigTest('neoblock', [$this, 'isNeoBlock']), 26 | ]; 27 | } 28 | 29 | /** 30 | * Determines if a value is a Neo block model. 31 | * 32 | * @param $value 33 | * @return bool 34 | */ 35 | public function isNeoBlock($value): bool 36 | { 37 | return $value instanceof Block; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/elements/conditions/OwnerDateCreatedConditionRule.php: -------------------------------------------------------------------------------- 1 | 14 | * @since 5.5.0 15 | */ 16 | class OwnerDateCreatedConditionRule extends DateCreatedConditionRule 17 | { 18 | use OwnerConditionRuleTrait; 19 | 20 | /** 21 | * @inheritdoc 22 | */ 23 | public function getLabel(): string 24 | { 25 | return Craft::t('neo', 'Owner Date Created'); 26 | } 27 | 28 | /** 29 | * @inheritdoc 30 | */ 31 | public function matchElement(ElementInterface $element): bool 32 | { 33 | return $this->_matchElement($element); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/elements/conditions/OwnerDateUpdatedConditionRule.php: -------------------------------------------------------------------------------- 1 | 14 | * @since 5.5.0 15 | */ 16 | class OwnerDateUpdatedConditionRule extends DateUpdatedConditionRule 17 | { 18 | use OwnerConditionRuleTrait; 19 | 20 | /** 21 | * @inheritdoc 22 | */ 23 | public function getLabel(): string 24 | { 25 | return Craft::t('neo', 'Owner Date Updated'); 26 | } 27 | 28 | /** 29 | * @inheritdoc 30 | */ 31 | public function matchElement(ElementInterface $element): bool 32 | { 33 | return $this->_matchElement($element); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | The plugin can be installed either through the [Plugin Store](https://plugins.craftcms.com/) or through [Composer](https://packagist.org/). 4 | 5 | ### Plugin Store 6 | Open the control panel for your website. Navigate to **Settings → Plugins** and search for **Neo**. Click **Install**. 7 | 8 | ### Composer 9 | Run the following command in the root directory of your Craft project: 10 | ``` 11 | composer require spicyweb/craft-neo 12 | ``` 13 | 14 | 15 | ## Requirements 16 | 17 | ### Craft version 18 | Neo requires Craft CMS 5.3.0 or later. 19 | 20 | ### Browser support 21 | Neo supports the same [browsers and versions that Craft CMS 5 supports](https://craftcms.com/docs/5.x/requirements.html#control-panel-browser-requirements). While Neo may work on browsers outside of those listed, this list is what Neo explicitly supports. The best option is to use an up-to-date major browser. 22 | -------------------------------------------------------------------------------- /src/elements/conditions/OwnerTagGroupConditionRule.php: -------------------------------------------------------------------------------- 1 | 15 | * @since 3.4.0 16 | */ 17 | class OwnerTagGroupConditionRule extends GroupConditionRule 18 | { 19 | use OwnerConditionRuleTrait; 20 | 21 | /** 22 | * @inheritdoc 23 | */ 24 | public function getLabel(): string 25 | { 26 | return Craft::t('neo', 'Owner Tag Group'); 27 | } 28 | 29 | /** 30 | * @inheritdoc 31 | */ 32 | public function matchElement(ElementInterface $element): bool 33 | { 34 | return $this->_matchElement($element, Tag::class); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/elements/conditions/OwnerVolumeConditionRule.php: -------------------------------------------------------------------------------- 1 | 15 | * @since 3.4.0 16 | */ 17 | class OwnerVolumeConditionRule extends VolumeConditionRule 18 | { 19 | use OwnerConditionRuleTrait; 20 | 21 | /** 22 | * @inheritdoc 23 | */ 24 | public function getLabel(): string 25 | { 26 | return Craft::t('neo', 'Owner Volume'); 27 | } 28 | 29 | /** 30 | * @inheritdoc 31 | */ 32 | public function matchElement(ElementInterface $element): bool 33 | { 34 | return $this->_matchElement($element, Asset::class); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/elements/conditions/OwnerSectionConditionRule.php: -------------------------------------------------------------------------------- 1 | 15 | * @since 3.4.0 16 | */ 17 | class OwnerSectionConditionRule extends SectionConditionRule 18 | { 19 | use OwnerConditionRuleTrait; 20 | 21 | /** 22 | * @inheritdoc 23 | */ 24 | public function getLabel(): string 25 | { 26 | return Craft::t('neo', 'Owner Section'); 27 | } 28 | 29 | /** 30 | * @inheritdoc 31 | */ 32 | public function matchElement(ElementInterface $element): bool 33 | { 34 | return $this->_matchElement($element, Entry::class); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/elements/conditions/OwnerUserGroupConditionRule.php: -------------------------------------------------------------------------------- 1 | 15 | * @since 3.4.0 16 | */ 17 | class OwnerUserGroupConditionRule extends GroupConditionRule 18 | { 19 | use OwnerConditionRuleTrait; 20 | 21 | /** 22 | * @inheritdoc 23 | */ 24 | public function getLabel(): string 25 | { 26 | return Craft::t('neo', 'Owner User Group'); 27 | } 28 | 29 | /** 30 | * @inheritdoc 31 | */ 32 | public function matchElement(ElementInterface $element): bool 33 | { 34 | return $this->_matchElement($element, User::class); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/elements/conditions/OwnerEntryTypeConditionRule.php: -------------------------------------------------------------------------------- 1 | 15 | * @since 3.4.0 16 | */ 17 | class OwnerEntryTypeConditionRule extends TypeConditionRule 18 | { 19 | use OwnerConditionRuleTrait; 20 | 21 | /** 22 | * @inheritdoc 23 | */ 24 | public function getLabel(): string 25 | { 26 | return Craft::t('neo', 'Owner Entry Type'); 27 | } 28 | 29 | /** 30 | * @inheritdoc 31 | */ 32 | public function matchElement(ElementInterface $element): bool 33 | { 34 | return $this->_matchElement($element, Entry::class); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /docs/upgrade-guides/neo-2.7-craft-3.4.md: -------------------------------------------------------------------------------- 1 | # Upgrading to Craft 3.4 and 2.7.x+ 2 | 3 | There are some steps required when updating to Craft 3.4 and Neo 2.7.x+ from older versions. This is because Craft 3.4 re-saves the entries before Neo has a chance to add the database changes which throws an error when saving. 4 | 5 | ## Steps 6 | 7 | 1. Edit your composer.json. 8 | 9 | Craft should be `3.4.0` and Neo `2.6.5.1`. Your composer.json for Craft and Neo should look like this: 10 | ``` 11 | "craftcms/cms": "3.4.0", 12 | "spicyweb/craft-neo": "2.6.5.1", 13 | ``` 14 | 15 | 2. Run the update command 16 | 17 | Either `./craft update` or `composer update` 18 | 19 | 3. Visit the CMS backend and let it run the migration/update 20 | 21 | 4. Change your composer.json file back or like below: 22 | ``` 23 | "craftcms/cms": "^3.4.0", 24 | "spicyweb/craft-neo": "^2.6.5.1", 25 | ``` 26 | 27 | 5. Rerun the update command. 28 | -------------------------------------------------------------------------------- /src/elements/conditions/OwnerCategoryGroupConditionRule.php: -------------------------------------------------------------------------------- 1 | 15 | * @since 3.4.0 16 | */ 17 | class OwnerCategoryGroupConditionRule extends GroupConditionRule 18 | { 19 | use OwnerConditionRuleTrait; 20 | 21 | /** 22 | * @inheritdoc 23 | */ 24 | public function getLabel(): string 25 | { 26 | return Craft::t('neo', 'Owner Category Group'); 27 | } 28 | 29 | /** 30 | * @inheritdoc 31 | */ 32 | public function matchElement(ElementInterface $element): bool 33 | { 34 | return $this->_matchElement($element, Category::class); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/migrations/m240224_024030_migrate_owners_table.php: -------------------------------------------------------------------------------- 1 | execute(sprintf( 19 | <<dropAllForeignKeysToTable('{{%neoblocks_owners}}'); 27 | $this->dropTable('{{%neoblocks_owners}}'); 28 | 29 | return true; 30 | } 31 | 32 | /** 33 | * @inheritdoc 34 | */ 35 | public function safeDown(): bool 36 | { 37 | echo "m240224_024030_migrate_owners_table cannot be reverted.\n"; 38 | return false; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /docs/upgrade-guides/neo-5.md: -------------------------------------------------------------------------------- 1 | # Upgrading to Neo 5 2 | 3 | In some cases, content can disappear from Neo blocks when upgrading to Craft 5, due to a field layout element UUID mismatch issue that can occur for older block types. To avoid this issue, the general process you should follow when using the [official Craft 5 upgrade guide](https://craftcms.com/docs/5.x/upgrade.html) should be: 4 | 5 | - Ensure you are running a version of Neo that provides the `php craft neo/block-types/resave` command 6 | - If you have Neo 3, this is at least version 3.10.0 7 | - If you have Neo 4, this is at least version 4.4.0 8 | - Follow the guide up to the step before the first deployment of project config changes to your live environment 9 | - Run `php craft neo/block-types/resave` 10 | - Deploy your project config changes and follow the rest of the guide 11 | 12 | If you still experience content loss after following the above, please add a comment to [Neo issue #943 on GitHub](https://github.com/spicywebau/craft-neo/issues/943). 13 | -------------------------------------------------------------------------------- /src/migrations/m240403_061537_content_refactor.php: -------------------------------------------------------------------------------- 1 | blockTypes->getAllBlockTypes() as $blockType) { 20 | $this->updateElements( 21 | (new Query())->from('{{%neoblocks}}')->where(['typeId' => $blockType->id]), 22 | $blockType->getFieldLayout(), 23 | ); 24 | } 25 | 26 | return true; 27 | } 28 | 29 | /** 30 | * @inheritdoc 31 | */ 32 | public function safeDown(): bool 33 | { 34 | echo "m240403_061537_content_refactor cannot be reverted.\n"; 35 | return false; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/migrations/m240722_092833_revert_index_change.php: -------------------------------------------------------------------------------- 1 | getProjectConfig()->get('plugins.neo.schemaVersion', true), '5.1.0', '=')) { 20 | MigrationHelper::dropIndex('{{%neoblocktypes}}', ['handle', 'fieldId', 'entryTypeId'], true, $this); 21 | $this->createIndex(null, '{{%neoblocktypes}}', ['handle', 'fieldId'], true); 22 | } 23 | 24 | return true; 25 | } 26 | 27 | /** 28 | * @inheritdoc 29 | */ 30 | public function safeDown(): bool 31 | { 32 | echo "m240722_092833_revert_index_change cannot be reverted.\n"; 33 | return false; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /docs/upgrade-guides/neo-4.md: -------------------------------------------------------------------------------- 1 | # Upgrading to Neo 4 2 | 3 | Most changes and removal of classes or methods in Neo 4 should only affect internal functionality. There are a handful of changes to be aware of if you are a developer of a plugin or module that extends Neo functionality. 4 | 5 | - `benf\neo\elements\Block::getOwner()` will now return `null` if the block's owner no longer exists, instead of throwing `yii\base\InvalidConfigException`. While the return type of this method has always allowed the possibility for `null` to be returned, in practice, until Neo 4, it has either returned a Craft element or thrown an exception. If you have code that at some point calls this method in a context where its owner might have been deleted, you will need to handle the `null` case. 6 | - `benf\neo\assets\FieldAsset`, which was deprecated in Neo 3.0.0, has been removed. If you have code that uses `EVENT_FILTER_BLOCK_TYPES`, replace `FieldAsset` with `InputAsset`. 7 | - `benf\neo\assets\SettingsAsset::EVENT_SET_CONDITION_ELEMENT_TYPES`, which was deprecated in Neo 3.6.0, has been removed. Code using this will need to use `benf\neo\services\BlockTypes::EVENT_SET_CONDITION_ELEMENT_TYPES` instead. 8 | -------------------------------------------------------------------------------- /src/templates/child-blocks.twig: -------------------------------------------------------------------------------- 1 | {% from 'neo/macros' import getBlockId %} 2 | 3 | {% set notPlaceholder = block and (block.id is not null or block.unsavedId is not null) %} 4 | {% set blockId = notPlaceholder ? getBlockId(block) : '__NEOBLOCK__' %} 5 | 6 |
7 |
8 |
9 | {% if notPlaceholder %} 10 | {% for child in block.children.status(null).all() %} 11 | {% include 'neo/block' with { handle, block: child, static } only %} 12 | {% endfor %} 13 | {% endif %} 14 |
15 | 16 | {% if not static %} 17 | 18 | 21 | {% endif %} 22 |
23 |
24 | -------------------------------------------------------------------------------- /src/web/assets/input/src/scripts/Buttons.js: -------------------------------------------------------------------------------- 1 | import { NewBlockMenu, GarnishNewBlockMenu } from './NewBlockMenu' 2 | 3 | class Buttons extends NewBlockMenu { 4 | initUi () { 5 | this.updateResponsiveness() 6 | 7 | // If no buttons were rendered (e.g. if all valid block types are disabled for the user), hide the button container 8 | if (this.$buttonsContainer.children().length === 0) { 9 | const parent = this.$container.parent() 10 | const grandParent = parent.parent() 11 | const childrenContainer = grandParent.children('.ni_blocks') 12 | 13 | if (childrenContainer.length === 0 || childrenContainer.children().length === 0) { 14 | grandParent.addClass('hidden') 15 | } else { 16 | parent.addClass('hidden') 17 | } 18 | } 19 | } 20 | 21 | updateResponsiveness () { 22 | this._buttonsContainerWidth ||= this.$buttonsContainer.width() 23 | const isMobile = this.$container.width() < this._buttonsContainerWidth 24 | 25 | this.$buttonsContainer.toggleClass('hidden', isMobile) 26 | this.$menuContainer.toggleClass('hidden', !isMobile) 27 | } 28 | } 29 | 30 | export default GarnishNewBlockMenu.extend({ 31 | init (settings = {}) { 32 | this.base(new Buttons(settings)) 33 | } 34 | }) 35 | -------------------------------------------------------------------------------- /src/migrations/m240212_040753_move_project_config_data.php: -------------------------------------------------------------------------------- 1 | getProjectConfig(); 19 | $blockTypes = $projectConfig->get('neoBlockTypes'); 20 | $blockTypeGroups = $projectConfig->get('neoBlockTypeGroups'); 21 | $muteEvents = $projectConfig->muteEvents; 22 | $projectConfig->muteEvents = true; 23 | $projectConfig->set('neo.blockTypes', $blockTypes); 24 | $projectConfig->set('neo.blockTypeGroups', $blockTypeGroups); 25 | $projectConfig->remove('neoBlockTypes'); 26 | $projectConfig->remove('neoBlockTypeGroups'); 27 | $projectConfig->muteEvents = $muteEvents; 28 | 29 | return true; 30 | } 31 | 32 | /** 33 | * @inheritdoc 34 | */ 35 | public function safeDown(): bool 36 | { 37 | echo "m240212_040753_move_project_config_data cannot be reverted.\n"; 38 | return false; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/controllers/Conversion.php: -------------------------------------------------------------------------------- 1 | 15 | * @author Benjamin Fleming 16 | * @since 2.2.0 17 | */ 18 | class Conversion extends Controller 19 | { 20 | /** 21 | * Converts a Neo field to a Matrix field. 22 | * 23 | * @return Response 24 | * @throws \Throwable 25 | */ 26 | public function actionConvertToMatrix(): Response 27 | { 28 | $this->requireAdmin(); 29 | $this->requireAcceptsJson(); 30 | $this->requirePostRequest(); 31 | 32 | $fieldId = Craft::$app->getRequest()->getParam('fieldId'); 33 | $neoField = Craft::$app->getFields()->getFieldById($fieldId); 34 | 35 | $return = []; 36 | 37 | try { 38 | $return['success'] = Neo::$plugin->conversion->convertFieldToMatrix($neoField); 39 | } catch (\Throwable $e) { 40 | $return['success'] = false; 41 | $return['errors'] = [$e->getMessage()]; 42 | } 43 | 44 | return $this->asJson($return); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/web/assets/configurator/src/scripts/Item.js: -------------------------------------------------------------------------------- 1 | import Garnish from 'garnish' 2 | 3 | const _defaults = { 4 | settings: null 5 | } 6 | 7 | export default Garnish.Base.extend({ 8 | 9 | $container: null, 10 | _field: null, 11 | _selected: false, 12 | 13 | init (settings = {}) { 14 | settings = Object.assign({}, _defaults, settings) 15 | this._field = settings.field 16 | this._settings = settings.settings 17 | }, 18 | 19 | /** 20 | * @since 3.8.0 21 | * @returns Promise 22 | */ 23 | load () { 24 | return Promise.resolve() 25 | }, 26 | 27 | /** 28 | * @since 3.8.0 29 | * @returns the Neo field this item belongs to 30 | */ 31 | getField () { 32 | return this._field 33 | }, 34 | 35 | getSettings () { 36 | return this._settings 37 | }, 38 | 39 | /** 40 | * @since 3.8.0 41 | */ 42 | getSortOrder () { 43 | return this.$container.index() + 1 44 | }, 45 | 46 | select () { 47 | this.toggleSelect(true) 48 | }, 49 | 50 | deselect () { 51 | this.toggleSelect(false) 52 | }, 53 | 54 | toggleSelect: function (select) { 55 | this._selected = (typeof select === 'boolean' ? select : !this._selected) 56 | 57 | this.trigger('toggleSelect', { 58 | selected: this._selected 59 | }) 60 | }, 61 | 62 | isSelected () { 63 | return this._selected 64 | } 65 | }) 66 | -------------------------------------------------------------------------------- /src/elements/conditions/OwnerConditionRuleTrait.php: -------------------------------------------------------------------------------- 1 | 13 | * @since 3.4.0 14 | */ 15 | trait OwnerConditionRuleTrait 16 | { 17 | /** 18 | * @inheritdoc 19 | */ 20 | public function getExclusiveQueryParams(): array 21 | { 22 | // Not used by Neo 23 | return []; 24 | } 25 | 26 | /** 27 | * @inheritdoc 28 | */ 29 | public function modifyQuery(ElementQueryInterface $query): void 30 | { 31 | // Not used by Neo 32 | } 33 | 34 | /** 35 | * Returns whether a Neo block matches condition rules based on its owner element. 36 | * 37 | * @var Block $element 38 | * @var string|null $ownerType The expected element type of the block's owner, if any 39 | * @return bool 40 | */ 41 | private function _matchElement(Block $element, ?string $ownerType = null): bool 42 | { 43 | $owner = $element->ownerId !== null ? $element->getOwner() : null; 44 | return $owner === null || 45 | ($ownerType !== null && $owner::class !== $ownerType) || 46 | parent::matchElement($owner); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/fieldlayoutelements/ChildBlocksUiElement.php: -------------------------------------------------------------------------------- 1 | 14 | * @since 2.10.0 15 | */ 16 | class ChildBlocksUiElement extends BaseUiElement 17 | { 18 | /** 19 | * @inheritdoc 20 | */ 21 | protected function selectorLabel(): string 22 | { 23 | return Craft::t('neo', 'Child Blocks'); 24 | } 25 | 26 | /** 27 | * @inheritdoc 28 | */ 29 | protected function selectorIcon(): ?string 30 | { 31 | return '@benf/neo/icon.svg'; 32 | } 33 | 34 | /** 35 | * @inheritdoc 36 | */ 37 | public function formHtml(?ElementInterface $element = null, bool $static = false): ?string 38 | { 39 | if ($element && $element->level && $element->fieldId) { 40 | $blockId = $element->id > 0 ? $element->id : 'new' . $element->unsavedId; 41 | } else { 42 | $blockId = '__NEOBLOCK__'; 43 | } 44 | 45 | // This will be replaced in `block.twig`; done like this so we can use `craft\models\FieldLayout::createForm()` 46 | // without repeatedly namespacing the child blocks 47 | return '
'; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/web/assets/configurator/src/scripts/Settings.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | import Garnish from 'garnish' 3 | 4 | export default Garnish.Base.extend({ 5 | 6 | $container: new $(), 7 | 8 | getFocusElement () { 9 | return new $() 10 | }, 11 | 12 | destroy () { 13 | this.$foot?.remove() 14 | this.trigger('destroy') 15 | }, 16 | 17 | _refreshSetting ($container, showSetting, animate) { 18 | animate = !Garnish.prefersReducedMotion() && (typeof animate === 'boolean' ? animate : true) 19 | 20 | if (animate) { 21 | if (showSetting) { 22 | if ($container.hasClass('hidden')) { 23 | $container 24 | .removeClass('hidden') 25 | .css({ 26 | opacity: 0, 27 | marginBottom: -($container.outerHeight()) 28 | }) 29 | .velocity({ 30 | opacity: 1, 31 | marginBottom: 24 32 | }, 'fast') 33 | } 34 | } else if (!$container.hasClass('hidden')) { 35 | $container 36 | .css({ 37 | opacity: 1, 38 | marginBottom: 24 39 | }) 40 | .velocity({ 41 | opacity: 0, 42 | marginBottom: -($container.outerHeight()) 43 | }, 'fast', () => { 44 | $container.addClass('hidden') 45 | }) 46 | } 47 | } else { 48 | $container 49 | .toggleClass('hidden', !showSetting) 50 | .css('margin-bottom', showSetting ? 24 : '') 51 | } 52 | } 53 | }) 54 | -------------------------------------------------------------------------------- /src/migrations/m240226_032156_migrate_deleted_with_owner.php: -------------------------------------------------------------------------------- 1 | select('id') 21 | ->from(['neoblocktypes' => '{{%neoblocktypes}}']) 22 | ->column(); 23 | 24 | foreach ([true, false] as $bool) { 25 | $this->update( 26 | Table::ELEMENTS, 27 | ['deletedWithOwner' => $bool], 28 | ['id' => (new Query()) 29 | ->select('id') 30 | ->from(['neoblocks' => '{{%neoblocks}}']) 31 | ->where([ 32 | 'neoblocks.typeId' => $blockTypeIds, 33 | 'neoblocks.deletedWithOwner' => $bool, 34 | ]), 35 | ], 36 | ); 37 | } 38 | 39 | $this->dropColumn('{{%neoblocks}}', 'deletedWithOwner'); 40 | 41 | return true; 42 | } 43 | 44 | /** 45 | * @inheritdoc 46 | */ 47 | public function safeDown(): bool 48 | { 49 | echo "m240226_032156_migrate_deleted_with_owner cannot be reverted.\n"; 50 | return false; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/jobs/DeleteBlocks.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class DeleteBlocks extends BaseJob 16 | { 17 | /** 18 | * @var int 19 | */ 20 | public $fieldId; 21 | 22 | /** 23 | * @var int 24 | */ 25 | public $elementId; 26 | 27 | /** 28 | * @var bool 29 | */ 30 | public $hardDelete; 31 | 32 | /** 33 | * @inheritdoc 34 | */ 35 | public function execute($queue): void 36 | { 37 | $elementsService = Craft::$app->getElements(); 38 | 39 | foreach (Craft::$app->getSites()->getAllSiteIds() as $siteId) { 40 | $blocks = Block::find() 41 | ->anyStatus() 42 | ->fieldId($this->fieldId) 43 | ->siteId($siteId) 44 | ->primaryOwnerId($this->elementId) 45 | ->inReverse() 46 | ->all(); 47 | 48 | foreach ($blocks as $block) { 49 | $block->deletedWithOwner = true; 50 | $elementsService->deleteElement($block, $this->hardDelete); 51 | } 52 | } 53 | 54 | $this->setProgress($queue, 1); 55 | } 56 | 57 | /** 58 | * @inheritdoc 59 | */ 60 | protected function defaultDescription(): ?string 61 | { 62 | return Craft::t('neo', 'Deleting old Neo blocks'); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/templates/_block-type-icon-input.twig: -------------------------------------------------------------------------------- 1 | {% set value = value ?: null %} 2 | 3 |
4 |
5 | 6 | {% if value != null %} 7 |

{{ value }}

8 | {% else %} 9 |

{{ 'None set'|t('neo') }}

10 | {% endif %} 11 |
12 | 13 | 14 | {% if iconUrls is not empty %} 15 | 22 | 34 | {% else %} 35 |

{{ 'No icons are available to select.'|t('neo') }}

36 | {% endif %} 37 |
{{ 'Remove'|t('neo') }}
38 |
39 | -------------------------------------------------------------------------------- /src/records/Block.php: -------------------------------------------------------------------------------- 1 | 13 | * @author Benjamin Fleming 14 | * @since 2.0.0 15 | */ 16 | class Block extends ActiveRecord 17 | { 18 | /** 19 | * @inheritdoc 20 | */ 21 | public static function tableName(): string 22 | { 23 | return '{{%neoblocks}}'; 24 | } 25 | 26 | /** 27 | * Returns the block's element. 28 | * 29 | * @return ActiveQueryInterface 30 | */ 31 | public function getElement(): ActiveQueryInterface 32 | { 33 | return $this->hasOne(Element::class, ['id' => 'id']); 34 | } 35 | 36 | /** 37 | * Returns the block's primary owner. 38 | * 39 | * @return ActiveQueryInterface 40 | */ 41 | public function getPrimaryOwner(): ActiveQueryInterface 42 | { 43 | return $this->hasOne(Element::class, ['id' => 'primaryOwnerId']); 44 | } 45 | 46 | /** 47 | * Returns the block's field. 48 | * 49 | * @return ActiveQueryInterface 50 | */ 51 | public function getField(): ActiveQueryInterface 52 | { 53 | return $this->hasOne(Element::class, ['id' => 'fieldId']); 54 | } 55 | 56 | /** 57 | * Returns the block's type. 58 | * 59 | * @return ActiveQueryInterface 60 | */ 61 | public function getType(): ActiveQueryInterface 62 | { 63 | return $this->hasOne(Element::class, ['id' => 'typeId']); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/gql/arguments/elements/Block.php: -------------------------------------------------------------------------------- 1 | [ 21 | 'name' => 'fieldId', 22 | 'type' => Type::listOf(QueryArgument::getType()), 23 | 'description' => 'Narrows the query results based on the field the Neo blocks belong to, per the fields’ IDs.', 24 | ], 25 | 'primaryOwnerId' => [ 26 | 'name' => 'primaryOwnerId', 27 | 'type' => Type::listOf(QueryArgument::getType()), 28 | 'description' => ' Narrows the query results based on the primary owner element of the Neo blocks, per the owners’ IDs.', 29 | ], 30 | 'typeId' => Type::listOf(QueryArgument::getType()), 31 | 'type' => [ 32 | 'name' => 'type', 33 | 'type' => Type::listOf(Type::string()), 34 | 'description' => 'Narrows the query results based on the Neo blocks’ block type handles.', 35 | ], 36 | 'level' => [ 37 | 'name' => 'level', 38 | 'type' => QueryArgument::getType(), 39 | 'description' => 'The block’s level within its field', 40 | ], 41 | ]); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/records/BlockStructure.php: -------------------------------------------------------------------------------- 1 | 13 | * @author Benjamin Fleming 14 | * @since 2.0.0 15 | */ 16 | class BlockStructure extends ActiveRecord 17 | { 18 | /** 19 | * @inheritdoc 20 | */ 21 | public static function tableName(): string 22 | { 23 | return '{{%neoblockstructures}}'; 24 | } 25 | 26 | /** 27 | * Returns the block structure's owner. 28 | * 29 | * @return ActiveQueryInterface 30 | */ 31 | public function getOwner(): ActiveQueryInterface 32 | { 33 | return $this->hasOne(Element::class, [ 'id' => 'ownerId' ]); 34 | } 35 | 36 | /** 37 | * Returns the block structure's site. 38 | * 39 | * @return ActiveQueryInterface 40 | */ 41 | public function getSite(): ActiveQueryInterface 42 | { 43 | return $this->hasOne(Element::class, [ 'id' => 'siteId' ]); 44 | } 45 | 46 | /** 47 | * Returns the block structure's associated field. 48 | * 49 | * @return ActiveQueryInterface 50 | */ 51 | public function getField(): ActiveQueryInterface 52 | { 53 | return $this->hasOne(Field::class, [ 'id' => 'fieldId' ]); 54 | } 55 | 56 | /** 57 | * Returns the block structure's associated structure. 58 | * 59 | * @return ActiveQueryInterface 60 | */ 61 | public function getStructure(): ActiveQueryInterface 62 | { 63 | return $this->hasOne(Structure::class, [ 'id' => 'structureId' ]); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /docs/feed-me.md: -------------------------------------------------------------------------------- 1 | # Feed Me 2 | 3 | Neo versions 3.5.0 and later include support for the [Feed Me](https://plugins.craftcms.com/feed-me) plugin. 4 | 5 | Feed Me support in Neo works in largely the same way as [importing into Matrix fields in Craft 4](https://docs.craftcms.com/feed-me/v4/guides/importing-into-matrix.html), with the exception that Neo blocks can have their level set. To adapt the second example from the [Note on structure](https://docs.craftcms.com/feed-me/v4/guides/importing-into-matrix.html#note-on-structure) section of the Matrix import guide, setting the block levels in the data to be imported would look something like this: 6 | 7 | XML: 8 | 9 | ```xml 10 | 11 | 12 | Lorem ipsum... 13 | Some more text. 14 | 1 15 | 16 | 17 | 18 | img_fjords.jpg 19 | 2 20 | 21 | 22 | ``` 23 | 24 | JSON: 25 | 26 | ```json 27 | { 28 | "NeoBlock": [ 29 | { 30 | "Copy": "Lorem ipsum...", 31 | "Caption": "Some more text.", 32 | "Level": 1 33 | }, 34 | { 35 | "Image": "img_fjords.jpg", 36 | "Level": 2 37 | } 38 | ] 39 | } 40 | ``` 41 | 42 | The name used for the level property must be consistent across blocks in the same Neo field, and there is a single row in the feed settings to set the feed element for block levels: 43 | 44 | ![Screenshot of the Neo block level row in Feed Me](assets/feed-me-block-level.png) 45 | 46 | A field for the default value of block levels is not provided, as Neo internally considers the default level to be 1. 47 | -------------------------------------------------------------------------------- /src/migrations/m231027_012155_project_config_sort_orders.php: -------------------------------------------------------------------------------- 1 | getProjectConfig(); 21 | 22 | if (version_compare($projectConfig->get('plugins.neo.schemaVersion', true), '3.10.0.1', '<')) { 23 | foreach (Neo::$plugin->fields->getNeoFields() as $neoField) { 24 | $itemOrder = []; 25 | 26 | foreach ($neoField->getItems() as $item) { 27 | if ($item instanceof BlockType) { 28 | $itemOrder[$item->sortOrder - 1] = "blockType:$item->uid"; 29 | $projectConfig->remove("neoBlockTypes.$item->uid.sortOrder"); 30 | } else { 31 | $itemOrder[$item->sortOrder - 1] = "blockTypeGroup:$item->uid"; 32 | $projectConfig->remove("neoBlockTypeGroups.$item->uid.sortOrder"); 33 | } 34 | } 35 | 36 | $projectConfig->set("neo.orders.$neoField->uid", $itemOrder); 37 | } 38 | } 39 | 40 | return true; 41 | } 42 | 43 | /** 44 | * @inheritdoc 45 | */ 46 | public function safeDown(): bool 47 | { 48 | echo "m231027_012155_project_config_sort_orders cannot be reverted.\n"; 49 | return false; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/models/BlockStructure.php: -------------------------------------------------------------------------------- 1 | 14 | * @author Benjamin Fleming 15 | * @since 2.0.0 16 | */ 17 | class BlockStructure extends Model 18 | { 19 | /** 20 | * @var int|null The block structure ID. 21 | */ 22 | public ?int $id = null; 23 | 24 | /** 25 | * @var int|null The structure ID. 26 | */ 27 | public ?int $structureId = null; 28 | 29 | /** 30 | * @var int|null The field ID. 31 | */ 32 | public ?int $fieldId = null; 33 | 34 | /** 35 | * @var int|null The owner ID. 36 | */ 37 | public ?int $ownerId = null; 38 | 39 | /** 40 | * @var int|null The site ID. 41 | */ 42 | public ?int $siteId = null; 43 | 44 | /** 45 | * @var Structure|null The associated structure. 46 | */ 47 | private ?Structure $_structure = null; 48 | 49 | /** 50 | * @inheritdoc 51 | */ 52 | protected function defineRules(): array 53 | { 54 | return [ 55 | [['id', 'structureId', 'fieldId', 'ownerId', 'siteId'], 'number', 'integerOnly' => true], 56 | ]; 57 | } 58 | 59 | /** 60 | * Returns the associated structure. 61 | * 62 | * @return Structure|null 63 | */ 64 | public function getStructure(): ?Structure 65 | { 66 | $structuresService = Craft::$app->getStructures(); 67 | 68 | if (!$this->_structure && $this->structureId) { 69 | $this->_structure = $structuresService->getStructureById($this->structureId); 70 | } 71 | 72 | return $this->_structure; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /docs/resources.md: -------------------------------------------------------------------------------- 1 | # Resources 2 | 3 | Here's a list of Neo-related resources out there that might be useful to you. And a _big_ thanks to everyone who has taken the time to put these together! 4 | 5 | ### [Matrix within Matrix. Part 2. Neo.](https://mediasurgery.co.uk/video-episodes/craft/matrix-within-matrix-part-2-neo) :play_or_pause_button: 6 | 7 | John Macpherson of [Media Surgery](https://mediasurgery.co.uk) has generously put together this video introduction to Neo, briefly discussing its features and practical use cases over the standard Matrix. We recommend starting with the [part 1 video on Super Table](https://mediasurgery.co.uk/video-episodes/craft/matrix-within-matrix-part-1-super-table) as it covers some of the background to the Neo video. 8 | 9 | ### [Pushing the Limits of Craft CMS's Matrix Field](https://www.youtube.com/watch?v=HXy_-LLjV_U) :play_or_pause_button: 10 | A [Straight Up Craft](http://straightupcraft.com/) hangout hosted by [Ben Parizek](https://twitter.com/benparizek) that features the original developer of Neo, [Ben Fleming](https://twitter.com/benjamminf), along with [Josh Crawford](https://github.com/engram-design) and [Andrew Welch](https://github.com/khalwat). Ben and Josh each showcase their plugins Neo and [Super Table](https://github.com/engram-design/SuperTable), with an in-depth discussion on the limitations of Craft's Matrix field, their experiences developing on Craft and what their goals were with Neo and Super Table. 11 | 12 | ### [Modelling flexible content sections and recommendations](http://craftcms.stackexchange.com/a/15940/3811) :memo: 13 | [Ian DeRanieri](https://twitter.com/ianderanieri) was kind enough to write up a detailed and screenshot-heavy solution on advanced content modelling techniques using Neo. It's quite a valuable resource for new users of Neo to see how it can be used to model real content. 14 | -------------------------------------------------------------------------------- /docs/plugin-compatibility.md: -------------------------------------------------------------------------------- 1 | # Plugin Compatibility 2 | 3 | Neo is officially compatible with some other Craft plugins, listed below: 4 | 5 | - [Content Templates](https://github.com/spicywebau/craft-content-templates) 6 | - [CP Field Inspect](https://github.com/mmikkel/CpFieldInspect-Craft) (edit cogs added to fields within Neo blocks) 7 | - [Feed Me](https://github.com/craftcms/feed-me) 8 | - [Field Manager](https://github.com/verbb/field-manager) 9 | - [Matrix Field Preview](https://github.com/weareferal/craft-matrix-field-preview) 10 | - [Embedded Assets](https://github.com/spicywebau/craft-embedded-assets) (field content is displayed in Neo's collapsed block previews) 11 | - [TinyMCE Field](https://github.com/spicywebau/craft-tinymce) (field content is displayed in Neo's collapsed block previews) 12 | - [Super Table](https://github.com/verbb/super-table) (field content is displayed in Neo's collapsed block previews) 13 | - [Linkit](https://github.com/presseddigital/linkit) (field content is displayed in Neo's collapsed block previews) 14 | - [Category Groups Field](https://github.com/ttempleton/craft-category-groups-field) (field content is displayed in Neo's collapsed block previews) 15 | - [oEmbed](https://github.com/wrav/oembed) (field input content is displayed in Neo's collapsed block previews) 16 | 17 | The following plugins are known to be incompatible with Neo: 18 | 19 | - [Preparse Field](https://github.com/besteadfast/craft-preparse-field) (may cause issues with [Neo blocks disappearing on save](https://github.com/spicywebau/craft-neo/issues/398), or [loss of block structure data](https://github.com/craftcms/cms/issues/13256)) 20 | 21 | If you know of a plugin that's incompatible with Neo, and no issue already exists for Neo or the other plugin, please let us know by [opening an issue on GitHub](https://github.com/spicywebau/craft-neo/issues/new). 22 | -------------------------------------------------------------------------------- /src/records/BlockType.php: -------------------------------------------------------------------------------- 1 | 14 | * @author Benjamin Fleming 15 | * @since 2.0.0 16 | */ 17 | class BlockType extends ActiveRecord 18 | { 19 | /** 20 | * @inheritdoc 21 | */ 22 | public static function tableName(): string 23 | { 24 | return '{{%neoblocktypes}}'; 25 | } 26 | 27 | /** 28 | * @inheritdoc 29 | */ 30 | public function rules() 31 | { 32 | return [ 33 | [['handle'], 'unique', 'targetAttribute' => ['handle', 'fieldId']], 34 | [['name', 'handle'], 'required'], 35 | [['name', 'handle'], 'string', 'max' => 255], 36 | [ 37 | ['handle'], 38 | HandleValidator::class, 39 | 'reservedWords' => [ 40 | 'id', 41 | 'dateCreated', 42 | 'dateUpdated', 43 | 'uid', 44 | 'title', 45 | ], 46 | ], 47 | ]; 48 | } 49 | 50 | /** 51 | * Returns the block type's associated field. 52 | * 53 | * @return ActiveQueryInterface 54 | */ 55 | public function getField(): ActiveQueryInterface 56 | { 57 | return $this->hasOne(Field::class, ['id' => 'fieldId']); 58 | } 59 | 60 | /** 61 | * Returns the block type's field layout. 62 | * 63 | * @return ActiveQueryInterface 64 | */ 65 | public function getFieldLayout(): ActiveQueryInterface 66 | { 67 | return $this->hasOne(FieldLayout::class, ['id' => 'fieldLayoutId']); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/gql/types/elements/Block.php: -------------------------------------------------------------------------------- 1 | 14 | * @since 3.3.0 15 | */ 16 | class Block extends Element 17 | { 18 | /** 19 | * @inheritdoc 20 | */ 21 | public function __construct(array $config) 22 | { 23 | $config['interfaces'] = [ 24 | NeoBlockInterface::getType(), 25 | ]; 26 | 27 | parent::__construct($config); 28 | } 29 | 30 | /** 31 | * @inheritdoc 32 | */ 33 | protected function resolve(mixed $source, array $arguments, mixed $context, ResolveInfo $resolveInfo): mixed 34 | { 35 | $fieldName = $resolveInfo->fieldName; 36 | 37 | if ($fieldName === 'typeHandle') { 38 | return $source->getType()->handle; 39 | } 40 | 41 | if ($fieldName === 'children') { 42 | $childrenLevel = (int)$source->level + 1; 43 | 44 | // The blocks at `$source->$fieldName` cannot be trusted, it will most likely be out of order and cached. 45 | // We should retrieve the children blocks by query instead, so it'll always be in the correct order. 46 | $descendants = $source->getDescendants()->ownerId($source->ownerId)->all(); 47 | $children = array_filter($descendants, function($block) use ($childrenLevel) { 48 | return (int)$block->level === $childrenLevel; 49 | }); 50 | 51 | return ElementCollection::make($children); 52 | } 53 | 54 | return parent::resolve($source, $arguments, $context, $resolveInfo); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ### How do I get support for Neo? 4 | 5 | You can get support for Neo by posting an issue on GitHub. First, check to make sure the bug you're reporting, feature you're requesting or question you're asking hasn't already been documented in the [issues section](https://github.com/spicywebau/craft-neo/issues). If it hasn't, the best course of action is to then [open an issue](https://github.com/spicywebau/craft-neo/issues/new). 6 | 7 | -- 8 | 9 | ### Why do asset fields with `{slug}` as an upload location break on Neo blocks? 10 | 11 | This is because when parsing the upload location, it applies any `{property}` tags to the fields' containing element. An asset field added to an entry will reference the entry, but an asset field added to a Neo block will reference the block. This is why the instructions change slightly when adding an asset field to a Matrix block - it says to use `{owner.slug}` instead of `{slug}`. 12 | 13 | There are two ways around this. The first is to create a duplicate field that uses `{owner.slug}` instead of `{slug}` just for Neo block types. 14 | 15 | The second is to use a little bit of Twig logic in your upload location. To begin, `{slug}` can actually be replaced with `{{ object.slug }}`. This shows that we have the ability to use double brace tags, which means we can use logic. So the idea is to check to see if `object` is a Neo block, and if so, use `object.owner.slug` instead. Normally this can't be done, but Neo provides a custom Twig extension that allows you test if some value is a Neo block. 16 | 17 | In short, replace `{slug}` with `{{ object is neoblock ? object.owner.slug : object.slug }}`. 18 | 19 | If your asset field is on a Matrix or Super Table field which is being used on a Neo block, you would instead need to check whether the owner of the Matrix block or Super Table row is a Neo block: `{{ owner is neoblock ? owner.owner.slug : owner.slug }}`. 20 | -------------------------------------------------------------------------------- /src/templates/input.twig: -------------------------------------------------------------------------------- 1 | {% set inputAttributes = { 2 | id: id, 3 | class: [ 4 | 'neo-input', 5 | "neo-input--#{handle}", 6 | static ? 'is-static', 7 | translatable ? 'is-translatable', 8 | ], 9 | } %} 10 | {% set neoSettings = craft.app.plugins.getPlugin('neo').settings %} 11 | 12 | {# Output the block type icons, for the non-Matrix new block styles #} 13 | {% if neoSettings.newBlockMenuStyle != 'classic' %} 14 | 33 | {% endif %} 34 | 35 | 36 |
37 | 38 |
39 | {% for block in blocks %} 40 | {% if block.level == 1 %} 41 | {% include 'neo/block' with { handle, block, static } only %} 42 | {% endif %} 43 | {% endfor %} 44 |
45 | {% if not static %} 46 |
47 | {% endif %} 48 |
49 | -------------------------------------------------------------------------------- /src/jobs/DeleteBlock.php: -------------------------------------------------------------------------------- 1 | 15 | * @since 2.13.16 16 | */ 17 | class DeleteBlock extends BaseJob 18 | { 19 | /** 20 | * @var int 21 | */ 22 | public $blockId; 23 | 24 | /** 25 | * @var int 26 | */ 27 | public $siteId; 28 | 29 | /** 30 | * @var bool 31 | */ 32 | public $deletedWithOwner; 33 | 34 | /** 35 | * @var bool 36 | */ 37 | public $hardDelete; 38 | 39 | /** 40 | * @inheritdoc 41 | */ 42 | public function execute($queue): void 43 | { 44 | $elementsService = Craft::$app->getElements(); 45 | $block = $elementsService->getElementById($this->blockId, Block::class, $this->siteId); 46 | 47 | if ($block !== null) { 48 | $block->deletedWithOwner = $this->deletedWithOwner; 49 | $elementsService->deleteElement($block, $this->hardDelete); 50 | } elseif ($elementsService->getElementById($this->blockId, Block::class, '*') === null) { 51 | // The owner has already been hard deleted and the block table data no longer exists; 52 | // make sure the elements table data is cleaned up 53 | Craft::$app->getDb() 54 | ->createCommand() 55 | ->delete( 56 | Table::ELEMENTS, 57 | ['id' => $this->blockId] 58 | ) 59 | ->execute(); 60 | } 61 | 62 | $this->setProgress($queue, 1); 63 | } 64 | 65 | /** 66 | * @inheritdoc 67 | */ 68 | protected function defaultDescription(): ?string 69 | { 70 | return Craft::t('neo', 'Deleting old Neo blocks'); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/web/twig/Variable.php: -------------------------------------------------------------------------------- 1 | 17 | * @author Benjamin Fleming 18 | * @since 5.0.0 19 | */ 20 | class Variable 21 | { 22 | /** 23 | * Get Neo blocks on their own, without requiring an owner element. 24 | * 25 | * If possible, avoid using this function. Neo blocks are supposed to be tied explicitly to elements, and the use of 26 | * this function ignores this relationship. Using this function can open you up to all kinds of performance and 27 | * unexpected behavioural issues if you're not careful. 28 | * 29 | * @param array|null $criteria 30 | * @return BlockQuery 31 | */ 32 | public function blocks(?array $criteria = null): BlockQuery 33 | { 34 | return Craft::configure(Block::find(), ($criteria ?? [])); 35 | } 36 | 37 | /** 38 | * Get data attribute for preview mode block highlighting. 39 | * 40 | * @param Block $block 41 | * @return Markup containing the data attribute, or an empty string outside of preview mode 42 | */ 43 | public function previewHighlightAttribute(Block $block): Markup 44 | { 45 | return Template::raw(Craft::$app->getRequest()->getIsPreview() 46 | ? sprintf('data-neo-preview-highlight="%s"', $block->id) 47 | : ''); 48 | } 49 | 50 | /** 51 | * Get prefix to use for block anchor IDs. 52 | * 53 | * @param Block $block 54 | * @return string 55 | * @since 5.5.0 56 | */ 57 | public function blockAnchorId(Block $block): string 58 | { 59 | return Neo::$plugin->blocks->blockAnchorId($block); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spicyweb/craft-neo", 3 | "description": "A Matrix-like field type with block hierarchy", 4 | "version": "5.5.3", 5 | "type": "craft-plugin", 6 | "keywords": [ 7 | "cms", 8 | "craftcms", 9 | "plugin", 10 | "neo", 11 | "matrix", 12 | "field" 13 | ], 14 | "license": "proprietary", 15 | "authors": [ 16 | { 17 | "name": "Spicy Web", 18 | "homepage": "https://github.com/spicywebau" 19 | } 20 | ], 21 | "support": { 22 | "issues": "https://github.com/spicywebau/craft-neo/issues", 23 | "source": "https://github.com/spicywebau/craft-neo", 24 | "docs": "https://github.com/spicywebau/craft-neo/blob/5.5.3/README.md", 25 | "rss": "https://github.com/spicywebau/craft-neo/commits/5.x.atom" 26 | }, 27 | "require": { 28 | "craftcms/cms": "^5.3.0", 29 | "php": "^8.2" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "benf\\neo\\": "src/" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "spicyweb\\neotests\\": "tests/" 39 | } 40 | }, 41 | "extra": { 42 | "handle": "neo", 43 | "name": "Neo", 44 | "schemaVersion": "5.1.0.1", 45 | "class": "benf\\neo\\Plugin", 46 | "changelogUrl": "https://github.com/spicywebau/craft-neo/blob/5.x/CHANGELOG.md", 47 | "downloadUrl": "https://github.com/spicywebau/craft-neo/archive/refs/tags/5.5.3.zip" 48 | }, 49 | "config": { 50 | "allow-plugins": { 51 | "yiisoft/yii2-composer": true, 52 | "craftcms/plugin-installer": true 53 | } 54 | }, 55 | "minimum-stability": "dev", 56 | "prefer-stable": true, 57 | "require-dev": { 58 | "craftcms/rector": "dev-main", 59 | "craftcms/ecs": "dev-main", 60 | "codeception/codeception": "^5.0.11", 61 | "codeception/module-asserts": "^3.0.0", 62 | "codeception/module-phpbrowser": "^3.0.0", 63 | "codeception/module-rest": "^3.3.2", 64 | "codeception/module-yii2": "^1.1.9", 65 | "vlucas/phpdotenv": "^5.0" 66 | }, 67 | "scripts": { 68 | "check-cs": "ecs check --ansi", 69 | "fix-cs": "ecs check --ansi --fix" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/web/assets/input/src/scripts/namespace.js: -------------------------------------------------------------------------------- 1 | export default { 2 | 3 | _stack: [[]], 4 | 5 | enter (segments, join = true) { 6 | if (typeof segments === 'string') { 7 | segments = this.fromFieldName(segments) 8 | } 9 | 10 | if (join) { 11 | const joined = this.getNamespace() 12 | joined.push(...segments) 13 | 14 | segments = joined 15 | } 16 | 17 | this._stack.push(segments) 18 | }, 19 | 20 | enterByFieldName (fieldName, join = true) { 21 | this.enter(this.fromFieldName(fieldName), join) 22 | }, 23 | 24 | leave () { 25 | return this._stack.length > 1 26 | ? this._stack.pop() 27 | : this.getNamespace() 28 | }, 29 | 30 | getNamespace () { 31 | return Array.from(this._stack[this._stack.length - 1]) 32 | }, 33 | 34 | parse (value) { 35 | if (typeof value === 'string') { 36 | if (value.indexOf('[') > -1) { 37 | return this.fromFieldName(value) 38 | } 39 | 40 | if (value.indexOf('-') > -1) { 41 | return value.split('-') 42 | } 43 | 44 | if (value.indexOf('.') > -1) { 45 | return value.split('.') 46 | } 47 | 48 | return value 49 | } 50 | 51 | return Array.from(value) 52 | }, 53 | 54 | value (value, separator = '-') { 55 | const segments = this.getNamespace() 56 | segments.push(value) 57 | 58 | return segments.join(separator) 59 | }, 60 | 61 | fieldName (fieldName = '') { 62 | const prefix = this.toFieldName() 63 | 64 | if (prefix) { 65 | return prefix + fieldName.replace(/([^'"[\]]+)([^'"]*)/, '[$1]$2') 66 | } 67 | 68 | return fieldName 69 | }, 70 | 71 | toString (separator = '-') { 72 | return this.getNamespace().join(separator) 73 | }, 74 | 75 | toFieldName () { 76 | const segments = this.getNamespace() 77 | 78 | switch (segments.length) { 79 | case 0: return '' 80 | case 1: return segments[0] 81 | } 82 | 83 | return segments[0] + '[' + segments.slice(1).join('][') + ']' 84 | }, 85 | 86 | fromFieldName (fieldName) { 87 | return fieldName.match(/[^[\]\s]+/g) || [] 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/web/assets/configurator/src/scripts/namespace.js: -------------------------------------------------------------------------------- 1 | export default { 2 | 3 | _stack: [[]], 4 | 5 | enter (segments, join = true) { 6 | if (typeof segments === 'string') { 7 | segments = this.fromFieldName(segments) 8 | } 9 | 10 | if (join) { 11 | const joined = this.getNamespace() 12 | joined.push(...segments) 13 | 14 | segments = joined 15 | } 16 | 17 | this._stack.push(segments) 18 | }, 19 | 20 | enterByFieldName (fieldName, join = true) { 21 | this.enter(this.fromFieldName(fieldName), join) 22 | }, 23 | 24 | leave () { 25 | return this._stack.length > 1 26 | ? this._stack.pop() 27 | : this.getNamespace() 28 | }, 29 | 30 | getNamespace () { 31 | return Array.from(this._stack[this._stack.length - 1]) 32 | }, 33 | 34 | parse (value) { 35 | if (typeof value === 'string') { 36 | if (value.indexOf('[') > -1) { 37 | return this.fromFieldName(value) 38 | } 39 | 40 | if (value.indexOf('-') > -1) { 41 | return value.split('-') 42 | } 43 | 44 | if (value.indexOf('.') > -1) { 45 | return value.split('.') 46 | } 47 | 48 | return value 49 | } 50 | 51 | return Array.from(value) 52 | }, 53 | 54 | value (value, separator = '-') { 55 | const segments = this.getNamespace() 56 | segments.push(value) 57 | 58 | return segments.join(separator) 59 | }, 60 | 61 | fieldName (fieldName = '') { 62 | const prefix = this.toFieldName() 63 | 64 | if (prefix) { 65 | return prefix + fieldName.replace(/([^'"[\]]+)([^'"]*)/, '[$1]$2') 66 | } 67 | 68 | return fieldName 69 | }, 70 | 71 | toString (separator = '-') { 72 | return this.getNamespace().join(separator) 73 | }, 74 | 75 | toFieldName () { 76 | const segments = this.getNamespace() 77 | 78 | switch (segments.length) { 79 | case 0: return '' 80 | case 1: return segments[0] 81 | } 82 | 83 | return segments[0] + '[' + segments.slice(1).join('][') + ']' 84 | }, 85 | 86 | fromFieldName (fieldName) { 87 | return fieldName.match(/[^[\]\s]+/g) || [] 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/templates/block-type-group-settings.twig: -------------------------------------------------------------------------------- 1 | {% import '_includes/forms' as forms %} 2 | 3 | {% set neoSettings = craft.app.plugins.getPlugin('neo').settings %} 4 | {% set alwaysShowDropdownValue = 'global' %} 5 | 6 | {% if group %} 7 | {% if group.alwaysShowDropdown %} 8 | {% set alwaysShowDropdownValue = 'show' %} 9 | {% elseif group.alwaysShowDropdown is not null %} 10 | {% set alwaysShowDropdownValue = 'hide' %} 11 | {% endif %} 12 | {% endif %} 13 | 14 |
15 |
16 | {{ forms.textField({ 17 | id: 'name', 18 | name: 'name', 19 | label: 'Name'|t('neo'), 20 | instructions: 'This can be left blank if you just want an unlabeled separator.'|t('neo'), 21 | value: group ? group.name : null, 22 | errors: group ? group.getErrors('name') : null, 23 | inputAttributes: { 24 | 'data-neo-gs': 'input.name', 25 | }, 26 | }) }} 27 | 28 |
29 | {{ forms.selectField({ 30 | label: 'Always Show Dropdown?'|t('neo'), 31 | instructions: 'Whether to show the dropdown for this group if it only has one available block type.'|t('neo'), 32 | id: 'alwaysShowDropdown', 33 | name: 'alwaysShowDropdown', 34 | options: [ 35 | { 36 | value: 'show', 37 | label: 'Show'|t('neo'), 38 | }, 39 | { 40 | value: 'hide', 41 | label: 'Hide'|t('neo'), 42 | }, 43 | { 44 | value: 'global', 45 | label: neoSettings.defaultAlwaysShowGroupDropdowns ? 'Use global setting (Show)'|t('neo') : 'Use global setting (Hide)'|t('neo'), 46 | } 47 | ], 48 | value: alwaysShowDropdownValue, 49 | }) }} 50 |
51 |
52 |
53 | {{ 'Delete group'|t('neo') }} 54 |
55 | -------------------------------------------------------------------------------- /docs/content-migration-guides/populating-neo-fields.md: -------------------------------------------------------------------------------- 1 | # Populating Neo Fields 2 | 3 | Setting or adding to a Neo field's content takes the following format. This example assumes a Neo field with the handle `neoField`, with two block types with handles `blockType1` and `blockType2`, with a subfield with the handle `plainTextField`. 4 | 5 | ```php 6 | $entry = \Craft::$app->entries->getEntryById($entryId); 7 | 8 | $entry->setFieldValues([ 9 | 'neoField' => [ 10 | 'blocks' => [ 11 | 'new1' => [ 12 | 'type' => 'blockType1', 13 | 'level' => 1, 14 | 'enabled' => true, 15 | 'collapsed' => false, 16 | 'fields' => [ 17 | 'plainTextField' => 'This is a new block.', 18 | ], 19 | ], 20 | 'new2' => [ 21 | 'type' => 'blockType2', 22 | 'level' => 2, 23 | 'enabled' => true, 24 | 'collapsed' => false, 25 | 'fields' => [ 26 | 'plainTextField' => 'This is another new block.', 27 | ], 28 | ], 29 | ], 30 | 'sortOrder' => [ 31 | 'new1', 32 | 'new2', 33 | ], 34 | ], 35 | ]); 36 | 37 | \Craft::$app->elements->saveElement($entry); 38 | ``` 39 | 40 | For new blocks, the following properties are optional: 41 | 42 | - `level` (defaults to 1) 43 | - `enabled` (defaults to `true`) 44 | - `collapsed` (defaults to `false`) 45 | 46 | For existing blocks, any properties not specified will just retain their existing values. 47 | 48 | When adding new blocks to a Neo field that already has blocks, or editing existing blocks, you only need to set the new blocks and any edited blocks (using the block IDs as keys instead of `new1`, `new2`, etc.) in the `blocks` array. However, you must include all of the field's block IDs in the `sortOrder` array. The `sortOrder` array exists not only to put the blocks in the correct order, but also to tell Neo which existing, unedited blocks should still exist after saving the field value. Thus, any existing, unedited blocks in the field being updated that don't have their IDs included in the `sortOrder` array will be deleted. 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © Spicy Web 2 | 3 | Permission is hereby granted to any person obtaining a copy of this software 4 | (the “Software”) to use, copy, modify, merge, publish and/or distribute copies 5 | of the Software, and to permit persons to whom the Software is furnished to do 6 | so, subject to the following conditions: 7 | 8 | 1. **Don’t plagiarize.** The above copyright notice and this license shall be 9 | included in all copies or substantial portions of the Software. 10 | 11 | 2. **Don’t use the same license on more than one project.** Each licensed copy 12 | of the Software shall be actively installed in no more than one production 13 | environment at a time. 14 | 15 | 3. **Don’t mess with the licensing features.** Software features related to 16 | licensing shall not be altered or circumvented in any way, including (but 17 | not limited to) license validation, payment prompts, feature restrictions, 18 | and update eligibility. 19 | 20 | 4. **Pay up.** Payment shall be made immediately upon receipt of any notice, 21 | prompt, reminder, or other message indicating that a payment is owed. 22 | 23 | 5. **Follow the law.** All use of the Software shall not violate any applicable 24 | law or regulation, nor infringe the rights of any other person or entity. 25 | 26 | Failure to comply with the foregoing conditions will automatically and 27 | immediately result in termination of the permission granted hereby. This 28 | license does not include any right to receive updates to the Software or 29 | technical support. Licensees bear all risk related to the quality and 30 | performance of the Software and any modifications made or obtained to it, 31 | including liability for actual and consequential harm, such as loss or 32 | corruption of data, and any necessary service, repair, or correction. 33 | 34 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 35 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 36 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 37 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER 38 | LIABILITY, INCLUDING SPECIAL, INCIDENTAL AND CONSEQUENTIAL DAMAGES, WHETHER IN 39 | AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 40 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 41 | -------------------------------------------------------------------------------- /src/models/BlockTypeGroup.php: -------------------------------------------------------------------------------- 1 | 13 | * @author Benjamin Fleming 14 | * @since 2.0.0 15 | */ 16 | class BlockTypeGroup extends Model 17 | { 18 | /** 19 | * @var int|null The block type group ID. 20 | */ 21 | public ?int $id = null; 22 | 23 | /** 24 | * @var int|null The field ID. 25 | */ 26 | public ?int $fieldId = null; 27 | 28 | /** 29 | * @var string|null The block type group name. 30 | */ 31 | public ?string $name = null; 32 | // TODO: $name should only be allowed to be a string in Neo 4 33 | 34 | /** 35 | * @var int|null The sort order. 36 | */ 37 | public ?int $sortOrder = null; 38 | 39 | /** 40 | * @var bool|null Whether to always show dropdowns for this block type group. 41 | * @since 3.0.0 42 | */ 43 | public ?bool $alwaysShowDropdown = null; 44 | 45 | /** 46 | * @var string|null 47 | */ 48 | public ?string $uid = null; 49 | 50 | /** 51 | * @inheritdoc 52 | */ 53 | protected function defineRules(): array 54 | { 55 | return [ 56 | [['id', 'fieldId', 'sortOrder' ], 'number', 'integerOnly' => true], 57 | ]; 58 | } 59 | 60 | /** 61 | * Returns the block type group's name as the string representation. 62 | * 63 | * @return string 64 | */ 65 | public function __toString(): string 66 | { 67 | return (string)$this->name; 68 | } 69 | 70 | /** 71 | * Returns whether this block type group is new. 72 | * 73 | * @return bool 74 | */ 75 | public function getIsNew(): bool 76 | { 77 | return (!$this->id || strpos($this->id, 'new') === 0); 78 | } 79 | 80 | /** 81 | * Returns the block type group config. 82 | * 83 | * @return array 84 | * @since 2.9.0 85 | */ 86 | public function getConfig(): array 87 | { 88 | return [ 89 | 'field' => Craft::$app->getFields()->getFieldById($this->fieldId)->uid, 90 | 'name' => $this->name ?? '', 91 | 'sortOrder' => (int)$this->sortOrder, 92 | 'alwaysShowDropdown' => $this->alwaysShowDropdown, 93 | ]; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/web/assets/converter/dist/scripts/converter.js: -------------------------------------------------------------------------------- 1 | (()=>{"use strict";var t={n:e=>{var n=e&&e.__esModule?()=>e.default:()=>e;return t.d(n,{a:n}),n},d:(e,n)=>{for(var o in n)t.o(n,o)&&!t.o(e,o)&&Object.defineProperty(e,o,{enumerable:!0,get:n[o]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e)};const e=Garnish;var n=t.n(e);const o=document.getElementById("type"),i=document.querySelector('input[name="fieldId"]');if("benf\\neo\\Field"===o.dataset.value&&null!==i){const t=window.Craft.cp.$primaryForm,e=t.find('input[type="submit"]');let o=document.getElementById("Matrix-convert_button"),r=document.getElementById("Matrix-convert_spinner"),a=!0;const d=n=>{a=!!n,o.classList.toggle("disabled",!a),e.toggleClass("disabled",!a),a?t.off("submit.neo"):t.on("submit.neo",(t=>t.preventDefault()))},s=()=>{d(!1),r.classList.remove("hidden"),window.Craft.postActionRequest("neo/conversion/convert-to-matrix",{fieldId:i.value},((t,e)=>{t.success?(window.Craft.cp.removeListener(n().$win,"beforeunload"),window.location.reload()):(d(!0),window.Craft.cp.displayError(window.Craft.t("neo","Could not convert Neo field to Matrix")),t.errors?.forEach((t=>window.Craft.cp.displayError(t))))}))},l=()=>{const t=document.getElementById("craft-fields-Matrix");null!==t&&null===t.querySelector("#conversion-prompt")&&(t.insertAdjacentHTML("afterbegin",`\n
\n
\n
\n \n

${window.Craft.t("neo","This field is currently of the Neo type. You may automatically convert it to Matrix along with all of its content.")}

\n
\n
\n \n \n
\n

${window.Craft.t("neo","By converting to Matrix, structural information will be lost.")}

\n
\n
\n
\n `),o=document.getElementById("Matrix-convert_button"),r=document.getElementById("Matrix-convert_spinner"),o.addEventListener("click",(t=>{t.preventDefault(),a&&window.confirm(window.Craft.t("neo","Are you sure? This is a one way operation. You cannot undo conversion from Neo to Matrix."))&&s()})))};new window.MutationObserver(l).observe(document.getElementById("settings"),{childList:!0,subtree:!0})}})(); 2 | //# sourceMappingURL=converter.js.map -------------------------------------------------------------------------------- /src/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/gql/resolvers/elements/Block.php: -------------------------------------------------------------------------------- 1 | 16 | * @since 3.3.0 17 | */ 18 | class Block extends ElementResolver 19 | { 20 | /** 21 | * @inheritdoc 22 | */ 23 | public static function resolve(mixed $source, array $arguments, mixed $context, ResolveInfo $resolveInfo): mixed 24 | { 25 | $query = self::prepareElementQuery($source, $arguments, $context, $resolveInfo); 26 | $blocks = $query instanceof BlockQuery ? $query->all() : $query; 27 | 28 | // If we have all blocks, memoize them to avoid database calls for child block queries 29 | if ( 30 | $query instanceof BlockQuery && $query->level == 0 || 31 | $query instanceof ElementCollection && isset($arguments['level']) && $arguments['level'] == 0 32 | ) { 33 | foreach ($blocks as $block) { 34 | $block->useMemoized($blocks); 35 | } 36 | } 37 | 38 | return GqlHelper::applyDirectives($source, $resolveInfo, $blocks); 39 | } 40 | 41 | /** 42 | * @inheritdoc 43 | */ 44 | public static function prepareQuery(mixed $source, array $arguments, $fieldName = null): mixed 45 | { 46 | // If this is the beginning of a resolver chain, start fresh 47 | if ($source === null) { 48 | $query = BlockElement::find(); 49 | // If not, get the prepared element query 50 | } else { 51 | $query = $source->$fieldName; 52 | } 53 | 54 | // If it's preloaded, it's preloaded. 55 | if (!$query instanceof ElementQuery) { 56 | $query = array_unique($query->all()); 57 | 58 | // Return level 1 blocks only, unless the `level` argument says otherwise 59 | $level = isset($arguments['level']) 60 | ? ($arguments['level'] !== 0 ? $arguments['level'] : null) 61 | : 1; 62 | $newBlocks = $level === null 63 | ? $query 64 | : array_filter($query, function($block) use ($level) { 65 | return (int)$block->level === $level; 66 | }); 67 | 68 | return ElementCollection::make(!empty($newBlocks) ? $newBlocks : $query); 69 | } 70 | 71 | // We require level 1 unless the arguments say otherwise 72 | $query->level(1); 73 | 74 | foreach ($arguments as $key => $value) { 75 | $query->$key($value); 76 | } 77 | 78 | return $query; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Neo 4 | #### A Matrix-like field type with block hierarchy 5 | 6 | Neo is a [Craft CMS](https://craftcms.com) field type that builds upon the concept of the [Matrix field type](https://craftcms.com/features/matrix) with a number of very useful features. Neo has been carefully crafted (pun intended) to ensure it matches the look and feel of Craft. 7 | 8 |
9 | 10 | ### Allow blocks to contain children 11 | 12 | Let block types have the ability to contain child block types. You can filter what blocks are allowed within others, as well as set whether a block type can only be a child of another. 13 | 14 | 15 | 16 |
17 | 18 | ### Group block buttons 19 | 20 | Sometimes you end up with many block types. Adding groups allows you to organise your block types into drop down menus. 21 | 22 | 23 | 24 |
25 | 26 | ### Set minimum and maximum blocks by type 27 | 28 | Neo has the ability to set minimum and maximum counts on individual block types, either throughout a Neo field, or under one parent block or at the top level. 29 | 30 | 31 | 32 | 33 | ### Copy, paste and clone blocks 34 | 35 | A copied block can be pasted anywhere within its Neo field that allows that block type to exist, including on a different entry. Blocks are copied and pasted with their descendants. 36 | 37 | 38 | 39 | 40 | 41 | ## Documentation 42 | 43 | - [Installation](docs/installation.md) 44 | - [Creating Neo Fields](docs/creating-neo-fields.md) 45 | - [Templating](docs/templating.md) 46 | - [Eager Loading](docs/eager-loading.md) 47 | - [GraphQL](docs/graphql.md) 48 | - [Resources](docs/resources.md) 49 | - [API](docs/api.md) 50 | - [Events](docs/events.md) 51 | - [Settings](docs/settings.md) 52 | - [Console Commands](docs/console-commands.md) 53 | - [Plugin Compatibility](docs/plugin-compatibility.md) 54 | - [Feed Me](docs/feed-me.md) 55 | - [FAQ](docs/faq.md) 56 | - Content Migration Guides: 57 | - [Populating Neo Fields](docs/content-migration-guides/populating-neo-fields.md) 58 | - [Updating, Duplicating and Creating Block Types](docs/content-migration-guides/updating-duplicating-creating-block-types.md) 59 | - Upgrade Guides: 60 | - [Upgrading to Neo 2.7 and Craft 3.4](docs/upgrade-guides/neo-2.7-craft-3.4.md) 61 | - [Upgrading to Neo 4](docs/upgrade-guides/neo-4.md) 62 | - [Upgrading to Neo 5](docs/upgrade-guides/neo-5.md) 63 | - [Changelog](CHANGELOG.md) 64 | 65 | 66 | --- 67 | 68 | *Created by [Benjamin Fleming](https://github.com/benjamminf)* 69 |
70 | *Maintained by [Spicy Web](https://spicyweb.com.au)* 71 | -------------------------------------------------------------------------------- /src/gql/types/generators/BlockType.php: -------------------------------------------------------------------------------- 1 | 21 | * @since 3.3.0 22 | */ 23 | class BlockType extends Generator implements GeneratorInterface, SingleGeneratorInterface 24 | { 25 | /** 26 | * @inheritdoc 27 | */ 28 | public static function generateTypes(mixed $context = null): array 29 | { 30 | if ($context) { 31 | $blockTypes = $context->getBlockTypes(); 32 | } else { 33 | $blockTypes = Neo::$plugin->blockTypes->getAllBlockTypes(); 34 | } 35 | 36 | $gqlTypes = []; 37 | 38 | foreach ($blockTypes as $blockType) { 39 | $type = static::generateType($blockType); 40 | $gqlTypes[$type->name] = $type; 41 | } 42 | 43 | return $gqlTypes; 44 | } 45 | 46 | /** 47 | * @inheritdoc 48 | */ 49 | public static function generateType(mixed $context): ObjectType 50 | { 51 | $typeName = BlockElement::gqlTypeNameByContext($context); 52 | 53 | if (!($entity = GqlEntityRegistry::getEntity($typeName))) { 54 | $contentFieldGqlTypes = self::getContentFields($context); 55 | $blockTypeFields = array_merge(BlockInterface::getFieldDefinitions(), $contentFieldGqlTypes); 56 | 57 | $entity = GqlEntityRegistry::getEntity($typeName); 58 | 59 | if (!$entity) { 60 | $entity = new Block([ 61 | 'name' => $typeName, 62 | 'fields' => function() use ($blockTypeFields, $typeName) { 63 | return Craft::$app->getGql()->prepareFieldDefinitions($blockTypeFields, $typeName); 64 | }, 65 | ]); 66 | 67 | $entity = GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, $entity); 68 | } 69 | } 70 | 71 | return $entity; 72 | } 73 | 74 | /** 75 | * @inheritdoc 76 | */ 77 | protected static function getContentFields(mixed $context): array 78 | { 79 | $contentFieldGqlTypes = parent::getContentFields($context); 80 | 81 | if (!empty($context->childBlocks)) { 82 | $contentFieldGqlTypes['children'] = [ 83 | 'name' => 'children', 84 | 'type' => Type::listOf(BlockInterface::getType()), 85 | 'description' => 'The child block types for this Neo block', 86 | ]; 87 | } 88 | 89 | return $contentFieldGqlTypes; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/gql/interfaces/elements/Block.php: -------------------------------------------------------------------------------- 1 | 16 | * @since 3.3.0 17 | */ 18 | class Block extends Element 19 | { 20 | /** 21 | * @inheritdoc 22 | */ 23 | public static function getTypeGenerator(): string 24 | { 25 | return BlockType::class; 26 | } 27 | 28 | /** 29 | * @inheritdoc 30 | */ 31 | public static function getType(): Type 32 | { 33 | if ($type = GqlEntityRegistry::getEntity(self::getName())) { 34 | return $type; 35 | } 36 | 37 | $type = GqlEntityRegistry::createEntity(self::getName(), new InterfaceType([ 38 | 'name' => static::getName(), 39 | 'fields' => self::class . '::getFieldDefinitions', 40 | 'description' => 'This is the interface implemented by all Neo blocks.', 41 | 'resolveType' => self::class . '::resolveElementTypeName', 42 | ])); 43 | 44 | BlockType::generateTypes(); 45 | 46 | return $type; 47 | } 48 | 49 | /** 50 | * @inheritdoc 51 | */ 52 | public static function getName(): string 53 | { 54 | return 'NeoBlockInterface'; 55 | } 56 | 57 | /** 58 | * @inheritdoc 59 | */ 60 | public static function getFieldDefinitions(): array 61 | { 62 | return Craft::$app->getGql()->prepareFieldDefinitions(array_merge(parent::getFieldDefinitions(), [ 63 | 'fieldId' => [ 64 | 'name' => 'fieldId', 65 | 'type' => Type::int(), 66 | 'description' => 'The ID of the field that owns the Neo block.', 67 | ], 68 | 'level' => [ 69 | 'name' => 'level', 70 | 'type' => Type::int(), 71 | 'description' => 'The Neo block’s level.', 72 | ], 73 | 'primaryOwnerId' => [ 74 | 'name' => 'primaryOwnerId', 75 | 'type' => Type::int(), 76 | 'description' => 'The ID of the primary owner of the Neo block.', 77 | ], 78 | 'typeId' => [ 79 | 'name' => 'typeId', 80 | 'type' => Type::int(), 81 | 'description' => 'The ID of the Neo block’s type.', 82 | ], 83 | 'typeHandle' => [ 84 | 'name' => 'typeHandle', 85 | 'type' => Type::string(), 86 | 'description' => 'The handle of the Neo block’s type.', 87 | ], 88 | 'sortOrder' => [ 89 | 'name' => 'sortOrder', 90 | 'type' => Type::int(), 91 | 'description' => 'The sort order of the Neo block within the owner element field.', 92 | ], 93 | ]), self::getName()); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/web/assets/configurator/src/scripts/GroupSettings.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | // import Craft from 'craft' 3 | import NS from './namespace' 4 | import Settings from './Settings' 5 | 6 | const _defaults = { 7 | namespace: [], 8 | id: null, 9 | sortOrder: 0, 10 | name: '', 11 | alwaysShowDropdown: null, 12 | defaultAlwaysShowGroupDropdowns: true 13 | } 14 | 15 | export default Settings.extend({ 16 | 17 | _templateNs: [], 18 | 19 | $container: null, 20 | $sortOrderInput: new $(), 21 | $nameInput: new $(), 22 | $handleInput: new $(), 23 | $maxBlocksInput: new $(), 24 | 25 | init (settings = {}) { 26 | settings = Object.assign({}, _defaults, settings) 27 | 28 | this._templateNs = NS.parse(settings.namespace) 29 | this._id = settings.id 30 | this._alwaysShowDropdown = settings.alwaysShowDropdown 31 | this._defaultAlwaysShowGroupDropdowns = settings.defaultAlwaysShowGroupDropdowns 32 | this._originalSettings = settings 33 | 34 | if (typeof settings.html !== 'undefined' && settings.html !== null) { 35 | this.createContainer({ 36 | html: settings.html, 37 | js: settings.js 38 | }) 39 | } 40 | }, 41 | 42 | createContainer (containerData) { 43 | // Only create it if it doesn't already exist 44 | if (this.$container !== null) { 45 | return 46 | } 47 | 48 | this.$container = $(containerData.html) 49 | this._js = containerData.js ?? '' 50 | 51 | const $neo = this.$container.find('[data-neo-gs]') 52 | this.$nameInput = $neo.filter('[data-neo-gs="input.name"]') 53 | this.$deleteButton = $neo.filter('[data-neo-gs="button.delete"]') 54 | this.$alwaysShowDropdownContainer = $neo.filter('[data-neo-gs="container.alwaysShowDropdown"]') 55 | 56 | this.setName(this._originalSettings.name) 57 | 58 | this.addListener(this.$nameInput, 'keyup change', () => this.setName(this.$nameInput.val())) 59 | this.addListener(this.$deleteButton, 'click', () => { 60 | if (window.confirm(window.Craft.t('neo', 'Are you sure you want to delete this group?'))) { 61 | this.destroy() 62 | } 63 | }) 64 | }, 65 | 66 | getFocusInput () { 67 | return this.$nameInput 68 | }, 69 | 70 | getId () { 71 | return this._id 72 | }, 73 | 74 | getName () { return this._name ?? this._originalSettings.name }, 75 | setName (name) { 76 | if (name !== this._name) { 77 | const oldName = this._name 78 | this._name = name 79 | 80 | this.$nameInput.val(this._name) 81 | this._refreshAlwaysShowDropdown() 82 | 83 | this.trigger('change', { 84 | property: 'name', 85 | oldValue: oldName, 86 | newValue: this._name 87 | }) 88 | } 89 | }, 90 | 91 | getAlwaysShowDropdown () { return this._alwaysShowDropdown ?? this._originalSettings.alwaysShowDropdown }, 92 | 93 | _refreshAlwaysShowDropdown (animate) { 94 | this._refreshSetting(this.$alwaysShowDropdownContainer, !!this._name, animate) 95 | } 96 | }, 97 | { 98 | _totalNewGroups: 0, 99 | 100 | getNewId () { 101 | return `new${this._totalNewGroups++}` 102 | } 103 | }) 104 | -------------------------------------------------------------------------------- /docs/events.md: -------------------------------------------------------------------------------- 1 | # Events 2 | 3 | ## BlockTypeEvent 4 | 5 | A `BlockTypeEvent` is triggered before and after a block type is saved. 6 | 7 | ### Example 8 | 9 | ```php 10 | use benf\neo\events\BlockTypeEvent; 11 | use benf\neo\services\BlockTypes; 12 | use yii\base\Event; 13 | 14 | Event::on(BlockTypes::class, BlockTypes::EVENT_BEFORE_SAVE_BLOCK_TYPE, function (BlockTypeEvent $event) { 15 | // Your code here... 16 | }); 17 | 18 | Event::on(BlockTypes::class, BlockTypes::EVENT_AFTER_SAVE_BLOCK_TYPE, function (BlockTypeEvent $event) { 19 | // Your code here... 20 | }); 21 | ``` 22 | 23 | ## FilterBlockTypesEvent 24 | 25 | A `FilterBlockTypesEvent` is triggered for a Neo field when loading a Craft element editor page that includes that field. It allows for filtering which block types or block type groups belonging to that field are allowed to be used, depending on the element being edited. 26 | 27 | Note that, if Neo blocks already exist in a context where their block type is filtered out, the blocks won't be rendered on the element editor page, and changes to the block structure will result in the filtered-out block(s) being deleted. 28 | 29 | ### Example 30 | 31 | This example removes the ability to use a block type with the handle `quote`, and a block type group with the name `Structure`, from a `contentBlocks` Neo field when loading an entry from the `blog` section. 32 | 33 | ```php 34 | use benf\neo\events\FilterBlockTypesEvent; 35 | use benf\neo\web\assets\input\InputAsset; 36 | use craft\elements\Entry; 37 | use yii\base\Event; 38 | 39 | Event::on(InputAsset::class, InputAsset::EVENT_FILTER_BLOCK_TYPES, function (FilterBlockTypesEvent $event) { 40 | $element = $event->element; 41 | $field = $event->field; 42 | 43 | if ($element instanceof Entry && $element->section->handle === 'blog' && $field->handle === 'contentBlocks') { 44 | $filteredBlockTypes = []; 45 | foreach ($event->blockTypes as $type) { 46 | if ($type->handle !== 'quote') { 47 | $filteredBlockTypes[] = $type; 48 | } 49 | } 50 | 51 | $filteredGroups = []; 52 | foreach ($event->blockTypeGroups as $group) { 53 | if ($group->name !== 'Structure') { 54 | $filteredGroups[] = $group; 55 | } 56 | } 57 | 58 | $event->blockTypes = $filteredBlockTypes; 59 | $event->blockTypeGroups = $filteredGroups; 60 | } 61 | }); 62 | ``` 63 | 64 | ## SetConditionElementTypesEvent 65 | 66 | A `SetConditionElementTypesEvent` is triggered when loading a Neo field's settings page. It allows other plugins to register element types that will have condition fields added to each block type's settings, to then allow users to control the conditions elements of that type must meet for that block to be allowed to be used. 67 | 68 | ### Example 69 | 70 | ```php 71 | use benf\neo\events\SetConditionElementTypesEvent; 72 | use benf\neo\services\BlockTypes; 73 | use yii\base\Event; 74 | 75 | Event::on( 76 | BlockTypes::class, 77 | BlockTypes::EVENT_SET_CONDITION_ELEMENT_TYPES, 78 | function (SetConditionElementTypesEvent $event) { 79 | $event->elementTypes[] = \some\added\ElementType::class; 80 | } 81 | ); 82 | ``` 83 | -------------------------------------------------------------------------------- /src/web/assets/configurator/src/scripts/BlockTypeIconSelect.js: -------------------------------------------------------------------------------- 1 | // import Craft from 'craft' 2 | 3 | /** 4 | * Class for managing the selected icon for a block type. 5 | * @since 4.0.0 6 | */ 7 | export default class BlockTypeIconSelect { 8 | /** 9 | * Container for the display of the set icon. 10 | * @public 11 | */ 12 | imageContainer = null 13 | 14 | /** 15 | * Image for the display of the set icon. 16 | * @public 17 | */ 18 | image = null 19 | 20 | /** 21 | * Text (filename) for the display of the set icon. 22 | * @public 23 | */ 24 | imageText = null 25 | 26 | /** 27 | * Icons that can be selected from the menu. 28 | * @public 29 | */ 30 | menuItems = [] 31 | 32 | /** 33 | * The button for setting the icon. 34 | * @public 35 | */ 36 | btnSet = null 37 | 38 | /** 39 | * The button for unsetting the icon. 40 | * @public 41 | */ 42 | btnRemove = null 43 | 44 | /** 45 | * The hidden input for the element editor form. 46 | * @public 47 | */ 48 | input = null 49 | 50 | /** 51 | * The constructor. 52 | * @param container - The icon field container. 53 | * @public 54 | */ 55 | constructor (container) { 56 | this.imageContainer = container.querySelector('[data-icon-select-show]') 57 | this.image = this.imageContainer?.querySelector('img') ?? null 58 | this.imageText = this.imageContainer?.querySelector('p') ?? null 59 | this.menuItems = container.querySelectorAll('[data-icon-select-item]') 60 | this.btnSet = container.querySelector('[data-icon-select-set]') 61 | this.btnRemove = container.querySelector('[data-icon-select-remove]') 62 | this.input = container.querySelector('input[name$="[iconFilename]"]') 63 | 64 | this.btnRemove?.addEventListener('click', (_) => this.remove()) 65 | this.menuItems.forEach((item) => { 66 | const filename = item.querySelector('span')?.textContent 67 | const url = item.querySelector('img')?.getAttribute('src') 68 | item.addEventListener('click', (_) => this.set({ filename, url })) 69 | }) 70 | } 71 | 72 | /** 73 | * Sets the selected icon. 74 | * @param item - An object representing the selected icon 75 | * @public 76 | */ 77 | set (item) { 78 | this.image?.setAttribute('src', item.url) 79 | this.input?.setAttribute('value', item.filename) 80 | this.btnRemove?.classList.remove('hidden') 81 | 82 | if (this.imageText !== null) { 83 | this.imageText.textContent = item.filename 84 | } 85 | 86 | if (this.btnSet !== null) { 87 | this.btnSet.textContent = window.Craft.t('neo', 'Replace') 88 | } 89 | } 90 | 91 | /** 92 | * Unsets the icon. 93 | * @public 94 | */ 95 | remove () { 96 | this.image?.setAttribute('src', '') 97 | this.input?.setAttribute('value', '') 98 | this.btnRemove?.classList.add('hidden') 99 | 100 | if (this.imageText !== null) { 101 | this.imageText.textContent = window.Craft.t('neo', 'None set') 102 | } 103 | 104 | if (this.btnSet !== null) { 105 | this.btnSet.textContent = window.Craft.t('neo', 'Add') 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/models/Settings.php: -------------------------------------------------------------------------------- 1 | 14 | * @since 2.3.0 15 | */ 16 | class Settings extends Model 17 | { 18 | /** 19 | * @var bool 20 | */ 21 | public bool $collapseAllBlocks = false; 22 | 23 | /** 24 | * @var bool 25 | * @since 2.10.0 26 | */ 27 | public bool $optimiseSearchIndexing = true; 28 | 29 | /** 30 | * @var bool 31 | * @since 3.0.0 32 | */ 33 | public bool $defaultAlwaysShowGroupDropdowns = true; 34 | 35 | /** 36 | * @var string 37 | * @since 3.6.0 38 | */ 39 | public string $newBlockMenuStyle = NewBlockMenuStyle::Classic; 40 | 41 | /** 42 | * @var string Whether to select block type icons from a path or from asset sources. 43 | * @since 4.0.0 44 | */ 45 | public string $blockTypeIconSelectMode = BlockTypeIconSelectMode::Sources; 46 | 47 | /** 48 | * @var string|array|null The asset sources block type icons can be selected from. 49 | * @since 3.6.0 50 | */ 51 | public string|array|null $blockTypeIconSources = '*'; 52 | 53 | /** 54 | * @var string|null The path block type icons can be selected from. 55 | * @since 4.0.0 56 | */ 57 | public ?string $blockTypeIconPath = '@webroot'; 58 | 59 | /** 60 | * @var bool Whether to enable the block type user permissions feature. 61 | * @since 3.7.4 62 | */ 63 | public bool $enableBlockTypeUserPermissions = true; 64 | 65 | /** 66 | * @var bool Whether `ResaveFieldBlockStructures` jobs will spawn individual `SaveBlockStructures` jobs. 67 | * @since 4.2.24 68 | */ 69 | public bool $resaveFieldBlockStructuresInIndividualJobs = false; 70 | 71 | /** 72 | * @var bool Whether to always show new block buttons above Neo fields. 73 | * @since 5.5.0 74 | */ 75 | public bool $alwaysShowButtonsAboveField = false; 76 | 77 | /** 78 | * @var string The prefix to use for block anchor IDs. 79 | * @since 5.5.0 80 | */ 81 | public string $blockAnchorIdPrefix = 'blockAnchorId'; 82 | 83 | /** 84 | * @inheritdoc 85 | */ 86 | protected function defineRules(): array 87 | { 88 | $rules = parent::defineRules(); 89 | $rules[] = [ 90 | [ 91 | 'collapseAllBlocks', 92 | 'optimiseSearchIndexing', 93 | 'defaultAlwaysShowGroupDropdowns', 94 | 'enableBlockTypeUserPermissions', 95 | ], 96 | 'boolean', 97 | ]; 98 | $rules[] = [ 99 | [ 100 | 'newBlockMenuStyle', 101 | ], 102 | 'in', 103 | 'range' => [ 104 | NewBlockMenuStyle::Classic, 105 | NewBlockMenuStyle::Grid, 106 | NewBlockMenuStyle::List, 107 | ], 108 | ]; 109 | 110 | return $rules; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/templates/_tabs.twig: -------------------------------------------------------------------------------- 1 | {% set blockId = blockId ?? block.id %} 2 | {% set typeName = block.type.name|t('site') %} 3 | {% set tabsBtnLabel = "#{typeName} #{'Tabs'|t('neo')}" %} 4 | {% set tabsMenuId = "neoblock-tabs-menu-#{blockId}" %} 5 | {% set tabsBtnAttributes = { 6 | type: 'button', 7 | role: 'button', 8 | title: 'Tabs'|t('neo'), 9 | aria: { 10 | controls: tabsMenuId, 11 | label: tabsBtnLabel, 12 | }, 13 | data: { 14 | 'disclosure-trigger': true, 15 | 'neo-b': "#{blockId}.button.tabs", 16 | }, 17 | } %} 18 | 19 | {% if tabs|length > 1 %} 20 | {# Convert the expected form tabs array to layout tabs #} 21 | {% set tabs = tabs|map(tab => tab.layoutTab) %} 22 | {% set selectedTabUid = selectedTab ?? (tabs|first).uid %} 23 | {% set hasErrors = false %} 24 |
25 | {%- for tab in tabs -%} 26 | {%- set tabName = tab.name|t('site') -%} 27 | {%- set tabHasErrors = tab.elementHasErrors(block) -%} 28 | 32 | {{ tabName }}{# 33 | #}{% if tabHasErrors %} {% endif %} 34 | 35 | {%- set hasErrors = (hasErrors or tabHasErrors) -%} 36 | {%- endfor -%} 37 |
38 |
39 | 49 | 70 |
71 | {% endif %} 72 | -------------------------------------------------------------------------------- /docs/templating.md: -------------------------------------------------------------------------------- 1 | # Templating 2 | 3 | ## Examples 4 | 5 | ### Basic 6 | 7 | ```twig 8 |
    9 | {% for block in entry.neoField.level(1).all() %} 10 | {% switch block.type.handle %} 11 | {% case 'someBlockType' %} 12 |
  1. 13 | {{ block.someField }} 14 | {% if block.children.all() is not empty %} 15 | ... 16 | {% endif %} 17 |
  2. 18 | {% case ... 19 | {% endswitch %} 20 | {% endfor %} 21 |
22 | ``` 23 | 24 | This is typically the most you'd need to know. Similar to how Matrix fields work, but with a notable difference. For Neo fields that have child blocks, you will first need to filter for blocks on the first level. It's essentially the same API as the [`craft.entries()`](https://craftcms.com/docs/5.x/reference/element-types/entries.html#querying-entries) element query. 25 | 26 | ### Recursive 27 | 28 | ```twig 29 |
    30 | {% nav block in entry.neoField.all() %} 31 |
  1. 32 | {{ block.someField }} 33 | {% ifchildren %} 34 |
      35 | {% children %} 36 |
    37 | {% endifchildren %} 38 |
  2. 39 | {% endnav %} 40 |
41 | ``` 42 | 43 | Because Neo blocks have a `level` attribute, Neo fields are compatible with the [`{% nav %}`](https://craftcms.com/docs/5.x/reference/twig/tags.html#nav) tag. 44 | 45 | ## Functions 46 | 47 | ### `craft.neo.blocks()` 48 | 49 | If you need to get Neo blocks in your template in a way that isn't connected to a Neo field value on a specific Craft element, you can use `craft.neo.blocks()`. This returns a [Neo block query](api.md#element-query) which can then be used in the same way as a typical [Craft element query](https://craftcms.com/docs/5.x/development/element-queries.html). 50 | 51 | ### `craft.neo.blockAnchorId()` 52 | 53 | This function takes a Neo block as its only argument, and outputs a string, based on the block's ID and the [`blockAnchorIdPrefix`](settings.md#blockanchoridprefix) plugin setting, that is intended to be used as an HTML element ID. For example, using the default `blockAnchorIdPrefix` setting of `'blockAnchorId'` and a block with id 12345, `craft.neo.blockAnchorId(block)` will return `'blockAnchorId-12345'`. This should then be set as the ID of the HTML element representing the block in your templates; e.g. `
`. 54 | 55 | This functionality was inspired by the [Matrix Block Anchor](https://plugins.craftcms.com/matrix-block-anchor) plugin. 56 | 57 | ### `craft.neo.previewHighlightAttribute()` 58 | 59 | This function takes a Neo block as its only argument, and outputs HTML attribute code when the template is rendered in Craft's preview mode. For example, if your block's ID is 12345, `craft.neo.previewHighlightAttribute(block)` will return `'data-neo-preview-highlight="12345"'`. This allows highlighting of blocks in the content editor sidebar when their previewed content is hovered over, and scrolling the content editor sidebar to blocks when their previewed content is clicked, just like the [Preview Mate](https://plugins.craftcms.com/preview-mate) plugin for Matrix fields that inspired this functionality. 60 | 61 | ## More information 62 | 63 | For a more in-depth breakdown of templating for Neo, [see this issue](https://github.com/spicywebau/craft-neo/issues/34). 64 | -------------------------------------------------------------------------------- /src/web/assets/configurator/src/scripts/BlockTypeFieldLayout.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | import Garnish from 'garnish' 3 | // import Craft from 'craft' 4 | import NS from './namespace' 5 | 6 | const _defaults = { 7 | namespace: [], 8 | html: '', 9 | layout: null, 10 | id: -1, 11 | uid: null, 12 | blockId: null, 13 | initUi: true 14 | } 15 | 16 | export default Garnish.Base.extend({ 17 | 18 | _templateNs: [], 19 | _initialised: false, 20 | 21 | init (settings = {}) { 22 | settings = Object.assign({}, _defaults, settings) 23 | 24 | this._templateNs = NS.parse(settings.namespace) 25 | this._id = settings.id | 0 26 | this._uid = settings.uid 27 | this._blockTypeId = settings.blockTypeId 28 | 29 | this.$container = $(settings.html).find('.layoutdesigner') 30 | this.$container.removeAttr('id') 31 | 32 | const nameInput = this.$container.find('input[name="fieldLayout"]') 33 | 34 | if (nameInput.length > 0) { 35 | nameInput[0].name = `neoBlockType${this._blockTypeId}[fieldLayout]` 36 | 37 | if (settings.layout) { 38 | nameInput[0].value = JSON.stringify(settings.layout) 39 | } 40 | } 41 | 42 | if (settings.initUi) { 43 | this.initUi() 44 | } 45 | }, 46 | 47 | initUi () { 48 | if (this._initialised) { 49 | return 50 | } 51 | 52 | NS.enter(this._templateNs) 53 | 54 | this._fld = new window.Craft.FieldLayoutDesigner(this.$container, { 55 | elementType: 'benf\\neo\\elements\\Block', 56 | customizableTabs: true, 57 | customizableUi: true 58 | }) 59 | 60 | NS.leave() 61 | this._updateChildBlocksUiElement() 62 | this._tabObserver = new window.MutationObserver(() => this._updateChildBlocksUiElement()) 63 | this._tabObserver.observe(this._fld.$tabContainer[0], { childList: true, subtree: true }) 64 | this._initialised = true 65 | }, 66 | 67 | getId () { 68 | return this._id 69 | }, 70 | 71 | /** 72 | * @since 4.0.5 73 | */ 74 | getUid () { 75 | return this._uid 76 | }, 77 | 78 | getBlockTypeId () { 79 | return this._blockTypeId 80 | }, 81 | 82 | getConfig () { 83 | const newConfig = { 84 | tabs: [], 85 | uid: this._uid 86 | } 87 | 88 | for (const tab of this._fld.config.tabs) { 89 | const newElements = [] 90 | 91 | for (const element of tab.elements) { 92 | const newElement = {} 93 | 94 | for (const key in element) { 95 | newElement[key] = key === 'required' && !element[key] ? '' : element[key] 96 | } 97 | 98 | newElements.push(newElement) 99 | } 100 | 101 | newConfig.tabs.push({ 102 | elements: newElements, 103 | name: tab.name.slice() 104 | }) 105 | } 106 | 107 | return newConfig 108 | }, 109 | 110 | _updateChildBlocksUiElement () { 111 | const selector = '[data-type=benf-neo-fieldlayoutelements-ChildBlocksUiElement]' 112 | const $uiLibraryElement = this._fld.$uiLibraryElements.filter(selector) 113 | const $tabUiElement = this._fld.$tabContainer.find(selector) 114 | $uiLibraryElement.toggleClass( 115 | 'hidden', 116 | $tabUiElement.length > 0 || $('body.dragging .draghelper' + selector).length > 0 117 | ) 118 | if ($tabUiElement.hasClass('velocity-animating')) { 119 | $tabUiElement.removeClass('hidden') 120 | } 121 | } 122 | }) 123 | -------------------------------------------------------------------------------- /src/web/assets/converter/src/scripts/main.js: -------------------------------------------------------------------------------- 1 | // import Craft from 'craft' 2 | import Garnish from 'garnish' 3 | 4 | const fieldType = document.getElementById('type') 5 | const fieldId = document.querySelector('input[name="fieldId"]') 6 | 7 | if (fieldType.dataset.value === 'benf\\neo\\Field' && fieldId !== null) { 8 | const $form = window.Craft.cp.$primaryForm 9 | const $formButton = $form.find('input[type="submit"]') 10 | let convertButton = document.getElementById('Matrix-convert_button') 11 | let spinner = document.getElementById('Matrix-convert_spinner') 12 | let enabled = true 13 | 14 | const toggleState = (state) => { 15 | enabled = !!state 16 | 17 | convertButton.classList.toggle('disabled', !enabled) 18 | $formButton.toggleClass('disabled', !enabled) 19 | 20 | if (enabled) { 21 | $form.off('submit.neo') 22 | } else { 23 | $form.on('submit.neo', (e) => e.preventDefault()) 24 | } 25 | } 26 | 27 | const perform = () => { 28 | toggleState(false) 29 | spinner.classList.remove('hidden') 30 | 31 | window.Craft.postActionRequest('neo/conversion/convert-to-matrix', { fieldId: fieldId.value }, (response, textStatus) => { 32 | if (response.success) { 33 | // Prevent the "Do you want to reload this site?" prompt from showing before page reload 34 | window.Craft.cp.removeListener(Garnish.$win, 'beforeunload') 35 | window.location.reload() 36 | } else { 37 | toggleState(true) 38 | window.Craft.cp.displayError(window.Craft.t('neo', 'Could not convert Neo field to Matrix')) 39 | response.errors?.forEach((error) => window.Craft.cp.displayError(error)) 40 | } 41 | }) 42 | } 43 | 44 | const applyHtml = () => { 45 | const matrixSettings = document.getElementById('craft-fields-Matrix') 46 | 47 | if (matrixSettings === null || matrixSettings.querySelector('#conversion-prompt') !== null) { 48 | return 49 | } 50 | 51 | matrixSettings.insertAdjacentHTML('afterbegin', ` 52 |
53 |
54 |
55 | 56 |

${window.Craft.t('neo', 'This field is currently of the Neo type. You may automatically convert it to Matrix along with all of its content.')}

57 |
58 |
59 | 60 | 61 |
62 |

${window.Craft.t('neo', 'By converting to Matrix, structural information will be lost.')}

63 |
64 |
65 |
66 | `) 67 | 68 | convertButton = document.getElementById('Matrix-convert_button') 69 | spinner = document.getElementById('Matrix-convert_spinner') 70 | 71 | convertButton.addEventListener('click', (event) => { 72 | event.preventDefault() 73 | 74 | if (enabled && window.confirm(window.Craft.t('neo', 'Are you sure? This is a one way operation. You cannot undo conversion from Neo to Matrix.'))) { 75 | perform() 76 | } 77 | }) 78 | } 79 | 80 | const settingsObserver = new window.MutationObserver(applyHtml) 81 | settingsObserver.observe( 82 | document.getElementById('settings'), 83 | { 84 | childList: true, 85 | subtree: true 86 | } 87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /src/web/assets/configurator/src/scripts/Group.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | // import Craft from 'craft' 3 | import Item from './Item' 4 | import NS from './namespace' 5 | 6 | const _defaults = { 7 | namespace: [] 8 | } 9 | 10 | export default Item.extend({ 11 | 12 | _templateNs: [], 13 | 14 | init (settings = {}) { 15 | this.base(settings) 16 | 17 | settings = Object.assign({}, _defaults, settings) 18 | 19 | const settingsObj = this.getSettings() 20 | this._templateNs = NS.parse(settings.namespace) 21 | const sidebarItem = this.getField()?.$sidebarContainer.find(`[data-neo-g="container.${this.getId()}`) 22 | 23 | if (sidebarItem?.length > 0) { 24 | this.$container = sidebarItem 25 | } else { 26 | this.$container = this._generateGroup(settingsObj) 27 | } 28 | 29 | const $neo = this.$container.find('[data-neo-g]') 30 | this.$nameText = $neo.filter('[data-neo-g="text.name"]') 31 | this.$moveButton = $neo.filter('[data-neo-g="button.move"]') 32 | 33 | if (settingsObj) { 34 | settingsObj.on('change', () => this._updateTemplate()) 35 | settingsObj.on('destroy', () => this.trigger('destroy')) 36 | } 37 | 38 | this.deselect() 39 | }, 40 | 41 | _generateGroup (settings) { 42 | const sortOrderNamespace = [...this._templateNs] 43 | sortOrderNamespace.pop() 44 | NS.enter(sortOrderNamespace) 45 | const sortOrderName = NS.fieldName('sortOrder') 46 | NS.leave() 47 | 48 | return $(` 49 |
50 |
${settings.getName() ?? ''}
51 | 52 | 53 |
`) 54 | }, 55 | 56 | /** 57 | * @inheritDoc 58 | */ 59 | load () { 60 | if (this._loaded) { 61 | // Already loaded 62 | return Promise.resolve() 63 | } 64 | 65 | this.trigger('beforeLoad') 66 | const data = { 67 | groupId: this.getId() 68 | } 69 | 70 | return new Promise((resolve, reject) => { 71 | window.Craft.sendActionRequest('POST', 'neo/configurator/render-block-type-group', { data }) 72 | .then(response => { 73 | this.getSettings().createContainer({ 74 | html: response.data.settingsHtml.replace(/__NEOBLOCKTYPEGROUP_ID__/g, data.groupId), 75 | js: response.data.settingsJs.replace(/__NEOBLOCKTYPEGROUP_ID__/g, data.groupId) 76 | }) 77 | this._loaded = true 78 | 79 | this.trigger('afterLoad') 80 | resolve() 81 | }) 82 | .catch(reject) 83 | }) 84 | }, 85 | 86 | getId () { 87 | return this.getSettings().getId() 88 | }, 89 | 90 | toggleSelect: function (select) { 91 | this.base(select) 92 | 93 | const settings = this.getSettings() 94 | const selected = this.isSelected() 95 | 96 | if (settings?.$container ?? false) { 97 | settings.$container.toggleClass('hidden', !selected) 98 | } 99 | 100 | if (selected) { 101 | this.load() 102 | } 103 | 104 | this.$container.toggleClass('is-selected', selected) 105 | }, 106 | 107 | _updateTemplate () { 108 | const settings = this.getSettings() 109 | 110 | if (settings) { 111 | this.$nameText.text(settings.getName()) 112 | } 113 | } 114 | }) 115 | -------------------------------------------------------------------------------- /src/jobs/SaveBlockStructures.php: -------------------------------------------------------------------------------- 1 | 15 | * @since 3.0.0 16 | */ 17 | class SaveBlockStructures extends BaseJob 18 | { 19 | /** 20 | * @var int 21 | */ 22 | public int $fieldId; 23 | 24 | /** 25 | * @var int 26 | */ 27 | public int $ownerId; 28 | 29 | /** 30 | * @var int 31 | */ 32 | public int $siteId; 33 | 34 | /** 35 | * @var int[] 36 | */ 37 | public array $otherSupportedSiteIds; 38 | 39 | /** 40 | * @var array of blocks' `id`, `lft`, `rgt`, `level` 41 | */ 42 | public array $blocks; 43 | 44 | /** 45 | * @inheritdoc 46 | */ 47 | public function execute($queue): void 48 | { 49 | $blocks = []; 50 | 51 | // Delete any existing block structures associated with this field/owner/site combination 52 | $blockStructures = Neo::$plugin->blocks->getStructures([ 53 | 'fieldId' => $this->fieldId, 54 | 'ownerId' => $this->ownerId, 55 | 'siteId' => $this->siteId, 56 | ]); 57 | foreach ($blockStructures as $blockStructure) { 58 | Neo::$plugin->blocks->deleteStructure($blockStructure); 59 | } 60 | 61 | $this->setProgress($queue, 0.3); 62 | 63 | foreach ($this->blocks as $b) { 64 | $neoBlock = Neo::$plugin->blocks->getBlockById($b['id'], $this->siteId, ['trashed' => null]); 65 | 66 | if ($neoBlock) { 67 | $neoBlock->lft = (int)$b['lft']; 68 | $neoBlock->rgt = (int)$b['rgt']; 69 | $neoBlock->level = (int)$b['level']; 70 | 71 | $blocks[] = $neoBlock; 72 | } 73 | } 74 | 75 | $this->setProgress($queue, 0.6); 76 | 77 | if (!empty($blocks)) { 78 | $blockStructure = new BlockStructure(); 79 | $blockStructure->fieldId = $this->fieldId; 80 | $blockStructure->ownerId = $this->ownerId; 81 | $blockStructure->siteId = $this->siteId; 82 | 83 | Neo::$plugin->blocks->saveStructure($blockStructure); 84 | Neo::$plugin->blocks->buildStructure($blocks, $blockStructure); 85 | 86 | // Now do the other supported sites 87 | foreach ($this->otherSupportedSiteIds as $siteId) { 88 | $otherBlockStructures = Neo::$plugin->blocks->getStructures([ 89 | 'fieldId' => $this->fieldId, 90 | 'ownerId' => $this->ownerId, 91 | 'siteId' => $siteId, 92 | ]); 93 | foreach ($otherBlockStructures as $otherBlockStructure) { 94 | Neo::$plugin->blocks->deleteStructure($otherBlockStructure); 95 | } 96 | 97 | $multiBlockStructure = $blockStructure; 98 | $multiBlockStructure->id = null; 99 | $multiBlockStructure->siteId = $siteId; 100 | 101 | Neo::$plugin->blocks->saveStructure($multiBlockStructure); 102 | } 103 | } 104 | 105 | $this->setProgress($queue, 1); 106 | } 107 | 108 | /** 109 | * @inheritdoc 110 | */ 111 | protected function defaultDescription(): ?string 112 | { 113 | return Translation::prep('neo', 'Saving Neo block structures for duplicated elements'); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /docs/settings.md: -------------------------------------------------------------------------------- 1 | # Settings 2 | 3 | ## `alwaysShowButtonsAboveField` 4 | 5 | Type: `bool` 6 | Default: `false` 7 | 8 | Whether to always show new block buttons above Neo fields, as well as below. 9 | 10 | ## `blockAnchorIdPrefix` 11 | 12 | Type: `string` 13 | Default: `'blockAnchorId'` 14 | 15 | The prefix to use for [block anchor IDs](templating#craftneoblockanchorid). 16 | 17 | ## `blockTypeIconPath` 18 | 19 | Type: `string` 20 | Default: `'@webroot'` 21 | 22 | If [`newBlockMenuStyle`][1] is set to something other than `'classic'` and [`blockTypeIconSelectMode`][2] is set to `'path'`, this setting sets the folder from which non-asset SVG files can be set as block type icons. 23 | 24 | ## `blockTypeIconSelectMode` 25 | 26 | Type: `string` 27 | Default: `'sources'` 28 | 29 | This setting controls whether to select block type icons from asset sources or from non-asset SVG files in a specific folder. The following options are available: 30 | 31 | - `'path'`: select block type icons from non-asset SVG files, in a specific folder set using [`blockTypeIconPath`][3] 32 | - `'sources'`: select block type icons from asset sources 33 | 34 | ## `blockTypeIconSources` 35 | 36 | Type: `string|string[]` 37 | Default: `'*'` (allowing all sources) 38 | 39 | If [`newBlockMenuStyle`][1] is set to something other than `'classic'` and [`blockTypeIconSelectMode`][2] is set to `'sources'`, this setting controls which icon asset sources are allowed to be used for setting block type icons. 40 | 41 | ## `collapseAllBlocks` 42 | 43 | Type: `bool` 44 | Default: `false` 45 | 46 | This setting controls whether all Neo input blocks should display as collapsed when loading an element editor. When this is enabled, expanding or collapsing previously-existing blocks will not cause their new collapsed state to be saved, however the collapsed state of new blocks will be saved, in case the setting is disabled later. 47 | 48 | ## `defaultAlwaysShowGroupDropdowns` 49 | 50 | Type: `bool` 51 | Default: `true` 52 | 53 | This setting controls the global setting for whether Neo block type groups should always have their dropdowns shown, even when only one block type from a group is available to use. When set to `false`, in such a case, the block type button will be shown instead. This behaviour can also be set on a per-group basis, through the Neo field settings edit page. 54 | 55 | ## `enableBlockTypeUserPermissions` 56 | 57 | Type: `bool` 58 | Default: `true` 59 | 60 | This setting controls whether to allow setting user permissions for creating, editing and deleting blocks of a certain type. Note that, if disabled, resaving a user's or user group's permissions will cause any existing block type permissions to be lost. 61 | 62 | ## `newBlockMenuStyle` 63 | 64 | Type: `string` 65 | Default: `'classic'` 66 | 67 | This setting controls the type of new block buttons/dropdowns that will be used on Neo input fields. The following options are available: 68 | 69 | - `'classic'`: buttons in the style of a Matrix field's buttons (prior to Neo 3.6.0 the only style available) 70 | - `'grid'`: a new block grid using block type icons, inspired by [Vizy](https://github.com/verbb/vizy) 71 | - `'list'`: show new block buttons in a permanent dropdown style, that also shows block type icons 72 | 73 | ## `optimiseSearchIndexing` 74 | 75 | Type: `bool` 76 | Default: `true` 77 | 78 | This setting controls whether to skip updating search indexes for Neo blocks that have no sub-fields set to use their values as search keywords, or that belong to Neo fields that aren't set to use the field's values as search keywords. 79 | 80 | ## `resaveFieldBlockStructuresInIndividualJobs` 81 | 82 | Type: `bool` 83 | Default: `false` 84 | 85 | This setting controls whether block structure resave jobs spawned while changing a Neo field's propagation method will spawn individual jobs for each block structure. Note that, if enabled, the number of jobs spawned when changing a Neo field's propagation method might be very large. 86 | 87 | [1]: #newblockmenustyle 88 | [2]: #blocktypeiconselectmode 89 | [3]: #blocktypeiconpath 90 | -------------------------------------------------------------------------------- /src/web/assets/input/src/scripts/ButtonsList.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | import { v4 as uuidv4 } from 'uuid' 3 | import { NewBlockMenu, GarnishNewBlockMenu } from './NewBlockMenu' 4 | 5 | class ButtonsList extends NewBlockMenu { 6 | /** 7 | * @inheritdoc 8 | */ 9 | renderButtons () { 10 | const field = this.getField() 11 | const ownerBlockType = this.$ownerContainer?.hasClass('ni_block') 12 | ? this.$ownerContainer.attr('class').match(/ni_block--([^\s]+)/)[1] 13 | : null 14 | const ungroupChildBlockTypes = ownerBlockType !== null && 15 | !field.getBlockTypeByHandle(ownerBlockType).getGroupChildBlockTypes() 16 | const buttonsHtml = [] 17 | const menuId = uuidv4() 18 | let currentGroup = null 19 | 20 | buttonsHtml.push(` 21 |
22 | `) 25 | 26 | let lastGroupHadBlockTypes = false 27 | buttonsHtml.push(` 28 | 87 |
`) 88 | 89 | return $(buttonsHtml.join('')) 90 | } 91 | 92 | initUi () { 93 | this.updateResponsiveness() 94 | 95 | // If no buttons were rendered (e.g. if all valid block types are disabled for the user), hide the button container 96 | if (this.$buttonsContainer.find('[data-neo-bn="button.addBlock"]').length === 0) { 97 | const parent = this.$container.parent() 98 | const grandParent = parent.parent() 99 | const childrenContainer = grandParent.children('.ni_blocks') 100 | 101 | if (childrenContainer.length === 0 || childrenContainer.children().length === 0) { 102 | grandParent.addClass('hidden') 103 | } else { 104 | parent.addClass('hidden') 105 | } 106 | } 107 | } 108 | } 109 | 110 | export default GarnishNewBlockMenu.extend({ 111 | init (settings = {}) { 112 | this.base(new ButtonsList(settings)) 113 | } 114 | }) 115 | -------------------------------------------------------------------------------- /src/web/assets/input/src/scripts/ButtonsGrid.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | import { v4 as uuidv4 } from 'uuid' 3 | import { NewBlockMenu, GarnishNewBlockMenu } from './NewBlockMenu' 4 | 5 | class ButtonsGrid extends NewBlockMenu { 6 | /** 7 | * @inheritdoc 8 | */ 9 | renderButtons () { 10 | const field = this.getField() 11 | const ownerBlockType = this.$ownerContainer?.hasClass('ni_block') 12 | ? this.$ownerContainer.attr('class').match(/ni_block--([^\s]+)/)[1] 13 | : null 14 | const ungroupChildBlockTypes = ownerBlockType !== null && 15 | !field.getBlockTypeByHandle(ownerBlockType).getGroupChildBlockTypes() 16 | const buttonsHtml = [] 17 | const menuId = uuidv4() 18 | let currentGroup = null 19 | 20 | buttonsHtml.push(` 21 |
22 | `) 25 | 26 | currentGroup = null 27 | let lastGroupHadBlockTypes = false 28 | buttonsHtml.push(` 29 | 88 |
`) 89 | 90 | return $(buttonsHtml.join('')) 91 | } 92 | 93 | initUi () { 94 | this.updateResponsiveness() 95 | 96 | // If no buttons were rendered (e.g. if all valid block types are disabled for the user), hide the button container 97 | if (this.$buttonsContainer.find('[data-neo-bn="button.addBlock"]').length === 0) { 98 | const parent = this.$container.parent() 99 | const grandParent = parent.parent() 100 | const childrenContainer = grandParent.children('.ni_blocks') 101 | 102 | if (childrenContainer.length === 0 || childrenContainer.children().length === 0) { 103 | grandParent.addClass('hidden') 104 | } else { 105 | parent.addClass('hidden') 106 | } 107 | } 108 | } 109 | } 110 | 111 | export default GarnishNewBlockMenu.extend({ 112 | init (settings = {}) { 113 | this.base(new ButtonsGrid(settings)) 114 | } 115 | }) 116 | -------------------------------------------------------------------------------- /src/translations/de/neo.php: -------------------------------------------------------------------------------- 1 | 'Auswählen', 5 | 'Disabled' => 'Deaktiviert', 6 | 'Actions' => 'Aktionen', 7 | 'Collapse' => 'Einklappen', 8 | 'Expand' => 'Ausklappen', 9 | 'Disable' => 'Deaktivieren', 10 | 'Enable' => 'Aktivieren', 11 | 'Add block above' => 'Block oberhalb hinzufügen', 12 | 'Copy' => 'Kopieren', 13 | 'Paste' => 'Einfügen', 14 | 'Clone' => 'Duplizieren', 15 | 'Delete' => 'Löschen', 16 | 'Reorder' => 'Neu ordnen', 17 | 'Add a block' => 'Fügen Sie einen Block hinzu', 18 | 'Name' => 'Name', 19 | 'What this block type will be called in the CP.' => 'Wie dieser Blocktyp in der CP genannt wird.', 20 | 'Handle' => 'Kurzname', 21 | 'How you’ll refer to this block type in the templates.' => 'Unter welchem Kurzname dieser Blocktyp im Template genutzt werden kann.', 22 | 'Max Blocks' => 'Maximale Anzahl von Blöcken', 23 | 'The maximum number of blocks of this type the field is allowed to have.' => 'Die maximale Anzahl von Blöcken dieses Typs, die im Feld sein dürfen.', 24 | 'All' => 'Alle', 25 | 'Child Blocks' => 'Untergeordnete Blöcke', 26 | 'Which block types do you want to allow as children?' => 'Welche Blocktypen möchten Sie als untergeordnete Blöcke zulassen?', 27 | 'Max Child Blocks' => 'Maximale Anzahl von untergeordneten Blöcken', 28 | 'The maximum number of child blocks this block type is allowed to have.' => 'Die maximale Anzahl von untergeordneten Blöcken, die dieser Blocktyp haben darf.', 29 | 'Top Level' => 'Oberste Stufe', 30 | 'Will this block type be allowed at the top level?' => 'Darf dieser Blocktyp auf der obersten Ebene genutzt werden?', 31 | 'Delete block type' => 'Blocktyp löschen', 32 | 'This can be left blank if you just want an unlabeled separator.' => 'Dies kann leer gelassen werden, wenn Sie nur einen unbeschrifteten Trenner brauchen.', 33 | 'Delete group' => 'Gruppe löschen', 34 | 'Block Types' => 'Blocktypen', 35 | 'Block type' => 'Blocktyp', 36 | 'Group' => 'Gruppe', 37 | 'Settings' => 'Einstellungen', 38 | 'Field Layout' => 'Feld Layout', 39 | 'Neo' => 'Neo', 40 | '{attribute} should contain at least {min, number} {min, plural, one{block} other{blocks}}.' => '{attribute} sollte mindestens {min, number} {min, plural, one{Block} other{Blöcke}}.', 41 | '{attribute} should contain at most {max, number} {max, plural, one{block} other{blocks}}.' => '{attribute} sollte maximal {max, number} {max, plural, one{Block} other{Blöcke}}.', 42 | 'Unable to nest Neo fields.' => 'Es ist nicht möglich Neo-Felder zu verschachteln.', 43 | 'Neo Block' => 'Neo-Block', 44 | 'Neo Blocks' => 'Neo-Blöcke', 45 | 'Configuration' => 'Konfiguration', 46 | 'Define the types of blocks that can be created within this Neo field, as well as the fields each block type is made up of.' => 'Definieren Sie die Blöcke, die in diesem Neo-Feld erstellt werden können, sowie die Felder, aus denen jeder Blocktyp besteht.', 47 | 'Propagation Method' => 'Verbreitungsmethode', 48 | 'Which sites should blocks be saved to?' => 'Auf welchen Seiten sollen Blöcke gespeichert werden?', 49 | 'Only save blocks to the site they were created in' => 'Speichere Blöcke nur auf der Seite, auf welcher sie erstellt wurden', 50 | 'Save blocks to other sites in the same site group' => 'Speichern von Blöcken in anderen Seiten innerhalb derselben Seiten-Gruppe', 51 | 'Save blocks to other sites with the same language' => 'Speichern von Blöcken auf anderen Websites mit derselben Sprache', 52 | 'Save blocks to all sites the owner element is saved in' => 'Speichere Blöcke in allen Seiten, in denen das zugehörige Element gespeichert ist', 53 | 'Min Blocks' => 'Minimale Anzahl von Blöcken', 54 | 'The minimum number of blocks the field must have.' => 'Die minimale Anzahl von Blöcken, die im Feld sein dürfen.', 55 | 'The maximum number of blocks the field is allowed to have.' => 'Die maximale Anzahl von Blöcken, die im Feld sein dürfen.', 56 | 'Max Top-Level Blocks' => 'Maximale Anzahl von Blöcken auf der obersten Ebene', 57 | 'The maximum number of top-level blocks the field is allowed to have.' => 'Die maximale Anzahl von Blöcken auf der obersten Ebene, die im Feld sein dürfen.', 58 | '{attribute} should contain at most {maxTopBlocks, number} top-level {maxTopBlocks, plural, one{block} other{blocks}}.' => '{attribute} sollte mindestens {maxTopBlocks, number} {maxTopBlocks, plural, one{Block} other{Blöcke}} auf der obersten Ebene haben.', 59 | '{label} "{value}" has already been taken.' => '{label} "{value}" wird bereits genutzt.', 60 | ]; 61 | -------------------------------------------------------------------------------- /src/gql/types/input/Block.php: -------------------------------------------------------------------------------- 1 | handle . '_NeoInput'; 21 | 22 | if ($inputType = GqlEntityRegistry::getEntity($typeName)) { 23 | return $inputType; 24 | } 25 | 26 | // Array of block types. 27 | $blockTypes = $context->getBlockTypes(); 28 | $blockInputTypes = []; 29 | 30 | // For all the blocktypes 31 | foreach ($blockTypes as $blockType) { 32 | $fields = $blockType->getCustomFields(); 33 | $blockTypeFields = [ 34 | 'id' => [ 35 | 'name' => 'id', 36 | 'type' => Type::id(), 37 | ], 38 | 'level' => [ 39 | 'name' => 'level', 40 | 'type' => Type::int(), 41 | ], 42 | ]; 43 | 44 | // Get the field input types 45 | foreach ($fields as $field) { 46 | $blockTypeFields[$field->handle] = $field->getContentGqlMutationArgumentType(); 47 | } 48 | 49 | $blockTypeGqlName = $context->handle . '_' . $blockType->handle . '_NeoBlockInput'; 50 | $blockInputTypes[$blockType->handle] = [ 51 | 'name' => $blockType->handle, 52 | 'type' => GqlEntityRegistry::createEntity($blockTypeGqlName, new InputObjectType([ 53 | 'name' => $blockTypeGqlName, 54 | 'fields' => $blockTypeFields, 55 | ])), 56 | ]; 57 | } 58 | 59 | // All the different field block types now get wrapped in a container input. 60 | // If two different block types are passed, the selected block type to parse is undefined. 61 | $blockTypeContainerName = $context->handle . '_NeoBlockContainerInput'; 62 | $blockContainerInputType = GqlEntityRegistry::createEntity($blockTypeContainerName, new InputObjectType([ 63 | 'name' => $blockTypeContainerName, 64 | 'fields' => function() use ($blockInputTypes) { 65 | return $blockInputTypes; 66 | }, 67 | ])); 68 | 69 | return GqlEntityRegistry::createEntity($typeName, new InputObjectType([ 70 | 'name' => $typeName, 71 | 'fields' => function() use ($blockContainerInputType) { 72 | return [ 73 | 'sortOrder' => [ 74 | 'name' => 'sortOrder', 75 | 'type' => Type::listOf(QueryArgument::getType()), 76 | ], 77 | 'blocks' => [ 78 | 'name' => 'blocks', 79 | 'type' => Type::listOf($blockContainerInputType), 80 | ], 81 | ]; 82 | }, 83 | 'normalizeValue' => [self::class, 'normalizeValue'], 84 | ])); 85 | } 86 | 87 | public static function normalizeValue(mixed $value): mixed 88 | { 89 | $preparedBlocks = []; 90 | $blockCounter = 1; 91 | 92 | if (!empty($value['blocks'])) { 93 | foreach ($value['blocks'] as $block) { 94 | if (!empty($block)) { 95 | $type = array_key_first($block); 96 | $block = reset($block); 97 | $blockId = !empty($block['id']) ? $block['id'] : 'new:' . ($blockCounter++); 98 | $blockLevel = null; 99 | 100 | if (!empty($block['level'])) { 101 | // Set the block's new level 102 | $blockLevel = $block['level']; 103 | } elseif (empty($block['id'])) { 104 | // Default new blocks to level 1 105 | $blockLevel = 1; 106 | } 107 | 108 | unset($block['id'], $block['level']); 109 | 110 | $preparedBlocks[$blockId] = [ 111 | 'type' => $type, 112 | 'level' => $blockLevel, 113 | 'fields' => $block, 114 | ]; 115 | } 116 | } 117 | 118 | $value['blocks'] = $preparedBlocks; 119 | } 120 | 121 | return $value; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/elements/conditions/BlockCondition.php: -------------------------------------------------------------------------------- 1 | 17 | * @since 3.4.0 18 | */ 19 | class BlockCondition extends ElementCondition 20 | { 21 | /** 22 | * @inheritdoc 23 | */ 24 | public function getBuilderConfig(): array 25 | { 26 | $config = parent::getBuilderConfig(); 27 | 28 | // Ensure UUIDs set on field layouts 29 | if (isset($config['fieldLayouts'])) { 30 | $fieldLayouts = $this->getFieldLayouts(); 31 | 32 | for ($i = 0; $i < count($fieldLayouts); $i++) { 33 | $config['fieldLayouts'][$i]['uid'] = $fieldLayouts[$i]->uid; 34 | } 35 | } 36 | 37 | return $config; 38 | } 39 | 40 | /** 41 | * @inheritdoc 42 | */ 43 | protected function selectableConditionRules(): array 44 | { 45 | $parentConditionRuleTypes = parent::selectableConditionRules(); 46 | $fieldConditionRuleTypes = []; 47 | 48 | // Get all field layouts associated with this object's associated Neo field(s), then temporarily replace this 49 | // object's field layouts so we get all possible parent block condition rules 50 | $layoutIds = array_values(Db::idsByUids( 51 | Table::FIELDLAYOUTS, 52 | array_map(fn($layout) => $layout->uid, $this->getFieldLayouts()), 53 | )); 54 | $layoutBlockTypes = Neo::$plugin->blockTypes->getByCriteria([ 55 | 'fieldLayoutId' => $layoutIds, 56 | ]); 57 | $fieldBlockTypes = Neo::$plugin->blockTypes->getByCriteria([ 58 | 'fieldId' => array_values(array_unique(array_map(fn($blockType) => $blockType->fieldId, $layoutBlockTypes))), 59 | ]); 60 | $fieldLayouts = array_map(fn($blockType) => $blockType->getFieldLayout(), $fieldBlockTypes); 61 | $fieldConditionRuleTypes = array_values(array_filter(array_map( 62 | function($ruleType) { 63 | if (!isset($ruleType['class'])) { 64 | return null; 65 | } 66 | 67 | $splitClass = explode('\\', $ruleType['class']); 68 | $className = __NAMESPACE__ . '\\fields\\Parent' . end($splitClass); 69 | 70 | if (class_exists($className)) { 71 | return [ 72 | 'class' => $className, 73 | 'fieldUid' => $ruleType['fieldUid'], 74 | 'layoutElementUid' => $ruleType['layoutElementUid'], 75 | ]; 76 | } 77 | }, 78 | $this->_swapFieldLayoutsWithThen( 79 | $fieldLayouts, 80 | fn() => parent::selectableConditionRules(), 81 | ), 82 | ))); 83 | 84 | return array_merge( 85 | $parentConditionRuleTypes, 86 | $fieldConditionRuleTypes, 87 | [ 88 | LevelConditionRule::class, 89 | OwnerCategoryGroupConditionRule::class, 90 | OwnerDateCreatedConditionRule::class, 91 | OwnerDateUpdatedConditionRule::class, 92 | OwnerEntryTypeConditionRule::class, 93 | OwnerHasUrlConditionRule::class, 94 | OwnerLevelConditionRule::class, 95 | OwnerSectionConditionRule::class, 96 | OwnerSlugConditionRule::class, 97 | OwnerTagGroupConditionRule::class, 98 | OwnerTitleConditionRule::class, 99 | OwnerUriConditionRule::class, 100 | OwnerUserGroupConditionRule::class, 101 | OwnerVolumeConditionRule::class, 102 | ], 103 | ); 104 | } 105 | 106 | /** 107 | * Temporarily swaps the condition field layouts before calling a given function. 108 | * 109 | * @param FieldLayout[]|null $with An array of field layouts to swap with the condition field layouts 110 | * @param callable $then A function to run while the condition field layouts are swapped 111 | * @return mixed The return value from $then 112 | */ 113 | private function _swapFieldLayoutsWithThen(?array $with, callable $then): mixed 114 | { 115 | if ($with) { 116 | $fieldLayouts = $this->getFieldLayouts(); 117 | $this->setFieldLayouts($with); 118 | $returnVal = $then(); 119 | $this->setFieldLayouts($fieldLayouts); 120 | 121 | return $returnVal; 122 | } 123 | 124 | return $then(); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /docs/creating-neo-fields.md: -------------------------------------------------------------------------------- 1 | # Creating Neo Fields 2 | 3 | ## Configuration Sidebar 4 | 5 | The sidebar of the configuration section of a Neo field settings page lists the block types and block type groups that exist for the field, and has buttons for creating block types and groups. If a block type group is created, all block types down until another group will belong to that group. If you want to simply close a group without creating a new one, you can create a group without a name. 6 | 7 | ## Block Types 8 | 9 | ### Settings tab 10 | 11 | #### Entry type setting 12 | 13 | Optionally set an entry type to associate with this block type, so that this block. If this is set, the name, handle and color settings fields, and the field layout tab, will be hidden, and the block type will use the entry type's settings for these. 14 | 15 | #### Name setting 16 | 17 | How the block type will be labeled throughout the control panel. 18 | 19 | If an entry type is set for this block type, the name field will be hidden. 20 | 21 | #### Handle setting 22 | 23 | How the block type will be referenced from your templates. 24 | 25 | If an entry type is set for this block type, the handle field will be hidden. 26 | 27 | #### Description setting 28 | 29 | Set the description to use as title text for this block type in the Neo field's new block menus. 30 | 31 | #### Icon setting 32 | 33 | Set an icon that blocks of this type will have on an element editor page. This icon will display on the top bar of blocks, and on the 'list' and 'grid' styles of [new block menu](settings.md#newblockmenustyle). 34 | 35 | Neo does _not_ use the icon of any associated entry type for this block type. 36 | 37 | #### Color setting 38 | 39 | Set the color that blocks of this type will have on an element editor page. 40 | 41 | If an entry type is set for this block type, the color field will be hidden. 42 | 43 | #### Enabled setting 44 | 45 | Whether to allow or disallow use of this block type. Disabling a block type can be useful in cases where its existence is not yet handled in your templates. 46 | 47 | #### Min/max blocks of type setting 48 | 49 | Use these fields to limit the number of blocks of this in a field. Note that the max blocks setting applies regardless of whether the block is a child of another. You cannot use this setting to restrict the number of child blocks in a block. 50 | 51 | #### Min/max sibling blocks of type setting 52 | 53 | Use these fields to limit the number of blocks of this type that can exist either as child blocks of a parent block, or at the top level of the Neo field. 54 | 55 | #### Allowed child block types setting 56 | 57 | Here you can define what blocks of certain types can be added as children. If any child blocks are set, it will add a new block menu at the bottom of each block. This setting will allow you to nest the same block type recursively. 58 | 59 | #### Min/max child blocks setting 60 | 61 | If any allowed child block types have been set, use these fields to limit the number of child blocks that blocks of this type can have. 62 | 63 | #### Group child block types setting 64 | 65 | If any allowed child block types have been set, use this field to set whether or not to show child block types in their groups in the new block menu. 66 | 67 | #### Top-level only setting 68 | 69 | This setting will determine if blocks of this type will only be allowed as children to another block. If this setting is disabled, then the button for this block type will be hidden on the input – except when it's inside another block. 70 | 71 | #### Ignore permissions setting 72 | 73 | Whether any user permissions set for this block type should be ignored. This is set by default, because all user permissions for newly-created block types are unchecked by default. Make sure to unset this if you want to make use of user permissions for block types. 74 | 75 | #### Condition settings 76 | 77 | Set conditions for where this block type can be used, depending on properties of the owner element. Neo includes support for the following owner element types: 78 | 79 | - Entries 80 | - Categories 81 | - Assets 82 | - Users 83 | - Tags 84 | - Addresses 85 | - Global sets 86 | - Craft Commerce products 87 | - Craft Commerce variants 88 | - Craft Commerce orders 89 | - Craft Commerce subscriptions 90 | 91 | Plugins and modules can listen for an [event to register more owner element types](events.md#setconditionelementtypesevent). 92 | 93 | ### Field layout tab 94 | 95 | Design the field layout for each block type using the familiar field layout designer. One small thing to look out for is asset fields that use `{property}` tags in their directory settings. [Read more about it here.](faq.md#why-do-asset-fields-with-slug-as-an-upload-location-break-on-neo-blocks) 96 | 97 | If an entry type is set for this block type, the field layout tab will be hidden. 98 | 99 | ### Screenshot 100 | 101 | 102 | -------------------------------------------------------------------------------- /src/web/assets/input/src/scripts/BlockType.js: -------------------------------------------------------------------------------- 1 | import Garnish from 'garnish' 2 | // import Craft from 'craft' 3 | import NS from './namespace' 4 | 5 | const _defaults = { 6 | id: -1, 7 | field: null, 8 | fieldLayoutId: -1, 9 | sortOrder: 0, 10 | name: '', 11 | handle: '', 12 | maxBlocks: 0, 13 | maxSiblingBlocks: 0, 14 | maxChildBlocks: 0, 15 | groupChildBlockTypes: true, 16 | childBlocks: false, 17 | topLevel: true, 18 | tabNames: [], 19 | hasChildBlocksUiElement: false, 20 | creatableByUser: true, 21 | deletableByUser: true, 22 | editableByUser: true 23 | } 24 | 25 | export default Garnish.Base.extend({ 26 | 27 | init (settings = {}) { 28 | settings = Object.assign({}, _defaults, settings) 29 | 30 | this._id = settings.id | 0 31 | this._field = settings.field 32 | this._fieldLayoutId = settings.fieldLayoutId | 0 33 | this._sortOrder = settings.sortOrder | 0 34 | this._name = settings.name 35 | this._handle = settings.handle 36 | this._description = settings.description 37 | this._enabled = settings.enabled 38 | this._minBlocks = settings.minBlocks | 0 39 | this._maxBlocks = settings.maxBlocks | 0 40 | this._minSiblingBlocks = settings.maxSiblingBlocks | 0 41 | this._maxSiblingBlocks = settings.maxSiblingBlocks | 0 42 | this._minChildBlocks = settings.minChildBlocks | 0 43 | this._maxChildBlocks = settings.maxChildBlocks | 0 44 | this._groupChildBlockTypes = settings.groupChildBlockTypes 45 | this._childBlocks = settings.childBlocks 46 | this._topLevel = settings.topLevel 47 | this._tabNames = settings.tabNames 48 | this._html = settings.tabs?.html ?? '' 49 | this._js = settings.tabs?.js ?? '' 50 | this._defaultVisibleLayoutElements = settings.tabs?.visibleLayoutElements ?? {} 51 | this._hasChildBlocksUiElement = settings.hasChildBlocksUiElement 52 | this._creatableByUser = settings.creatableByUser 53 | this._deletableByUser = settings.deletableByUser 54 | this._editableByUser = settings.editableByUser 55 | }, 56 | 57 | getType () { return 'blockType' }, 58 | getId () { return this._id }, 59 | getFieldLayoutId () { return this._fieldLayoutId }, 60 | getSortOrder () { return this._sortOrder }, 61 | getName () { return this._name }, 62 | getHandle () { return this._handle }, 63 | getDescription () { return this._description }, 64 | getEnabled () { return this._enabled }, 65 | getMinBlocks () { return this._minBlocks }, 66 | getMaxBlocks () { return this._maxBlocks }, 67 | getMinSiblingBlocks () { return this._minSiblingBlocks }, 68 | getMaxSiblingBlocks () { return this._maxSiblingBlocks }, 69 | getMinChildBlocks () { return this._minChildBlocks }, 70 | getMaxChildBlocks () { return this._maxChildBlocks }, 71 | getGroupChildBlockTypes () { return this._groupChildBlockTypes }, 72 | getChildBlocks () { return this._childBlocks }, 73 | getTopLevel () { return this._topLevel }, 74 | getTabNames () { return this._tabNames }, 75 | 76 | /** 77 | * @since 4.2.0 78 | */ 79 | async newBlock (settings = {}) { 80 | NS.enter(this._field.getNamespace()) 81 | const data = { 82 | namespace: NS.toFieldName(), 83 | fieldId: this._field?.getId(), 84 | siteId: this._field?.getSiteId(), 85 | unsavedIds: this._field?.getUnsavedIds(), 86 | blocks: [Object.assign({ 87 | collapsed: false, 88 | enabled: true, 89 | level: 1, 90 | ownerId: this._field?.getOwnerId(), 91 | type: this._id 92 | }, settings)] 93 | } 94 | NS.leave() 95 | const response = await window.Craft.sendActionRequest('POST', 'neo/input/render-blocks', { data }) 96 | 97 | return response.data.blocks[0] 98 | }, 99 | 100 | getDefaultVisibleLayoutElements () { 101 | return { 102 | ...this._defaultVisibleLayoutElements 103 | } 104 | }, 105 | 106 | getChildBlockItems (items) { 107 | const firstPass = items.filter(item => item.getType() === 'group' || this.hasChildBlock(item.getHandle())) 108 | return firstPass.filter((item, i) => { 109 | if (item.getType() === 'group') { 110 | const nextItem = firstPass[i + 1] 111 | return nextItem && nextItem.getType() !== 'group' 112 | } 113 | 114 | return true 115 | }) 116 | }, 117 | 118 | isParent () { 119 | const cb = this.getChildBlocks() 120 | return cb === true || cb === '*' || (Array.isArray(cb) && cb.length > 0) 121 | }, 122 | 123 | hasChildBlock (handle) { 124 | const cb = this.getChildBlocks() 125 | return cb === true || cb === '*' || (Array.isArray(cb) && cb.includes(handle)) 126 | }, 127 | 128 | isValidChildBlock (block) { 129 | return this.hasChildBlock(block.getBlockType().getHandle()) 130 | }, 131 | 132 | hasChildBlocksUiElement () { 133 | return this._hasChildBlocksUiElement 134 | }, 135 | 136 | isCreatableByUser () { 137 | return this._creatableByUser 138 | }, 139 | 140 | isDeletableByUser () { 141 | return this._deletableByUser 142 | }, 143 | 144 | isEditableByUser () { 145 | return this._editableByUser 146 | } 147 | }) 148 | -------------------------------------------------------------------------------- /docs/graphql.md: -------------------------------------------------------------------------------- 1 | # GraphQL 2 | 3 | Neo fields, and content within them, can be accessed and manipulated using Craft's GraphQL API. 4 | 5 | ## Fragment syntax 6 | `{neo field handle}_{neo block type handle}_BlockType` 7 | 8 | ## How to use 9 | 10 | In the following example we'll be using a Neo field with the handle `body`. 11 | 12 | ### Single level queries 13 | 14 | Example on how to query the text block type that has a text field within: 15 | ``` 16 | body { 17 | ... on body_text_BlockType { 18 | # text field 19 | text 20 | } 21 | } 22 | ``` 23 | 24 | Returned Data example: 25 | ``` 26 | "body": [ 27 | { 28 | "text": "Test content" 29 | } 30 | ] 31 | ``` 32 | 33 | --- 34 | 35 | Another example with assets: 36 | ``` 37 | body { 38 | ... on body_media_BlockType { 39 | # asset field 40 | media { 41 | url 42 | } 43 | } 44 | } 45 | ``` 46 | 47 | Returned Data example: 48 | ``` 49 | "body": [ 50 | { 51 | "media": [ 52 | { 53 | "url": "assets/media/image.png" 54 | } 55 | ] 56 | } 57 | ] 58 | ``` 59 | 60 | ------ 61 | 62 | ### Multi level queries 63 | 64 | Multi level block types will have a field called `children`. These will return the children blocks within the parent block type. 65 | 66 | #### Example: 67 | ``` 68 | body { 69 | ... on body_text_BlockType { 70 | text 71 | } 72 | ... on body_myContent_BlockType { 73 | children { 74 | ... on body_text_BlockType { 75 | # text field 76 | text 77 | } 78 | 79 | ... on body_media_BlockType { 80 | # asset field 81 | media { 82 | url 83 | } 84 | } 85 | } 86 | } 87 | } 88 | ``` 89 | 90 | #### Returned data example: 91 | 92 | ``` 93 | "body": [ 94 | { 95 | "text": test 96 | }, 97 | { 98 | "children": [ 99 | { 100 | "text": "test" 101 | }, 102 | { 103 | "media": [ 104 | { 105 | "url": "assets/media/image1.png" 106 | } 107 | ] 108 | } 109 | ] 110 | } 111 | ] 112 | ``` 113 | 114 | --- 115 | 116 | #### Multiple levels example: 117 | ``` 118 | body { 119 | ... on body_myContent_BlockType { 120 | children { 121 | ... on body_text_BlockType { 122 | # text field 123 | text 124 | } 125 | 126 | ... on body_innerContent_BlockType { 127 | children: { 128 | text 129 | } 130 | } 131 | 132 | ... on body_media_BlockType { 133 | # asset field 134 | media { 135 | url 136 | } 137 | } 138 | } 139 | } 140 | } 141 | ``` 142 | 143 | #### Returned data example: 144 | 145 | ``` 146 | "body": [ 147 | { 148 | "children": [ 149 | { 150 | "text": "test" 151 | }, 152 | { 153 | "children": [ 154 | { 155 | "text": "test test" 156 | }, 157 | { 158 | "text": "test test test" 159 | } 160 | ] 161 | }, 162 | { 163 | "media": [ 164 | { 165 | "url": "assets/media/image1.png" 166 | } 167 | ] 168 | } 169 | ] 170 | } 171 | ] 172 | ``` 173 | 174 | ### Returning all blocks 175 | 176 | By default, a Neo field query will return only the blocks at the top level of the field. Other levels can be targeted using the `level` argument, and setting `level` to `0` will return all blocks. Blocks' levels can be returned using the `level` field. 177 | 178 | #### Returning all blocks example: 179 | 180 | ``` 181 | body(level: 0) { 182 | ... on body_text_BlockType { 183 | level 184 | text 185 | } 186 | } 187 | ``` 188 | 189 | #### Returned data example: 190 | 191 | ``` 192 | "body": [ 193 | { 194 | "level": 1, 195 | "text": "Parent text block" 196 | }, 197 | { 198 | "level": 2, 199 | "text": "Child text block" 200 | }, 201 | { 202 | "level": 1, 203 | "text": "Another top-level text block" 204 | } 205 | ] 206 | ``` 207 | 208 | ---- 209 | 210 | ## Mutations 211 | 212 | Mutating Neo fields is largely the same process as with [Matrix fields in Craft 4](https://craftcms.com/docs/4.x/graphql.html#matrix-fields-in-mutations), but with two differences: 213 | 214 | - Neo-related input types have Neo in their name, rather than Matrix; e.g. the input type for a Neo field with the handle `yourNeoField` is `yourNeoField_NeoInput`. 215 | - Neo block data can include a `level` argument for setting the block's level. If `level` isn't specified for new blocks, it will default to 1. 216 | -------------------------------------------------------------------------------- /src/templates/plugin-settings.twig: -------------------------------------------------------------------------------- 1 | {% import '_includes/forms' as forms %} 2 | 3 |
4 | {{ forms.lightswitchField({ 5 | label: 'Collapse all blocks?'|t('neo'), 6 | instructions: 'Whether all blocks should display as collapsed when loading an element editor.'|t('neo'), 7 | id: 'collapseAllBlocks', 8 | name: 'collapseAllBlocks', 9 | on: settings.collapseAllBlocks, 10 | }) }} 11 | 12 | {{ forms.lightswitchField({ 13 | label: 'Default ’Always Show Dropdown’ setting'|t('neo'), 14 | instructions: 'Whether block type groups will always show as a dropdown menu, if their ’Always Show Dropdown’ setting is set to use the global setting.'|t('neo'), 15 | id: 'defaultAlwaysShowGroupDropdowns', 16 | name: 'defaultAlwaysShowGroupDropdowns', 17 | on: settings.defaultAlwaysShowGroupDropdowns, 18 | }) }} 19 | 20 | {{ forms.lightswitchField({ 21 | label: 'Optimise search indexing?'|t('neo'), 22 | instructions: 'Whether to skip updating search indexes for Neo blocks that have no sub-fields set to use their values as search keywords, or that belong to Neo fields that aren’t set to use the field’s values as search keywords.'|t('neo'), 23 | id: 'optimiseSearchIndexing', 24 | name: 'optimiseSearchIndexing', 25 | on: settings.optimiseSearchIndexing, 26 | }) }} 27 | 28 | {{ forms.selectField({ 29 | id: 'newBlockMenuStyle', 30 | name: 'newBlockMenuStyle', 31 | label: 'New block menu style'|t('neo'), 32 | instructions: 'Which style to use for Neo’s ’new block’ menus.'|t('neo'), 33 | options: [ 34 | {label: 'Classic'|t('neo'), value: 'classic'}, 35 | {label: 'Grid'|t('neo'), value: 'grid'}, 36 | {label: 'List'|t('neo'), value: 'list'}, 37 | ], 38 | value: settings.newBlockMenuStyle, 39 | }) }} 40 | 41 | {% tag('div') with { 42 | id: 'block-type-icon-container', 43 | } %} 44 | {{ forms.selectField({ 45 | id: 'blockTypeIconSelectMode', 46 | name: 'blockTypeIconSelectMode', 47 | label: 'Block type icon select mode'|t('neo'), 48 | instructions: 'Which style to use to select block type icons.'|t('neo'), 49 | options: [ 50 | {label: 'Path'|t('neo'), value: 'path'}, 51 | {label: 'Sources'|t('neo'), value: 'sources'}, 52 | ], 53 | value: settings.blockTypeIconSelectMode, 54 | }) }} 55 | 56 | {% tag('div') with { 57 | id: 'block-type-icon-path-container', 58 | } %} 59 | {{ forms.autosuggestField({ 60 | label: 'Block type icon path'|t('neo'), 61 | instructions: 'The path of the folder to select block type icons from.'|t('neo'), 62 | id: 'blockTypeIconPath', 63 | name: 'blockTypeIconPath', 64 | suggestEnvVars: true, 65 | suggestAliases: true, 66 | value: settings.blockTypeIconPath, 67 | errors: settings.getErrors('blockTypeIconPath'), 68 | }) }} 69 | {% endtag %} 70 | {% tag('div') with { 71 | id: 'block-type-icon-sources-container', 72 | } %} 73 | {{ forms.checkboxSelectField({ 74 | label: 'Block type icon sources'|t('neo'), 75 | instructions: 'Which sources do you want to select block type icons from?'|t('neo'), 76 | id: 'blockTypeIconSources', 77 | name: 'blockTypeIconSources', 78 | options: blockTypeIconSourceOptions, 79 | values: settings.blockTypeIconSources, 80 | showAllOption: true, 81 | }) }} 82 | {% endtag %} 83 | {% endtag %} 84 | 85 | {{ forms.lightswitchField({ 86 | label: 'Enable block type user permissions'|t('neo'), 87 | instructions: 'Whether to allow setting user permissions for creating, editing and deleting blocks of a certain type.'|t('neo'), 88 | id: 'enableBlockTypeUserPermissions', 89 | name: 'enableBlockTypeUserPermissions', 90 | on: settings.enableBlockTypeUserPermissions, 91 | }) }} 92 | 93 | {{ forms.lightswitchField({ 94 | label: 'Always show buttons above Neo fields?'|t('neo'), 95 | instructions: 'Whether to always show new block buttons above Neo fields, as well as below.'|t('neo'), 96 | id: 'alwaysShowButtonsAboveField', 97 | name: 'alwaysShowButtonsAboveField', 98 | on: settings.alwaysShowButtonsAboveField, 99 | }) }} 100 | 101 | {{ forms.textField({ 102 | label: 'Block anchor ID prefix'|t('neo'), 103 | instructions: 'The prefix to use for block anchor IDs.'|t('neo'), 104 | id: 'blockAnchorIdPrefix', 105 | name: 'blockAnchorIdPrefix', 106 | value: settings.blockAnchorIdPrefix, 107 | errors: settings.getErrors('blockAnchorIdPrefix'), 108 | }) }} 109 |
110 | 111 | {% js %} 112 | function toggleNewBlockMenuStyle (toggle) { 113 | $('#{{ 'block-type-icon-container'|namespaceInputId }}').toggleClass('hidden', !toggle) 114 | } 115 | toggleNewBlockMenuStyle({{ settings.newBlockMenuStyle != 'classic' }}) 116 | $('#{{ 'newBlockMenuStyle'|namespaceInputId }}').on('change', function() { 117 | toggleNewBlockMenuStyle($(this).val() !== 'classic') 118 | }) 119 | 120 | function toggleNewBlockIconSelectStyle (toggle) { 121 | $('#{{ 'block-type-icon-path-container'|namespaceInputId }}').toggleClass('hidden', !toggle) 122 | $('#{{ 'block-type-icon-sources-container'|namespaceInputId }}').toggleClass('hidden', toggle) 123 | } 124 | toggleNewBlockIconSelectStyle({{ settings.blockTypeIconSelectMode != 'sources' ? 'true' : 'false' }}) 125 | $('#{{ 'blockTypeIconSelectMode'|namespaceInputId }}').on('change', function() { 126 | toggleNewBlockIconSelectStyle($(this).val() !== 'sources') 127 | }) 128 | {% endjs %} 129 | -------------------------------------------------------------------------------- /src/jobs/ResaveFieldBlockStructures.php: -------------------------------------------------------------------------------- 1 | 23 | * @since 4.0.0 24 | */ 25 | class ResaveFieldBlockStructures extends BaseJob 26 | { 27 | /** 28 | * @var int 29 | */ 30 | public ?int $fieldId = null; 31 | 32 | /** 33 | * @var array Optional override structure data, nested by site ID -> owner ID -> block ID 34 | */ 35 | public array $structureOverrides = []; 36 | 37 | /** 38 | * @inheritdoc 39 | */ 40 | public function execute($queue): void 41 | { 42 | $elementsService = Craft::$app->getElements(); 43 | $field = Craft::$app->getFields()->getFieldById($this->fieldId); 44 | $ownerIds = (new Query()) 45 | ->select(['ownerId']) 46 | ->from('{{%neoblockstructures}}') 47 | ->where([ 48 | 'fieldId' => $this->fieldId, 49 | ]) 50 | ->distinct() 51 | ->column(); 52 | $ownerIdsTotal = count($ownerIds); 53 | $ownerIdsCounter = 0; 54 | 55 | foreach (array_filter($ownerIds) as $ownerId) { 56 | $owner = $elementsService->getElementById($ownerId, criteria: [ 57 | 'status' => null, 58 | 'trashed' => null, 59 | ]); 60 | $blocks = []; 61 | 62 | try { 63 | $supportedSiteIds = $owner !== null 64 | ? $this->_supportedSiteIds($owner) 65 | : []; 66 | } catch (InvalidConfigException $e) { 67 | // Owner's section was deleted, no supported sites then 68 | $supportedSiteIds = []; 69 | } 70 | 71 | // Get the blocks with the existing structure data first 72 | foreach ($supportedSiteIds as $siteId) { 73 | try { 74 | $blocks[$siteId] = (clone $owner->getFieldValue($field->handle)) 75 | ->status(null) 76 | ->siteId($siteId) 77 | ->all(); 78 | } catch (InvalidFieldException $e) { 79 | // Field has since been deleted from the owner's field layout 80 | continue; 81 | } 82 | 83 | // Ensure any passed-in structure data is prioritised 84 | foreach ($blocks[$siteId] as $block) { 85 | $overrideStructureData = $this->structureOverrides[$siteId][$ownerId][$block->id] ?? null; 86 | 87 | if ($overrideStructureData !== null) { 88 | $block->level = $overrideStructureData['level']; 89 | $block->lft = $overrideStructureData['lft']; 90 | $block->rgt = $overrideStructureData['rgt']; 91 | } 92 | } 93 | } 94 | 95 | // Now it's safe to recreate the block structures 96 | foreach ($supportedSiteIds as $siteId) { 97 | if (!isset($blocks[$siteId])) { 98 | continue; 99 | } 100 | 101 | if (Neo::$plugin->getSettings()->resaveFieldBlockStructuresInIndividualJobs) { 102 | Queue::push(new SaveBlockStructures([ 103 | 'fieldId' => $this->fieldId, 104 | 'ownerId' => $ownerId, 105 | 'siteId' => $siteId, 106 | 'otherSupportedSiteIds' => [], 107 | 'blocks' => array_map( 108 | fn($block) => [ 109 | 'id' => $block->id, 110 | 'level' => $block->level, 111 | 'lft' => $block->lft, 112 | 'rgt' => $block->rgt, 113 | ], 114 | $blocks[$siteId], 115 | ), 116 | ])); 117 | } else { 118 | $oldBlockStructures = Neo::$plugin->blocks->getStructures([ 119 | 'fieldId' => $this->fieldId, 120 | 'ownerId' => $ownerId, 121 | 'siteId' => $siteId, 122 | ]); 123 | 124 | foreach ($oldBlockStructures as $oldBlockStructure) { 125 | Neo::$plugin->blocks->deleteStructure($oldBlockStructure, true); 126 | } 127 | 128 | $blockStructure = new BlockStructure(); 129 | $blockStructure->fieldId = $this->fieldId; 130 | $blockStructure->ownerId = $ownerId; 131 | $blockStructure->siteId = $siteId; 132 | Neo::$plugin->blocks->saveStructure($blockStructure); 133 | Neo::$plugin->blocks->buildStructure($blocks[$siteId], $blockStructure); 134 | } 135 | } 136 | 137 | $this->setProgress($queue, ++$ownerIdsCounter / $ownerIdsTotal); 138 | } 139 | } 140 | 141 | /** 142 | * @inheritdoc 143 | */ 144 | protected function defaultDescription(): ?string 145 | { 146 | return Translation::prep('neo', 'Saving Neo block structures for duplicated elements'); 147 | } 148 | 149 | private function _supportedSiteIds(ElementInterface $element): array 150 | { 151 | return ArrayHelper::getColumn( 152 | ElementHelper::supportedSitesForElement($element), 153 | 'siteId' 154 | ); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/templates/feed-me.twig: -------------------------------------------------------------------------------- 1 | {# 2 | This file is based on the Matrix field settings template file, from Feed Me version 5.0.4, by Pixel & Tonic, Inc. 3 | https://github.com/craftcms/feed-me/blob/5.0.4/src/templates/_includes/fields/matrix.html 4 | Feed Me is released under the terms of the Craft License, a copy of which is included below. 5 | https://github.com/craftcms/feed-me/blob/5.0.4/LICENSE.md 6 | 7 | Copyright © Pixel & Tonic 8 | 9 | Permission is hereby granted to any person obtaining a copy of this software 10 | (the “Software”) to use, copy, modify, merge, publish and/or distribute copies 11 | of the Software, and to permit persons to whom the Software is furnished to do 12 | so, subject to the following conditions: 13 | 14 | 1. **Don’t plagiarize.** The above copyright notice and this license shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | 2. **Don’t use the same license on more than one project.** Each licensed copy 18 | of the Software shall be actively installed in no more than one production 19 | environment at a time. 20 | 21 | 3. **Don’t mess with the licensing features.** Software features related to 22 | licensing shall not be altered or circumvented in any way, including (but 23 | not limited to) license validation, payment prompts, feature restrictions, 24 | and update eligibility. 25 | 26 | 4. **Pay up.** Payment shall be made immediately upon receipt of any notice, 27 | prompt, reminder, or other message indicating that a payment is owed. 28 | 29 | 5. **Follow the law.** All use of the Software shall not violate any applicable 30 | law or regulation, nor infringe the rights of any other person or entity. 31 | 32 | Failure to comply with the foregoing conditions will automatically and 33 | immediately result in termination of the permission granted hereby. This 34 | license does not include any right to receive updates to the Software or 35 | technical support. Licensees bear all risk related to the quality and 36 | performance of the Software and any modifications made or obtained to it, 37 | including liability for actual and consequential harm, such as loss or 38 | corruption of data, and any necessary service, repair, or correction. 39 | 40 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 41 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 42 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 43 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER 44 | LIABILITY, INCLUDING SPECIAL, INCIDENTAL AND CONSEQUENTIAL DAMAGES, WHETHER IN 45 | AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 46 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 47 | #} 48 | 49 | {# ------------------------ #} 50 | {# Available Variables #} 51 | {# ------------------------ #} 52 | {# Attributes: #} 53 | {# type, name, handle, instructions, attribute, default, feed, feedData #} 54 | {# ------------------------ #} 55 | {# Fields: #} 56 | {# name, handle, instructions, feed, feedData, field, fieldClass #} 57 | {# ------------------------ #} 58 | 59 | {% import 'feed-me/_macros' as feedMeMacro %} 60 | {% import '_includes/forms' as forms %} 61 | 62 | {% set classes = ['complex-field'] %} 63 | 64 |
65 | {% namespace 'fieldMapping[' ~ handle ~ ']' %} 66 | 67 | {% endnamespace %} 68 |
69 | 70 | {# Block levels #} 71 | {% set nameLabel = name ~ ': ' ~ 'Level'|t('neo') %} 72 | {% set instructionsHandle = handle ~ '[*][level]' %} 73 | {% include 'feed-me/_includes/fields/default' ignore missing with { 74 | handle: 'level', 75 | path: [ handle, 'level' ], 76 | default: {}, 77 | } %} 78 | 79 | {% for blocktype in field.blocktypes %} 80 | {% if blocktype.fields|length %} 81 | 82 | 83 |
84 |
85 | 86 |
87 |
88 | 89 | 90 | 91 |
92 | {% set blockPath = [ handle, 'blocks', blocktype.handle ] %} 93 | 94 | {% set disabled = hash_get(feed.fieldMapping, blockPath|join('.') ~ '.disabled') ?: '' %} 95 | {% set collapsed = hash_get(feed.fieldMapping, blockPath|join('.') ~ '.collapsed') ?: '' %} 96 | 97 | {% namespace 'fieldMapping[' ~ blockPath|join('][') ~ ']' %} 98 | {{ feedMeMacro.checkbox({ 99 | label: 'Disabled'|t('feed-me'), 100 | name: 'disabled', 101 | value: 1, 102 | checked: disabled, 103 | }) }} 104 | 105 | {{ feedMeMacro.checkbox({ 106 | label: 'Collapsed'|t('feed-me'), 107 | name: 'collapsed', 108 | value: 1, 109 | checked: collapsed, 110 | }) }} 111 | {% endnamespace %} 112 |
113 | 114 | 115 | 116 | {% for blocktypefield in blocktype.getCustomFields() %} 117 | {% set nameLabel = blocktype.name ~ ': ' ~ blocktypefield.name %} 118 | {% set instructionsHandle = handle ~ '[' ~ blocktype.handle ~ '][' ~ blocktypefield.handle ~ ']' %} 119 | 120 | {% set parentPath = [ handle, 'blocks', blocktype.handle, 'fields', blocktypefield.handle ] %} 121 | 122 | {% set fieldClass = craft.feedme.fields.getRegisteredField(className(blocktypefield)) %} 123 | {% set template = fieldClass.getMappingTemplate() %} 124 | 125 | {% include template ignore missing with { 126 | field: blocktypefield, 127 | handle: blocktypefield.handle, 128 | path: parentPath, 129 | } %} 130 | {% endfor %} 131 | {% endif %} 132 | {% endfor %} 133 | -------------------------------------------------------------------------------- /src/web/assets/configurator/dist/styles/configurator.css: -------------------------------------------------------------------------------- 1 | .neo-configurator>.field>.input{display:flex;min-height:400px}[data-neo="template.fld"]{display:none}.nc_sidebar{width:202px;border:1px solid rgba(96,125,159,.25);background-color:var(--gray-050)}body.ltr .nc_sidebar{border-top-left-radius:var(--small-border-radius);border-bottom-left-radius:var(--small-border-radius)}body.rtl .nc_sidebar{border-top-right-radius:var(--small-border-radius);border-bottom-right-radius:var(--small-border-radius)}.nc_sidebar_title{padding:10px 24px;border-bottom:1px solid rgba(96,125,159,.25);background-image:linear-gradient(rgba(51, 64, 77, 0), rgba(51, 64, 77, 0.05));color:var(--medium-text-color);text-align:center}.nc_sidebar_list{margin:0 -1px}.nc_sidebar_list:not(:empty){padding-top:10px}.nc_sidebar_list_item{cursor:default;position:relative;margin-top:-1px;padding-top:6px;padding-bottom:6px;border:1px solid var(--hairline-color);background-color:var(--gray-100)}body.ltr .nc_sidebar_list_item{padding-left:10px;padding-right:70px}body.rtl .nc_sidebar_list_item{padding-left:70px;padding-right:10px}@media screen and (prefers-reduced-motion: no-preference){.nc_sidebar_list_item{transition:margin-left .15s,margin-right .15s}}.nc_sidebar_list_item::before{content:"";display:block;position:absolute;top:-1px;bottom:-1px;width:0;border-top:1px solid var(--hairline-color);border-bottom:1px solid var(--hairline-color);background-color:var(--gray-050)}body.ltr .nc_sidebar_list_item::before{left:0}body.rtl .nc_sidebar_list_item::before{right:0}@media screen and (prefers-reduced-motion: no-preference){.nc_sidebar_list_item::before{transition:left .15s,right .15s,width .15s}}.nc_sidebar_list_item>.label{color:var(--text-color)}.nc_sidebar_list_item>.label:empty{font-style:italic;color:var(--gray-350)}.nc_sidebar_list_item>.label:empty::before{content:"(blank)"}.nc_sidebar_list_item>.code:empty::before{content:"(blank)"}.nc_sidebar_list_item>.move,.nc_sidebar_list_item>.menubtn{position:absolute;width:25px;text-align:center}.nc_sidebar_list_item>.move{display:block;top:calc(50% - 10px)}body.ltr .nc_sidebar_list_item>.move{right:3px}body.rtl .nc_sidebar_list_item>.move{left:3px}.nc_sidebar_list_item>.move:not(.has-error) ::before{color:var(--ui-control-color)}.nc_sidebar_list_item>.move:not(.has-error):hover::before{color:var(--ui-control-hover-color)}.nc_sidebar_list_item>.move.has-error ::before{color:var(--error-color)}.nc_sidebar_list_item>.move.has-error:hover::before{color:var(--ui-control-hover-color)}.nc_sidebar_list_item>.menubtn{display:inline-flex;top:calc(50% - 12.5px);padding:0 !important}body.ltr .nc_sidebar_list_item>.menubtn{right:32px}body.rtl .nc_sidebar_list_item>.menubtn{left:32px}.nc_sidebar_list_item>.menubtn:not(.has-error){color:var(--ui-control-color)}.nc_sidebar_list_item>.menubtn:not(.has-error):hover{color:var(--ui-control-hover-color)}.nc_sidebar_list_item>.menubtn.has-error{color:var(--error-color)}.nc_sidebar_list_item>.menubtn.has-error:hover{color:var(--ui-control-hover-color)}.nc_sidebar_list_item.is-selected{z-index:1;background-color:var(--gray-200)}.nc_sidebar_list_item.is-child::before{width:9px}body.ltr .nc_sidebar_list_item.is-child{margin-left:10px}body.ltr .nc_sidebar_list_item.is-child::before{left:-10px}body.rtl .nc_sidebar_list_item.is-child{margin-right:10px}body.rtl .nc_sidebar_list_item.is-child::before{right:-10px}.nc_sidebar_list_item.has-errors{z-index:2}.nc_sidebar_list_item.has-errors>.label{color:var(--error-color)}.nc_sidebar_list_item.type-heading{margin-top:9px;padding-top:10px;padding-bottom:10px}.nc_sidebar_list_item.type-heading:first-child{margin-top:0}.nc_sidebar_list_item.type-heading>.label{font-size:11px;font-weight:bold;text-transform:uppercase;color:rgba(63,77,90,.8)}.nc_sidebar_list_item.type-heading>.label:empty{font-weight:normal;color:var(--gray-350)}.nc_sidebar_list_item.type-spinner{height:42px}.nc_sidebar_list_item.type-spinner .spinner{position:absolute;top:calc(50% - 17px);left:calc(50% - 12px);opacity:1}.nc_sidebar_buttons{padding:14px 12px}.nc_sidebar_buttons>.btn{padding-left:12px;padding-right:12px}.nc_sidebar_buttons>.btn.type-heading{font-size:11px;font-weight:bold;text-transform:uppercase;color:rgba(63,77,90,.8)}.nc_main{flex-grow:1;flex-shrink:9999;border:1px solid rgba(96,125,159,.25)}body.ltr .nc_main{border-left:0;border-top-right-radius:var(--small-border-radius);border-bottom-right-radius:var(--small-border-radius)}body.rtl .nc_main{border-right:0;border-top-left-radius:var(--small-border-radius);border-bottom-left-radius:var(--small-border-radius)}.nc_main_tabs{display:flex;border-bottom:1px solid rgba(96,125,159,.25);background-color:var(--gray-050);background-image:linear-gradient(rgba(51, 64, 77, 0), rgba(51, 64, 77, 0.05))}.nc_main_tabs_tab{display:block;padding:10px 24px;color:var(--medium-text-color)}.nc_main_tabs_tab:hover{text-decoration:none;color:#0d78f2}.nc_main_tabs_tab.is-selected{margin-bottom:-1px;padding-bottom:11px;border-left:1px solid rgba(96,125,159,.25);border-right:1px solid rgba(96,125,159,.25);background-color:#fff;color:var(--text-color)}body.ltr .nc_main_tabs_tab.is-selected:first-child{border-left:0}body.rtl .nc_main_tabs_tab.is-selected:first-child{border-right:0}.nc_main_content{position:relative;padding:var(--padding)}.nc_main_content[data-neo="container.fieldLayout"]{display:flex;align-items:stretch;height:calc(100% - var(--padding) - var(--padding))}.nc_main_content[data-neo="container.fieldLayout"]>.layoutdesigner{flex:1}.nc_main_content>.spinner{position:absolute;top:calc(50% - 17px);left:calc(50% - 12px);opacity:1}.nc_main_content .checkbox+label{color:var(--medium-text-color)}.nc_main_content .checkbox+label:empty::after{content:"(blank)";font-size:inherit;font-style:italic;color:var(--gray-350)}body.ltr .nc_main_content .checkbox+label:empty{padding-left:20px}body.rtl .nc_main_content .checkbox+label:empty{padding-right:20px}.nc_icon-select>.btn{margin-top:8px}.nc_icon-select-show>img{margin:0}.nc_icon-select-show>p{margin:0}.nc_icon-select-menu{width:388px}.nc_icon-select-menu>ul{display:flex;flex-flow:row wrap;width:100%}.nc_icon-select-menu>ul>li{display:flex;flex-direction:column;padding:10px !important;width:120px !important}.nc_icon-select-menu>ul>li>a{flex-grow:1;padding:10px !important;width:120px !important;text-align:center !important;white-space:normal;cursor:pointer}body.ltr .nc_icon-select-menu>ul>li>a{transform:translateX(4px)}body.rtl .nc_icon-select-menu>ul>li>a{transform:translateX(-4px)}.nc_icon-select-menu>ul>li>a>img{margin:0 auto} 2 | 3 | /*# sourceMappingURL=configurator.css.map*/ -------------------------------------------------------------------------------- /src/console/controllers/BlockTypeGroupsController.php: -------------------------------------------------------------------------------- 1 | 19 | * @since 3.1.0 20 | */ 21 | class BlockTypeGroupsController extends Controller 22 | { 23 | /** 24 | * @var int|null A block type group ID. 25 | */ 26 | public ?int $groupId = null; 27 | 28 | /** 29 | * @var bool Whether to delete block types belonging to the block type group. 30 | */ 31 | public bool $deleteBlockTypes = false; 32 | 33 | /** 34 | * @var string|null A new name to set for the block type group. 35 | */ 36 | public ?string $setName = null; 37 | 38 | /** 39 | * @var bool Whether to set a blank name for the block type group. 40 | */ 41 | public bool $blankName = false; 42 | 43 | /** 44 | * @var string What behaviour should be used for showing the block type group's dropdown (either 'show', 'hide', or 'global'). 45 | */ 46 | public ?string $dropdown = null; 47 | 48 | /** 49 | * @inheritdoc 50 | */ 51 | public function options($actionID): array 52 | { 53 | $options = parent::options($actionID); 54 | 55 | if ($actionID === 'delete') { 56 | $options[] = 'groupId'; 57 | $options[] = 'deleteBlockTypes'; 58 | } elseif ($actionID === 'edit') { 59 | $options[] = 'groupId'; 60 | $options[] = 'setName'; 61 | $options[] = 'blankName'; 62 | $options[] = 'dropdown'; 63 | } 64 | 65 | return $options; 66 | } 67 | 68 | /** 69 | * Deletes a Neo block type group. 70 | * 71 | * @return int 72 | */ 73 | public function actionDelete(): int 74 | { 75 | if (!$this->groupId) { 76 | $this->stderr('The --group-id option must be specified.' . PHP_EOL, Console::FG_RED); 77 | return ExitCode::USAGE; 78 | } 79 | 80 | $group = Neo::$plugin->blockTypes->getGroupById($this->groupId); 81 | 82 | if ($group === null) { 83 | $this->stderr('The block type group ID specified does not exist.' . PHP_EOL, Console::FG_RED); 84 | return ExitCode::USAGE; 85 | } 86 | 87 | $projectConfig = Craft::$app->getProjectConfig(); 88 | $blockTypeUids = (new Query()) 89 | ->select(['uid']) 90 | ->from('{{%neoblocktypes}}') 91 | ->where(['groupId' => $this->groupId]) 92 | ->column(); 93 | 94 | if ($this->deleteBlockTypes) { 95 | $this->stdout('Deleting the group\'s block types...' . PHP_EOL); 96 | foreach ($blockTypeUids as $blockTypeUid) { 97 | $projectConfig->remove('neo.blockTypes.' . $blockTypeUid); 98 | } 99 | } elseif (!empty($blockTypeUids)) { 100 | $this->stdout('Reassigning the group\'s block types to the field\'s previous group...' . PHP_EOL); 101 | $prevGroupUid = (new Query()) 102 | ->select(['uid']) 103 | ->from(['{{%neoblocktypegroups}}']) 104 | ->where(['fieldId' => $group->fieldId]) 105 | ->andWhere(['<', 'sortOrder', $group->sortOrder]) 106 | ->orderBy(['sortOrder' => SORT_DESC]) 107 | ->scalar(); 108 | 109 | foreach ($blockTypeUids as $blockTypeUid) { 110 | $projectConfig->set('neo.blockTypes.' . $blockTypeUid . '.group', $prevGroupUid); 111 | } 112 | } 113 | 114 | // Now we can delete the group 115 | $this->stdout('Deleting the group...' . PHP_EOL); 116 | $projectConfig->remove('neo.blockTypeGroups.' . $group->uid); 117 | $this->stdout('Done.' . PHP_EOL); 118 | 119 | return ExitCode::OK; 120 | } 121 | 122 | /** 123 | * Edits a Neo block type group. 124 | * 125 | * @return int 126 | */ 127 | public function actionEdit(): int 128 | { 129 | if (!$this->groupId) { 130 | $this->stderr('The --group-id option must be specified.' . PHP_EOL, Console::FG_RED); 131 | return ExitCode::USAGE; 132 | } 133 | 134 | if ($this->blankName && $this->setName) { 135 | $this->stderr('Only one of --blank-name or --set-name may be specified.' . PHP_EOL, Console::FG_RED); 136 | return ExitCode::USAGE; 137 | } 138 | 139 | $dropdownOptions = [ 140 | BlockTypeGroupDropdown::Show, 141 | BlockTypeGroupDropdown::Hide, 142 | BlockTypeGroupDropdown::Global, 143 | ]; 144 | 145 | if ($this->dropdown && !in_array($this->dropdown, $dropdownOptions)) { 146 | $this->stderr('The --dropdown value must be one of ' . implode(', ', $dropdownOptions) . '.' . PHP_EOL, Console::FG_RED); 147 | return ExitCode::USAGE; 148 | } 149 | 150 | $groupUid = Db::uidById('{{%neoblocktypegroups}}', $this->groupId); 151 | 152 | if ($groupUid === null) { 153 | $this->stderr('The block type group ID specified does not exist.' . PHP_EOL, Console::FG_RED); 154 | return ExitCode::USAGE; 155 | } 156 | 157 | $projectConfig = Craft::$app->getProjectConfig(); 158 | $groupPath = 'neo.blockTypeGroups.' . $groupUid; 159 | $groupConfig = $projectConfig->get($groupPath); 160 | 161 | if ($this->setName || $this->blankName) { 162 | $groupConfig['name'] = $this->setName ?? ''; 163 | } 164 | 165 | if ($this->dropdown) { 166 | $groupConfig['alwaysShowDropdown'] = match ($this->dropdown) { 167 | BlockTypeGroupDropdown::Show => true, 168 | BlockTypeGroupDropdown::Hide => false, 169 | BlockTypeGroupDropdown::Global => null, 170 | }; 171 | } 172 | 173 | $projectConfig->set($groupPath, $groupConfig); 174 | $this->stdout('Done.' . PHP_EOL); 175 | 176 | return ExitCode::OK; 177 | } 178 | } 179 | --------------------------------------------------------------------------------