Release 2.21.0 (#5014)

This commit is contained in:
Kevin Papst
2024-08-28 17:16:37 +02:00
committed by GitHub
parent 75fa7a20b8
commit b1903ba183
54 changed files with 284 additions and 430 deletions

View File

@@ -85,7 +85,7 @@ export default class KimaiLoader {
kimai.registerPlugin(new KimaiDatatable('section.content', 'table.dataTable'));
kimai.registerPlugin(new KimaiToolbar('form.searchform', 'toolbar-action'));
kimai.registerPlugin(new KimaiAlternativeLinks('.alternative-link'));
kimai.registerPlugin(new KimaiAjaxModalForm('.modal-ajax-form'));
kimai.registerPlugin(new KimaiAjaxModalForm('.modal-ajax-form', ['td.multiCheckbox', 'td.actions']));
kimai.registerPlugin(new KimaiRemoteModal());
kimai.registerPlugin(new KimaiActiveRecords());
kimai.registerPlugin(new KimaiAPILink('api-link'));

View File

@@ -17,9 +17,10 @@ import { Modal } from 'bootstrap';
export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
constructor(selector) {
constructor(selector, stopSelector) {
super();
this._selector = selector;
this._stopSelector = stopSelector;
}
getId()
@@ -63,7 +64,7 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
this.addClickHandler(this._selector, (href) => {
this.openUrlInModal(href);
});
}, this._stopSelector);
}
_getModal()

View File

@@ -24,7 +24,7 @@ export default class KimaiAlternativeLinks extends KimaiReducedClickHandler {
init() {
this.addClickHandler(this._selector, function(href) {
window.location = href;
});
}, []);
}
}

View File

