use a safer, less coupled AppVariable for templates

This commit is contained in:
Kevin Papst
2026-01-16 18:02:43 +01:00
parent 2e54a56996
commit 59ecbbb591
21 changed files with 138 additions and 65 deletions

View File

@@ -30,3 +30,6 @@ Do not use method chaining: all fluent interface, especially in Entities, are no
- Removed and renamed translations, most important `action.edit` => `edit`, `my.profile` => `user_profile`
- Removed `User::isExportDecimal()`
- Use duration format `HH:mm` in default PDF exports
- Replace Twig `AppVariable` with custom implementation
- Replace `app.request.locale` with `app.locale`
- Replace `app.request.attributes.get('_route')` with `app.current_route`

View File

@@ -5,6 +5,8 @@ twig:
paths:
'%kernel.project_dir%/templates/bundles/TablerBundle': theme
'%kernel.project_dir%/vendor/kevinpapst/tabler-bundle/templates': theme
globals:
app: '@App\Twig\AppVariable'
when@test:
twig:

View File

@@ -243,3 +243,7 @@ services:
App\Validator\Constraints\QuickEntryTimesheetValidator:
arguments: ['%kimai.validator_timesheet%']
App\Twig\AppVariable:
tags:
- {name: twig.global, alias: app}

View File

