Preventing deep content nesting in Neos CMS

Categories:

How to use content collections without adding unnecessary levels to your content structure

Neos CMS allows building content elements that can contain other elements (also called nodes). To make this dynamic the type Neos.Neos:ContentCollection exists. Almost all code examples I have seen and also most of the elements I built myself are having a structure similar to the following simplified multicolumn example:

MultiColumn:
  ContentCollection:
    SingleColumn:
      ContentCollection:
        Headline
        Text
    SingleColumn:
      ContentCollection:
        Headline
        Text

The two ContentCollection nodes are used to contain child nodes but don't have any further semantic use as we already have parent that contains its children. 

We have several disadvantages because of this:

  • In our code we have to go a level deeper and search for the children of a collection.
  • The editors will have a useless nesting level in their content tree.
  • We have additional nodes in the database that will also be duplicated for translation etc.

I regularly see the question in the Neos community how to get rid of those nesting levels and I saw several projects which suffer in their usability because they have an even more complex structure that has 8 or more levels. This led me to write this blog post to show how to solve this issue.

Our goal

To solve the mentioned issues we need to define our nodetypes a bit differently and we also need to adapt the Fusion code. There is a simple way of doing this which doesn't use component based rendering and a bit more complex one that uses components. I will start with the simple one as both use the same nodetype definitions.

The following is the structure we want to achieve:

MultiColumn:
  SingleColumn:
    Headline
    Text
  SingleColumn:
    Headline
    Text

We now have only semantically relevant nodes in our element that would match the structure of a multicolumn element when we would describe it to someone else.

Defining the nodetypes

To make our example work we need a few nodetypes. 

The first nodetypes are for the multicolumn element that can later hold several columns:

'My.Site:Content.Columns':
  superTypes:
    'Neos.Neos:Content': true
    'Neos.Neos:ContentCollection': true
  ui:
    label: 'Columns'
    icon: columns
    group: 'structure'
  constraints:
    nodeTypes:
      '*': false
      'My.Site:Content.Column': true

The big difference to the elements you usually see is that we inherit from Neos.Neos:Content and from Neos.Neos:ContentCollection at the same time. This is necessary to allow the element being added in the Neos UI and also enable the functionality of having child nodes being added or removed

Additionally we define a constraint to only allow column type children to be added. This way we can rely in our code what kind of content we will find there.

The next nodetypes are for the column. There is an abstract column definition and a simple implementation.

'My.Site:Mixin.Column':
  abstract: true
  superTypes:
    'Neos.Neos:Content': true
    'Neos.Neos:ContentCollection': true
  ui:
    label: 'Column'
    icon: columns
    group: 'structure'

'My.Site:Content.Column':
  superTypes:
    'My.Site:Mixin.Column': true

This split allows us to create additional column types later which can bring their own special predefined behaviour without needing a lot more configuration.

With these nodetypes you can already start creating content in your Neos instance and build your structure. But you will also get a lot of error messages as the rendering is not defined. This will be solved in the next step.

Basic Fusion rendering

The Fusion part is very important for these nodetypes and most people struggle with this, especially when trying to use Fusion components. Therefore I first explain how to render those columns in the most basic way. 

We start again with the multicolumn element itself.

prototype(My.Site:Content.Columns) < prototype(Neos.Neos:ContentCollection) {
    @context.node = ${node}
}

Inheriting from Neos.Neos:ContentCollection already gives us all the functionality we need. It has the necessary caching configuration and renders a tag with the content-collection class. We don't even need to override the node in the context as the default would work but this prevents the call of an internal method which tries to find the closest collection.

The next question which usually comes up is how to add your own css class:

prototype(My.Site:Content.Columns) < prototype(Neos.Neos:ContentCollection) {
    @context.node = ${node}
    attributes.class = 'columns'																		    
}

With this we already have a valid rendering for the multicolumn element.

Now let's look at the Fusion code for the column:

prototype(My.Site:Content.Column) < prototype(Neos.Neos:ContentCollection) {
    @context.node = ${node}
    attributes.class = 'column'
}

It is basically the same code as before.

With these two Fusion prototypes in your code you should have a fully working element already. You can add as many columns in your multicolumn element as you want and you can add any type of child into each column.

Of course often you don't want to have any number of columns but predefine some multicolumn elements with two, tree or four columns that an editor can use. This only requires some small changes.

This will currently not work yet with document nodes as there is some specific behaviour in the UI that prevents adding children directly to it. This means you cannot prevent having a ContentCollection between the document node in the structure tree and the first level of content. Except of course if you define fixed child nodes in your nodetype.

You can see in the screenshot how it would look in the Neos backend.

Fixed column count elements

First we split our columns nodetype to increase flexibility:

'My.Site:Mixin.Columns':
  abstract: true
  superTypes:
    'Neos.Neos:Content': true
  ui:
    icon: columns
    group: 'structure'
    inspector:
      groups:
        layout:
          label: 'Layout'
          icon: arrows-h
  constraints:
    nodeTypes:
      'Neos.Neos:Content': false
      'My.Site:Mixin.Column': true

'My.Site:Content.Columns':
  superTypes:
    'My.Site:Mixin.Columns': true
    'Neos.Neos:ContentCollection': true
  ui:
    label: 'Columns'

Me moved most of our configuration to the abstract columns nodetype to make it reusable for our additional multicolumn elements that have a fixed number of columns. We won't move the parent type Neos.Neos:ContentCollection to the abstract nodetype as it's only needed when requiring an interactive container that allows adding and removing children.

Now we can create an element that offers two columns:

'My.Site:Content.TwoColumns':
  superTypes:
    'My.Site:Mixin.Columns': true
  ui:
    label: 'Two columns'
  childNodes:
    column0:
      nodeType: 'My.Site:Content.Column'
    column1:
      nodeType: 'My.Site:Content.Column'

The new nodetype is quite simple. We have a predefined child nodes of the same type we already used before. 

The Fusion part is as easy as before:

prototype(My.Site:Content.TwoColumns) < prototype(Neos.Neos:ContentCollection) {
    @context.node = ${node}
}

Here we still use the Neos.Neos:ContentCollection Fusion prototype to inherit from like before as it provides all the necessary rendering code we need.

Done. You could now do the same for three or more column elements.

Read further if you are interested in building component based elements and how to add some flexible styling to the elements.

Advanced Fusion rendering

The current best practise to render elements with Fusion is to use components. This increases flexibility, reusability and makes the code most of the time better maintainable.

When you just switch the parent prototype for the columns to Neos.Neos:ContentComponent and set the renderer to Neos.Neos:ContentCollection it will render but there will be additional markup in the html content for each element. This happens because both prototypes will add some context wrapping required for the Neos UI. Additional markup often causes issues with existing templates or when using Javascript sliders. So we want to get rid of it.

The nodetype definitions will stay the same. We just have to change the Fusion rendering.

We first start with the columns element again:

prototype(My.Site:Content.Columns) < prototype(Neos.Neos:ContentComponent) {
    renderer = afx`
        <div class="columns">
            <Neos.Neos:ContentCollectionRenderer/>
        </div>
    `
}

This code will already render your element again and you can add and remove columns. But there are some issues with this. When you modify a child the whole element might not update as we are missing the necessary caching configuration from Neos.Neos:ContentCollection.

So we first create a helper component which contains the caching configuration for both our prototypes.

prototype(My.Site:Component.ContentCollection) < prototype(Neos.Neos:ContentComponent) {
    @cache {
        mode = 'cached'
        entryIdentifier {
            collection = ${node}
        }
        entryTags {
            1 = ${Neos.Caching.descendantOfTag(node)}
            2 = ${Neos.Caching.nodeTag(node)}
        }
        maximumLifetime = ${q(node).context({'invisibleContentShown': true}).children().cacheLifetime()}
    }

    @exceptionHandler = 'Neos\\Neos\\Fusion\\ExceptionHandlers\\NodeWrappingHandler'
}

And now we adapt the columns prototype:

prototype(My.Site:Content.Columns) < prototype(My.Site:Component.ContentCollection) {
    renderer = afx`
        <div class="columns">
            <Neos.Neos:ContentCollectionRenderer/>
        </div>
    `
}

With this change the caching behaviour is fixed and also we get better exceptions when there is some issue with a child node. 

The last step is to adapt the column node in the same way:

prototype(My.Site:Content.Column) < prototype(My.Site:Component.ContentCollection) {
    renderer = afx`
        <div class="column">
            <Neos.Neos:ContentCollectionRenderer/>
        </div>
    `
}

Done. You can now work with these elements like with any other component. You can use this way of defining the parent and child nodes for other element types too. This can be sliders, accordions, lists and more. It also possible to create a deeper nesting this way when you need more than 2 levels of nesting. The code is always the same.

The next and last chapter will introduce some basic but flexible way of styling the elements.

