Repository: neos/neos-development-collection
Pull request #5515: FEATURE: Custom label for auto created child nodes
Author: login: Sebobo
Labels: name: Feature
name: 9.0
Created: 2025-03-14T11:37:59Z | Merged: 2025-03-14T19:48:24Z

This is the port from the feature merged into Neos 8.4, which was skipped during upmerge, see #530

![CleanShot 2025-03-14 at 12 37 45@2x](https://github.com/user-attachments/assets/39313e5b-ce34-45a9-9c34-9cfc92e926af)



Diff:
diff --git a/Neos.ContentRepositoryRegistry/Resources/Private/Schema/NodeTypes.schema.yaml b/Neos.ContentRepositoryRegistry/Resources/Private/Schema/NodeTypes.schema.yaml
index bdeececdb70..fdc5ae5c934 100644
--- a/Neos.ContentRepositoryRegistry/Resources/Private/Schema/NodeTypes.schema.yaml
+++ b/Neos.ContentRepositoryRegistry/Resources/Private/Schema/NodeTypes.schema.yaml
@@ -18,7 +18,7 @@ additionalProperties:
 
     'label':
       type: ['string', 'array']
-      description: "An EEL expression for generating a node's of this node type's label. Alternatively an array with the key generatorClass and optional options."
+      description: "An EEL expression for generating a node's of this node type's label. Alternatively an array with the key generatorClass and optional generator options."
 
     'constraints': &constraints
       type: dictionary
@@ -41,6 +41,7 @@ additionalProperties:
             type: dictionary
             additionalProperties: false
             properties:
+              'label': { type: string, description: "A string or an EEL expression for generating a node's of this node type's label. Alternatively an array with the key generatorClass and optional generator options." }
               'type': { type: string, description: "Node Type of this child node." }
               'position':
                 type: ['string', 'null']
diff --git a/Neos.Neos/Classes/Domain/NodeLabel/ExpressionBasedNodeLabelGenerator.php b/Neos.Neos/Classes/Domain/NodeLabel/ExpressionBasedNodeLabelGenerator.php
index cf72f4c118d..e5c517c40b8 100644
--- a/Neos.Neos/Classes/Domain/NodeLabel/ExpressionBasedNodeLabelGenerator.php
+++ b/Neos.Neos/Classes/Domain/NodeLabel/ExpressionBasedNodeLabelGenerator.php
@@ -14,7 +14,10 @@
  * source code.
  */
 
+use Neos\ContentRepository\Core\NodeType\NodeType;
 use Neos\ContentRepository\Core\Projection\ContentGraph\Node;
+use Neos\ContentRepository\Core\SharedModel\Node\NodeName;
+use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
 use Neos\Eel\EelEvaluatorInterface;
 use Neos\Eel\Utility;
 use Neos\Flow\Annotations as Flow;
@@ -26,28 +29,23 @@
  */
 class ExpressionBasedNodeLabelGenerator implements NodeLabelGeneratorInterface
 {
-    /**
-     * @Flow\Inject
-     */
+    #[Flow\Inject]
     protected EelEvaluatorInterface $eelEvaluator;
 
+    #[Flow\Inject]
+    protected ContentRepositoryRegistry $contentRepositoryRegistry;
+
     /**
-     * @Flow\InjectConfiguration(package="Neos.Neos", path="labelGenerator.eel.defaultContext")
      * @var array<string, string>
      */
-    protected $defaultContextConfiguration;
+    #[Flow\InjectConfiguration('labelGenerator.eel.defaultContext', 'Neos.Neos')]
+    protected ?array $defaultContextConfiguration;
 
-    /**
-     * @var string
-     */
-    protected $expression = <<<'EEL'
+    protected string $expression = <<<'EEL'
     ${(Neos.Node.nodeType(node).label || node.nodeTypeName) + (node.nodeName ? ' (' + node.nodeName + ')' : '')}
     EEL;
 
-    /**
-     * @return string
-     */
-    public function getExpression()
+    public function getExpression(): string
     {
         return $this->expression;
     }
@@ -63,9 +61,33 @@ public function setExpression(string $expression): void
      */
     public function getLabel(Node $node): string
     {
-        if (Utility::parseEelExpression($this->getExpression()) === null) {
-            return $this->getExpression();
+        $contentRepository = $this->contentRepositoryRegistry->get($node->contentRepositoryId);
+        $nodeTypeManager = $contentRepository->getNodeTypeManager();
+        $subgraph = $this->contentRepositoryRegistry->subgraphForNode($node);
+        $expression = $this->getExpression();
+
+        // If the node is tethered, we try to find a label expression in the parent node type configuration
+        if ($node->name && $node->classification->isTethered()) {
+            $parentNode = $subgraph->findParentNode($node->aggregateId);
+            $parentNodeType = $parentNode ? $nodeTypeManager->getNodeType($parentNode->nodeTypeName) : null;
+
+            if ($parentNodeType && $parentNodeType->tetheredNodeTypeDefinitions->contain($node->name)) {
+                $property = 'childNodes.' . $node->name . '.label';
+                if ($parentNodeType->hasConfiguration($property)) {
+                    $expression = $parentNodeType->getConfiguration($property);
+                }
+            }
         }
-        return (string)Utility::evaluateEelExpression($this->getExpression(), $this->eelEvaluator, ['node' => $node], $this->defaultContextConfiguration);
+
+        if (Utility::parseEelExpression($expression) === null) {
+            return $expression;
+        }
+
+        return (string)Utility::evaluateEelExpression(
+            $expression,
+            $this->eelEvaluator,
+            ['node' => $node],
+            $this->defaultContextConfiguration ?? []
+        );
     }
 }
diff --git a/Neos.Neos/Tests/Behavior/Features/Fusion/FlowQuery.feature b/Neos.Neos/Tests/Behavior/Features/Fusion/FlowQuery.feature
index 8501beac07b..219bb673c1a 100644
--- a/Neos.Neos/Tests/Behavior/Features/Fusion/FlowQuery.feature
+++ b/Neos.Neos/Tests/Behavior/Features/Fusion/FlowQuery.feature
@@ -24,6 +24,8 @@ Feature: Tests for the "Neos.ContentRepository" Flow Query methods.
       properties:
         title:
           type: string
+    'Neos.Neos:ContentCollection':
+      label: "${Neos.Node.labelForNode(node)}"
     'Neos.Neos:Test.DocumentType1':
       superTypes:
         'Neos.Neos:Document': true
@@ -36,7 +38,16 @@ Feature: Tests for the "Neos.ContentRepository" Flow Query methods.
     'Neos.Neos:Test.Content':
       superTypes:
         'Neos.Neos:Content': true
-
+    'Neos.Neos:Test.Columns':
+      superTypes:
+        'Neos.Neos:Content': true
+      childNodes:
+        column0:
+          label: 'Left Column'
+          type: 'Neos.Neos:ContentCollection'
+        column1:
+          label: 'Right Column'
+          type: 'Neos.Neos:ContentCollection'
     """
     And using identifier "default", I define a content repository
     And I am in content repository "default"
@@ -72,6 +83,7 @@ Feature: Tests for the "Neos.ContentRepository" Flow Query methods.
       | a1b3            | a1b                   | Neos.Neos:Test.DocumentType1  | {"uriPathSegment": "a1b3", "title": "Node a1b3"}                     | a1b3     |
       | a1c             | a1                    | Neos.Neos:Test.DocumentType1  | {"uriPathSegment": "a1c", "title": "Node a1c", "hiddenInMenu": true} | a1c      |
       | a1c1            | a1c                   | Neos.Neos:Test.DocumentType1  | {"uriPathSegment": "a1c1", "title": "Node a1c1"}                     | a1c1     |
+      | a1d             | a1                    | Neos.Neos:Test.Columns        | {}                                                                   | a1d      |
     Given the current date and time is "2024-09-22T18:00:00+01:00"
     And the following CreateNodeAggregateWithNode commands are executed:
       | nodeAggregateId | parentNodeAggregateId | nodeTypeName                  | initialPropertyValues                                                | nodeName |
@@ -468,3 +480,21 @@ Feature: Tests for the "Neos.ContentRepository" Flow Query methods.
         "nodeTypeName": "Neos.Neos:Test.DocumentType1"
     }
     """
+
+  Scenario: Childnode label rendering
+    When the Fusion context node is "a1d"
+    When I execute the following Fusion code:
+    """fusion
+    test = Neos.Fusion:DataStructure {
+      leftColumnLabel = ${Neos.Node.label(q(node).children().get(0))}
+      rightColumnLabel = ${Neos.Node.label(q(node).children().get(1))}
+      @process.render = ${Json.stringify(value, ['JSON_PRETTY_PRINT'])}
+    }
+    """
+    Then I expect the following Fusion rendering result:
+    """
+    {
+        "leftColumnLabel": "Left Column",
+        "rightColumnLabel": "Right Column"
+    }
+    """


Reviews:
user: login: dlubitz
body: Thank you. This looks good. I left one change request.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T11:49:47Z
user: login: mhsdesign
body: Thanks for upmerging that ... it would be super great to have a simple test like https://github.com/neos/neos-development-collection/blob/2308db7e1684d52a7a07e3d53ff1a5f249e3b2a1/Neos.Neos/Tests/Behavior/Features/Fusion/FlowQuery.feature#L453-L470 to show that this work ... :) youll love working with behat^^ 
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-14T12:56:32Z
user: login: Sebobo
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T13:14:02Z
user: login: mhsdesign
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T14:22:33Z
user: login: mhsdesign
body: Thanks looks good ❤️  now only `composer run lint` must be green :D
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-14T15:15:03Z
user: login: dlubitz
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T19:21:56Z

Review comments:
diff_hunk: @@ -63,9 +61,37 @@ public function setExpression(string $expression): void
      */
     public function getLabel(Node $node): string
     {
-        if (Utility::parseEelExpression($this->getExpression()) === null) {
-            return $this->getExpression();
+        $contentRepository = $this->contentRepositoryRegistry->get($node->contentRepositoryId);
+        $nodeTypeManager = $contentRepository->getNodeTypeManager();
+        $nodeTypeManager->getNodeType($node->nodeTypeName);
+        $subgraph = $this->contentRepositoryRegistry->subgraphForNode($node);
+
+        $expression = $this->getExpression();
+        $parentNode = $subgraph->findParentNode($node->aggregateId);
+        $parentNodeType = $nodeTypeManager->getNodeType($parentNode?->nodeTypeName);
path: Neos.Neos/Classes/Domain/NodeLabel/ExpressionBasedNodeLabelGenerator.php
user: login: dlubitz
body: This will crash, if the $parentNode is null.
```suggestion
        $parentNodeType = $parentNode ? $nodeTypeManager->getNodeType($parentNode->nodeTypeName) : null;
```
created_at: 2025-03-14T11:48:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -63,9 +61,37 @@ public function setExpression(string $expression): void
      */
     public function getLabel(Node $node): string
     {
-        if (Utility::parseEelExpression($this->getExpression()) === null) {
-            return $this->getExpression();
+        $contentRepository = $this->contentRepositoryRegistry->get($node->contentRepositoryId);
+        $nodeTypeManager = $contentRepository->getNodeTypeManager();
+        $nodeTypeManager->getNodeType($node->nodeTypeName);
path: Neos.Neos/Classes/Domain/NodeLabel/ExpressionBasedNodeLabelGenerator.php
user: login: mhsdesign
body: unused

```suggestion
```
created_at: 2025-03-14T12:48:01Z
author_association: MEMBER
diff_hunk: @@ -63,9 +61,37 @@ public function setExpression(string $expression): void
      */
     public function getLabel(Node $node): string
     {
-        if (Utility::parseEelExpression($this->getExpression()) === null) {
-            return $this->getExpression();
+        $contentRepository = $this->contentRepositoryRegistry->get($node->contentRepositoryId);
+        $nodeTypeManager = $contentRepository->getNodeTypeManager();
+        $nodeTypeManager->getNodeType($node->nodeTypeName);
+        $subgraph = $this->contentRepositoryRegistry->subgraphForNode($node);
+
+        $expression = $this->getExpression();
+        $parentNode = $subgraph->findParentNode($node->aggregateId);
+        $parentNodeType = $parentNode ? $nodeTypeManager->getNodeType($parentNode->nodeTypeName) : null;
+
+        if ($parentNodeType && $this->isAutoCreatedChildNode(
+            $parentNodeType,
+            $node->name
+        )) {
+            $property = 'childNodes.' . $node->name . '.label';
+            if ($parentNodeType->hasConfiguration($property)) {
+                $expression = $parentNodeType->getConfiguration($property);
+            }
+        }
+        if (Utility::parseEelExpression($expression) === null) {
+            return $expression;
         }
-        return (string)Utility::evaluateEelExpression($this->getExpression(), $this->eelEvaluator, ['node' => $node], $this->defaultContextConfiguration);
+        return (string)Utility::evaluateEelExpression(
+            $this->getExpression(),
path: Neos.Neos/Classes/Domain/NodeLabel/ExpressionBasedNodeLabelGenerator.php
user: login: mhsdesign
body: ```suggestion
            $expression,
```
created_at: 2025-03-14T12:49:17Z
author_association: MEMBER
diff_hunk: @@ -63,9 +61,37 @@ public function setExpression(string $expression): void
      */
     public function getLabel(Node $node): string
     {
-        if (Utility::parseEelExpression($this->getExpression()) === null) {
-            return $this->getExpression();
+        $contentRepository = $this->contentRepositoryRegistry->get($node->contentRepositoryId);
+        $nodeTypeManager = $contentRepository->getNodeTypeManager();
+        $nodeTypeManager->getNodeType($node->nodeTypeName);
+        $subgraph = $this->contentRepositoryRegistry->subgraphForNode($node);
+
+        $expression = $this->getExpression();
+        $parentNode = $subgraph->findParentNode($node->aggregateId);
+        $parentNodeType = $parentNode ? $nodeTypeManager->getNodeType($parentNode->nodeTypeName) : null;
+
+        if ($parentNodeType && $this->isAutoCreatedChildNode(
+            $parentNodeType,
+            $node->name
+        )) {
+            $property = 'childNodes.' . $node->name . '.label';
+            if ($parentNodeType->hasConfiguration($property)) {
+                $expression = $parentNodeType->getConfiguration($property);
+            }
+        }
+        if (Utility::parseEelExpression($expression) === null) {
+            return $expression;
         }
-        return (string)Utility::evaluateEelExpression($this->getExpression(), $this->eelEvaluator, ['node' => $node], $this->defaultContextConfiguration);
+        return (string)Utility::evaluateEelExpression(
+            $this->getExpression(),
path: Neos.Neos/Classes/Domain/NodeLabel/ExpressionBasedNodeLabelGenerator.php
user: login: mhsdesign
body: this cannot work without this:)
created_at: 2025-03-14T12:50:02Z
author_association: MEMBER
diff_hunk: @@ -63,9 +61,37 @@ public function setExpression(string $expression): void
      */
     public function getLabel(Node $node): string
     {
-        if (Utility::parseEelExpression($this->getExpression()) === null) {
-            return $this->getExpression();
+        $contentRepository = $this->contentRepositoryRegistry->get($node->contentRepositoryId);
+        $nodeTypeManager = $contentRepository->getNodeTypeManager();
+        $nodeTypeManager->getNodeType($node->nodeTypeName);
+        $subgraph = $this->contentRepositoryRegistry->subgraphForNode($node);
+
+        $expression = $this->getExpression();
+        $parentNode = $subgraph->findParentNode($node->aggregateId);
+        $parentNodeType = $parentNode ? $nodeTypeManager->getNodeType($parentNode->nodeTypeName) : null;
+
+        if ($parentNodeType && $this->isAutoCreatedChildNode(
path: Neos.Neos/Classes/Domain/NodeLabel/ExpressionBasedNodeLabelGenerator.php
user: login: mhsdesign
body: before fetching the `$parentNode` id like to make a non expensive check if the `$node->classification->isTethered()` because otherwise we can just not do that :D
created_at: 2025-03-14T12:54:16Z
author_association: MEMBER
diff_hunk: @@ -63,9 +61,37 @@ public function setExpression(string $expression): void
      */
     public function getLabel(Node $node): string
     {
-        if (Utility::parseEelExpression($this->getExpression()) === null) {
-            return $this->getExpression();
+        $contentRepository = $this->contentRepositoryRegistry->get($node->contentRepositoryId);
+        $nodeTypeManager = $contentRepository->getNodeTypeManager();
+        $nodeTypeManager->getNodeType($node->nodeTypeName);
+        $subgraph = $this->contentRepositoryRegistry->subgraphForNode($node);
+
+        $expression = $this->getExpression();
+        $parentNode = $subgraph->findParentNode($node->aggregateId);
+        $parentNodeType = $parentNode ? $nodeTypeManager->getNodeType($parentNode->nodeTypeName) : null;
+
+        if ($parentNodeType && $this->isAutoCreatedChildNode(
path: Neos.Neos/Classes/Domain/NodeLabel/ExpressionBasedNodeLabelGenerator.php
user: login: mhsdesign
body: Its kindof unfortunate that fetching the label is now an expensive operation also for main content collections etc. but this is the pain for crazy comfort :)
created_at: 2025-03-14T12:55:03Z
author_association: MEMBER
diff_hunk: @@ -63,9 +61,37 @@ public function setExpression(string $expression): void
      */
     public function getLabel(Node $node): string
     {
-        if (Utility::parseEelExpression($this->getExpression()) === null) {
-            return $this->getExpression();
+        $contentRepository = $this->contentRepositoryRegistry->get($node->contentRepositoryId);
+        $nodeTypeManager = $contentRepository->getNodeTypeManager();
+        $nodeTypeManager->getNodeType($node->nodeTypeName);
+        $subgraph = $this->contentRepositoryRegistry->subgraphForNode($node);
+
+        $expression = $this->getExpression();
+        $parentNode = $subgraph->findParentNode($node->aggregateId);
+        $parentNodeType = $parentNode ? $nodeTypeManager->getNodeType($parentNode->nodeTypeName) : null;
+
+        if ($parentNodeType && $this->isAutoCreatedChildNode(
path: Neos.Neos/Classes/Domain/NodeLabel/ExpressionBasedNodeLabelGenerator.php
user: login: Sebobo
body: Is the check `$nodeTypeOfParent->tetheredNodeTypeDefinitions->contain($nodeName)` still needed afterwards?
created_at: 2025-03-14T13:14:02Z
author_association: MEMBER
diff_hunk: @@ -63,9 +61,37 @@ public function setExpression(string $expression): void
      */
     public function getLabel(Node $node): string
     {
-        if (Utility::parseEelExpression($this->getExpression()) === null) {
-            return $this->getExpression();
+        $contentRepository = $this->contentRepositoryRegistry->get($node->contentRepositoryId);
+        $nodeTypeManager = $contentRepository->getNodeTypeManager();
+        $nodeTypeManager->getNodeType($node->nodeTypeName);
+        $subgraph = $this->contentRepositoryRegistry->subgraphForNode($node);
+
+        $expression = $this->getExpression();
+        $parentNode = $subgraph->findParentNode($node->aggregateId);
+        $parentNodeType = $parentNode ? $nodeTypeManager->getNodeType($parentNode->nodeTypeName) : null;
+
+        if ($parentNodeType && $this->isAutoCreatedChildNode(
path: Neos.Neos/Classes/Domain/NodeLabel/ExpressionBasedNodeLabelGenerator.php
user: login: mhsdesign
body: nope not really because by accessing `.childNodes` like you do safely this is the same
created_at: 2025-03-14T14:22:33Z
author_association: MEMBER

PR comments:
user: login: Sebobo
created_at: 2025-03-14T13:14:55Z
body: > Thanks for upmerging that ... it would be super great to have a simple test like
> 
> https://github.com/neos/neos-development-collection/blob/2308db7e1684d52a7a07e3d53ff1a5f249e3b2a1/Neos.Neos/Tests/Behavior/Features/Fusion/FlowQuery.feature#L453-L470
> 
> to show that this work ... :) youll love working with behat^^

Yes, all the old tests seem to be gone and I didn't unterstand where to put the new test I started. So I hoped you would point that out 😄
author_association: MEMBER
user: login: Sebobo
created_at: 2025-03-14T14:56:30Z
body: @mhsdesign thx for the feedback. Got the test running and cleaned up the code.
author_association: MEMBER
user: login: Sebobo
created_at: 2025-03-14T17:44:31Z
body: Realised we missed the `i18n` translation feature also in the original PR.

Need to wrap my head around the `NodeTypeEnrichmentService` to fix this, as there is also some other place (ui?) where the actual translation then needs to happen, as simply converting `i18n` to the correct identifier is not working on its own. But we can do this in a separate PR imo and we need it for both versions.
author_association: MEMBER