@@ -60,6 +60,7 @@ final class ProjectDateRangeController extends AbstractController
}
return $this->render('reporting/project/daterange.html.twig', [
'expand_projects' => $request->query->get('view', '1') === '1',
'report_title' => 'report_project_daterange',
'entries' => $byCustomer,
'form' => $form->createView(),

76
src/Twig/AppVariable.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Twig;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Replaces the Symfony default AppVariable, which exposes security relevant information.
*/
final class AppVariable
{
public function __construct(private readonly RequestStack $requestStack, private readonly TokenStorageInterface $tokenStorage)
{
}
public function getLocale(): string
{
return $this->requestStack->getMainRequest()?->getLocale() ?? 'en';
}
public function getUser(): ?UserInterface
{
return $this->tokenStorage->getToken()?->getUser();
}
public function getCurrent_route(): ?string
{
return $this->requestStack->getCurrentRequest()->attributes->get('_route');
}
public function getFlashes(): array
{
$session = $this->getSession2();
if (!$session instanceof FlashBagAwareSessionInterface) {
return [];
}
return $session->getFlashBag()->all();
}
private function getSession2(): ?SessionInterface
{
try {
if (null !== $session = $this->requestStack->getSession()) {
return $session;
}
} catch (\RuntimeException) {
}
return null;
}
/**
* The request should not be exposed under any circumstance to the frontend.
* This here is added as fallback for old customer templates still using this object.
*/
public function getRequest(): array
{
return [
'locale' => $this->getLocale(),
'pathinfo' => $this->requestStack->getCurrentRequest()?->getPathInfo(),
];
}
}

View File

@@ -7,17 +7,16 @@
* file that was distributed with this source code.
*/
namespace App\Twig;
namespace App\Twig\Runtime;
use App\Entity\Bookmark;
use App\Entity\User;
use App\Repository\BookmarkRepository;
use App\Utils\ProfileManager;
use Symfony\Component\HttpFoundation\Session\Session;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Extension\RuntimeExtensionInterface;
final class DatatableExtensions extends AbstractExtension
final class DatatableExtensions implements RuntimeExtensionInterface
{
/**
* @var array<string, array<string, array<string, string|bool>>>
@@ -26,18 +25,14 @@ final class DatatableExtensions extends AbstractExtension
private array $tableNames = [];
private ?string $prefix = null;
public function __construct(private BookmarkRepository $bookmarkRepository, private ProfileManager $profileManager)
public function __construct(
private readonly BookmarkRepository $bookmarkRepository,
private readonly ProfileManager $profileManager,
private readonly RequestStack $requestStack,
)
{
}
public function getFunctions(): array
{
return [
new TwigFunction('initialize_datatable', [$this, 'initializeDatatable']),
new TwigFunction('datatable_column_class', [$this, 'getDatatableColumnClass']),
];
}
private function getDatatableName(string $dataTable): string
{
if (!\array_key_exists($dataTable, $this->tableNames)) {
@@ -47,10 +42,10 @@ final class DatatableExtensions extends AbstractExtension
return $this->tableNames[$dataTable];
}
public function initializeDatatable(User $user, Session $session, string $dataTable, array $defaultColumns): array
public function initializeDatatable(User $user, string $dataTable, array $defaultColumns): array
{
if ($this->prefix === null) {
$this->prefix = $this->profileManager->getProfileFromSession($session);
$this->prefix = $this->profileManager->getProfileFromSession($this->requestStack->getSession());
$dataTable = $this->getDatatableName($dataTable);
}
@@ -97,7 +92,10 @@ final class DatatableExtensions extends AbstractExtension
$this->dataTables[$dataTable] = $columns;
}
return $this->dataTables[$dataTable];
return [
'columns' => $this->dataTables[$dataTable],
'profile' => $this->prefix,
];
}
public function getDatatableColumnClass(string $dataTable, string $column): string

View File

@@ -9,6 +9,7 @@
namespace App\Twig;
use App\Twig\Runtime\DatatableExtensions;
use App\Twig\Runtime\EncoreExtension;
use App\Twig\Runtime\MarkdownExtension;
use App\Twig\Runtime\MenuExtension;
@@ -40,6 +41,8 @@ final class RuntimeExtensions extends AbstractExtension
new TwigFunction('icon', [RuntimeExtension::class, 'createIcon'], ['is_safe' => ['html']]),
new TwigFunction('qr_code_data_uri', [QrCodeExtension::class, 'qrCodeDataUriFunction']),
new TwigFunction('user_shortcuts', [MenuExtension::class, 'getUserShortcuts']),
new TwigFunction('initialize_datatable', [DatatableExtensions::class, 'initializeDatatable']),
new TwigFunction('datatable_column_class', [DatatableExtensions::class, 'getDatatableColumnClass']),
];
}

View File

@@ -15,10 +15,10 @@ use Symfony\Component\HttpFoundation\Session\SessionInterface;
final class ProfileManager
{
public const SESSION_PROFILE = 'datatable_profile';
public const PROFILE_DESKTOP = 'desktop';
public const PROFILE_MOBILE = 'mobile';
public const COOKIE_PROFILE = 'K2P';
public const string SESSION_PROFILE = 'datatable_profile';
public const string PROFILE_DESKTOP = 'desktop';
public const string PROFILE_MOBILE = 'mobile';
public const string COOKIE_PROFILE = 'K2P';
public function __construct()
{
@@ -40,9 +40,6 @@ final class ProfileManager
/**
* Always returns a valid profile name (default: desktop).
*
* @param string $profile
* @return string
*/
public function getProfile(string $profile): string
{
@@ -64,9 +61,6 @@ final class ProfileManager
/**
* Always returns a valid profile name (default: desktop).
*
* @param Request $request
* @return string
*/
public function getProfileFromCookie(Request $request): string
{
@@ -77,9 +71,6 @@ final class ProfileManager
/**
* Always returns a valid profile name (default: desktop).
*
* @param Session $session
* @return string
*/
public function getProfileFromSession(Session $session): string
{

View File

@@ -26,12 +26,13 @@
{% endblock %}
{% block page_content_start %}
{% if app.session and app.session.started and app.session.flashbag.peekAll|length > 0 %}
{% set flashes = app.flashes %}
{% if flashes|length > 0 %}
{% set domain = 'flashmessages' %}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function(options) {
const ALERT = options.detail.kimai.getPlugin('alert');
{% for type, messages in app.session.flashbag.all %}
{% for type, messages in flashes %}
{% for message in messages %}
{% if type == 'error' %}
ALERT.error('{{ message|trans({}, domain)|e('js') }}');
@@ -85,7 +86,7 @@
{% endblock %}
{% block footer %}
{% if 'dashboard' in app.request.attributes.get('_route') %}
{% if 'dashboard' in app.current_route %}
{{ parent() }}
{% endif %}
{% endblock %}

View File

@@ -213,7 +213,7 @@
return '{{ path(editRoute, {id: '-XX-'}) }}'.replace('-XX-', timesheetId);
},
actions: (timesheetId) => {
return '{{ path('get_timesheet_actions', {id: 1, 'view': 'calendar', 'locale': (app.request.locale)}) }}'.replace('1', timesheetId);
return '{{ path('get_timesheet_actions', {id: 1, 'view': 'calendar', 'locale': (app.locale)}) }}'.replace('1', timesheetId);
},
},
preparePayloadForUpdate: (data) => {
@@ -244,4 +244,4 @@
KimaiReloadPageWidget.create('kimai.systemConfigUpdate', true);
});
</script>
{% endblock %}
{% endblock %}

View File

@@ -14,7 +14,7 @@
{% endblock %}
{% block footer %}
{% if 'dashboard' in app.request.attributes.get('_route') %}
{% if 'dashboard' in app.current_route %}
{{ parent() }}
{% endif %}
{% endblock %}

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{ app.request.locale }}">
<html lang="{{ app.locale }}">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1" name="viewport">

View File

@@ -35,7 +35,7 @@
{% endif %}
{% set summaryColumns = summaryColumns|merge(['rate']) %}
{% endif %}
<html{% if app.request is defined and app.request is not null %} lang="{{ app.request.locale }}"{% endif %}>
<html lang="{{ app.locale }}">
<head>
<title>{% block document_title %}{{ 'export'|trans }}{% endblock %}</title>
{% block styles %}

View File

@@ -1,4 +1,4 @@
<html lang="{{ locale ?? app.request.locale }}">
<html lang="{{ locale ?? app.locale }}">
{% set title = (template.options.name ?? 'export.document_title')|trans %}
{# show the generation date of the PDF #}
{% set now = create_date('now', app.user) %}

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{ app.request.locale }}">
<html lang="{{ app.locale }}">
<head>
<title>{% block page_title %}{{- get_title() -}}{% endblock %}</title>
</head>

View File

@@ -77,7 +77,7 @@
{% endif %}
{% if filterCount > 0 and not form.vars.data.isBookmarkSearch() %}
<a class="{{ searchBtnClass }} text-orange" href="{{ path(app.request.attributes.get('_route')) }}" data-toggle="tooltip" title="{{ 'remove_filter'|trans }}">
<a class="{{ searchBtnClass }} text-orange" href="{{ path(app.current_route) }}" data-toggle="tooltip" title="{{ 'remove_filter'|trans }}">
{{ icon('cancel') }}
</a>
{% endif %}
@@ -90,7 +90,8 @@
{% endmacro %}
{% macro data_table_column_modal(name, columns) %}
{% set visibility = initialize_datatable(app.user, app.session, name, columns) %}
{% set datatable = initialize_datatable(app.user, name, columns) %}
{% set is_desktop = datatable.profile != constant('\\App\\Utils\\ProfileManager::PROFILE_MOBILE') %}
<div class="modal" data-bs-backdrop="static" id="modal_{{ name }}" data-column-visibility="{{ name }}" tabindex="-1" role="dialog" aria-labelledby="data_table_modal_label">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
@@ -129,13 +130,13 @@
<label class="form-label">{{ 'modal.columns.profile'|trans }}</label>
<div class="form-selectgroup">
<label class="form-selectgroup-item">
<input type="radio" name="datatable_profile" value="{{ constant('\\App\\Utils\\ProfileManager::PROFILE_DESKTOP') }}" class="form-selectgroup-input" data-href="{{ path('bookmark_profile') }}"{% if app.session.get(constant('\\App\\Utils\\ProfileManager::SESSION_PROFILE'), '') == '' %} checked="checked"{% endif %}>
<input type="radio" name="datatable_profile" value="{{ constant('\\App\\Utils\\ProfileManager::PROFILE_DESKTOP') }}" class="form-selectgroup-input" data-href="{{ path('bookmark_profile') }}"{% if is_desktop %} checked="checked"{% endif %}>
<span class="form-selectgroup-label">
{{ 'desktop'|trans }}
</span>
</label>
<label class="form-selectgroup-item">
<input type="radio" name="datatable_profile" value="{{ constant('\\App\\Utils\\ProfileManager::PROFILE_MOBILE') }}" class="form-selectgroup-input" data-href="{{ path('bookmark_profile') }}"{% if app.session.get(constant('\\App\\Utils\\ProfileManager::SESSION_PROFILE'), '') == 'mobile' %} checked="checked"{% endif %}>
<input type="radio" name="datatable_profile" value="{{ constant('\\App\\Utils\\ProfileManager::PROFILE_MOBILE') }}" class="form-selectgroup-input" data-href="{{ path('bookmark_profile') }}"{% if not is_desktop %} checked="checked"{% endif %}>
<span class="form-selectgroup-label">
{{ 'mobile'|trans }}
</span>
@@ -158,7 +159,7 @@
{% endmacro %}
{% macro datatable_header(tableName, columns, query, options) %}
{% set visibility = initialize_datatable(app.user, app.session, tableName, columns) %}
{% set datatable = initialize_datatable(app.user, tableName, columns) %}
{% if query is not null %}
{% set orderBy = options.orderBy|default(query.orderBy) %}
{% set order = query.order|lower %}

View File

@@ -16,7 +16,7 @@
</button>
{% endif %}
{% if form.vars.data.countFilter() > 0 and not form.vars.data.isBookmarkSearch() %}
<a href="{{ path(app.request.attributes.get('_route')) }}" class="btn btn-icon btn-outline-secondary" data-toggle="tooltip" title="{{ 'remove_filter'|trans }}">{{ icon('cancel', true) }}</a>
<a href="{{ path(app.current_route) }}" class="btn btn-icon btn-outline-secondary" data-toggle="tooltip" title="{{ 'remove_filter'|trans }}">{{ icon('cancel', true) }}</a>
{% endif %}
</div>
<button type="submit" name="performSearch" value="performSearch" class="performSearch btn btn-primary ms-auto" data-type="submit">{{ 'search'|trans }}</button>

View File

@@ -1,6 +1,6 @@
{% macro init_frontend_loader() %}
{% set fdow = app.user is not null ? app.user.firstDayOfWeek : 'monday' %}
{% set configurations = javascript_configurations(app.user, app.request.locale)|merge({
{% set configurations = javascript_configurations(app.user, app.locale)|merge({
login: path('login'),
direction: tabler_bundle.rightToLeft ? 'rtl' : 'ltr',
first_dow_iso: iso_day_by_name(fdow),

View File

@@ -28,7 +28,6 @@
'actions': {'class': 'actions alwaysVisible'},
}) %}
{% set tableName = 'project_daterange_reporting' %}
{% set queryValue = app.request.query.get('view') %}
{% block main_before %}
{{ tables.data_table_column_modal(tableName, columns) }}
@@ -52,14 +51,13 @@
{{ form_widget(form.includeNoWork) }}
</li>
</ul>
<button onclick="{{ form.view.vars.id }}.value = {{ queryValue == '1' ? 0 : 1 }}" type="submit" class="btn btn-icon {% if queryValue != '1' %}active{% endif %}">
<button onclick="{{ form.view.vars.id }}.value = {{ expand_projects ? 0 : 1 }}" type="submit" class="btn btn-icon {% if expand_projects %}active{% endif %}">
{{ icon('collapse', true) }}
</button>
</div>
{% endblock %}
{% block report %}
{% set hasData = entries|length > 0 %}
{% embed '@theme/embeds/card.html.twig' %}
@@ -74,7 +72,7 @@
{% else %}
{{ tables.datatable_header(tableName, columns, null, {'boxClass': ''}) }}
{% if queryValue != '1' %}
{% if expand_projects %}
{% for id, mapping in entries|sort((a, b) => a.customer.name <=> b.customer.name) %}
{% set currency = mapping.customer.currency %}
{% set totalDuration = 0 %}

View File

@@ -1,12 +1,11 @@
<!DOCTYPE html>
{% import "macros/datatables.html.twig" as tables %}
{% set language = app.request is not null ? app.request.locale : 'en' %}
{% set decimal = false %}
{% set showUserColumn = true %}
{% if query.user %}
{% set showUserColumn = false %}
{% endif %}
<html lang="{{ language }}">
<html lang="{{ app.locale }}">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">

View File

@@ -10,32 +10,28 @@
namespace App\Tests\Twig;
use App\Repository\BookmarkRepository;
use App\Twig\DatatableExtensions;
use App\Twig\Runtime\DatatableExtensions;
use App\Utils\ProfileManager;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Twig\TwigFunction;
use Symfony\Component\HttpFoundation\Session\Session;
#[CoversClass(DatatableExtensions::class)]
class DatatableExtensionsTest extends TestCase
{
protected function getSut(string $locale): DatatableExtensions
protected function getSut(): DatatableExtensions
{
$repository = $this->createMock(BookmarkRepository::class);
return new DatatableExtensions($repository, new ProfileManager());
return new DatatableExtensions($repository, new ProfileManager(), new Session());
}
public function testGetFunctions(): void
{
$functions = ['initialize_datatable', 'datatable_column_class'];
$sut = $this->getSut('de');
$twigFunctions = $sut->getFunctions();
self::assertCount(\count($functions), $twigFunctions);
$i = 0;
foreach ($twigFunctions as $function) {
self::assertInstanceOf(TwigFunction::class, $function);
self::assertEquals($functions[$i++], $function->getName());
$functions = ['initializeDatatable', 'getDatatableColumnClass'];
$sut = $this->getSut();
foreach ($functions as $function) {
self::assertTrue(method_exists($sut, $function), 'Failed finding method: ' . $function);
}
}
}