Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Modern fragment foundation #4393

Merged
merged 30 commits into from Apr 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
046cb0f
prefer legacy element when using fragment with same name
m-vo Mar 26, 2022
bb86d9d
always set the template in the RegisterFragmentsPass
m-vo Mar 26, 2022
d481dcd
create a new FragmentTemplate
m-vo Apr 3, 2022
1b44558
allow creating enhanced contexts from plain arrays
m-vo Apr 3, 2022
b3d33e9
rework rendering fragments in the AbstractFragmentController
m-vo Apr 3, 2022
6a1504a
rework default template context for modern fragments
m-vo Apr 3, 2022
7ceab8a
deprecate the old template class, so that its usage in the old getRes…
m-vo Apr 3, 2022
d5aa393
rework back end wildcard
m-vo Apr 3, 2022
52284e8
adjust and add tests
m-vo Apr 3, 2022
bea6eef
fix quoting in the TemplateLoader
m-vo Apr 3, 2022
31f1084
fix a discouraged usage in the TwoFactorController
m-vo Apr 3, 2022
3e3cb31
rename headline attributes
m-vo Apr 4, 2022
d9498d0
fix testReturnsWildCardInBackendScope test
m-vo Apr 4, 2022
d12a7d0
CS
m-vo Apr 4, 2022
6187b54
add clarifying comment
m-vo Apr 5, 2022
31e4082
CS
leofeyer Apr 5, 2022
1fe0bf9
enable deprecation warning
m-vo Apr 6, 2022
9ad7872
rename 'back end' to 'backend'
m-vo Apr 6, 2022
23fecb1
expect self deprecations
m-vo Apr 6, 2022
6ed574a
Revert "expect self deprecations"
m-vo Apr 9, 2022
8e74730
remove triggering deprecation for legacy fragment templates for now
m-vo Apr 9, 2022
d0d73f1
enforce type hints
m-vo Apr 20, 2022
b498e2d
Revert "enforce type hints"
m-vo Apr 22, 2022
9614168
Merge branch '5.x' into modern-fragment-foundation
m-vo Apr 26, 2022
f5bf545
apply changes from #4536 + adjust tests
m-vo Apr 26, 2022
03ff8ad
Merge remote-tracking branch 'origin/5.x' into modern-fragment-founda…
m-vo Apr 26, 2022
facfa23
drop removed methods
m-vo Apr 26, 2022
abd3fc6
CS
leofeyer Apr 26, 2022
a3d3aed
Update core-bundle/tests/Twig/FragmentTemplateTest.php
m-vo Apr 26, 2022
8815efd
Adjust the deprecation message
leofeyer Apr 26, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
155 changes: 133 additions & 22 deletions core-bundle/src/Controller/AbstractFragmentController.php
Expand Up @@ -12,22 +12,28 @@

namespace Contao\CoreBundle\Controller;

use Contao\CoreBundle\Controller\ContentElement\AbstractContentElementController;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\EventListener\SubrequestCacheSubscriber;
use Contao\CoreBundle\Fragment\FragmentOptionsAwareInterface;
use Contao\CoreBundle\Routing\ScopeMatcher;
use Contao\FragmentTemplate;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\CoreBundle\Twig\Interop\ContextFactory;
use Contao\CoreBundle\Twig\Loader\ContaoFilesystemLoader;
use Contao\FrontendTemplate;
use Contao\Model;
use Contao\PageModel;
use Contao\StringUtil;
use Contao\Template;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;

abstract class AbstractFragmentController extends AbstractController implements FragmentOptionsAwareInterface
{
protected array $options = [];
private string|null $view = null;

public function setFragmentOptions(array $options): void
{
Expand All @@ -43,6 +49,8 @@ public static function getSubscribedServices(): array

$services['request_stack'] = RequestStack::class;
$services['contao.routing.scope_matcher'] = ScopeMatcher::class;
$services['contao.twig.filesystem_loader'] = ContaoFilesystemLoader::class;
$services['contao.twig.interop.context_factory'] = ContextFactory::class;

return $services;
}
Expand All @@ -59,47 +67,87 @@ protected function getPageModel(): PageModel|null
}

/**
* Creates a template by name or from the "customTpl" field of the model.
* Creates a FragmentTemplate container object by template name or from the
* "customTpl" field of the model and registers the effective template as
* default view when using render().
*
* Calling getResponse() on the returned object will internally call
* render() with the set parameters and return the response.
*
* Note: The $fallbackTemplateName argument will be removed in Contao 6;
* always set a template via the fragment options, instead.
*/
protected function createTemplate(Model $model, string $templateName): Template
protected function createTemplate(Model $model, string|null $fallbackTemplateName = null): FragmentTemplate
{
if (isset($this->options['template'])) {
$templateName = $this->options['template'];
}
$templateName = $this->getTemplateName($model, $fallbackTemplateName);
$isLegacyTemplate = $this->isLegacyTemplate($templateName);

$request = $this->container->get('request_stack')->getCurrentRequest();
// Allow calling render() without a view
$this->view = !$isLegacyTemplate ? "@Contao/$templateName.html.twig" : null;

$onGetResponse = function (FragmentTemplate $template, Response|null $preBuiltResponse) use ($templateName, $isLegacyTemplate): Response {
if ($isLegacyTemplate) {
// Render using the legacy framework
$legacyTemplate = $this->container->get('contao.framework')->createInstance(FrontendTemplate::class, [$templateName]);
$legacyTemplate->setData($template->getData());

$response = $legacyTemplate->getResponse();

if (null !== $preBuiltResponse) {
return $preBuiltResponse->setContent($response->getContent());
}

$this->markResponseForInternalCaching($response);

if ($model->customTpl) {
// Use the custom template unless it is a back end request
if (null === $request || !$this->container->get('contao.routing.scope_matcher')->isBackendRequest($request)) {
$templateName = $model->customTpl;
return $response;
}
}

$templateClass = FragmentTemplate::class;
// Directly render with Twig
$context = $this->container->get('contao.twig.interop.context_factory')->fromData($template->getData());

// Current request is the main request (e.g. ESI fragment), so we have to replace
// insert tags etc. on the template output
if ($request === $this->container->get('request_stack')->getMainRequest()) {
$templateClass = FrontendTemplate::class;
}
return $this->render($template->getName(), $context, $preBuiltResponse);
};

$template = new FragmentTemplate($templateName, $onGetResponse);

/** @var Template $template */
$template = $this->container->get('contao.framework')->createInstance($templateClass, [$templateName]);
$template->setData($model->row());
if ($isLegacyTemplate) {
$template->setData($model->row());
}

return $template;
}

/**
* @internal
*/
protected function isLegacyTemplate(string $templateName): bool
Toflar marked this conversation as resolved.
Show resolved Hide resolved
{
return !str_contains($templateName, '/');
}

/**
* @internal the addHeadlineToTemplate() method is considered internal in
* Contao 5 and won't be accessible anymore in Contao 6. Headline data is
* always added to the context of modern fragment templates.
*/
protected function addHeadlineToTemplate(Template $template, array|string|null $headline): void
{
$this->triggerDeprecationIfCallingFromCustomClass(__METHOD__);

$data = StringUtil::deserialize($headline);
$template->headline = \is_array($data) ? $data['value'] : $data;
$template->hl = \is_array($data) ? $data['unit'] : 'h1';
}

/**
* @internal the addCssAttributesToTemplate() method is considered internal
* in Contao 5 and won't be accessible anymore in Contao 6. Attributes data
* is always added to the context of modern fragment templates.
*/
protected function addCssAttributesToTemplate(Template $template, string $templateName, array|string|null $cssID, array $classes = null): void
{
$this->triggerDeprecationIfCallingFromCustomClass(__METHOD__);

$data = StringUtil::deserialize($cssID, true);
$template->class = trim($templateName.' '.($data[1] ?? ''));
$template->cssID = !empty($data[0]) ? ' id="'.$data[0].'"' : '';
Expand All @@ -109,20 +157,38 @@ protected function addCssAttributesToTemplate(Template $template, string $templa
}
}

/**
* @internal the addPropertiesToTemplate() method is considered internal in
* Contao 5 and won't be accessible anymore in Contao 6. Custom properties
* are always added to the context of modern fragment templates.
*/
protected function addPropertiesToTemplate(Template $template, array $properties): void
{
$this->triggerDeprecationIfCallingFromCustomClass(__METHOD__);

foreach ($properties as $k => $v) {
$template->{$k} = $v;
}
}

/**
* @internal the addSectionToTemplate() method is considered internal in
* Contao 5 and won't be accessible anymore in Contao 6. Section data is
* always added to the context of modern fragment templates.
*/
protected function addSectionToTemplate(Template $template, string $section): void
{
$this->triggerDeprecationIfCallingFromCustomClass(__METHOD__);

$template->inColumn = $section;
}

/**
* Returns the type from the class name.
*
* @internal the getType() method is considered internal in Contao 5 and
* won't be accessible anymore in Contao 6. Retrieve the type from the
* fragment options instead.
*/
protected function getType(): string
{
Expand All @@ -139,8 +205,19 @@ protected function getType(): string
return Container::underscore($className);
}

protected function render(string $view, array $parameters = [], Response $response = null): Response
/**
* Renders a template. If $view is set to null, the default template of
* this fragment will be rendered.
*
* By default, the returned response will have the appropriate headers set,
* that allow our SubrequestCacheSubscriber to merge it with others of the
* same page. Pass a prebuilt Response if you want to have full control -
* no headers will be set then.
*/
protected function render(string|null $view = null, array $parameters = [], Response $response = null): Response
{
$view ??= $this->view ?? throw new \InvalidArgumentException('Cannot derive template name, please make sure createTemplate() was called before or specify the template explicitly.');

if (null === $response) {
$response = new Response();

Expand All @@ -150,6 +227,13 @@ protected function render(string $view, array $parameters = [], Response $respon
return parent::render($view, $parameters, $response);
}

protected function isBackendScope(Request $request = null): bool
{
$request ??= $this->container->get('request_stack')->getCurrentRequest();

return null !== $request && $this->container->get('contao.routing.scope_matcher')->isBackendRequest($request);
}

/**
* Marks the response to affect the caching of the current page but removes any default cache header.
*/
Expand All @@ -158,4 +242,31 @@ protected function markResponseForInternalCaching(Response $response): void
$response->headers->set(SubrequestCacheSubscriber::MERGE_CACHE_HEADER, '1');
$response->headers->remove('Cache-Control');
}

private function triggerDeprecationIfCallingFromCustomClass(string $method): void
{
$caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['class'];

if (!\in_array($caller, [AbstractContentElementController::class, AbstractFrontendModuleController::class], true)) {
trigger_deprecation('contao/core-bundle', '5.0', 'The "%s" method is considered internal and won\'t be accessible anymore in Contao 6.', $method);
}
}

private function getTemplateName(Model $model, string|null $fallbackTemplateName): string
{
// If set, use the custom template unless it is a back end request
if ($model->customTpl && !$this->isBackendScope()) {
return $model->customTpl;
}

$definedTemplateName = $this->options['template'] ?? null;

// Always use the defined name for legacy templates and for modern
// templates that exist (= those that do not need to have a fallback)
if (null !== $definedTemplateName && ($this->isLegacyTemplate($definedTemplateName) || $this->container->get('contao.twig.filesystem_loader')->exists("@Contao/$definedTemplateName.html.twig"))) {
return $definedTemplateName;
}

return $fallbackTemplateName ?? throw new \InvalidArgumentException('No template was set in the fragment options.');
}
}
Expand Up @@ -14,21 +14,27 @@

use Contao\ContentModel;
use Contao\CoreBundle\Controller\AbstractFragmentController;
use Contao\Template;
use Contao\CoreBundle\String\HtmlAttributes;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\StringUtil;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

abstract class AbstractContentElementController extends AbstractFragmentController
{
public function __invoke(Request $request, ContentModel $model, string $section, array $classes = null): Response
{
$type = $this->getType();
$template = $this->createTemplate($model, 'ce_'.$type);
$template = $this->createTemplate($model, 'ce_'.$this->getType());

$this->addDefaultDataToTemplate(
$template,
$model->row(),
$section,
$classes ?? [],
$request->attributes->get('templateProperties', []),
$this->isBackendScope($request)
);

$this->addHeadlineToTemplate($template, $model->headline);
$this->addCssAttributesToTemplate($template, 'ce_'.$type, $model->cssID, $classes);
$this->addPropertiesToTemplate($template, $request->attributes->get('templateProperties', []));
$this->addSectionToTemplate($template, $section);
$this->tagResponse($model);

return $this->getResponse($template, $model, $request);
Expand All @@ -54,5 +60,44 @@ protected function addSharedMaxAgeToResponse(Response $response, ContentModel $m
$response->setSharedMaxAge(min($min));
}

abstract protected function getResponse(Template $template, ContentModel $model, Request $request): Response;
/**
* Add default content element data to the template context.
*
* @param array<string, mixed> $modelData
* @param array<string> $classes
* @param array<string, mixed> $properties
*/
protected function addDefaultDataToTemplate(FragmentTemplate $template, array $modelData = [], string $section = 'main', array $classes = [], array $properties = [], bool $asOverview = false): void
{
if ($this->isLegacyTemplate($template->getName())) {
// Legacy fragments
$this->addHeadlineToTemplate($template, $modelData['headline'] ?? null);
$this->addCssAttributesToTemplate($template, $template->getName(), $modelData['cssID'] ?? null, $classes);
$this->addPropertiesToTemplate($template, $properties);
$this->addSectionToTemplate($template, $section);

return;
}

$headlineData = StringUtil::deserialize($modelData['headline'] ?? [] ?: '', true);
$attributesData = StringUtil::deserialize($modelData['cssID'] ?? [] ?: '', true);

$template->setData([
'type' => $this->getType(),
'template' => $template->getName(),
'as_overview' => $asOverview,
'data' => $modelData,
'section' => $section,
'properties' => $properties,
'attributes' => (new HtmlAttributes())
->setIfExists('id', $attributesData[0] ?? null)
->addClass($attributesData[1] ?? '', ...$classes),
'headline' => [
'text' => $headlineData['value'] ?? '',
'tagName' => $headlineData['unit'] ?? 'h1',
],
]);
}

abstract protected function getResponse(FragmentTemplate $template, ContentModel $model, Request $request): Response;
}