Making things pretty

Usually you would already be done by now as you have the necessary css classes in your html to add some styling and make things pretty.

But while building the example code I had some idea for a flexible inline styling variant for multicolumn elements which I wanted to present.

First adjust the columns nodetype as follows:

'My.Site:Content.Columns':
  superTypes:
    'My.Site:Mixin.Columns': true
    'Neos.Neos:ContentCollection': true
  ui:
    label: 'Columns'
  properties:
    columnBaseWidth:
      type: string
      ui:
        label: 'Column min width'
        reloadIfChanged: true
        inspector:
          group: layout
          editorOptions:
            placeholder: 'e.g. 33% or 200px'

Then adjust the single column nodetypes:

'My.Site:Mixin.Column':
  abstract: true
  superTypes:
    'Neos.Neos:Content': true
    'Neos.Neos:ContentCollection': true
  ui:
    label: 'Column'
    icon: columns
    group: 'structure'
    inspector:
      groups:
        layout:
          label: 'Layout'
          icon: arrows-h

'My.Site:Content.Column':
  superTypes:
    'My.Site:Mixin.Column': true
  properties:
    width:
      type: integer
      defaultValue: 1
      ui:
        label: 'Width'
        reloadIfChanged: true
        inspector:
          group: layout

With these changes we can now define the basic width of a column in the parent and define the individual proportion of a single column. We need these two values later to build the flexbox based styling for the frontend. The columnBaseWidth will define the starting width of a single column, later multiplied by its proportional value. This is needed to keep the columns at a certain width before wrapping into another row when there is more than one column and the viewport gets smaller. The proportional width defines the relation between several columns. Having one column with "2" as width and one with "1" will show in the browser as a 66% / 33% layout.

The Fusion part has to be adjusted too. We start with a small helper to render inline styles:

prototype(My.Site:Helper.Styles) < prototype(Neos.Fusion:DataStructure) {
    @process {
        filter = ${Array.filter(value, (val) => val != '' && val != null)}
        map = ${Array.map(value, (val, prop) => prop + ':' + val)}
        join = ${Array.join(value, ';')}
    }
}

This helper can be used to quickly add some inline styles to a component. Empty values will be filtered, so you don't have to worry when using conditions.

Now we adjust the columns renderer again to add the necessary styles for flexbox.

prototype(My.Site:Content.Columns) < prototype(My.Site:Component.ContentCollection) {
    renderer = afx`
        <div class="columns">
            <My.Site:Helper.Styles @path="attributes.style"
                display="flex"
                flex-wrap="wrap"
                margin="-.5rem"
            />
            <Neos.Neos:ContentCollectionRenderer/>
        </div>
    `
}

With these inline styles we get columns that stay next to each other and will wrap when needed. The margin will allow us to add some spacing between the columns.

Now let's change the column element too:

prototype(My.Site:Content.Column) < prototype(My.Site:Component.ContentCollection) {
    width = ${q(node).property('width')}
    columnBaseWidth = ${q(node).parent().property('columnBaseWidth')}

    renderer = afx`
        <div class="column">
            <My.Site:Helper.Styles @path="attributes.style"
                flex={props.width}
                flex-basis={'calc(' + props.width + '*' + props.columnBaseWidth + ')'}
                padding=".5rem"
            />
            <Neos.Neos:ContentCollectionRenderer/>
        </div>
    `

    @cache.entryTags.parent = ${Neos.Caching.nodeTag(q(node).parent())}
}

We retrieve the two width related values and put them into the inline styles for the column. To make a column with a larger proportion behave correctly, we use the css calc function to adjust the basic width. We also have to modify the caching configuration to force the column to be rendered when the parent is changed.

We are done! 

You now have a very flexible multicolumn element that allows making individual columns narrower or wider and you can influence the wrapping behaviour via the parent. The same works of course also with the previously shown two columns element or any other variant.

If you don't like the inline styles you could instead use select boxes for the the width properties and then render a css class based on that for the wrapping div. Another improvement would also to have some validation so that not any kind of value can be entered.

You can also find all the code as gist.

Conclusion

With the proposed nodetype definitions and Fusion rendering you get a much reduced content structure. This has benefits for performance and usability. Your editors will be more productive and happier when using Neos CMS.

My examples are of course very basic and should be improved and extended in a real project. But if you think even for this blog post something could be done better or if you found an error, please get in touch.

Update 5.11.2019: Added two screenshots of the structure in the Neos backend