Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions Classes/DataSource/ReferenceResolverSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

namespace Mireo\RepeatableFields\DataSource;

use Neos\ContentRepository\Domain\Model\NodeInterface;
use Neos\ContentRepository\Domain\Service\ContextFactoryInterface;
use Neos\Flow\Annotations as Flow;
use Neos\Neos\Service\DataSource\AbstractDataSource;
use Neos\Neos\Service\UserService;

/**
* DataSource for resolving node references to their properties.
* Used by the repeatable field preview to display meaningful information
* about referenced nodes instead of just their identifiers.
*/
class ReferenceResolverSource extends AbstractDataSource
{
/**
* @var string
*/
protected static $identifier = 'resolve-references';

/**
* @Flow\Inject
* @var ContextFactoryInterface
*/
protected $contextFactory;

/**
* @Flow\Inject
* @var UserService
*/
protected $userService;

/**
* Resolve node identifiers to their properties.
*
* @param NodeInterface $node The node that is currently edited (provides context)
* @param array $arguments Additional arguments containing 'identifiers' array and 'contextNodePath'
* @return array Map of nodeIdentifier => { label, identifier, nodeType, icon, properties }
* @api
*/
public function getData(NodeInterface $node = null, array $arguments = [])
{
$identifiers = $arguments['identifiers'] ?? [];

if (empty($identifiers) || !is_array($identifiers)) {
return [];
}

// Get context from the node if available, otherwise create one for the current user's workspace
if ($node !== null) {
$context = $node->getContext();
} else {
$context = $this->contextFactory->create([
'workspaceName' => $this->userService->getPersonalWorkspaceName(),
'invisibleContentShown' => true,
'removedContentShown' => false,
'inaccessibleContentShown' => true
]);
}

$result = [];

foreach ($identifiers as $identifier) {
if (empty($identifier) || !is_string($identifier)) {
continue;
}

$referencedNode = $context->getNodeByIdentifier($identifier);

if ($referencedNode instanceof NodeInterface) {
$nodeType = $referencedNode->getNodeType();

// Get all node properties
$properties = [];
foreach ($referencedNode->getProperties() as $propertyName => $propertyValue) {
// Only include serializable properties (skip objects like images, assets)
if (is_scalar($propertyValue) || is_null($propertyValue)) {
$properties[$propertyName] = $propertyValue;
} elseif (is_array($propertyValue)) {
// Include arrays if they contain only scalar values
$isSerializable = true;
array_walk_recursive($propertyValue, function ($item) use (&$isSerializable) {
if (!is_scalar($item) && !is_null($item)) {
$isSerializable = false;
}
});
if ($isSerializable) {
$properties[$propertyName] = $propertyValue;
}
}
}

$result[$identifier] = [
'label' => $referencedNode->getLabel(),
'identifier' => $identifier,
'nodeType' => $nodeType->getName(),
'icon' => $nodeType->getConfiguration('ui.icon') ?? 'question',
'properties' => $properties
];
}
}

return $result;
}
}
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Create property with type `reapeatable`.
# ...
# collapse view on load. controls.collapse must be true. defaults to false
collapsed: true
# Set preview
# Set preview (see "Preview with Reference Properties" section below)
preview:
text: 'ItemEval: item.field0'
image: 'ItemEval: item.field1'
Expand All @@ -93,6 +93,52 @@ Create property with type `reapeatable`.
placeholder: 'test placeholder 2'
```

## Preview with Reference Properties

When using `reference` type properties in a repeatable field, you can access the referenced node's properties in preview expressions. This allows you to display meaningful information like a person's name instead of just the node identifier.

### Example

```YAML
properties:
teamMembers:
type: repeatable
ui:
inspector:
editorOptions:
collapsed: true
preview:
text: 'ItemEval: item.person?.properties?.firstName + " " + item.person?.properties?.lastName'
properties:
person:
type: reference
label: 'Person'
editorOptions:
nodeTypes: ['My.Package:Document.Person']
```

### Available Properties

When a property is a `reference` type, the preview expression can access:

**Node properties** (nested under `properties` to avoid naming collisions):
- `item.propertyName.properties.firstName` - Any property defined on the referenced node
- `item.propertyName.properties.lastName`
- `item.propertyName.properties.position`
- etc.

**Metadata** (top-level):
- `item.propertyName.label` - The node's label
- `item.propertyName.identifier` - The node identifier (UUID)
- `item.propertyName.nodeType` - The node type name
- `item.propertyName.icon` - The node type icon

### Notes

- Reference resolution only applies to preview expressions (`preview.text` and `preview.image`)
- Editors continue to work with the original node identifier
- Only scalar properties (strings, numbers, booleans) are available; complex objects like images are not included

## Important notice

Please don't name any property (in the example `fieldN`) `_UUID_`, as this is used internaly to set a unique key to the items
Expand Down
62 changes: 60 additions & 2 deletions Resources/Private/Editor/Repeatable/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ function Repeatable({
const [options, setOptions] = useState(hasDataSource ? null : props.options);
const [emptyGroup, setEmptyGroup] = useState({});
const [collapsed, setCollapsed] = useState({});
// Separate state for resolved references - used ONLY for preview, never merged into currentValue
const [resolvedReferences, setResolvedReferences] = useState({});

// We use this hack to prevent the editor from re-rendering all the time, even if the options are the same.
const returnCurrentValueAsJSON = () => JSON.stringify(currentValue);
Expand Down Expand Up @@ -121,6 +123,52 @@ function Repeatable({
});
}, [dataSourceIdentifier, dataSourceUri, dataSourceAdditionalData]);

// Fetch resolved reference data for preview - stored separately, never modifies currentValue
useEffect(() => {
if (!options?.properties || !currentValue?.length) {
return;
}

// Find properties that are reference types
const referenceProperties = Object.entries(options.properties)
.filter(([, config]) => config?.type === "reference")
.map(([name]) => name);

if (referenceProperties.length === 0) {
return;
}

// Collect all unique node identifiers from reference properties
const identifiers = new Set();
currentValue.forEach((item) => {
referenceProperties.forEach((propName) => {
const value = item[propName];
if (typeof value === "string" && value) {
identifiers.add(value);
}
});
});

if (identifiers.size === 0) {
return;
}

// Fetch resolved reference data from the DataSource
backend
.get()
.endpoints.dataSource("resolve-references", null, {
identifiers: Array.from(identifiers),
})
.then((resolved) => {
if (resolved && typeof resolved === "object") {
setResolvedReferences(resolved);
}
})
.catch((error) => {
console.warn("Failed to resolve references for preview:", error);
});
}, [currentValue, options?.properties]);

function getEmptyGroup() {
let group = {};
const properties = options.properties;
Expand Down Expand Up @@ -366,11 +414,21 @@ function Repeatable({
if (!text && !image) {
return null;
}

// Create a TEMPORARY enriched copy for preview evaluation ONLY
// This does NOT modify currentValue or affect editors
const itemForPreview = { ...currentValue[idx] };
for (const [propName, value] of Object.entries(itemForPreview)) {
if (typeof value === "string" && resolvedReferences[value]) {
itemForPreview[propName] = resolvedReferences[value];
}
}

if (text) {
text = ItemEvalRecursive(text, currentValue[idx], props.node, props.parentNode, props.documentNode);
text = ItemEvalRecursive(text, itemForPreview, props.node, props.parentNode, props.documentNode);
}
if (image) {
image = ItemEvalRecursive(image, currentValue[idx], props.node, props.parentNode, props.documentNode);
image = ItemEvalRecursive(image, itemForPreview, props.node, props.parentNode, props.documentNode);
}
return <Preview text={i18nRegistry.translate(text)} image={image} />;
}
Expand Down
8 changes: 4 additions & 4 deletions Resources/Public/Plugin.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions Resources/Public/Plugin.js.map

Large diffs are not rendered by default.