@@ -15,10 +15,11 @@ export default class KimaiReducedClickHandler extends KimaiPlugin {
/**
* No _underscore naming for now, as it would be mangled otherwise
* @param selector
* @param callback
* @param {string} selector
* @param {callback} callback
* @param {array<string>} stopSelector
*/
addClickHandler(selector, callback) {
addClickHandler(selector, callback, stopSelector) {
document.body.addEventListener('click', (event) => {
// event.currentTarget is ALWAYS the body
@@ -38,6 +39,12 @@ export default class KimaiReducedClickHandler extends KimaiPlugin {
return;
}
for (let x of stopSelector) {
if (target.matches(x)) {
return;
}
}
target = target.parentNode;
}
@@ -54,6 +61,12 @@ export default class KimaiReducedClickHandler extends KimaiPlugin {
return;
}
for (let x of stopSelector) {
if (target.matches(x)) {
return;
}
}
event.preventDefault();
event.stopPropagation();

34
composer.lock generated
View File

@@ -10448,16 +10448,16 @@
},
{
"name": "composer/pcre",
"version": "3.3.0",
"version": "3.3.1",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "1637e067347a0c40bbb1e3cd786b20dcab556a81"
"reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/1637e067347a0c40bbb1e3cd786b20dcab556a81",
"reference": "1637e067347a0c40bbb1e3cd786b20dcab556a81",
"url": "https://api.github.com/repos/composer/pcre/zipball/63aaeac21d7e775ff9bc9d45021e1745c97521c4",
"reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4",
"shasum": ""
},
"require": {
@@ -10507,7 +10507,7 @@
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.0"
"source": "https://github.com/composer/pcre/tree/3.3.1"
},
"funding": [
{
@@ -10523,7 +10523,7 @@
"type": "tidelift"
}
],
"time": "2024-08-19T19:43:53+00:00"
"time": "2024-08-27T18:44:43+00:00"
},
{
"name": "composer/xdebug-handler",
@@ -11002,16 +11002,16 @@
},
{
"name": "friendsofphp/php-cs-fixer",
"version": "v3.62.0",
"version": "v3.63.2",
"source": {
"type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
"reference": "627692f794d35c43483f34b01d94740df2a73507"
"reference": "9d427f3f14984403a6ae9fc726b61765ca0c005e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/627692f794d35c43483f34b01d94740df2a73507",
"reference": "627692f794d35c43483f34b01d94740df2a73507",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/9d427f3f14984403a6ae9fc726b61765ca0c005e",
"reference": "9d427f3f14984403a6ae9fc726b61765ca0c005e",
"shasum": ""
},
"require": {
@@ -11093,7 +11093,7 @@
],
"support": {
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.62.0"
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.63.2"
},
"funding": [
{
@@ -11101,7 +11101,7 @@
"type": "github"
}
],
"time": "2024-08-07T17:03:09+00:00"
"time": "2024-08-28T10:47:21+00:00"
},
{
"name": "masterminds/html5",
@@ -11348,16 +11348,16 @@
},
{
"name": "phpstan/phpstan",
"version": "1.11.11",
"version": "1.12.0",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan.git",
"reference": "707c2aed5d8d0075666e673a5e71440c1d01a5a3"
"reference": "384af967d35b2162f69526c7276acadce534d0e1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/707c2aed5d8d0075666e673a5e71440c1d01a5a3",
"reference": "707c2aed5d8d0075666e673a5e71440c1d01a5a3",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/384af967d35b2162f69526c7276acadce534d0e1",
"reference": "384af967d35b2162f69526c7276acadce534d0e1",
"shasum": ""
},
"require": {
@@ -11402,7 +11402,7 @@
"type": "github"
}
],
"time": "2024-08-19T14:37:29+00:00"
"time": "2024-08-27T09:18:05+00:00"
},
{
"name": "phpstan/phpstan-deprecation-rules",

View File

@@ -7,6 +7,8 @@ framework:
csrf_protection: true
annotations: false
handle_all_throwables: true
disallow_search_engine_index: true
set_content_language_from_locale: true
serializer:
enable_attributes: true

View File

@@ -23,7 +23,3 @@ services:
App\User\UserService:
public: true
arguments: ['@App\Repository\UserRepository', '@event_dispatcher', '@validator', '@App\Configuration\SystemConfiguration', '@security.user_password_hasher']
App\Plugin\PluginManager:
public: true
arguments: [!tagged kimai.plugin]

View File

@@ -34,6 +34,7 @@ parameters:
tmpDir: %rootDir%/../../../var/cache/phpstan
excludePaths:
- %rootDir%/../../../src/Ldap/LdapDriver.php
- %rootDir%/../../../src/Event/PageActionsEvent.php
treatPhpDocTypesAsCertain: false
inferPrivatePropertyTypeFromConstructor: true
doctrine:
@@ -1368,11 +1369,6 @@ parameters:
count: 1
path: src/Entity/Tag.php
-
message: "#^Property App\\\\Entity\\\\Tag\\:\\:\\$timesheets with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection does not specify its types\\: TKey, T$#"
count: 1
path: src/Entity/Tag.php
-
message: "#^Cannot call method removeMembership\\(\\) on App\\\\Entity\\\\User\\|null\\.$#"
count: 1
@@ -1613,26 +1609,6 @@ parameters:
count: 1
path: src/Event/CalendarConfigurationEvent.php
-
message: "#^Method App\\\\Event\\\\PageActionsEvent\\:\\:addAction\\(\\) has parameter \\$action with no value type specified in iterable type array\\.$#"
count: 1
path: src/Event/PageActionsEvent.php
-
message: "#^Method App\\\\Event\\\\PageActionsEvent\\:\\:addActionToSubmenu\\(\\) has parameter \\$action with no value type specified in iterable type array\\.$#"
count: 1
path: src/Event/PageActionsEvent.php
-
message: "#^Method App\\\\Event\\\\PageActionsEvent\\:\\:getActions\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Event/PageActionsEvent.php
-
message: "#^Method App\\\\Event\\\\PageActionsEvent\\:\\:replaceAction\\(\\) has parameter \\$action with no value type specified in iterable type array\\.$#"
count: 1
path: src/Event/PageActionsEvent.php
-
message: "#^Method App\\\\Event\\\\PermissionsEvent\\:\\:getPermissions\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -2098,21 +2074,6 @@ parameters:
count: 1
path: src/Form/CustomerEditForm.php
-
message: "#^Class App\\\\Form\\\\DataTransformer\\\\DurationStringToSecondsTransformer implements generic interface Symfony\\\\Component\\\\Form\\\\DataTransformerInterface but does not specify its types\\: T, R$#"
count: 1
path: src/Form/DataTransformer/DurationStringToSecondsTransformer.php
-
message: "#^Parameter \\#1 \\$formatToInt \\(string\\|null\\) of method App\\\\Form\\\\DataTransformer\\\\DurationStringToSecondsTransformer\\:\\:reverseTransform\\(\\) should be contravariant with parameter \\$value \\(mixed\\) of method Symfony\\\\Component\\\\Form\\\\DataTransformerInterface\\<mixed,mixed\\>\\:\\:reverseTransform\\(\\)$#"
count: 1
path: src/Form/DataTransformer/DurationStringToSecondsTransformer.php
-
message: "#^Parameter \\#1 \\$intToFormat \\(int\\) of method App\\\\Form\\\\DataTransformer\\\\DurationStringToSecondsTransformer\\:\\:transform\\(\\) should be contravariant with parameter \\$value \\(mixed\\) of method Symfony\\\\Component\\\\Form\\\\DataTransformerInterface\\<mixed,mixed\\>\\:\\:transform\\(\\)$#"
count: 1
path: src/Form/DataTransformer/DurationStringToSecondsTransformer.php
-
message: "#^Class App\\\\Form\\\\DataTransformer\\\\SearchTermTransformer implements generic interface Symfony\\\\Component\\\\Form\\\\DataTransformerInterface but does not specify its types\\: T, R$#"
count: 1
@@ -3963,166 +3924,11 @@ parameters:
count: 1
path: src/Reporting/ProjectDetails/ProjectDetailsModel.php
-
message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Cannot call method getSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Cannot call method hasSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 2
path: src/Repository/ActivityRepository.php
-
message: "#^Method App\\\\Repository\\\\ActivityRepository\\:\\:addPermissionCriteria\\(\\) has parameter \\$teams with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Method App\\\\Repository\\\\ActivityRepository\\:\\:getPermissionCriteria\\(\\) has parameter \\$teams with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Method App\\\\Repository\\\\BookmarkRepository\\:\\:deleteBookmark\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/BookmarkRepository.php
-
message: "#^Method App\\\\Repository\\\\BookmarkRepository\\:\\:saveBookmark\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/BookmarkRepository.php
-
message: "#^Parameter \\#1 \\$string of function mb_substr expects string, string\\|null given\\.$#"
count: 1
path: src/Repository/BookmarkRepository.php
-
message: "#^Parameter \\#1 \\$user of method App\\\\Repository\\\\BookmarkRepository\\:\\:clearCache\\(\\) expects App\\\\Entity\\\\User, App\\\\Entity\\\\User\\|null given\\.$#"
count: 2
path: src/Repository/BookmarkRepository.php
-
message: "#^Property App\\\\Repository\\\\BookmarkRepository\\:\\:\\$userCache type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/BookmarkRepository.php
-
message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/CustomerRepository.php
-
message: "#^Cannot call method getSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/CustomerRepository.php
-
message: "#^Cannot call method hasSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 2
path: src/Repository/CustomerRepository.php
-
message: "#^Method App\\\\Repository\\\\CustomerRepository\\:\\:addPermissionCriteria\\(\\) has parameter \\$teams with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/CustomerRepository.php
-
message: "#^Method App\\\\Repository\\\\CustomerRepository\\:\\:getComments\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/CustomerRepository.php
-
message: "#^Method App\\\\Repository\\\\CustomerRepository\\:\\:getPermissionCriteria\\(\\) has parameter \\$teams with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/CustomerRepository.php
-
message: "#^Cannot access offset 'counter' on mixed\\.$#"
count: 1
path: src/Repository/InvoiceRepository.php
-
message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/InvoiceRepository.php
-
message: "#^Cannot call method getSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/InvoiceRepository.php
-
message: "#^Cannot call method hasSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 2
path: src/Repository/InvoiceRepository.php
-
message: "#^Method App\\\\Repository\\\\InvoiceRepository\\:\\:addPermissionCriteria\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/InvoiceRepository.php
-
message: "#^Method App\\\\Repository\\\\InvoiceRepository\\:\\:addPermissionCriteria\\(\\) has parameter \\$teams with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/InvoiceRepository.php
-
message: "#^Method App\\\\Repository\\\\InvoiceRepository\\:\\:getCounterFor\\(\\) should return int but returns mixed\\.$#"
count: 1
path: src/Repository/InvoiceRepository.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Repository/Loader/ProjectLoader.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Repository/Loader/TimesheetLoader.php
-
message: "#^Interface App\\\\Repository\\\\Paginator\\\\PaginatorInterface extends generic interface Pagerfanta\\\\Adapter\\\\AdapterInterface but does not specify its types\\: T$#"
count: 1
path: src/Repository/Paginator/PaginatorInterface.php
-
message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Cannot call method getSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Cannot call method hasSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 2
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\ProjectRepository\\:\\:addPermissionCriteria\\(\\) has parameter \\$teams with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\ProjectRepository\\:\\:getComments\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\ProjectRepository\\:\\:getPermissionCriteria\\(\\) has parameter \\$teams with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\Query\\\\BaseQuery\\:\\:getOrderGroups\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -4168,11 +3974,6 @@ parameters:
count: 1
path: src/Repository/Query/TimesheetQuery.php
-
message: "#^Method App\\\\Repository\\\\Result\\\\TimesheetResult\\:\\:toIterable\\(\\) return type has no value type specified in iterable type iterable\\.$#"
count: 1
path: src/Repository/Result/TimesheetResult.php
-
message: "#^Method App\\\\Repository\\\\RoleRepository\\:\\:deleteRole\\(\\) has no return type specified\\.$#"
count: 1
@@ -4183,16 +3984,6 @@ parameters:
count: 1
path: src/Repository/RoleRepository.php
-
message: "#^Cannot call method getSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/TagRepository.php
-
message: "#^Cannot call method hasSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/TagRepository.php
-
message: "#^Argument of an invalid type array\\<string\\>\\|string supplied for foreach, only iterables are supported\\.$#"
count: 1
@@ -4238,21 +4029,6 @@ parameters:
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Cannot call method getSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Cannot call method hasSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 2
path: src/Repository/TimesheetRepository.php
-
message: "#^Cannot clone DateTime\\|null\\.$#"
count: 1
@@ -4303,31 +4079,6 @@ parameters:
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/UserRepository.php
-
message: "#^Cannot call method getSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/UserRepository.php
-
message: "#^Cannot call method hasSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/UserRepository.php
-
message: "#^Method App\\\\Repository\\\\UserRepository\\:\\:findByUsername\\(\\) has parameter \\$username with no type specified\\.$#"
count: 1
path: src/Repository/UserRepository.php
-
message: "#^Parameter \\#1 \\$identifier of method App\\\\Repository\\\\UserRepository\\:\\:loadUserByIdentifier\\(\\) expects string, mixed given\\.$#"
count: 1
path: src/Repository/UserRepository.php
-
message: "#^Parameter \\#1 \\$message of class Symfony\\\\Component\\\\Security\\\\Core\\\\Exception\\\\AuthenticationException constructor expects string, string\\|null given\\.$#"
count: 1

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
"app": {
"js": [
"/build/runtime.74179306.js",
"/build/app.41121fe4.js"
"/build/app.13d5a0d9.js"
],
"css": [
"/build/app.a7ba9fd6.css"
@@ -72,7 +72,7 @@
},
"integrity": {
"/build/runtime.74179306.js": "sha384-OC1hTNUXUalKJcvmzrZ0TMCOIwnhxCxgG9dQkbcwR7WcBBCkl2H8bs3giiT2pAwG",
"/build/app.41121fe4.js": "sha384-4v3lVToJIcpMJ++4WaIZ5r9rXR5Vb4waSoHcEzx3vOfcLL+1+TN70di7XPzYSW7I",
"/build/app.13d5a0d9.js": "sha384-1g1GY1GLRW3qbMZV2ZywyP9MR1Z8N65ayJqb+hyV2xyrhzY3JoVPyFF9IWozaEK4",
"/build/app.a7ba9fd6.css": "sha384-GfMYf1vYI9zkkywMoxYk6wpC4UyK185zjDBi16qa0237oztrtOuWdBZsKjWOkyeY",
"/build/app-rtl.97153087.js": "sha384-jX7jRUAa8rH29Eg8jLIUKGfGcOT6RBz/P90plXmZPadf2CXKUBdcNGrspaejCHkr",
"/build/app-rtl.6516df2d.css": "sha384-5hF5akg98Hnj/bihpbsiTjMyZ3BsAC4+Z1kzqi1O3/U+tgpnQ+JmwsOADDpMld6d",

View File

@@ -1,6 +1,6 @@
{
"build/app.css": "/build/app.a7ba9fd6.css",
"build/app.js": "/build/app.41121fe4.js",
"build/app.js": "/build/app.13d5a0d9.js",
"build/app-rtl.css": "/build/app-rtl.6516df2d.css",
"build/app-rtl.js": "/build/app-rtl.97153087.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",

View File

@@ -46,6 +46,7 @@ final class TagController extends BaseApiController
#[Rest\QueryParam(name: 'name', strict: true, nullable: true, description: 'Search term to filter tag list')]
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{
// not using a query, as these are too expensive to fetch, the simple "name" list is easier to use
$filter = $paramFetcher->get('name');
$data = $this->repository->findAllTagNames($filter);
@@ -57,7 +58,7 @@ final class TagController extends BaseApiController
}
/**
* Fetch tags by filter as entities
* Fetch tags by filter (as full entities)
*/
#[OA\Response(response: 200, description: 'Find the collection of all matching tags', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TagEntity')))]
#[Route(path: '/find', name: 'get_tags_full', methods: ['GET'])]

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.20.0';
public const VERSION = '2.21.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 22000;
public const VERSION_ID = 22100;
/**
* The software name
*/

View File

@@ -41,12 +41,14 @@ final class PermissionController extends AbstractController
{
public const TOKEN_NAME = 'user_role_permissions';
public function __construct(private RolePermissionManager $manager, private RoleRepository $roleRepository)
public function __construct(
private readonly RolePermissionManager $manager,
private readonly RoleRepository $roleRepository
)
{
}
#[Route(path: '', name: 'admin_user_permissions', methods: ['GET', 'POST'])]
#[IsGranted('role_permissions')]
public function permissions(EventDispatcherInterface $dispatcher, CsrfTokenManagerInterface $csrfTokenManager, RoleService $roleService, UserRepository $userRepository): Response
{
$all = $this->roleRepository->findAll();
@@ -167,7 +169,6 @@ final class PermissionController extends AbstractController
}
#[Route(path: '/roles/create', name: 'admin_user_roles', methods: ['GET', 'POST'])]
#[IsGranted('role_permissions')]
public function createRole(Request $request): Response
{
$role = new Role();
@@ -200,8 +201,7 @@ final class PermissionController extends AbstractController
]);
}
#[Route(path: '/roles/{id}/delete/{csrfToken}', name: 'admin_user_role_delete', methods: ['GET', 'POST'])]
#[IsGranted('role_permissions')]
#[Route(path: '/roles/{role}/delete/{csrfToken}', name: 'admin_user_role_delete', methods: ['GET', 'POST'])]
public function deleteRole(Role $role, string $csrfToken, UserRepository $userRepository, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $csrfToken)) {
@@ -230,8 +230,7 @@ final class PermissionController extends AbstractController
return $this->redirectToRoute('admin_user_permissions');
}
#[Route(path: '/roles/{id}/{name}/{value}/{csrfToken}', name: 'admin_user_permission_save', methods: ['POST'])]
#[IsGranted('role_permissions')]
#[Route(path: '/roles/{role}/{name}/{value}/{csrfToken}', name: 'admin_user_permission_save', methods: ['POST'])]
public function savePermission(Role $role, string $name, bool $value, string $csrfToken, PermissionService $permissionService, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $csrfToken)) {

View File

@@ -17,7 +17,7 @@ use Doctrine\ORM\Query\TokenType;
final class Date extends FunctionNode
{
private Node|string|null $value;
private Node|string $value;
public function getSql(SqlWalker $sqlWalker): string
{

View File

@@ -17,7 +17,7 @@ use Doctrine\ORM\Query\TokenType;
final class Day extends FunctionNode
{
private Node|string|null $value;
private Node|string $value;
public function getSql(SqlWalker $sqlWalker): string
{

View File

@@ -17,7 +17,7 @@ use Doctrine\ORM\Query\TokenType;
final class Month extends FunctionNode
{
private Node|string|null $value;
private Node|string $value;
public function getSql(SqlWalker $sqlWalker): string
{

View File

@@ -17,7 +17,7 @@ use Doctrine\ORM\Query\TokenType;
final class Year extends FunctionNode
{
private Node|string|null $value;
private Node|string $value;
public function getSql(SqlWalker $sqlWalker): string
{

View File

@@ -10,8 +10,6 @@
namespace App\Entity;
use App\Utils\Color;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@@ -53,21 +51,8 @@ class Tag
use ColorTrait;
/**
* This is ONLY here, so we can count the amount of timesheets.
*
* See TagRepository "SIZE(t.timesheets)"
* Removing this makes the count more complicated.
* Should be refactored at some point in the future.
*
* @var Collection<Timesheet>
*/
#[ORM\ManyToMany(targetEntity: Timesheet::class, mappedBy: 'tags', fetch: 'EXTRA_LAZY')]
private Collection $timesheets;
public function __construct()
{
$this->timesheets = new ArrayCollection();
}
public function getId(): ?int

View File

@@ -200,7 +200,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
#[ORM\JoinTable(name: 'kimai2_timesheet_tags')]
#[ORM\JoinColumn(name: 'timesheet_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'tag_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'timesheets', cascade: ['persist'])]
#[ORM\ManyToMany(targetEntity: Tag::class, cascade: ['persist'])]
#[Assert\Valid]
private Collection $tags;
/**

View File

@@ -16,14 +16,17 @@ use App\Entity\User;
* - once per side load for page actions
* - once for every entity item (table row)
*
* @property array{'actions': array, 'view': string} $payload
* @property array{actions: array<string, string|array<mixed>>, view: string} $payload
*/
class PageActionsEvent extends ThemeEvent
{
private int $divider = 0;
private ?string $locale = null;
public function __construct(User $user, array $payload, private string $action, private string $view)
/**
* @param array<mixed> $payload
*/
public function __construct(User $user, array $payload, private readonly string $action, private readonly string $view)
{
// only for BC reasons, do not access it directly!
if (!\array_key_exists('actions', $payload)) {
@@ -58,8 +61,6 @@ class PageActionsEvent extends ThemeEvent
/**
* Custom view can only be table listings.
*
* @return bool
*/
public function isCustomView(): bool
{
@@ -71,6 +72,9 @@ class PageActionsEvent extends ThemeEvent
return $this->view;
}
/**
* @return array<string, array<mixed>>
*/
public function getActions(): array
{
$actions = $this->payload['actions'];
@@ -99,28 +103,38 @@ class PageActionsEvent extends ThemeEvent
public function hasSubmenu(string $submenu): bool
{
if (!$this->hasAction($submenu)) {
if (!\array_key_exists($submenu, $this->payload['actions'])) {
return false;
}
return \array_key_exists('children', $this->payload['actions'][$submenu]);
}
/**
* @param array<string, mixed> $action
*/
public function addActionToSubmenu(string $submenu, string $key, array $action): void
{
if ($this->hasAction($submenu)) {
if (!\array_key_exists($submenu, $this->payload['actions'])) {
$this->payload['actions'][$submenu] = ['children' => []];
}
if (!\array_key_exists('children', $this->payload['actions'][$submenu])) {
$this->payload['actions'][$submenu]['children'] = [];
}
}
$this->payload['actions'][$submenu]['children'][$key] = $action;
}
/**
* @param array<string, mixed> $action
*/
public function replaceAction(string $key, array $action): void
{
$this->payload['actions'][$key] = $action;
}
/**
* @param array<string, mixed> $action
*/
public function addAction(string $key, array $action): void
{
if (!$this->hasAction($key)) {

View File

@@ -28,7 +28,7 @@ class ThemeEvent extends Event
/**
* @param array<string, mixed|array<mixed>> $payload
*/
public function __construct(private ?User $user = null, protected array $payload = [])
public function __construct(private readonly ?User $user = null, protected array $payload = [])
{
}

View File

@@ -14,42 +14,54 @@ use App\Validator\Constraints\Duration as DurationConstraint;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
/**
* @implements DataTransformerInterface<string|int|null, string|null>
*/
final class DurationStringToSecondsTransformer implements DataTransformerInterface
{
/**
* @param int $intToFormat
* @return string|null
*/
public function transform(mixed $intToFormat): ?string
public function transform(mixed $value): ?string
{
if ($value === null) {
return null;
}
try {
return (new Duration())->format($intToFormat);
if (!\is_int($value) && is_numeric($value)) {
$value = (int) $value;
}
if (!\is_int($value)) {
// do not throw an exception, that would break the frontend, make it null / empty instead
return null;
}
return (new Duration())->format($value);
} catch (\Exception | \TypeError $e) {
throw new TransformationFailedException($e->getMessage());
}
}
/**
* @param string|null $formatToInt
* @return int|null
*/
public function reverseTransform(mixed $formatToInt): ?int
public function reverseTransform(mixed $value): ?int
{
if (null === $formatToInt) {
if ($value === null) {
return null;
}
if (empty($formatToInt)) {
if ($value === '') {
return 0;
}
if (\is_int($value) || \is_float($value)) {
$value = (string) $value;
}
// we need this one here, because the data transformer is executed BEFORE the constraint is called
if (!preg_match((new DurationConstraint())->pattern, $formatToInt)) {
if (!preg_match((new DurationConstraint())->pattern, $value)) {
throw new TransformationFailedException('Invalid duration format given');
}
try {
$seconds = (new Duration())->parseDurationString($formatToInt);
$seconds = (new Duration())->parseDurationString($value);
// DateTime throws if a duration with too many seconds is passed and an amount of so
// many seconds is likely not required in a time-tracking application ;-)

View File

@@ -143,8 +143,8 @@ class ProjectStatisticService
)
)
)
->setParameter('begin', $begin, Types::DATETIME_MUTABLE)
->setParameter('end', $end, Types::DATETIME_MUTABLE)
->setParameter('begin', DateTimeImmutable::createFromInterface($begin), Types::DATETIME_IMMUTABLE)
->setParameter('end', DateTimeImmutable::createFromInterface($end), Types::DATETIME_IMMUTABLE)
;
if (!$query->isIncludeNoWork()) {
@@ -323,14 +323,14 @@ class ProjectStatisticService
if ($begin !== null) {
$qb
->andWhere($qb->expr()->gte('t.begin', ':begin'))
->setParameter('begin', $begin, Types::DATETIME_MUTABLE)
->setParameter('begin', DateTimeImmutable::createFromInterface($begin), Types::DATETIME_IMMUTABLE)
;
}
if ($end !== null) {
$qb
->andWhere($qb->expr()->lte('t.begin', ':end'))
->setParameter('end', $end, Types::DATETIME_MUTABLE)
->setParameter('end', DateTimeImmutable::createFromInterface($end), Types::DATETIME_IMMUTABLE)
;
}
@@ -646,7 +646,7 @@ class ProjectStatisticService
)
)
->addGroupBy('p')
->setParameter('project_end', $today, Types::DATETIME_MUTABLE)
->setParameter('project_end', DateTimeImmutable::createFromInterface($today), Types::DATETIME_IMMUTABLE)
;
if ($query->getCustomer() !== null) {
@@ -732,7 +732,7 @@ class ProjectStatisticService
$qb = clone $tplQb;
$qb
->andWhere('DATE(t.date) = :start_date')
->setParameter('start_date', $today, Types::DATETIME_MUTABLE)
->setParameter('start_date', DateTimeImmutable::createFromInterface($today), Types::DATETIME_IMMUTABLE)
;
$result = $qb->getQuery()->getScalarResult();
@@ -744,8 +744,8 @@ class ProjectStatisticService
$qb = clone $tplQb;
$qb
->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date')
->setParameter('start_date', $startOfWeek, Types::DATETIME_MUTABLE)
->setParameter('end_date', $endOfWeek, Types::DATETIME_MUTABLE)
->setParameter('start_date', DateTimeImmutable::createFromInterface($startOfWeek), Types::DATETIME_IMMUTABLE)
->setParameter('end_date', DateTimeImmutable::createFromInterface($endOfWeek), Types::DATETIME_IMMUTABLE)
;
$result = $qb->getQuery()->getScalarResult();
@@ -757,8 +757,8 @@ class ProjectStatisticService
$qb = clone $tplQb;
$qb
->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date')
->setParameter('start_date', $startMonth, Types::DATETIME_MUTABLE)
->setParameter('end_date', $endMonth, Types::DATETIME_MUTABLE)
->setParameter('start_date', DateTimeImmutable::createFromInterface($startMonth), Types::DATETIME_IMMUTABLE)
->setParameter('end_date', DateTimeImmutable::createFromInterface($endMonth), Types::DATETIME_IMMUTABLE)
;
$result = $qb->getQuery()->getScalarResult();

View File

@@ -91,6 +91,9 @@ class ActivityRepository extends EntityRepository
return $this->count([]);
}
/**
* @param array<Team> $teams
*/
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): void
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams, $globalsOnly);
@@ -99,6 +102,9 @@ class ActivityRepository extends EntityRepository
}
}
/**
* @param array<Team> $teams
*/
private function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): Andx
{
$andX = $qb->expr()->andX();

View File

@@ -14,36 +14,46 @@ use App\Entity\User;
use Doctrine\ORM\EntityRepository;
/**
* @extends \Doctrine\ORM\EntityRepository<Bookmark>
* @extends EntityRepository<Bookmark>
*/
class BookmarkRepository extends EntityRepository
{
/** @var array<string, array<string, array<string, Bookmark>>> */
private array $userCache = [];
public function saveBookmark(Bookmark $bookmark)
public function saveBookmark(Bookmark $bookmark): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($bookmark);
$entityManager->flush();
if ($bookmark->getUser()) {
$this->clearCache($bookmark->getUser());
}
}
private function clearCache(User $user): void
private function clearCache(?User $user): void
{
if ($user === null || $user->getId() === null) {
return;
}
$key = 'user_' . $user->getId();
if (\array_key_exists($key, $this->userCache)) {
unset($this->userCache[$key]);
}
}
public function deleteBookmark(Bookmark $bookmark)
public function deleteBookmark(Bookmark $bookmark): void
{
$em = $this->getEntityManager();
$em->remove($bookmark);
$em->flush();
if ($bookmark->getUser()) {
$this->clearCache($bookmark->getUser());
}
}
public function getSearchDefaultOptions(User $user, string $name): ?Bookmark
{
@@ -59,7 +69,7 @@ class BookmarkRepository extends EntityRepository
$this->userCache[$key] = [];
$all = $this->findBy(['user' => $user->getId()]);
foreach ($all as $item) {
$this->userCache[$key][$item->getType()][mb_substr($item->getName(), 0, 50)] = $item;
$this->userCache[$key][$item->getType()][mb_substr($item->getName() ?? '__DEFAULT__', 0, 50)] = $item;
}
}

View File

@@ -79,6 +79,9 @@ class CustomerRepository extends EntityRepository
return $this->count([]);
}
/**
* @param array<Team> $teams
*/
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams);
@@ -87,6 +90,9 @@ class CustomerRepository extends EntityRepository
}
}
/**
* @param array<Team> $teams
*/
private function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): Andx
{
$andX = $qb->expr()->andX();
@@ -349,6 +355,9 @@ class CustomerRepository extends EntityRepository
}
}
/**
* @return array<CustomerComment>
*/
public function getComments(Customer $customer): array
{
$qb = $this->getEntityManager()->createQueryBuilder();

View File

@@ -83,13 +83,14 @@ class InvoiceRepository extends EntityRepository
;
}
/** @var array{'counter': int|numeric-string}|null $result */
$result = $qb->getQuery()->getOneOrNullResult();
if ($result === null) {
return 0;
}
return $result['counter'];
return (int) $result['counter'];
}
public function getCounterForDay(\DateTimeInterface $date, ?Customer $customer = null, ?User $user = null): int
@@ -137,7 +138,10 @@ class InvoiceRepository extends EntityRepository
return $this->count([]);
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
/**
* @param array<Team> $teams
*/
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
{
// make sure that all queries without a user see all projects
if (null === $user && empty($teams)) {

View File

@@ -71,7 +71,7 @@ final class TimesheetLoader implements LoaderInterface
if ($this->fullyHydrated) {
$customerIds = array_filter(array_unique(array_map(function (Project $project) {
return $project->getCustomer()->getId();
return $project->getCustomer()?->getId();
}, $projects)), function ($value) { return $value !== null; });
$qb = $em->createQueryBuilder();

View File

@@ -13,6 +13,7 @@ use Pagerfanta\Adapter\AdapterInterface;
/**
* @template-covariant T
* @extends AdapterInterface<T>
*/
interface PaginatorInterface extends AdapterInterface
{

View File

@@ -85,6 +85,9 @@ class ProjectRepository extends EntityRepository
return $this->count([]);
}
/**
* @param array<Team> $teams
*/
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams);
@@ -93,6 +96,9 @@ class ProjectRepository extends EntityRepository
}
}
/**
* @param array<Team> $teams
*/
private function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): Andx
{
$andX = $qb->expr()->andX();
@@ -429,6 +435,9 @@ class ProjectRepository extends EntityRepository
}
}
/**
* @return array<ProjectComment>
*/
public function getComments(Project $project): array
{
$qb = $this->getEntityManager()->createQueryBuilder();

View File

@@ -40,12 +40,12 @@ trait RepositorySearchTrait
private function addSearchTerm(QueryBuilder $qb, BaseQuery $query): void
{
if (!$query->hasSearchTerm()) {
$searchTerm = $query->getSearchTerm();
if ($searchTerm === null) {
return;
}
$searchTerm = $query->getSearchTerm();
if (!$this->supportsMetaFields() && !$searchTerm->hasSearchTerm()) {
return;
}

View File

@@ -67,6 +67,9 @@ final class TimesheetResult
return $this->statisticCache;
}
/**
* @return iterable<Timesheet>
*/
public function toIterable(): iterable
{
return $this->query->toIterable();

View File

@@ -10,6 +10,7 @@
namespace App\Repository;
use App\Entity\Tag;
use App\Entity\Timesheet;
use App\Repository\Paginator\QueryPaginator;
use App\Repository\Query\TagFormTypeQuery;
use App\Repository\Query\TagQuery;
@@ -131,7 +132,11 @@ class TagRepository extends EntityRepository
{
$qb = $this->createQueryBuilder('tag');
$qb->select('tag.id, tag.name, tag.color, tag.visible, SIZE(tag.timesheets) as amount');
$qb1 = $this->getEntityManager()->createQueryBuilder();
$qb1->from(Timesheet::class, 't')->select('COUNT(tags)')->innerJoin('t.tags', 'tags')->where('tags.id = tag.id');
$qb->select('tag.id, tag.name, tag.color, tag.visible');
$qb->addSelect('(' . $qb1->getDQL() . ') as amount');
$orderBy = $query->getOrderBy();
$orderBy = match ($orderBy) {
@@ -149,8 +154,8 @@ class TagRepository extends EntityRepository
$qb->addOrderBy($orderBy, $query->getOrder());
if ($query->hasSearchTerm()) {
$searchTerm = $query->getSearchTerm();
if ($searchTerm !== null) {
$searchAnd = $qb->expr()->andX();
if ($searchTerm->hasSearchTerm()) {

View File

@@ -117,14 +117,14 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
*/
public function findOneBy(array $criteria, array $orderBy = null): ?object
{
if (\count($criteria) === 1 && isset($criteria['username'])) {
if (\count($criteria) === 1 && isset($criteria['username']) && \is_string($criteria['username'])) {
return $this->loadUserByIdentifier($criteria['username']);
}
return parent::findOneBy($criteria, $orderBy);
}
public function findByUsername($username): ?User
public function findByUsername(string $username): ?User
{
return parent::findOneBy(['username' => $username]);
}
@@ -321,9 +321,9 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
$qb->setParameter('system', $query->getSystemAccount(), Types::BOOLEAN);
}
if ($query->hasSearchTerm()) {
$searchAnd = $qb->expr()->andX();
$searchTerm = $query->getSearchTerm();
if ($searchTerm !== null) {
$searchAnd = $qb->expr()->andX();
foreach ($searchTerm->getSearchFields() as $metaName => $metaValue) {
$qb->leftJoin('u.preferences', 'meta');

View File

@@ -17,13 +17,13 @@ use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
final class RoundingService
{
/**
* @var array<string, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}>
* @var null|non-empty-array<array-key, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}>
*/
private ?array $rulesCache = null;
/**
* @param RoundingInterface[] $roundingModes
* @param array<string, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}> $rules
* @param array<string, array{'days': string, 'begin': int, 'end': int, 'duration': int, 'mode': string}> $rules
*/
public function __construct(
private readonly SystemConfiguration $configuration,
@@ -35,13 +35,13 @@ final class RoundingService
}
/**
* @return array<string, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}>
* @return non-empty-array<array-key, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}>
*/
private function getRoundingRules(): array
{
if ($this->rulesCache === null) {
$rules = $this->rules;
$rules['default']['days'] = $this->configuration->getTimesheetDefaultRoundingDays();
$rules['default']['days'] = $this->parseDays($this->configuration->getTimesheetDefaultRoundingDays());
$rules['default']['begin'] = $this->configuration->getTimesheetDefaultRoundingBegin();
$rules['default']['end'] = $this->configuration->getTimesheetDefaultRoundingEnd();
$rules['default']['duration'] = $this->configuration->getTimesheetDefaultRoundingDuration();
@@ -49,17 +49,13 @@ final class RoundingService
// see AppExtension, conversion from string to array due to system configuration not allowing to store arrays
foreach ($rules as $key => $settings) {
if (\is_array($settings['days'])) {
continue;
}
if (\is_string($settings['days'])) {
if ($settings['days'] === '') {
$rules[$key]['days'] = [];
continue;
}
$days = explode(',', $settings['days']);
$days = array_map('trim', $days);
$days = array_map('strtolower', $days);
$rules[$key]['days'] = $days;
$rules[$key]['days'] = array_map('strtolower', array_map('trim', explode(',', $settings['days'])));
}
}
$this->rulesCache = $rules; // @phpstan-ignore-line
}
@@ -67,6 +63,14 @@ final class RoundingService
return $this->rulesCache; // @phpstan-ignore-line
}
/**
* @return array<string>
*/
private function parseDays(string $days): array
{
return array_map('strtolower', array_map('trim', explode(',', $days)));
}
public function roundBegin(Timesheet $record): void
{
foreach ($this->getRoundingRules() as $rounding) {

View File

@@ -93,7 +93,7 @@
{% endif %}
{% if year is defined %}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'working_time_detail_box', border: false, item: {options: {bodyExtraClass: 'border-top', open: not box_configuration.collapsed}, bodyClasses: 'p-0 table-responsive'}} %}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'working_time_detail_box', border: false, item: {options: {bodyExtraClass: 'border-top', open: true}, bodyClasses: 'p-0 table-responsive'}} %}
{% set yearShould = 0 %}
{% set yearIs = 0 %}
{% set approved = 0 %}

View File

@@ -34,7 +34,7 @@
{{ role.getName()|trans }}
{% if canEditPermissions and (role.name not in system_roles) %}
&nbsp;
<a href="{{ path('admin_user_role_delete', {'id': role.id, 'csrfToken': token}) }}" class="confirmation-link" data-question="confirm.delete">{{ icon('trash') }}</a>
<a href="{{ path('admin_user_role_delete', {'role': role.id, 'csrfToken': token}) }}" class="confirmation-link" data-question="confirm.delete">{{ icon('trash') }}</a>
{% endif %}
</h4>
<div class="list-unstyled d-flex mb-0">
@@ -82,10 +82,10 @@
{% if value %}
{{ widgets.label('yes'|trans, 'warning') }}
{% else %}
<a class="togglePerm permOff" href="#" data-href="{{ path('admin_user_permission_save', {'id': role.id, 'name': permission, 'value': '1', 'csrfToken': '__TOKEN__'}) }}">{{ widgets.label('no'|trans, 'danger') }}</a>
<a class="togglePerm permOff" href="#" data-href="{{ path('admin_user_permission_save', {'role': role.id, 'name': permission, 'value': '1', 'csrfToken': '__TOKEN__'}) }}">{{ widgets.label('no'|trans, 'danger') }}</a>
{% endif %}
{% else %}
<a class="togglePerm {{ value ? 'permOn' : 'permOff' }}" href="#" data-href="{{ path('admin_user_permission_save', {'id': role.id, 'name': permission, 'value': '__VALUE__', 'csrfToken': '__TOKEN__'}) }}">{{ widgets.label_boolean(value) }}</a>
<a class="togglePerm {{ value ? 'permOn' : 'permOff' }}" href="#" data-href="{{ path('admin_user_permission_save', {'role': role.id, 'name': permission, 'value': '__VALUE__', 'csrfToken': '__TOKEN__'}) }}">{{ widgets.label_boolean(value) }}</a>
{% endif %}
</td>
{% endfor %}

View File

@@ -2,7 +2,7 @@
{% block report_form_layout %}
{{ form_widget(form.date) }}
{{ form_widget(form.customer, {'label': false, 'placeholder': 'please_choose'}) }}
{{ form_widget(form.customer, {'label': false, 'placeholder': 'customer'}) }}
{{ form_widget(form.sumType) }}
{% from '@theme/components/buttons.html.twig' import submit_button %}
{{ submit_button('download', {'attr': {'formaction': path(export_route)}, 'icon': 'download', 'combined': false}, 'primary') }}

View File

@@ -46,7 +46,7 @@
{% block report_form_layout %}
{{ form_widget(form.month, {'label': false}) }}
{{ form_widget(form.customer, {'label': false, 'placeholder': 'please_choose'}) }}
{{ form_widget(form.customer, {'label': false, 'placeholder': 'customer'}) }}
<div class="dropdown">
<button type="button" class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ icon('filter', true) }}

View File

@@ -55,7 +55,7 @@
{% endblock %}
{% block report_form_layout %}
{{ form_widget(form.project, {'label': false, 'placeholder': 'please_choose'}) }}
{{ form_widget(form.project, {'label': false, 'placeholder': 'project'}) }}
{% endblock %}
{% block report %}

View File

@@ -1,7 +1,7 @@
{% extends 'reporting/project_list_data.html.twig' %}
{% block report_form_layout %}
{{ form_widget(form.customer, {'label': false, 'placeholder': 'please_choose'}) }}
{{ form_widget(form.customer, {'label': false, 'placeholder': 'customer'}) }}
<div class="dropdown">
<button type="button" class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ icon('filter', true) }}

View File

@@ -3,10 +3,10 @@
{% block report_form_layout %}
{{ form_widget(form.date) }}
{% if form.team is defined %}
{{ form_widget(form.team, {'label': false, 'placeholder': 'please_choose'}) }}
{{ form_widget(form.team, {'label': false, 'placeholder': 'team'}) }}
{% endif %}
{% if form.project is defined %}
{{ form_widget(form.project, {'label': false, 'placeholder': 'please_choose'}) }}
{{ form_widget(form.project, {'label': false, 'placeholder': 'project'}) }}
{% endif %}
{{ form_widget(form.sumType) }}
{% from '@theme/components/buttons.html.twig' import submit_button %}

View File

@@ -7,7 +7,7 @@
* file that was distributed with this source code.
*/
namespace API\Authentication;
namespace App\Tests\API\Authentication;
use App\API\Authentication\AccessTokenHandler;
use App\Entity\AccessToken;

View File

@@ -7,11 +7,10 @@
* file that was distributed with this source code.
*/
namespace API;
namespace App\Tests\API;
use App\DataFixtures\UserFixtures;
use App\Entity\User;
use App\Tests\API\APIControllerBaseTest;
use Symfony\Component\HttpFoundation\Response;
/**

View File

@@ -178,18 +178,19 @@ abstract class ControllerBaseTest extends WebTestCase
/** @var RedirectResponse $response */
$response = $client->getResponse();
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertTrue(
$response->isRedirect(),
\sprintf('The secure URL %s is not protected.', $url)
\sprintf('The URL %s is not protected (%s occurred).', $url, $response->getStatusCode())
);
self::assertStringEndsWith(
'/login',
$response->getTargetUrl(),
\sprintf('The secure URL %s does not redirect to the login form.', $url)
\sprintf('The URL %s does not redirect to the login form.', $url)
);
self::assertInstanceOf(RedirectResponse::class, $response);
}
protected function assertSuccessResponse(HttpKernelBrowser $client, string $message = ''): void

View File

@@ -18,27 +18,26 @@ use Symfony\Component\Form\Exception\TransformationFailedException;
*/
class DurationStringToSecondsTransformerTest extends TestCase
{
/**
* @var DurationStringToSecondsTransformer
*/
private $sut;
private DurationStringToSecondsTransformer $sut;
protected function setUp(): void
{
$this->sut = new DurationStringToSecondsTransformer();
}
public function getValidTestDataTransform()
public function getValidTestDataTransform(): array
{
return [
['0:00', '0'],
['123:00', '442800'],
['123:00', 442800],
['0:00', 0],
['2:00', 7213], // by default no seconds are returned
[null, null],
];
}
public function getInvalidTestDataTransform()
public function getInvalidTestDataTransform(): array
{
return [
[''],
@@ -59,12 +58,14 @@ class DurationStringToSecondsTransformerTest extends TestCase
*/
public function testInvalidTransformThrowsException($transform): void
{
$this->expectException(TransformationFailedException::class);
$this->sut->transform($transform);
$value = $this->sut->transform($transform);
$this->assertNull($value);
}
public function getValidTestDataReverseTransform()
/**
* @return array<int, array<int, string|int|float|null>>
*/
public function getValidTestDataReverseTransform(): array
{
return [
['2h3s', 7203],
@@ -72,16 +73,23 @@ class DurationStringToSecondsTransformerTest extends TestCase
['0', null],
[null, null],
['87600000000:00:00', 315360000000000],
[87600000000, 315360000000000], // int will be treated we could argue if this is a correct behavior
[87599999999.5, 315359999998200], // only int can be used as hourly input
];
}
public function getInvalidTestDataReverseTransform()
/**
* @return array<int, array<int, string|int>>
*/
public function getInvalidTestDataReverseTransform(): array
{
return [
['xxx'],
[':::'],
['0::0'],
// too large
['87600000000:00:01'],
// too large
[315360000000001],
];
}

View File

@@ -58,7 +58,7 @@ class TranslationsTest extends TestCase
foreach ($body->children() as $transUnit) {
preg_match_all('/%[a-zA-Z]{1,}%/Uu', (string) $transUnit->target, $matches);
if (!empty($matches) && !empty($matches[0])) {
if (!empty($matches[0])) {
asort($matches[0]);
$trans[(string) $transUnit->source] = array_values($matches[0]);
}

View File

@@ -18,6 +18,7 @@ parameters:
doctrine:
objectManagerLoader: %rootDir%/../../../tests/phpstan-doctrine.php
ignoreErrors:
- identifier: missingType.iterableValue
-
message: "#^Method App\\\\Tests\\\\API\\\\APIControllerBaseTest\\:\\:assertApiException\\(\\) has parameter \\$expectedErrors with no value type specified in iterable type array\\.$#"
count: 1
@@ -2108,26 +2109,6 @@ parameters:
count: 1
path: Form/CustomerEditFormTest.php
-
message: "#^Method App\\\\Tests\\\\Form\\\\DataTransformer\\\\DurationStringToSecondsTransformerTest\\:\\:getInvalidTestDataReverseTransform\\(\\) has no return type specified\\.$#"
count: 1
path: Form/DataTransformer/DurationStringToSecondsTransformerTest.php
-
message: "#^Method App\\\\Tests\\\\Form\\\\DataTransformer\\\\DurationStringToSecondsTransformerTest\\:\\:getInvalidTestDataTransform\\(\\) has no return type specified\\.$#"
count: 1
path: Form/DataTransformer/DurationStringToSecondsTransformerTest.php
-
message: "#^Method App\\\\Tests\\\\Form\\\\DataTransformer\\\\DurationStringToSecondsTransformerTest\\:\\:getValidTestDataReverseTransform\\(\\) has no return type specified\\.$#"
count: 1
path: Form/DataTransformer/DurationStringToSecondsTransformerTest.php
-
message: "#^Method App\\\\Tests\\\\Form\\\\DataTransformer\\\\DurationStringToSecondsTransformerTest\\:\\:getValidTestDataTransform\\(\\) has no return type specified\\.$#"
count: 1
path: Form/DataTransformer/DurationStringToSecondsTransformerTest.php
-
message: "#^Method App\\\\Tests\\\\Form\\\\DataTransformer\\\\DurationStringToSecondsTransformerTest\\:\\:testInvalidReverseTransformThrowsException\\(\\) has parameter \\$transform with no type specified\\.$#"
count: 1

View File

@@ -278,6 +278,10 @@
<source>created_at</source>
<target>Erstellt am</target>
</trans-unit>
<trans-unit id="QG7.sen" resname="created">
<source>Created</source>
<target>Erstellt</target>
</trans-unit>
<trans-unit id="DodjLNR" resname="date">
<source>date</source>
<target>Datum</target>
@@ -1254,10 +1258,18 @@
<source>status.approved</source>
<target>Genehmigt</target>
</trans-unit>
<trans-unit id="dOIWgOr" resname="approve">
<source>approve</source>
<target>Genehmigen</target>
</trans-unit>
<trans-unit id="Na24cyT" resname="status.requested">
<source>status.requested</source>
<target>Beantragt</target>
</trans-unit>
<trans-unit id="H1i5FFs" resname="request">
<source>request</source>
<target>Beantragen</target>
</trans-unit>
<trans-unit id="crrlEUA" resname="preview.skipped_rows">
<source>preview.skipped_rows</source>
<target>Überspringe Vorschau von %rows% weiteren Zeilen …</target>
@@ -1470,6 +1482,10 @@
<source>Updated at</source>
<target>Aktualisiert am</target>
</trans-unit>
<trans-unit id="J_teUVB" resname="updated">
<source>Updated</source>
<target>Aktualisiert</target>
</trans-unit>
<trans-unit id="nlwMf5w" resname="invoice_document.max_reached">
<source>invoice_document.max_reached</source>
<target>Maximale Anzahl von %max% Rechnungsdokumenten erreicht. Um weitere hinzufügen müssen Sie zunächst eins löschen.</target>

View File

@@ -278,6 +278,10 @@
<source>created_at</source>
<target>Created at</target>
</trans-unit>
<trans-unit id="QG7.sen" resname="created">
<source>Created</source>
<target>Created</target>
</trans-unit>
<trans-unit id="DodjLNR" resname="date">
<source>date</source>
<target>Date</target>
@@ -1254,10 +1258,18 @@
<source>status.approved</source>
<target>Approved</target>
</trans-unit>
<trans-unit id="dOIWgOr" resname="approve">
<source>approve</source>
<target>Approve</target>
</trans-unit>
<trans-unit id="Na24cyT" resname="status.requested">
<source>status.requested</source>
<target>Requested</target>
</trans-unit>
<trans-unit id="H1i5FFs" resname="request">
<source>request</source>
<target>Request</target>
</trans-unit>
<trans-unit id="crrlEUA" resname="preview.skipped_rows">
<source>Skipped preview of %rows% more rows …</source>
<target state="translated">Skipped preview of %rows% more rows …</target>
@@ -1470,6 +1482,10 @@
<source>Updated at</source>
<target>Updated at</target>
</trans-unit>
<trans-unit id="J_teUVB" resname="updated">
<source>Updated</source>
<target>Updated</target>
</trans-unit>
<trans-unit id="nlwMf5w" resname="invoice_document.max_reached">
<source>invoice_document.max_reached</source>
<target>Reached maximum amount of %max% invoice documents. You can add more after removing one.</target>

View File

@@ -1110,6 +1110,10 @@
<source>Updated at</source>
<target state="translated">Zaktualizowano</target>
</trans-unit>
<trans-unit id="J_teUVB" resname="updated">
<source>Updated</source>
<target>Zaktualizowany</target>
</trans-unit>
<trans-unit id="nlwMf5w" resname="invoice_document.max_reached">
<source>invoice_document.max_reached</source>
<target state="translated">Osiągnięto maksymalną ilość z %max% faktur. Możesz dodać więcej po usunięciu jednej.</target>
@@ -1434,6 +1438,10 @@
<source>created_at</source>
<target state="translated">Utworzono o</target>
</trans-unit>
<trans-unit id="QG7.sen" resname="created">
<source>Created</source>
<target>Stworzony</target>
</trans-unit>
<trans-unit id="eCEGRzN" resname="work_contract">
<source>work_contract</source>
<target state="translated">Umowa o pracę</target>