use a safer, less coupled AppVariable for templates
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -243,3 +243,7 @@ services:
|
||||
|
||||
App\Validator\Constraints\QuickEntryTimesheetValidator:
|
||||
arguments: ['%kimai.validator_timesheet%']
|
||||
|
||||
App\Twig\AppVariable:
|
||||
tags:
|
||||
- {name: twig.global, alias: app}
|
||||
|
||||
@@ -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
76
src/Twig/AppVariable.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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']),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
{% if 'dashboard' in app.request.attributes.get('_route') %}
|
||||
{% if 'dashboard' in app.current_route %}
|
||||
{{ parent() }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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) %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user