Commit 37ac299f authored by Christian Kuhn's avatar Christian Kuhn
Browse files

[TASK] Simplify Templating Bootstrap in BE Controllers

This patch introduces a new EXT:fluid view class
"BackendTemplateView" to be used as main view for
backend-related non-Extbase views.

This class is the base of a new non-Extbase and
non-request dependent backend view. The class is for
now marked @internal and experimental since we'll
probably add a factory to configure backend template
overrides for any backend view later-on.

A few ViewHelpers are changed to work without
accessing the request if enough VH arguments are provided.

This is the first patch in a series of patches that will
switch from StandaloneView usages in backend
controllers to this new BackendTemplateView.

Basic strategy:
* $view->getRequest()->setControllerExtensionName('SysNote')
  is removed. This is Extbase-specific and not needed nor
  wanted for common non-Extbase controllers.
* Instantiate the View (for now with makeInstance, will be
  replaced with a factory later-on)
* Set the needed paths via ->setTemplateRootPaths() etc.
  For these, we *always* use the main extension's entry
  templating paths, for instance
  'EXT:sys_note/Resources/Private/Templates' or
  'EXT:sys_note/Resources/Private/Partials'.
  We do *not* use sub directories here to clear up path
  logic.
* ->assign() / ->assignMultiple() whatever is needed.
* ->render('SubDirectory/TemplateName') the actual
  action / template, no '.html' suffix.

As a demo, EXT:sys_note is adapted accordingly which hands
over arguments to the above mentioned VH's in a way so
these don't access the request object anymore. The sys_note
code gets a couple of additional changes so the hooks can
prepare request dependent arguments and set them as
template variables (here: returnUrl).

This patch triggers a hidden gem: Since ViewHelpers no
longer receive an Extbase request, they also don't trigger
Extbase magic anymore. The casual victim here is
f:translate, which has already been prepared to not trigger
Extbase's frontend TypoScript parsing if there is no
Extbase request. This often improves backend view performance
by 25% or more, depending on the amount of frontend
TypoScript to parse.

Further patches will adapt other core backend routes and will
relate to this patch for reference.

