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
24 changes: 19 additions & 5 deletions src/Core/Component/ComponentRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
namespace TYPO3Fluid\Fluid\Core\Component;

use TYPO3Fluid\Fluid\Core\Rendering\RenderingContextInterface;
use TYPO3Fluid\Fluid\Core\ViewHelper\ViewHelperVariableContainer;
use TYPO3Fluid\Fluid\View\TemplateView;
use TYPO3Fluid\Fluid\ViewHelpers\SlotViewHelper;

Expand All @@ -36,14 +35,29 @@ public function renderComponent(string $viewHelperName, array $arguments, array
$renderingContext->setViewHelperResolver($renderingContext->getViewHelperResolver()->getScopedCopy());
$renderingContext->setVariableProvider($renderingContext->getVariableProvider()->getScopeCopy($arguments));

// Provide slots to SlotViewHelper
$renderingContext->setViewHelperVariableContainer(new ViewHelperVariableContainer());
$renderingContext->getViewHelperVariableContainer()->addAll(SlotViewHelper::class, $slots);
// Provide slots to SlotViewHelper while preserving the parent ViewHelperVariableContainer
// so that context set by outer ViewHelpers (e.g. TYPO3's FormViewHelper) remains available
// inside components. Slot state is saved and restored to support proper component nesting.
$parentVhvc = $parentRenderingContext->getViewHelperVariableContainer();
$previousSlots = $parentVhvc->getAll(SlotViewHelper::class);
foreach ($slots as $slotName => $slotClosure) {
$parentVhvc->addOrUpdate(SlotViewHelper::class, $slotName, $slotClosure);
}
$renderingContext->setViewHelperVariableContainer($parentVhvc);

// Create Fluid view for component
// render() call includes validation of provided arguments
$view = new TemplateView($renderingContext);
$view->assignMultiple($this->componentResolver->getAdditionalVariables($viewHelperName));
return (string)$view->render($this->componentResolver->resolveTemplateName($viewHelperName));
$result = (string)$view->render($this->componentResolver->resolveTemplateName($viewHelperName));

// Restore previous slot state so that outer components can still access their own slots.
foreach (array_keys($slots) as $slotName) {
$parentVhvc->remove(SlotViewHelper::class, $slotName);
if (array_key_exists($slotName, $previousSlots)) {
$parentVhvc->addOrUpdate(SlotViewHelper::class, $slotName, $previousSlots[$slotName]);
}
}
return $result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);

/*
* This file belongs to the package "TYPO3 Fluid".
* See LICENSE.txt that was shipped with this package.
*/

namespace TYPO3Fluid\Fluid\Tests\Functional\Core\Component;

use PHPUnit\Framework\Attributes\Test;
use TYPO3Fluid\Fluid\Tests\Functional\AbstractFunctionalTestCase;
use TYPO3Fluid\Fluid\View\TemplateView;

/**
* Verifies that ViewHelpers rendered inside a component can still access ViewHelperVariableContainer
* state set up by an outer ViewHelper (e. g. a form ViewHelper), matching the behavior components need
* to be usable as drop-in replacements for sitegeist/fluid-components.
*/
final class ComponentViewHelperVariableContainerTest extends AbstractFunctionalTestCase
{
private function renderTemplate(string $source): string
{
$view = new TemplateView();
$view->getRenderingContext()->setCache(self::$cache);
$view->getRenderingContext()->getViewHelperResolver()->addNamespace('my', 'TYPO3Fluid\Fluid\Tests\Functional\Fixtures\ComponentCollections\BasicComponentCollection');
$view->getRenderingContext()->getViewHelperResolver()->addNamespace('formComponent', 'TYPO3Fluid\Fluid\Tests\Functional\Fixtures\ComponentCollections\FormComponentCollection');
$view->getRenderingContext()->getViewHelperResolver()->addNamespace('form', 'TYPO3Fluid\Fluid\Tests\Functional\Fixtures\ViewHelpers\MockForm');
$view->getRenderingContext()->getTemplatePaths()->setTemplateSource($source);
return $view->render();
}

#[Test]
public function outerViewHelperContextIsAvailableInsideComponent(): void
{
$source = '<form:context><formComponent:formFieldComponent name="field1" /></form:context>';
$expected = '<input name="field1" />' . "\n" . '|fieldNames:field1';

self::assertSame($expected, $this->renderTemplate($source), 'uncached');
self::assertSame($expected, $this->renderTemplate($source), 'cached');
}

#[Test]
public function fieldViewHelperWithoutOuterContextRendersWithoutIt(): void
{
$source = '<formComponent:formFieldComponent name="field1" />';
$expected = '<input name="field1" without-context />' . "\n";

self::assertSame($expected, $this->renderTemplate($source), 'uncached');
self::assertSame($expected, $this->renderTemplate($source), 'cached');
}

#[Test]
public function multipleComponentsInsideOuterContextAllRegisterThemselves(): void
{
$source = '<form:context><formComponent:formFieldComponent name="field1" /><formComponent:formFieldComponent name="field2" /></form:context>';
$expected = '<input name="field1" />' . "\n" . '<input name="field2" />' . "\n" . '|fieldNames:field1,field2';

self::assertSame($expected, $this->renderTemplate($source), 'uncached');
self::assertSame($expected, $this->renderTemplate($source), 'cached');
}

#[Test]
public function nestedComponentSlotsRemainIsolatedWhileSharingOuterContext(): void
{
$source = '<form:context><my:namedSlots><f:fragment name="test1"><formComponent:formFieldComponent name="field1" /></f:fragment></my:namedSlots></form:context>';
$expected = '|<input name="field1" />' . "\n" . '|||' . "\n" . '|fieldNames:field1';

self::assertSame($expected, $this->renderTemplate($source), 'uncached');
self::assertSame($expected, $this->renderTemplate($source), 'cached');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

/*
* This file belongs to the package "TYPO3 Fluid".
* See LICENSE.txt that was shipped with this package.
*/

namespace TYPO3Fluid\Fluid\Tests\Functional\Fixtures\ComponentCollections;

use TYPO3Fluid\Fluid\Core\Component\AbstractComponentCollection;
use TYPO3Fluid\Fluid\View\TemplatePaths;

final class FormComponentCollection extends AbstractComponentCollection
{
public function getTemplatePaths(): TemplatePaths
{
$templatePaths = new TemplatePaths();
$templatePaths->setTemplateRootPaths([
__DIR__ . '/FormComponents/',
]);
return $templatePaths;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<f:argument name="name" type="string" /><form:field name="{name}" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

/*
* This file belongs to the package "TYPO3 Fluid".
* See LICENSE.txt that was shipped with this package.
*/

namespace TYPO3Fluid\Fluid\Tests\Functional\Fixtures\ViewHelpers\MockForm;

use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper;

/**
* Minimal stand-in for a form ViewHelper (such as TYPO3's FormViewHelper) that stores state in the
* ViewHelperVariableContainer for nested field ViewHelpers to consume, e. g. to collect field names
* for "trustedProperties". Used to test that this context remains available inside components.
*/
final class ContextViewHelper extends AbstractViewHelper
{
protected $escapeOutput = false;

public function render(): string
{
$variableContainer = $this->renderingContext->getViewHelperVariableContainer();
$variableContainer->addOrUpdate(self::class, 'fieldNames', []);

$content = $this->renderChildren();

$fieldNames = $variableContainer->get(self::class, 'fieldNames', []);
$variableContainer->remove(self::class, 'fieldNames');

return $content . '|fieldNames:' . implode(',', $fieldNames);
}
}
43 changes: 43 additions & 0 deletions tests/Functional/Fixtures/ViewHelpers/MockForm/FieldViewHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

/*
* This file belongs to the package "TYPO3 Fluid".
* See LICENSE.txt that was shipped with this package.
*/

namespace TYPO3Fluid\Fluid\Tests\Functional\Fixtures\ViewHelpers\MockForm;

use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper;

/**
* Minimal stand-in for a form field ViewHelper (such as TYPO3's TextfieldViewHelper) that reads
* context from a surrounding ContextViewHelper via the ViewHelperVariableContainer and registers
* its name for "trustedProperties". Used to test that this context remains available inside components.
*/
final class FieldViewHelper extends AbstractViewHelper
{
protected $escapeOutput = false;

public function initializeArguments(): void
{
$this->registerArgument('name', 'string', 'Field name', true);
}

public function render(): string
{
$name = $this->arguments['name'];
$variableContainer = $this->renderingContext->getViewHelperVariableContainer();

if (!$variableContainer->exists(ContextViewHelper::class, 'fieldNames')) {
return sprintf('<input name="%s" without-context />', $name);
}

$fieldNames = $variableContainer->get(ContextViewHelper::class, 'fieldNames');
$fieldNames[] = $name;
$variableContainer->addOrUpdate(ContextViewHelper::class, 'fieldNames', $fieldNames);

return sprintf('<input name="%s" />', $name);
}
}