Change-Id: I4fec3ad690452a00e731c9f6928273048397dd89
Resolves: #96513
Related: #96473
Releases: main
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/72966

Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Oliver Bartsch's avatarOliver Bartsch <bo@cedev.de>
Reviewed-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Tested-by: Oliver Bartsch's avatarOliver Bartsch <bo@cedev.de>
Tested-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
parent 3c76b9e0
......@@ -614,7 +614,7 @@ class PageLayoutController
// Page title
$content .= '<h1 class="' . ($this->isPageEditable($this->current_sys_language) ? 't3js-title-inlineedit' : '') . '">' . htmlspecialchars($this->getLocalizedPageTitle()) . '</h1>';
// All other listings
$content .= $this->renderContent();
$content .= $this->renderContent($request);
$content .= '</form>';
// Setting up the buttons for the docheader
$this->makeButtons($request);
......@@ -664,7 +664,7 @@ class PageLayoutController
*
* @return string
*/
protected function renderContent(): string
protected function renderContent(ServerRequestInterface $request): string
{
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/ContextMenu');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/Tooltip');
......@@ -727,14 +727,14 @@ class PageLayoutController
$content = '';
// Additional header content
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/db_layout.php']['drawHeaderHook'] ?? [] as $hook) {
$params = [];
$params = ['request' => $request];
$content .= GeneralUtility::callUserFunction($hook, $params, $this);
}
$content .= $tableOutput;
// Additional footer content
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/db_layout.php']['drawFooterHook'] ?? [] as $hook) {
$params = [];
$params = ['request' => $request];
$content .= GeneralUtility::callUserFunction($hook, $params, $this);
}
return $content;
......
......@@ -17,6 +17,7 @@ declare(strict_types=1);
namespace TYPO3\CMS\Backend\ViewHelpers\Link;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Routing\Exception\RouteNotFoundException;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
......@@ -84,8 +85,12 @@ final class EditRecordViewHelper extends AbstractTagBasedViewHelper
if ($this->arguments['uid'] < 1) {
throw new \InvalidArgumentException('Uid must be a positive integer, ' . $this->arguments['uid'] . ' given.', 1526127158);
}
if (empty($this->arguments['returnUrl'])) {
$this->arguments['returnUrl'] = $this->renderingContext->getRequest()->getAttribute('normalizedParams')->getRequestUri();
$request = $this->renderingContext->getRequest();
if (empty($this->arguments['returnUrl'])
&& $request instanceof ServerRequestInterface
) {
// @todo: We may want to deprecate fetching returnUrl from request
$this->arguments['returnUrl'] = $request->getAttribute('normalizedParams')->getRequestUri();
}
$params = [
......
......@@ -17,6 +17,7 @@ declare(strict_types=1);
namespace TYPO3\CMS\Backend\ViewHelpers;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
......@@ -64,8 +65,14 @@ final class ModuleLinkViewHelper extends AbstractViewHelper
if ($arguments['query'] !== null) {
ArrayUtility::mergeRecursiveWithOverrule($parameters, GeneralUtility::explodeUrl2Array($arguments['query']));
}
if ($arguments['currentUrlParameterName'] !== null) {
$parameters[$arguments['currentUrlParameterName']] = $renderingContext->getRequest()->getAttribute('normalizedParams')->getRequestUri();
$request = $renderingContext->getRequest();
if (!empty($arguments['currentUrlParameterName'])
&& empty($arguments['arguments'][$arguments['currentUrlParameterName']])
&& $request instanceof ServerRequestInterface
) {
// If currentUrlParameterName is given and if that argument is not hand over yet, and if there is a request, fetch it from request
// @todo: We may want to deprecate fetching stuff from request and advise handing over a proper value as 'arguments' argument.
$parameters[$arguments['currentUrlParameterName']] = $request->getAttribute('normalizedParams')->getRequestUri();
}
return (string)$uriBuilder->buildUriFromRoute($arguments['route'], $parameters);
}
......
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Fluid\View;
/**
* A view dedicated for backend non-extbase usage.
*
* @internal Do not use in extensions at the moment.
* This view will most likely receive a configuration system and a factory in further v12 progress.
*/
class BackendTemplateView extends AbstractTemplateView
{
}
......@@ -39,7 +39,7 @@ use TYPO3Fluid\Fluid\Core\ViewHelper\Traits\CompileWithRenderStatic;
* includeCssFiles="{0: 'EXT:my_ext/Resources/Public/Css/Stylesheet.css'}"
* includeJsFiles="{0: 'EXT:my_ext/Resources/Public/JavaScript/Library1.js', 1: 'EXT:my_ext/Resources/Public/JavaScript/Library2.js'}"
* addJsInlineLabels="{'my_ext.label1': 'LLL:EXT:my_ext/Resources/Private/Language/locallang.xlf:label1'}"
* includesRequireJsModules="{0: 'EXT:my_ext/Resources/Public/JavaScript/RequireJsModule.js'}"
* includeRequireJsModules="{0: 'EXT:my_ext/Resources/Public/JavaScript/RequireJsModule.js'}"
* addInlineSettings="{'some.setting.key': 'some.setting.value'}"
* />
*
......
......@@ -17,6 +17,7 @@ declare(strict_types=1);
namespace TYPO3\CMS\Fluid\ViewHelpers\Link;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Http\ApplicationType;
use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractTagBasedViewHelper;
......@@ -81,7 +82,11 @@ final class EmailViewHelper extends AbstractTagBasedViewHelper
$attributes = [];
$linkText = htmlspecialchars($email);
$escapeSpecialCharacters = true;
if (ApplicationType::fromRequest($this->renderingContext->getRequest())->isFrontend()) {
$request = $this->renderingContext->getRequest();
if ($request instanceof ServerRequestInterface
&& ApplicationType::fromRequest($this->renderingContext->getRequest())->isFrontend()
) {
// If there is no request, backend is assumed.
/** @var TypoScriptFrontendController $frontend */
$frontend = $GLOBALS['TSFE'];
$frontend->cObj->typoLink($email, ['parameter' => $linkHref]);
......
......@@ -17,9 +17,7 @@ declare(strict_types=1);
namespace TYPO3\CMS\Fluid\Tests\Functional\ViewHelpers\Link;
use TYPO3\CMS\Core\Core\SystemEnvironmentBuilder;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\ServerRequest;
use TYPO3\CMS\Core\Tests\Functional\SiteHandling\SiteBasedTestTrait;
use TYPO3\CMS\Fluid\View\StandaloneView;
use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest;
......@@ -34,9 +32,8 @@ class EmailViewHelperTest extends FunctionalTestCase
*/
public function renderCreatesProperMarkupInBackend(): void
{
$GLOBALS['TYPO3_REQUEST'] = (new ServerRequest())
->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_BE);
$view = new StandaloneView();
$view->setRequest();
$view->setTemplateSource('<f:link.email email="foo@example.com">send mail</f:link.email>');
self::assertEquals('<a href="mailto:foo@example.com">send mail</a>', $view->render());
}
......@@ -46,9 +43,8 @@ class EmailViewHelperTest extends FunctionalTestCase
*/
public function renderCreatesProperMarkupInBackendWithEmptyChild(): void
{
$GLOBALS['TYPO3_REQUEST'] = (new ServerRequest())
->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_BE);
$view = new StandaloneView();
$view->setRequest();
$view->setTemplateSource('<f:link.email email="foo@example.com" />');
self::assertEquals('<a href="mailto:foo@example.com">foo@example.com</a>', $view->render());
}
......
......@@ -21,7 +21,7 @@ use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Fluid\View\StandaloneView;
use TYPO3\CMS\Fluid\View\BackendTemplateView;
use TYPO3\CMS\SysNote\Domain\Repository\SysNoteRepository;
/**
......@@ -31,11 +31,7 @@ use TYPO3\CMS\SysNote\Domain\Repository\SysNoteRepository;
*/
class NoteController
{
/**
* @var SysNoteRepository
*/
protected $notesRepository;
protected SysNoteRepository $notesRepository;
protected array $pagePermissionCache = [];
public function __construct()
......@@ -44,34 +40,34 @@ class NoteController
}
/**
* Render notes by single PID or PID list
* Render notes by single PID
*
* @param string $pids Single PID or comma separated list of PIDs
* @param int $pid The page id notes should be rendered for
* @param int|null $position null for no restriction, integer for defined position
* @param string $returnUrl Url to return to when editing and closing a notes record again
* @return string
*/
public function listAction($pids, int $position = null): string
public function listAction(int $pid, int $position = null, string $returnUrl = ''): string
{
$backendUser = $this->getBackendUser();
if (empty($pids)
if ($pid <= 0
|| empty($backendUser->user[$backendUser->userid_column])
|| !$backendUser->check('tables_select', 'sys_note')
) {
return '';
}
$notes = $this->notesRepository->findByPidsAndAuthorId($pids, (int)$backendUser->user[$backendUser->userid_column], $position);
$notes = $this->notesRepository->findByPidsAndAuthorId($pid, (int)$backendUser->user[$backendUser->userid_column], $position);
if (!$notes) {
return '';
}
$view = GeneralUtility::makeInstance(StandaloneView::class);
$view->setTemplatePathAndFilename(GeneralUtility::getFileAbsFileName(
'EXT:sys_note/Resources/Private/Templates/Note/List.html'
));
$view->setLayoutRootPaths(['EXT:sys_note/Resources/Private/Layouts']);
$view->getRequest()->setControllerExtensionName('SysNote');
$view->assign('notes', $this->enrichWithEditPermissions($notes));
return $view->render();
$view = GeneralUtility::makeInstance(BackendTemplateView::class);
$view->setTemplateRootPaths(['EXT:sys_note/Resources/Private/Templates']);
$view->assignMultiple([
'notes' => $this->enrichWithEditPermissions($notes),
'returnUrl' => $returnUrl,
]);
return $view->render('List');
}
protected function enrichWithEditPermissions(array $notes): array
......
......@@ -17,24 +17,27 @@ declare(strict_types=1);
namespace TYPO3\CMS\SysNote\Hook;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\SysNote\Controller\NoteController;
/**
* Hook for the info module
* Hook for the info module.
*
* @internal This is a specific hook implementation and is not considered part of the Public TYPO3 API.
*/
class InfoModuleHook
{
/**
* Add sys_notes as additional content to the footer of the info module
*
* @return string
*/
public function render()
public function render(array $params): string
{
/** @var ServerRequestInterface $request */
$request = $params['request'];
$controller = GeneralUtility::makeInstance(NoteController::class);
$id = (int)GeneralUtility::_GP('id');
return $controller->listAction($id);
$id = (int)($request->getQueryParams()['id'] ?? 0);
$returnUrl = $request->getAttribute('normalizedParams')->getRequestUri();
return $controller->listAction($id, null, $returnUrl);
}
}
......@@ -17,13 +17,14 @@ declare(strict_types=1);
namespace TYPO3\CMS\SysNote\Hook;
use TYPO3\CMS\Backend\Controller\PageLayoutController;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\SysNote\Controller\NoteController;
use TYPO3\CMS\SysNote\Domain\Repository\SysNoteRepository;
/**
* Hook for the page module
* Hook to render notes in the page module.
*
* @internal This is a specific hook implementation and is not considered part of the Public TYPO3 API.
*/
class PageHook
......@@ -31,27 +32,27 @@ class PageHook
/**
* Add sys_notes as additional content to the header of the page module
*
* @param array $params
* @param \TYPO3\CMS\Backend\Controller\PageLayoutController $parentObject
* @return string
*/
public function renderInHeader(array $params, PageLayoutController $parentObject)
public function renderInHeader(array $params)
{
/** @var ServerRequestInterface $request */
$request = $params['request'];
$id = (int)($request->getQueryParams()['id'] ?? 0);
$returnUrl = $request->getAttribute('normalizedParams')->getRequestUri();
$controller = GeneralUtility::makeInstance(NoteController::class);
return $controller->listAction($parentObject->id, SysNoteRepository::SYS_NOTE_POSITION_TOP);
return $controller->listAction($id, SysNoteRepository::SYS_NOTE_POSITION_TOP, $returnUrl);
}
/**
* Add sys_notes as additional content to the footer of the page module
*
* @param array $params
* @param \TYPO3\CMS\Backend\Controller\PageLayoutController $parentObject
* @return string
*/
public function renderInFooter(array $params, PageLayoutController $parentObject)
public function renderInFooter(array $params): string
{
/** @var ServerRequestInterface $request */
$request = $params['request'];
$id = (int)($request->getQueryParams()['id'] ?? 0);
$returnUrl = $request->getAttribute('normalizedParams')->getRequestUri();
$controller = GeneralUtility::makeInstance(NoteController::class);
return $controller->listAction($parentObject->id, SysNoteRepository::SYS_NOTE_POSITION_BOTTOM);
return $controller->listAction($id, SysNoteRepository::SYS_NOTE_POSITION_BOTTOM, $returnUrl);
}
}
......@@ -22,12 +22,13 @@ use TYPO3\CMS\SysNote\Controller\NoteController;
use TYPO3\CMS\SysNote\Domain\Repository\SysNoteRepository;
/**
* Class RecordListProvider
* Render existing notes within list module.
*
* @internal
*/
class RecordListProvider
{
protected $noteController;
protected NoteController $noteController;
public function __construct(NoteController $noteController)
{
......@@ -36,8 +37,10 @@ class RecordListProvider
public function __invoke(RenderAdditionalContentToRecordListEvent $event): void
{
$id = (int)($event->getRequest()->getParsedBody()['id'] ?? $event->getRequest()->getQueryParams()['id'] ?? 0);
$event->addContentAbove($this->noteController->listAction($id, SysNoteRepository::SYS_NOTE_POSITION_TOP));
$event->addContentBelow($this->noteController->listAction($id, SysNoteRepository::SYS_NOTE_POSITION_BOTTOM));
$request = $event->getRequest();
$pid = (int)($event->getRequest()->getParsedBody()['id'] ?? $event->getRequest()->getQueryParams()['id'] ?? 0);
$returnUrl = $request->getAttribute('normalizedParams')->getRequestUri();
$event->addContentAbove($this->noteController->listAction($pid, SysNoteRepository::SYS_NOTE_POSITION_TOP, $returnUrl));
$event->addContentBelow($this->noteController->listAction($pid, SysNoteRepository::SYS_NOTE_POSITION_BOTTOM, $returnUrl));
}
}
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers"
xmlns:be="http://typo3.org/ns/TYPO3/CMS/Backend/ViewHelpers"
>
<f:if condition="{notes}">
<div class="note-list">
<h2><f:translate key="LLL:EXT:sys_note/Resources/Private/Language/locallang.xlf:internal_note" /></h2>
<f:for each="{notes}" as="note">
<div class="note note-category-{note.category}">
<div class="note-header">
<div class="note-header-bar">
<span class="note-icon t3js-contextmenutrigger" data-table="sys_note" data-uid="{note.uid}">
<core:icon identifier="sysnote-type-{note.category}" />
</span>
<span class="note-author">
<f:translate key="LLL:EXT:sys_note/Resources/Private/Language/locallang.xlf:author" />:
<f:if condition="{note.authorDisabled} || {note.authorDeleted} || !{note.authorUsername}">
<f:then>[{f:translate(key: 'LLL:EXT:sys_note/Resources/Private/Language/locallang.xlf:author_deleted')}]</f:then>
<f:else if="{note.authorRealName}">{note.authorRealName}</f:else>
<f:else>{note.authorUsername}</f:else>
</f:if>
</span>
<span class="note-date">
<f:translate key="LLL:EXT:sys_note/Resources/Private/Language/locallang.xlf:date" />:
<f:format.date>@{note.tstamp}</f:format.date>
</span>
<f:if condition="{note.personal}">
<span class="note-badge">
<span class="badge badge-info"><f:translate key="LLL:EXT:sys_note/Resources/Private/Language/locallang.xlf:personal" /></span>
</span>
</f:if>
<span class="note-actions">
<span class="btn-group">
<f:if condition="{note.canBeEdited}">
<be:link.editRecord uid="{note.uid}" table="sys_note" class="btn btn-default btn-sm" returnUrl="{returnUrl}">
<core:icon identifier="actions-open" />
</be:link.editRecord>
</f:if>
<f:if condition="{note.canBeDeleted}">
<a href="{be:moduleLink(route:'tce_db', query:'cmd[sys_note][{note.uid}][delete]=1', arguments:'{redirect: returnUrl}')}"
class="btn btn-default btn-sm t3js-modal-trigger"
data-severity="warning"
data-title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:delete')}"
data-bs-content="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:deleteWarning')}"
data-button-close-text="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:cancel')}"
>
<core:icon identifier="actions-edit-delete" />
</a>
</f:if>
</span>
</span>
</div>
</div>
<div class="note-body">
<h4><span>
<f:if condition="{note.category}">
<f:translate key="LLL:EXT:sys_note/Resources/Private/Language/locallang.xlf:category.{note.category}" />:
</f:if>
</span>{note.subject}</h4>
<f:format.nl2br>{note.message}</f:format.nl2br>
</div>
</div>
</f:for>
</div>
</f:if>
</html>
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers" xmlns:be="http://typo3.org/ns/TYPO3/CMS/Backend/ViewHelpers" xmlns:notes="http://typo3.org/ns/TYPO3/CMS/SysNote/ViewHelpers">
<f:layout name="Default" />
<f:section name="main">
<f:if condition="{notes}">
<div class="note-list">
<h2><f:translate key="internal_note" /></h2>
<f:for each="{notes}" as="note">
<div class="note note-category-{note.category}">
<div class="note-header">
<div class="note-header-bar">
<span class="note-icon t3js-contextmenutrigger" data-table="sys_note" data-uid="{note.uid}">
<core:icon identifier="sysnote-type-{note.category}" />
</span>
<span class="note-author">
<f:translate key="author" />:
<f:if condition="{note.authorDisabled} || {note.authorDeleted} || !{note.authorUsername}">
<f:then>[{f:translate(key: 'author_deleted')}]</f:then>
<f:else if="{note.authorRealName}">{note.authorRealName}</f:else>
<f:else>{note.authorUsername}</f:else>
</f:if>
</span>
<span class="note-date">
<f:translate key="date" />:
<f:format.date>@{note.tstamp}</f:format.date>
</span>
<f:if condition="{note.personal}">
<span class="note-badge">
<span class="badge badge-info"><f:translate key="personal" /></span>
</span>
</f:if>
<span class="note-actions">
<span class="btn-group">
<f:if condition="{note.canBeEdited}">
<be:link.editRecord uid="{note.uid}" table="sys_note" class="btn btn-default btn-sm">
<core:icon identifier="actions-open" />
</be:link.editRecord>
</f:if>
<f:if condition="{note.canBeDeleted}">
<a href="{be:moduleLink(route:'tce_db', query:'cmd[sys_note][{note.uid}][delete]=1', currentUrlParameterName:'redirect')}" class="btn btn-default btn-sm t3js-modal-trigger" data-severity="warning" data-title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:delete')}" data-bs-content="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:deleteWarning')}" data-button-close-text="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:cancel')}">
<core:icon identifier="actions-edit-delete" />
</a>
</f:if>
</span>
</span>
</div>
</div>
<div class="note-body">
<h4><span><f:if condition="{note.category}"><f:translate key="category.{note.category}" />: </f:if></span>{note.subject}</h4>
<f:format.nl2br>{note.message}</f:format.nl2br>
</div>
</div>
</f:for>
</div>
</f:if>
</f:section>
</html>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment