Release 2.7.0 (#4506)

* added api URL for simpler integration
* allow to request password change upon next login
* make ModifiedAt timesheet independent
* improved plugin api
* replace kernel calls with AutoconfigureTag and TaggedIterator attributes
* new translation keys (e.g. for days)
* support setting min and max date on date and daterange pickers
This commit is contained in:
Kevin Papst
2023-12-26 14:49:11 +01:00
committed by GitHub
parent f86eca05ae
commit ad645f5b58
72 changed files with 378 additions and 735 deletions

View File

@@ -57,6 +57,16 @@ export default class KimaiDatePicker extends KimaiFormPlugin {
if (element.dataset.format === undefined) { if (element.dataset.format === undefined) {
console.log('Trying to bind litepicker to an element without data-format attribute'); console.log('Trying to bind litepicker to an element without data-format attribute');
} }
if (element.hasAttribute('min') !== undefined) {
options = {...options, ...{
'minDate': element.getAttribute('min'),
}};
}
if (element.hasAttribute('max') !== undefined) {
options = {...options, ...{
'maxDate': element.getAttribute('max'),
}};
}
options = {...options, ...{ options = {...options, ...{
format: element.dataset.format, format: element.dataset.format,
showTooltip: false, showTooltip: false,
@@ -66,7 +76,7 @@ export default class KimaiDatePicker extends KimaiFormPlugin {
firstDay: FIRST_DOW, // Litepicker: 0 = Sunday, 1 = Monday firstDay: FIRST_DOW, // Litepicker: 0 = Sunday, 1 = Monday
setup: (picker) => { setup: (picker) => {
// nasty hack, because litepicker does not trigger change event on the input and the available // nasty hack, because litepicker does not trigger change event on the input and the available
// event "selected" is triggered why to often, even when moving the cursor inside the input // event "selected" is triggered way to often, even when moving the cursor inside the input
// element (not even typing is necessary) and so we have to make sure that the manual "click" event // element (not even typing is necessary) and so we have to make sure that the manual "click" event
// (works for touch as well) happened before we actually dispatch the change event manually ... // (works for touch as well) happened before we actually dispatch the change event manually ...
// what? report forms would be submitted upon cursor move without the "preselect” check // what? report forms would be submitted upon cursor move without the "preselect” check

View File

@@ -60,18 +60,6 @@ services:
arguments: arguments:
$cacheDirectory: '%kernel.cache_dir%' $cacheDirectory: '%kernel.cache_dir%'
App\Plugin\PluginManager:
arguments: [!tagged kimai.plugin]
App\Validator\Constraints\TimesheetValidator:
arguments: [!tagged timesheet.validator]
App\Validator\Constraints\ProjectValidator:
arguments: [!tagged project.validator]
App\Validator\Constraints\QuickEntryTimesheetValidator:
arguments: [!tagged timesheet.validator]
App\Utils\FileHelper: App\Utils\FileHelper:
arguments: arguments:
$dataDir: '%kimai.data_dir%' $dataDir: '%kimai.data_dir%'
@@ -83,35 +71,19 @@ services:
arguments: arguments:
$mailer: '@App\Mail\KimaiMailer' $mailer: '@App\Mail\KimaiMailer'
# ================================================================================
# DATABASE
# ================================================================================
# updates timesheet records and apply configured rate & rounding rules
App\Doctrine\TimesheetSubscriber:
class: App\Doctrine\TimesheetSubscriber
arguments: [!tagged timesheet.calculator]
# updates timestampable columns (higher priority, so the TimesheetSubscriber will be executed later)
App\Doctrine\ModifiedSubscriber:
class: App\Doctrine\ModifiedSubscriber
# ================================================================================ # ================================================================================
# TIMESHEET RECORD CALCULATOR # TIMESHEET RECORD CALCULATOR
# ================================================================================ # ================================================================================
App\Timesheet\RoundingService: App\Timesheet\RoundingService:
arguments: arguments:
$roundingModes: !tagged timesheet.rounding_mode # this is currently required, as local.yaml allows to configure several rules,
# while the database system only allows one rounding rule
$rules: '%kimai.timesheet.rounding%' $rules: '%kimai.timesheet.rounding%'
App\Timesheet\RateService: App\Timesheet\RateService:
arguments: ['%kimai.timesheet.rates%'] arguments: ['%kimai.timesheet.rates%']
App\Timesheet\TrackingModeService:
arguments:
$modes: !tagged timesheet.tracking_mode
# ================================================================================ # ================================================================================
# SECURITY & VOTER # SECURITY & VOTER
# ================================================================================ # ================================================================================

View File

@@ -81,11 +81,6 @@ parameters:
count: 1 count: 1
path: src/API/Model/PageAction.php path: src/API/Model/PageAction.php
-
message: "#^Cannot call method getVersion\\(\\) on App\\\\Plugin\\\\PluginMetadata\\|null\\.$#"
count: 1
path: src/API/Model/Plugin.php
- -
message: "#^Property App\\\\API\\\\Model\\\\Plugin\\:\\:\\$name is never read, only written\\.$#" message: "#^Property App\\\\API\\\\Model\\\\Plugin\\:\\:\\$name is never read, only written\\.$#"
count: 1 count: 1
@@ -526,16 +521,6 @@ parameters:
count: 1 count: 1
path: src/Command/InvoiceCreateCommand.php path: src/Command/InvoiceCreateCommand.php
-
message: "#^Cannot call method getKimaiVersion\\(\\) on App\\\\Plugin\\\\PluginMetadata\\|null\\.$#"
count: 1
path: src/Command/PluginCommand.php
-
message: "#^Cannot call method getVersion\\(\\) on App\\\\Plugin\\\\PluginMetadata\\|null\\.$#"
count: 1
path: src/Command/PluginCommand.php
- -
message: "#^Method App\\\\Command\\\\PromoteUserCommand\\:\\:executeRoleCommand\\(\\) has parameter \\$role with no type specified\\.$#" message: "#^Method App\\\\Command\\\\PromoteUserCommand\\:\\:executeRoleCommand\\(\\) has parameter \\$role with no type specified\\.$#"
count: 1 count: 1
@@ -1936,11 +1921,6 @@ parameters:
count: 2 count: 2
path: src/EventSubscriber/Actions/ActivitySubscriber.php path: src/EventSubscriber/Actions/ActivitySubscriber.php
-
message: "#^Cannot call method getHomepage\\(\\) on App\\\\Plugin\\\\PluginMetadata\\|null\\.$#"
count: 1
path: src/EventSubscriber/Actions/PluginSubscriber.php
- -
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\User\\|null\\.$#" message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\User\\|null\\.$#"
count: 1 count: 1
@@ -5231,21 +5211,6 @@ parameters:
count: 1 count: 1
path: src/Timesheet/Rounding/FloorRounding.php path: src/Timesheet/Rounding/FloorRounding.php
-
message: "#^Method App\\\\Timesheet\\\\RoundingService\\:\\:__construct\\(\\) has parameter \\$rules with no value type specified in iterable type array\\.$#"
count: 1
path: src/Timesheet/RoundingService.php
-
message: "#^Method App\\\\Timesheet\\\\RoundingService\\:\\:getRoundingRules\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Timesheet/RoundingService.php
-
message: "#^Property App\\\\Timesheet\\\\RoundingService\\:\\:\\$rulesCache type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Timesheet/RoundingService.php
- -
message: "#^Cannot call method getTimezone\\(\\) on App\\\\Entity\\\\User\\|null\\.$#" message: "#^Cannot call method getTimezone\\(\\) on App\\\\Entity\\\\User\\|null\\.$#"
count: 2 count: 2

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": { "app": {
"js": [ "js": [
"/build/runtime.f0079159.js", "/build/runtime.f0079159.js",
"/build/app.63b27bee.js" "/build/app.adf0b03a.js"
], ],
"css": [ "css": [
"/build/app.0ecb28c1.css" "/build/app.0ecb28c1.css"
@@ -63,7 +63,7 @@
}, },
"integrity": { "integrity": {
"/build/runtime.f0079159.js": "sha384-H22sAW1aTvyIPqvHOvGXWSWTxf0y6mptp+MsVmyXCfjx/WJjBbhX9gbUZ+qIuihV", "/build/runtime.f0079159.js": "sha384-H22sAW1aTvyIPqvHOvGXWSWTxf0y6mptp+MsVmyXCfjx/WJjBbhX9gbUZ+qIuihV",
"/build/app.63b27bee.js": "sha384-1CV+he7m+KfetJpq/DmRwKfUhKH4L7MImv7rRUz/mr/KNR3VkT0fQJHF7ViPTVYE", "/build/app.adf0b03a.js": "sha384-z0Evh/1m2s8eBABcPDKISlFOKIzUpZE5CJt7HQIVgciHgD8GxpK50XZyNCNq+gyd",
"/build/app.0ecb28c1.css": "sha384-iCao48T4VAR0rv39D1+kCk8GCRtve2QDTH5gGq1I1TMXmy1xA/OPxjeBFgGKkRk+", "/build/app.0ecb28c1.css": "sha384-iCao48T4VAR0rv39D1+kCk8GCRtve2QDTH5gGq1I1TMXmy1xA/OPxjeBFgGKkRk+",
"/build/export-pdf.d367a32e.js": "sha384-Z5baqnzjI636nYFs4g63ViIKBZKRW4Jhv/7PQmTEQlqhfA7eK0vUMUtiyy0R5A9u", "/build/export-pdf.d367a32e.js": "sha384-Z5baqnzjI636nYFs4g63ViIKBZKRW4Jhv/7PQmTEQlqhfA7eK0vUMUtiyy0R5A9u",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg", "/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",

View File

@@ -1,6 +1,6 @@
{ {
"build/app.css": "/build/app.0ecb28c1.css", "build/app.css": "/build/app.0ecb28c1.css",
"build/app.js": "/build/app.63b27bee.js", "build/app.js": "/build/app.adf0b03a.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css", "build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.d367a32e.js", "build/export-pdf.js": "/build/export-pdf.d367a32e.js",
"build/invoice.css": "/build/invoice.5bea118e.css", "build/invoice.css": "/build/invoice.5bea118e.css",

View File

@@ -66,7 +66,6 @@ final class StatusController extends BaseApiController
{ {
$plugins = []; $plugins = [];
foreach ($pluginManager->getPlugins() as $plugin) { foreach ($pluginManager->getPlugins() as $plugin) {
$pluginManager->loadMetadata($plugin);
$plugins[] = new Plugin($plugin); $plugins[] = new Plugin($plugin);
} }

View File

@@ -16,6 +16,7 @@ use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
@@ -43,6 +44,7 @@ final class CreateUserCommand extends AbstractUserCommand
User::DEFAULT_ROLE User::DEFAULT_ROLE
) )
->addArgument('password', InputArgument::OPTIONAL, 'Password for the new user (requested if not provided)') ->addArgument('password', InputArgument::OPTIONAL, 'Password for the new user (requested if not provided)')
->addOption('request-password', null, InputOption::VALUE_NONE, 'The user needs to set a new password during next login')
; ;
} }
@@ -69,6 +71,10 @@ final class CreateUserCommand extends AbstractUserCommand
$user->setEnabled(true); $user->setEnabled(true);
$user->setRoles(explode(',', $role)); $user->setRoles(explode(',', $role));
if ($input->getOption('request-password') === true) {
$user->setRequiresPasswordReset(true);
}
try { try {
$this->userService->saveNewUser($user); $this->userService->saveNewUser($user);
$io->success(sprintf('Success! Created user: %s', $username)); $io->success(sprintf('Success! Created user: %s', $username));

View File

@@ -48,16 +48,16 @@ final class PluginCommand extends Command
$rows = []; $rows = [];
foreach ($plugins as $plugin) { foreach ($plugins as $plugin) {
$this->plugins->loadMetadata($plugin);
$meta = $plugin->getMetadata(); $meta = $plugin->getMetadata();
$rows[] = [ $rows[] = [
$plugin->getId(),
$plugin->getName(), $plugin->getName(),
$meta->getVersion(), $meta->getVersion(),
$meta->getKimaiVersion(), $meta->getKimaiVersion(),
$plugin->getPath(), $plugin->getPath(),
]; ];
} }
$io->table(['Name', 'Version', 'Requires', 'Directory'], $rows); $io->table(['Id', 'Name', 'Version', 'Requires', 'Directory'], $rows);
return Command::SUCCESS; return Command::SUCCESS;
} }

View File

@@ -17,11 +17,11 @@ class Constants
/** /**
* The current release version * The current release version
*/ */
public const VERSION = '2.6.0'; public const VERSION = '2.7.0';
/** /**
* The current release: major * 10000 + minor * 100 + patch * The current release: major * 10000 + minor * 100 + patch
*/ */
public const VERSION_ID = 20600; public const VERSION_ID = 20700;
/** /**
* The software name * The software name
*/ */

View File

@@ -28,7 +28,6 @@ final class PluginController extends AbstractController
$installed = []; $installed = [];
$plugins = $manager->getPlugins(); $plugins = $manager->getPlugins();
foreach ($plugins as $plugin) { foreach ($plugins as $plugin) {
$manager->loadMetadata($plugin);
$installed[] = $plugin->getId(); $installed[] = $plugin->getId();
} }

View File

@@ -9,8 +9,10 @@
namespace App\DependencyInjection\Compiler; namespace App\DependencyInjection\Compiler;
use App\Export\ExportRepositoryInterface;
use App\Export\RendererInterface;
use App\Export\ServiceExport; use App\Export\ServiceExport;
use App\Kernel; use App\Export\TimesheetExportInterface;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\Reference;
@@ -24,17 +26,17 @@ final class ExportServiceCompilerPass implements CompilerPassInterface
{ {
$definition = $container->findDefinition(ServiceExport::class); $definition = $container->findDefinition(ServiceExport::class);
$taggedRenderer = $container->findTaggedServiceIds(Kernel::TAG_EXPORT_RENDERER); $taggedRenderer = $container->findTaggedServiceIds(RendererInterface::class);
foreach ($taggedRenderer as $id => $tags) { foreach ($taggedRenderer as $id => $tags) {
$definition->addMethodCall('addRenderer', [new Reference($id)]); $definition->addMethodCall('addRenderer', [new Reference($id)]);
} }
$taggedExporter = $container->findTaggedServiceIds(Kernel::TAG_TIMESHEET_EXPORTER); $taggedExporter = $container->findTaggedServiceIds(TimesheetExportInterface::class);
foreach ($taggedExporter as $id => $tags) { foreach ($taggedExporter as $id => $tags) {
$definition->addMethodCall('addTimesheetExporter', [new Reference($id)]); $definition->addMethodCall('addTimesheetExporter', [new Reference($id)]);
} }
$taggedRepository = $container->findTaggedServiceIds(Kernel::TAG_EXPORT_REPOSITORY); $taggedRepository = $container->findTaggedServiceIds(ExportRepositoryInterface::class);
foreach ($taggedRepository as $id => $tags) { foreach ($taggedRepository as $id => $tags) {
$definition->addMethodCall('addExportRepository', [new Reference($id)]); $definition->addMethodCall('addExportRepository', [new Reference($id)]);
} }

View File

@@ -9,8 +9,11 @@
namespace App\DependencyInjection\Compiler; namespace App\DependencyInjection\Compiler;
use App\Invoice\CalculatorInterface;
use App\Invoice\InvoiceItemRepositoryInterface;
use App\Invoice\NumberGeneratorInterface;
use App\Invoice\RendererInterface;
use App\Invoice\ServiceInvoice; use App\Invoice\ServiceInvoice;
use App\Kernel;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\Reference;
@@ -24,22 +27,22 @@ final class InvoiceServiceCompilerPass implements CompilerPassInterface
{ {
$definition = $container->findDefinition(ServiceInvoice::class); $definition = $container->findDefinition(ServiceInvoice::class);
$taggedRenderer = $container->findTaggedServiceIds(Kernel::TAG_INVOICE_RENDERER); $taggedRenderer = $container->findTaggedServiceIds(RendererInterface::class);
foreach ($taggedRenderer as $id => $tags) { foreach ($taggedRenderer as $id => $tags) {
$definition->addMethodCall('addRenderer', [new Reference($id)]); $definition->addMethodCall('addRenderer', [new Reference($id)]);
} }
$taggedGenerator = $container->findTaggedServiceIds(Kernel::TAG_INVOICE_NUMBER_GENERATOR); $taggedGenerator = $container->findTaggedServiceIds(NumberGeneratorInterface::class);
foreach ($taggedGenerator as $id => $tags) { foreach ($taggedGenerator as $id => $tags) {
$definition->addMethodCall('addNumberGenerator', [new Reference($id)]); $definition->addMethodCall('addNumberGenerator', [new Reference($id)]);
} }
$taggedCalculator = $container->findTaggedServiceIds(Kernel::TAG_INVOICE_CALCULATOR); $taggedCalculator = $container->findTaggedServiceIds(CalculatorInterface::class);
foreach ($taggedCalculator as $id => $tags) { foreach ($taggedCalculator as $id => $tags) {
$definition->addMethodCall('addCalculator', [new Reference($id)]); $definition->addMethodCall('addCalculator', [new Reference($id)]);
} }
$taggedRepository = $container->findTaggedServiceIds(Kernel::TAG_INVOICE_REPOSITORY); $taggedRepository = $container->findTaggedServiceIds(InvoiceItemRepositoryInterface::class);
foreach ($taggedRepository as $id => $tags) { foreach ($taggedRepository as $id => $tags) {
$definition->addMethodCall('addInvoiceItemRepository', [new Reference($id)]); $definition->addMethodCall('addInvoiceItemRepository', [new Reference($id)]);
} }

View File

@@ -9,7 +9,7 @@
namespace App\DependencyInjection\Compiler; namespace App\DependencyInjection\Compiler;
use App\Kernel; use App\Widget\WidgetInterface;
use App\Widget\WidgetService; use App\Widget\WidgetService;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -24,7 +24,7 @@ final class WidgetCompilerPass implements CompilerPassInterface
{ {
$definition = $container->findDefinition(WidgetService::class); $definition = $container->findDefinition(WidgetService::class);
$taggedRenderer = $container->findTaggedServiceIds(Kernel::TAG_WIDGET); $taggedRenderer = $container->findTaggedServiceIds(WidgetInterface::class);
foreach ($taggedRenderer as $id => $tags) { foreach ($taggedRenderer as $id => $tags) {
$definition->addMethodCall('registerWidget', [new Reference($id)]); $definition->addMethodCall('registerWidget', [new Reference($id)]);
} }

View File

@@ -0,0 +1,15 @@
<?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\Doctrine;
interface ModifiedAt
{
public function setModifiedAt(\DateTimeImmutable $dateTime): void;
}

View File

@@ -9,14 +9,13 @@
namespace App\Doctrine; namespace App\Doctrine;
use App\Entity\Timesheet;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber; use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
/** /**
* Automatically set the modifiedAt field for all Timesheet entries. * Automatically set the modifiedAt field for all ModifiedAt instances.
*/ */
#[AsDoctrineListener(event: Events::onFlush, priority: 60)] #[AsDoctrineListener(event: Events::onFlush, priority: 60)]
final class ModifiedSubscriber implements EventSubscriber, DataSubscriberInterface final class ModifiedSubscriber implements EventSubscriber, DataSubscriberInterface
@@ -34,17 +33,15 @@ final class ModifiedSubscriber implements EventSubscriber, DataSubscriberInterfa
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
foreach ($uow->getScheduledEntityUpdates() as $entity) { foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!($entity instanceof Timesheet)) { if ($entity instanceof ModifiedAt) {
continue; $entity->setModifiedAt($now);
} }
$entity->setModifiedAt($now);
} }
foreach ($uow->getScheduledEntityInsertions() as $entity) { foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!($entity instanceof Timesheet)) { if ($entity instanceof ModifiedAt) {
continue; $entity->setModifiedAt($now);
} }
$entity->setModifiedAt($now);
} }
} }
} }

View File

@@ -15,9 +15,12 @@ use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber; use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
/** /**
* A listener to make sure all Timesheet entries will be calculated properly (e.g. duration and rates). * A listener to make sure all Timesheet entries will be calculated properly.
*
* E.g. updates timesheet records and applies configured rate & rounding rules.
*/ */
#[AsDoctrineListener(event: Events::onFlush, priority: 50)] #[AsDoctrineListener(event: Events::onFlush, priority: 50)]
final class TimesheetSubscriber implements EventSubscriber, DataSubscriberInterface final class TimesheetSubscriber implements EventSubscriber, DataSubscriberInterface
@@ -30,7 +33,10 @@ final class TimesheetSubscriber implements EventSubscriber, DataSubscriberInterf
/** /**
* @param CalculatorInterface[] $calculators * @param CalculatorInterface[] $calculators
*/ */
public function __construct(private iterable $calculators) public function __construct(
#[TaggedIterator(CalculatorInterface::class)]
private readonly iterable $calculators
)
{ {
} }

View File

@@ -9,6 +9,7 @@
namespace App\Entity; namespace App\Entity;
use App\Doctrine\ModifiedAt;
use App\Validator\Constraints as Constraints; use App\Validator\Constraints as Constraints;
use DateTime; use DateTime;
use DateTimeZone; use DateTimeZone;
@@ -45,7 +46,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[Serializer\VirtualProperty('TagsAsArray', exp: 'object.getTagsAsArray()', options: [new Serializer\SerializedName('tags'), new Serializer\Type(name: 'array<string>'), new Serializer\Groups(['Default'])])] #[Serializer\VirtualProperty('TagsAsArray', exp: 'object.getTagsAsArray()', options: [new Serializer\SerializedName('tags'), new Serializer\Type(name: 'array<string>'), new Serializer\Groups(['Default'])])]
#[Constraints\Timesheet] #[Constraints\Timesheet]
#[Constraints\TimesheetDeactivated] #[Constraints\TimesheetDeactivated]
class Timesheet implements EntityWithMetaFields, ExportableItem class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
{ {
/** /**
* Category: Normal work-time (default category) * Category: Normal work-time (default category)

View File

@@ -11,7 +11,9 @@ namespace App\Export;
use App\Entity\ExportableItem; use App\Entity\ExportableItem;
use App\Repository\Query\ExportQuery; use App\Repository\Query\ExportQuery;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag]
interface ExportRepositoryInterface interface ExportRepositoryInterface
{ {
/** /**

View File

@@ -9,6 +9,9 @@
namespace App\Export; namespace App\Export;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag]
interface RendererInterface extends ExportRendererInterface interface RendererInterface extends ExportRendererInterface
{ {
} }

View File

@@ -11,8 +11,10 @@ namespace App\Export;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
#[AutoconfigureTag]
interface TimesheetExportInterface interface TimesheetExportInterface
{ {
/** /**

View File

@@ -15,6 +15,8 @@ use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer; use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
/** /**
@@ -50,6 +52,20 @@ class DatePickerType extends AbstractType
)); ));
} }
public function buildView(FormView $view, FormInterface $form, array $options): void
{
if ($options['min_day'] !== null) {
$view->vars['attr'] = array_merge($view->vars['attr'], [
'min' => $options['min_day'],
]);
}
if ($options['max_day'] !== null) {
$view->vars['attr'] = array_merge($view->vars['attr'], [
'max' => $options['max_day'],
]);
}
}
public function configureOptions(OptionsResolver $resolver): void public function configureOptions(OptionsResolver $resolver): void
{ {
$format = $this->localeService->getDateFormat(\Locale::getDefault()); $format = $this->localeService->getDateFormat(\Locale::getDefault());
@@ -64,6 +80,8 @@ class DatePickerType extends AbstractType
'model_timezone' => date_default_timezone_get(), 'model_timezone' => date_default_timezone_get(),
'view_timezone' => date_default_timezone_get(), 'view_timezone' => date_default_timezone_get(),
'force_time' => null, 'force_time' => null,
'min_day' => null,
'max_day' => null,
]); ]);
} }

View File

@@ -50,6 +50,8 @@ final class DateRangeType extends AbstractType
'separator' => self::DATE_SPACER, 'separator' => self::DATE_SPACER,
'allow_empty' => true, 'allow_empty' => true,
'with_presets' => true, 'with_presets' => true,
'min_day' => null,
'max_day' => null,
'attr' => [ 'attr' => [
'pattern' => $pattern . self::DATE_SPACER . $pattern 'pattern' => $pattern . self::DATE_SPACER . $pattern
], ],
@@ -87,6 +89,18 @@ final class DateRangeType extends AbstractType
$view->vars['attr'] = array_merge($view->vars['attr'], [ $view->vars['attr'] = array_merge($view->vars['attr'], [
'data-separator' => $options['separator'], 'data-separator' => $options['separator'],
]); ]);
if ($options['min_day'] !== null) {
$view->vars['attr'] = array_merge($view->vars['attr'], [
'min' => $options['min_day'],
]);
}
if ($options['max_day'] !== null) {
$view->vars['attr'] = array_merge($view->vars['attr'], [
'max' => $options['max_day'],
]);
}
} }
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void

View File

@@ -9,9 +9,12 @@
namespace App\Invoice; namespace App\Invoice;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
/** /**
* CalculatorInterface defines all methods for any invoice price calculator. * CalculatorInterface defines all methods for any invoice price calculator.
*/ */
#[AutoconfigureTag]
interface CalculatorInterface interface CalculatorInterface
{ {
/** /**

View File

@@ -11,7 +11,9 @@ namespace App\Invoice;
use App\Entity\ExportableItem; use App\Entity\ExportableItem;
use App\Repository\Query\InvoiceQuery; use App\Repository\Query\InvoiceQuery;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag]
interface InvoiceItemRepositoryInterface interface InvoiceItemRepositoryInterface
{ {
/** /**

View File

@@ -9,9 +9,12 @@
namespace App\Invoice; namespace App\Invoice;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
/** /**
* Class NumberGeneratorInterface defines all methods that invoice number generator have to implement. * Class NumberGeneratorInterface defines all methods that invoice number generator have to implement.
*/ */
#[AutoconfigureTag]
interface NumberGeneratorInterface interface NumberGeneratorInterface
{ {
public function setModel(InvoiceModel $model): void; public function setModel(InvoiceModel $model): void;

View File

@@ -10,8 +10,10 @@
namespace App\Invoice; namespace App\Invoice;
use App\Model\InvoiceDocument; use App\Model\InvoiceDocument;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
#[AutoconfigureTag]
interface RendererInterface interface RendererInterface
{ {
/** /**

View File

@@ -14,22 +14,9 @@ use App\DependencyInjection\Compiler\ExportServiceCompilerPass;
use App\DependencyInjection\Compiler\InvoiceServiceCompilerPass; use App\DependencyInjection\Compiler\InvoiceServiceCompilerPass;
use App\DependencyInjection\Compiler\TwigContextCompilerPass; use App\DependencyInjection\Compiler\TwigContextCompilerPass;
use App\DependencyInjection\Compiler\WidgetCompilerPass; use App\DependencyInjection\Compiler\WidgetCompilerPass;
use App\Export\ExportRepositoryInterface;
use App\Export\RendererInterface as ExportRendererInterface;
use App\Export\TimesheetExportInterface;
use App\Invoice\CalculatorInterface as InvoiceCalculator;
use App\Invoice\InvoiceItemRepositoryInterface;
use App\Invoice\NumberGeneratorInterface;
use App\Invoice\RendererInterface as InvoiceRendererInterface;
use App\Ldap\FormLoginLdapFactory; use App\Ldap\FormLoginLdapFactory;
use App\Plugin\PluginInterface; use App\Plugin\PluginInterface;
use App\Plugin\PluginMetadata; use App\Plugin\PluginMetadata;
use App\Timesheet\CalculatorInterface as TimesheetCalculator;
use App\Timesheet\Rounding\RoundingInterface;
use App\Timesheet\TrackingMode\TrackingModeInterface;
use App\Validator\Constraints\ProjectConstraint;
use App\Validator\Constraints\TimesheetConstraint;
use App\Widget\WidgetInterface;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Bundle\SecurityBundle\DependencyInjection\SecurityExtension; use Symfony\Bundle\SecurityBundle\DependencyInjection\SecurityExtension;
use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\Config\Loader\LoaderInterface;
@@ -47,21 +34,6 @@ class Kernel extends BaseKernel
public const PLUGIN_DIRECTORY = '/var/plugins'; public const PLUGIN_DIRECTORY = '/var/plugins';
public const CONFIG_EXTS = '.{php,yaml}'; public const CONFIG_EXTS = '.{php,yaml}';
public const TAG_PLUGIN = 'kimai.plugin';
public const TAG_WIDGET = 'widget';
public const TAG_EXPORT_RENDERER = 'export.renderer';
public const TAG_EXPORT_REPOSITORY = 'export.repository';
public const TAG_INVOICE_RENDERER = 'invoice.renderer';
public const TAG_INVOICE_NUMBER_GENERATOR = 'invoice.number_generator';
public const TAG_INVOICE_CALCULATOR = 'invoice.calculator';
public const TAG_INVOICE_REPOSITORY = 'invoice.repository';
public const TAG_TIMESHEET_CALCULATOR = 'timesheet.calculator';
public const TAG_TIMESHEET_VALIDATOR = 'timesheet.validator';
public const TAG_TIMESHEET_EXPORTER = 'timesheet.exporter';
public const TAG_TIMESHEET_TRACKING_MODE = 'timesheet.tracking_mode';
public const TAG_TIMESHEET_ROUNDING_MODE = 'timesheet.rounding_mode';
public const TAG_PROJECT_VALIDATOR = 'project.validator';
public function getCacheDir(): string public function getCacheDir(): string
{ {
return $this->getProjectDir() . '/var/cache/' . $this->environment; return $this->getProjectDir() . '/var/cache/' . $this->environment;
@@ -74,21 +46,6 @@ class Kernel extends BaseKernel
protected function build(ContainerBuilder $container): void protected function build(ContainerBuilder $container): void
{ {
$container->registerForAutoconfiguration(TimesheetCalculator::class)->addTag(self::TAG_TIMESHEET_CALCULATOR);
$container->registerForAutoconfiguration(ExportRendererInterface::class)->addTag(self::TAG_EXPORT_RENDERER);
$container->registerForAutoconfiguration(ExportRepositoryInterface::class)->addTag(self::TAG_EXPORT_REPOSITORY);
$container->registerForAutoconfiguration(InvoiceRendererInterface::class)->addTag(self::TAG_INVOICE_RENDERER);
$container->registerForAutoconfiguration(NumberGeneratorInterface::class)->addTag(self::TAG_INVOICE_NUMBER_GENERATOR);
$container->registerForAutoconfiguration(InvoiceCalculator::class)->addTag(self::TAG_INVOICE_CALCULATOR);
$container->registerForAutoconfiguration(InvoiceItemRepositoryInterface::class)->addTag(self::TAG_INVOICE_REPOSITORY);
$container->registerForAutoconfiguration(PluginInterface::class)->addTag(self::TAG_PLUGIN);
$container->registerForAutoconfiguration(WidgetInterface::class)->addTag(self::TAG_WIDGET);
$container->registerForAutoconfiguration(TimesheetExportInterface::class)->addTag(self::TAG_TIMESHEET_EXPORTER);
$container->registerForAutoconfiguration(TrackingModeInterface::class)->addTag(self::TAG_TIMESHEET_TRACKING_MODE);
$container->registerForAutoconfiguration(RoundingInterface::class)->addTag(self::TAG_TIMESHEET_ROUNDING_MODE);
$container->registerForAutoconfiguration(TimesheetConstraint::class)->addTag(self::TAG_TIMESHEET_VALIDATOR);
$container->registerForAutoconfiguration(ProjectConstraint::class)->addTag(self::TAG_PROJECT_VALIDATOR);
/** @var SecurityExtension $extension */ /** @var SecurityExtension $extension */
$extension = $container->getExtension('security'); $extension = $container->getExtension('security');
$extension->addAuthenticatorFactory(new FormLoginLdapFactory()); $extension->addAuthenticatorFactory(new FormLoginLdapFactory());
@@ -152,7 +109,7 @@ class Kernel extends BaseKernel
throw new \Exception(sprintf('Bundle "%s" does not implement %s, which is not supported since 2.0.', $bundleName, PluginInterface::class)); throw new \Exception(sprintf('Bundle "%s" does not implement %s, which is not supported since 2.0.', $bundleName, PluginInterface::class));
} }
$meta = PluginMetadata::loadFromComposer($fullPath); $meta = new PluginMetadata($fullPath);
if ($meta->getKimaiVersion() > Constants::VERSION_ID) { if ($meta->getKimaiVersion() > Constants::VERSION_ID) {
throw new \Exception(sprintf('Bundle "%s" requires minimum Kimai version %s, but yours is lower: %s (%s). Please update Kimai or use a lower Plugin version.', $bundleName, $meta->getKimaiVersion(), Constants::VERSION, Constants::VERSION_ID)); throw new \Exception(sprintf('Bundle "%s" requires minimum Kimai version %s, but yours is lower: %s (%s). Please update Kimai or use a lower Plugin version.', $bundleName, $meta->getKimaiVersion(), Constants::VERSION, Constants::VERSION_ID));
@@ -173,7 +130,7 @@ class Kernel extends BaseKernel
$confDir = $this->getProjectDir() . '/config'; $confDir = $this->getProjectDir() . '/config';
// using this one instead of $loader->load($confDir . '/packages/*' . self::CONFIG_EXTS, 'glob'); // using this one instead of $loader->load($confDir . '/packages/*' . self::CONFIG_EXTS, 'glob');
// to get rid of the local.yaml from the list, we load it afterwards explicit // to get rid of the local.yaml from the list, we load it afterward explicit
$finder = (new Finder()) $finder = (new Finder())
->files() ->files()
->in([$confDir . '/packages/']) ->in([$confDir . '/packages/'])
@@ -199,8 +156,7 @@ class Kernel extends BaseKernel
$container->addCompilerPass(new WidgetCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000); $container->addCompilerPass(new WidgetCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
} }
/** @phpstan-ignore-next-line */ private function configureRoutes(RoutingConfigurator $routes): void // @phpstan-ignore-line
private function configureRoutes(RoutingConfigurator $routes): void
{ {
$configDir = $this->getConfigDir(); $configDir = $this->getConfigDir();

View File

@@ -11,42 +11,38 @@ namespace App\Plugin;
final class Plugin final class Plugin
{ {
private string $id;
private string $path;
private ?PluginMetadata $metadata = null; private ?PluginMetadata $metadata = null;
public function __construct(PluginInterface $bundle) public function __construct(private readonly PluginInterface $bundle)
{ {
$this->id = $bundle->getName();
$this->path = $bundle->getPath();
} }
public function getMetadata(): ?PluginMetadata public function getMetadata(): PluginMetadata
{ {
if ($this->metadata === null) {
$this->metadata = new PluginMetadata($this->getPath());
}
return $this->metadata; return $this->metadata;
} }
public function setMetadata(PluginMetadata $metadata): void
{
$this->metadata = $metadata;
}
public function getPath(): string public function getPath(): string
{ {
return $this->path; return $this->bundle->getPath();
} }
public function getName(): string public function getName(): string
{ {
if ($this->metadata !== null && $this->metadata->getName() !== null) { $meta = $this->getMetadata();
return $this->metadata->getName(); if ($meta->getName() !== null) {
return $meta->getName();
} }
return $this->id; return $this->getId();
} }
public function getId(): string public function getId(): string
{ {
return $this->id; return $this->bundle->getName();
} }
} }

View File

@@ -9,6 +9,9 @@
namespace App\Plugin; namespace App\Plugin;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag]
interface PluginInterface interface PluginInterface
{ {
public function getName(): string; public function getName(): string;

View File

@@ -9,23 +9,23 @@
namespace App\Plugin; namespace App\Plugin;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
final class PluginManager final class PluginManager
{ {
/** /**
* @var array<Plugin>|null * @var array<Plugin>|null
*/ */
private ?array $plugins = null; private ?array $plugins = null;
/**
* @var iterable<PluginInterface>
*/
private iterable $bundles;
/** /**
* @param iterable<PluginInterface> $plugins * @param iterable<PluginInterface> $bundles
*/ */
public function __construct(iterable $plugins) public function __construct(
#[TaggedIterator(PluginInterface::class)]
private readonly iterable $bundles
)
{ {
$this->bundles = $plugins;
} }
/** /**
@@ -46,29 +46,27 @@ final class PluginManager
return $this->plugins; return $this->plugins;
} }
public function hasPlugin(string $name): bool
{
foreach ($this->bundles as $plugin) {
if ($plugin->getName() === $name) {
return true;
}
}
return false;
}
public function getPlugin(string $name): ?Plugin public function getPlugin(string $name): ?Plugin
{ {
$plugins = $this->getPlugins(); $plugins = $this->getPlugins();
foreach ($plugins as $plugin) { foreach ($plugins as $plugin) {
if ($plugin->getName() === $name) { if ($plugin->getId() === $name) {
return $plugin; return $plugin;
} }
} }
return null; return null;
} }
/**
* Call this method and pass a plugin, to set its metadata.
* This is not pre-filled by default, as it would mean to parse several composer.json on each request.
*
* @param Plugin $plugin
* @throws \Exception
*/
public function loadMetadata(Plugin $plugin): void
{
$meta = PluginMetadata::loadFromComposer($plugin->getPath());
$plugin->setMetadata($meta);
}
} }

View File

@@ -20,11 +20,9 @@ class PluginMetadata
private ?string $name = null; private ?string $name = null;
/** /**
* @param string $path
* @return PluginMetadata
* @throws \Exception * @throws \Exception
*/ */
public static function loadFromComposer(string $path): PluginMetadata public function __construct(string $path)
{ {
if (!is_dir($path) || !is_readable($path)) { if (!is_dir($path) || !is_readable($path)) {
throw new \Exception(sprintf('Bundle directory "%s" cannot be accessed.', $path)); throw new \Exception(sprintf('Bundle directory "%s" cannot be accessed.', $path));
@@ -59,16 +57,13 @@ class PluginMetadata
throw new \Exception(sprintf('Bundle "%s" defines an invalid Kimai minimum version in extra.kimai.require. Please provide an integer as in Constants::VERSION_ID.', $pluginName)); throw new \Exception(sprintf('Bundle "%s" defines an invalid Kimai minimum version in extra.kimai.require. Please provide an integer as in Constants::VERSION_ID.', $pluginName));
} }
$meta = new self(); $this->description = $json['description'] ?? '';
$meta->description = $json['description'] ?? ''; $this->homepage = $json['homepage'] ?? Constants::HOMEPAGE . '/store/';
$meta->homepage = $json['homepage'] ?? Constants::HOMEPAGE . '/store/'; $this->name = $json['extra']['kimai']['name'];
$meta->name = $json['extra']['kimai']['name']; $this->kimaiVersion = $json['extra']['kimai']['require'];
$meta->kimaiVersion = $json['extra']['kimai']['require'];
// the version field is required if we use composer to install a plugin via var/packages/ // the version field is required if we use composer to install a plugin via var/packages/
$meta->version = $json['extra']['kimai']['version'] ?? ($json['version'] ?? 'unknown'); $this->version = $json['extra']['kimai']['version'] ?? ($json['version'] ?? 'unknown');
return $meta;
} }
public function getDescription(): ?string public function getDescription(): ?string

View File

@@ -10,11 +10,13 @@
namespace App\Timesheet; namespace App\Timesheet;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
/** /**
* A calculator is called before a Timesheet entity will be updated. * A calculator is called before a Timesheet entity will be updated.
* These classes will normally be used when calculating duration or rates. * These classes will normally be used when calculating duration or rates.
*/ */
#[AutoconfigureTag]
interface CalculatorInterface interface CalculatorInterface
{ {
/** /**

View File

@@ -10,10 +10,12 @@
namespace App\Timesheet\Rounding; namespace App\Timesheet\Rounding;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
/** /**
* Apply rounding rules to the given timesheet. * Apply rounding rules to the given timesheet.
*/ */
#[AutoconfigureTag]
interface RoundingInterface interface RoundingInterface
{ {
public function roundBegin(Timesheet $record, int $minutes): void; public function roundBegin(Timesheet $record, int $minutes): void;

View File

@@ -12,45 +12,59 @@ namespace App\Timesheet;
use App\Configuration\SystemConfiguration; use App\Configuration\SystemConfiguration;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Timesheet\Rounding\RoundingInterface; use App\Timesheet\Rounding\RoundingInterface;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
final class RoundingService final class RoundingService
{ {
/** /**
* @var array * @var array<string, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}>
*/ */
private $rulesCache; private ?array $rulesCache = null;
/** /**
* @param SystemConfiguration $configuration
* @param RoundingInterface[] $roundingModes * @param RoundingInterface[] $roundingModes
* @param array $rules * @param array<string, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}> $rules
*/ */
public function __construct(private SystemConfiguration $configuration, private iterable $roundingModes, private array $rules) public function __construct(
private readonly SystemConfiguration $configuration,
#[TaggedIterator(RoundingInterface::class)]
private readonly iterable $roundingModes,
private readonly array $rules
)
{ {
} }
/**
* @return array<string, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}>
*/
private function getRoundingRules(): array private function getRoundingRules(): array
{ {
if (empty($this->rulesCache)) { if ($this->rulesCache === null) {
$this->rulesCache = $this->rules; $rules = $this->rules;
if (empty($this->rulesCache) || \array_key_exists('default', $this->rulesCache)) { $rules['default']['days'] = $this->configuration->getTimesheetDefaultRoundingDays();
$this->rulesCache['default']['days'] = $this->configuration->getTimesheetDefaultRoundingDays(); $rules['default']['begin'] = $this->configuration->getTimesheetDefaultRoundingBegin();
$this->rulesCache['default']['begin'] = $this->configuration->getTimesheetDefaultRoundingBegin(); $rules['default']['end'] = $this->configuration->getTimesheetDefaultRoundingEnd();
$this->rulesCache['default']['end'] = $this->configuration->getTimesheetDefaultRoundingEnd(); $rules['default']['duration'] = $this->configuration->getTimesheetDefaultRoundingDuration();
$this->rulesCache['default']['duration'] = $this->configuration->getTimesheetDefaultRoundingDuration(); $rules['default']['mode'] = $this->configuration->getTimesheetDefaultRoundingMode();
$this->rulesCache['default']['mode'] = $this->configuration->getTimesheetDefaultRoundingMode();
}
// see AppExtension, conversion from string to array due to system configuration ont allowing to store arrays // see AppExtension, conversion from string to array due to system configuration not allowing to store arrays
foreach ($this->rulesCache as $key => $settings) { foreach ($rules as $key => $settings) {
if (\is_array($settings['days'])) {
continue;
}
if ($settings['days'] === '') {
$rules[$key]['days'] = [];
continue;
}
$days = explode(',', $settings['days']); $days = explode(',', $settings['days']);
$days = array_map('trim', $days); $days = array_map('trim', $days);
$days = array_map('strtolower', $days); $days = array_map('strtolower', $days);
$this->rulesCache[$key]['days'] = $days; $rules[$key]['days'] = $days;
} }
$this->rulesCache = $rules; // @phpstan-ignore-line
} }
return $this->rulesCache; return $this->rulesCache; // @phpstan-ignore-line
} }
public function roundBegin(Timesheet $record): void public function roundBegin(Timesheet $record): void
@@ -61,7 +75,7 @@ final class RoundingService
} }
$weekday = $record->getBegin()->format('l'); $weekday = $record->getBegin()->format('l');
if (\in_array(strtolower($weekday), $rounding['days'])) { if (\in_array(strtolower($weekday), $rounding['days'], true)) {
$rounder = $this->getRoundingMode($rounding['mode']); $rounder = $this->getRoundingMode($rounding['mode']);
$rounder->roundBegin($record, $rounding['begin']); $rounder->roundBegin($record, $rounding['begin']);
} }
@@ -76,7 +90,7 @@ final class RoundingService
} }
$weekday = $record->getEnd()->format('l'); $weekday = $record->getEnd()->format('l');
if (\in_array(strtolower($weekday), $rounding['days'])) { if (\in_array(strtolower($weekday), $rounding['days'], true)) {
$rounder = $this->getRoundingMode($rounding['mode']); $rounder = $this->getRoundingMode($rounding['mode']);
$rounder->roundEnd($record, $rounding['end']); $rounder->roundEnd($record, $rounding['end']);
} }
@@ -91,7 +105,7 @@ final class RoundingService
} }
$weekday = $record->getEnd()->format('l'); $weekday = $record->getEnd()->format('l');
if (\in_array(strtolower($weekday), $rounding['days'])) { if (\in_array(strtolower($weekday), $rounding['days'], true)) {
$rounder = $this->getRoundingMode($rounding['mode']); $rounder = $this->getRoundingMode($rounding['mode']);
$rounder->roundDuration($record, $rounding['duration']); $rounder->roundDuration($record, $rounding['duration']);
} }
@@ -110,7 +124,7 @@ final class RoundingService
} }
$weekday = $record->getEnd()->format('l'); $weekday = $record->getEnd()->format('l');
if (\in_array(strtolower($weekday), $rounding['days'])) { if (\in_array(strtolower($weekday), $rounding['days'], true)) {
$rounder = $this->getRoundingMode($rounding['mode']); $rounder = $this->getRoundingMode($rounding['mode']);
$rounder->roundBegin($record, $rounding['begin']); $rounder->roundBegin($record, $rounding['begin']);
$rounder->roundEnd($record, $rounding['end']); $rounder->roundEnd($record, $rounding['end']);

View File

@@ -10,6 +10,7 @@
namespace App\Timesheet\TrackingMode; namespace App\Timesheet\TrackingMode;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
/** /**
@@ -18,6 +19,7 @@ use Symfony\Component\HttpFoundation\Request;
* *
* @internal do not implement this interface in your bundle, but rather drop a PR to add it to Kimai core * @internal do not implement this interface in your bundle, but rather drop a PR to add it to Kimai core
*/ */
#[AutoconfigureTag]
interface TrackingModeInterface interface TrackingModeInterface
{ {
/** /**

View File

@@ -11,6 +11,7 @@ namespace App\Timesheet;
use App\Configuration\SystemConfiguration; use App\Configuration\SystemConfiguration;
use App\Timesheet\TrackingMode\TrackingModeInterface; use App\Timesheet\TrackingMode\TrackingModeInterface;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
final class TrackingModeService final class TrackingModeService
@@ -19,7 +20,11 @@ final class TrackingModeService
* @param SystemConfiguration $configuration * @param SystemConfiguration $configuration
* @param TrackingModeInterface[] $modes * @param TrackingModeInterface[] $modes
*/ */
public function __construct(private SystemConfiguration $configuration, private iterable $modes) public function __construct(
private readonly SystemConfiguration $configuration,
#[TaggedIterator(TrackingModeInterface::class)]
private readonly iterable $modes
)
{ {
} }

View File

@@ -9,11 +9,13 @@
namespace App\Validator\Constraints; namespace App\Validator\Constraints;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraint;
/** /**
* Extend this class if you want to add dynamic project validation (eg. via a bundle). * Extend this class if you want to add dynamic project validation (eg. via a bundle).
*/ */
#[AutoconfigureTag]
abstract class ProjectConstraint extends Constraint abstract class ProjectConstraint extends Constraint
{ {
} }

View File

@@ -10,7 +10,8 @@
namespace App\Validator\Constraints; namespace App\Validator\Constraints;
use App\Entity\Project; use App\Entity\Project;
use App\Validator\Constraints\Project as ProjectConstraint; use App\Validator\Constraints\Project as ProjectEntityConstraint;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -19,9 +20,12 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class ProjectValidator extends ConstraintValidator final class ProjectValidator extends ConstraintValidator
{ {
/** /**
* @param Constraint[] $constraints * @param ProjectConstraint[] $constraints
*/ */
public function __construct(private iterable $constraints = []) public function __construct(
#[TaggedIterator(ProjectConstraint::class)]
private iterable $constraints = []
)
{ {
} }
@@ -31,8 +35,8 @@ final class ProjectValidator extends ConstraintValidator
*/ */
public function validate(mixed $value, Constraint $constraint): void public function validate(mixed $value, Constraint $constraint): void
{ {
if (!($constraint instanceof ProjectConstraint)) { if (!($constraint instanceof ProjectEntityConstraint)) {
throw new UnexpectedTypeException($constraint, ProjectConstraint::class); throw new UnexpectedTypeException($constraint, ProjectEntityConstraint::class);
} }
if (!\is_object($value) || !($value instanceof Project)) { if (!\is_object($value) || !($value instanceof Project)) {
@@ -52,10 +56,10 @@ final class ProjectValidator extends ConstraintValidator
protected function validateProject(Project $project, ExecutionContextInterface $context): void protected function validateProject(Project $project, ExecutionContextInterface $context): void
{ {
if (null !== $project->getStart() && null !== $project->getEnd() && $project->getStart()->getTimestamp() > $project->getEnd()->getTimestamp()) { if (null !== $project->getStart() && null !== $project->getEnd() && $project->getStart()->getTimestamp() > $project->getEnd()->getTimestamp()) {
$context->buildViolation(ProjectConstraint::getErrorName(ProjectConstraint::END_BEFORE_BEGIN_ERROR)) $context->buildViolation(ProjectEntityConstraint::getErrorName(ProjectEntityConstraint::END_BEFORE_BEGIN_ERROR))
->atPath('end') ->atPath('end')
->setTranslationDomain('validators') ->setTranslationDomain('validators')
->setCode(ProjectConstraint::END_BEFORE_BEGIN_ERROR) ->setCode(ProjectEntityConstraint::END_BEFORE_BEGIN_ERROR)
->addViolation(); ->addViolation();
} }
} }

View File

@@ -11,6 +11,7 @@ namespace App\Validator\Constraints;
use App\Entity\Timesheet as TimesheetEntity; use App\Entity\Timesheet as TimesheetEntity;
use App\Validator\Constraints\QuickEntryTimesheet as QuickEntryTimesheetConstraint; use App\Validator\Constraints\QuickEntryTimesheet as QuickEntryTimesheetConstraint;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException; use Symfony\Component\Validator\Exception\UnexpectedTypeException;
@@ -18,9 +19,12 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class QuickEntryTimesheetValidator extends ConstraintValidator final class QuickEntryTimesheetValidator extends ConstraintValidator
{ {
/** /**
* @param Constraint[] $constraints * @param TimesheetConstraint[] $constraints
*/ */
public function __construct(private iterable $constraints) public function __construct(
#[TaggedIterator(TimesheetConstraint::class)]
private iterable $constraints
)
{ {
} }

View File

@@ -9,11 +9,13 @@
namespace App\Validator\Constraints; namespace App\Validator\Constraints;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraint;
/** /**
* Extend this class if you want to add dynamic timesheet validation (eg. via a bundle). * Extend this class if you want to add dynamic timesheet validation (eg. via a bundle).
*/ */
#[AutoconfigureTag]
abstract class TimesheetConstraint extends Constraint abstract class TimesheetConstraint extends Constraint
{ {
} }

View File

@@ -10,7 +10,8 @@
namespace App\Validator\Constraints; namespace App\Validator\Constraints;
use App\Entity\Timesheet as TimesheetEntity; use App\Entity\Timesheet as TimesheetEntity;
use App\Validator\Constraints\Timesheet as TimesheetConstraint; use App\Validator\Constraints\Timesheet as TimesheetEntityConstraint;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException; use Symfony\Component\Validator\Exception\UnexpectedTypeException;
@@ -18,9 +19,12 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetValidator extends ConstraintValidator final class TimesheetValidator extends ConstraintValidator
{ {
/** /**
* @param Constraint[] $constraints * @param TimesheetConstraint[] $constraints
*/ */
public function __construct(private iterable $constraints) public function __construct(
#[TaggedIterator(TimesheetConstraint::class)]
private iterable $constraints
)
{ {
} }
@@ -30,8 +34,8 @@ final class TimesheetValidator extends ConstraintValidator
*/ */
public function validate(mixed $timesheet, Constraint $constraint): void public function validate(mixed $timesheet, Constraint $constraint): void
{ {
if (!($constraint instanceof TimesheetConstraint)) { if (!($constraint instanceof TimesheetEntityConstraint)) {
throw new UnexpectedTypeException($constraint, Timesheet::class); throw new UnexpectedTypeException($constraint, TimesheetEntityConstraint::class);
} }
if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) { if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {

View File

@@ -10,12 +10,14 @@
namespace App\Widget; namespace App\Widget;
use App\Entity\User; use App\Entity\User;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\Form\Form; use Symfony\Component\Form\Form;
/** /**
* No BC promise! * No BC promise!
* Use AbstractWidget to get a BC safe base class. * Use AbstractWidget to get a BC safe base class.
*/ */
#[AutoconfigureTag]
interface WidgetInterface interface WidgetInterface
{ {
public const COLOR_TODAY = 'green'; public const COLOR_TODAY = 'green';

View File

@@ -15,8 +15,10 @@
<div class="d-flex"> <div class="d-flex">
<div class="me-auto"> <div class="me-auto">
<p>{{ 'api_password.intro'|trans }}</p> <p>{{ 'api_password.intro'|trans }}</p>
<ul>
<p>{{ 'username'|trans }}: {{ user.userIdentifier }}</p> <li>{{ 'username'|trans }}: {{ user.userIdentifier }}</li>
<li>URL: {{ url('api.swagger_ui', {}, false)|replace({'/doc': ''}) }}</li>
</ul>
</div> </div>
<div class="ms-auto"> <div class="ms-auto">

View File

@@ -17,22 +17,22 @@ use App\Entity\User;
*/ */
class StatusControllerTest extends APIControllerBaseTest class StatusControllerTest extends APIControllerBaseTest
{ {
public function testIsSecurePing() public function testIsSecurePing(): void
{ {
$this->assertUrlIsSecured('/api/ping'); $this->assertUrlIsSecured('/api/ping');
} }
public function testIsSecureVersion() public function testIsSecureVersion(): void
{ {
$this->assertUrlIsSecured('/api/version'); $this->assertUrlIsSecured('/api/version');
} }
public function testIsSecurePlugins() public function testIsSecurePlugins(): void
{ {
$this->assertUrlIsSecured('/api/plugins'); $this->assertUrlIsSecured('/api/plugins');
} }
public function testPing() public function testPing(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/ping'); $this->assertAccessIsGranted($client, '/api/ping');
@@ -42,7 +42,7 @@ class StatusControllerTest extends APIControllerBaseTest
$this->assertEquals(['message' => 'pong'], $result); $this->assertEquals(['message' => 'pong'], $result);
} }
public function testVersion() public function testVersion(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/version'); $this->assertAccessIsGranted($client, '/api/version');
@@ -62,12 +62,13 @@ class StatusControllerTest extends APIControllerBaseTest
); );
} }
public function testPlugins() public function testPlugins(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/plugins'); $this->assertAccessIsGranted($client, '/api/plugins');
$result = json_decode($client->getResponse()->getContent(), true); $result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result); $this->assertIsArray($result);
// no asserts, as plugins are disabled in tests
} }
} }

View File

@@ -24,10 +24,7 @@ use Symfony\Component\Console\Tester\CommandTester;
*/ */
class ActivateUserCommandTest extends KernelTestCase class ActivateUserCommandTest extends KernelTestCase
{ {
/** private Application $application;
* @var Application
*/
private $application;
protected function setUp(): void protected function setUp(): void
{ {
@@ -41,7 +38,7 @@ class ActivateUserCommandTest extends KernelTestCase
$this->application->add(new ActivateUserCommand($userService)); $this->application->add(new ActivateUserCommand($userService));
} }
public function testCommandName() public function testCommandName(): void
{ {
$application = $this->application; $application = $this->application;
@@ -49,7 +46,7 @@ class ActivateUserCommandTest extends KernelTestCase
self::assertInstanceOf(ActivateUserCommand::class, $command); self::assertInstanceOf(ActivateUserCommand::class, $command);
} }
protected function callCommand(?string $username) private function callCommand(?string $username): CommandTester
{ {
$command = $this->application->find('kimai:user:activate'); $command = $this->application->find('kimai:user:activate');
$input = [ $input = [
@@ -66,7 +63,7 @@ class ActivateUserCommandTest extends KernelTestCase
return $commandTester; return $commandTester;
} }
public function testActivate() public function testActivate(): void
{ {
$commandTester = $this->callCommand('chris_user'); $commandTester = $this->callCommand('chris_user');
@@ -81,7 +78,7 @@ class ActivateUserCommandTest extends KernelTestCase
self::assertTrue($user->isEnabled()); self::assertTrue($user->isEnabled());
} }
public function testActivateOnActiveUser() public function testActivateOnActiveUser(): void
{ {
$commandTester = $this->callCommand('susan_super'); $commandTester = $this->callCommand('susan_super');
@@ -89,7 +86,7 @@ class ActivateUserCommandTest extends KernelTestCase
$this->assertStringContainsString('[WARNING] User "susan_super" is already active.', $output); $this->assertStringContainsString('[WARNING] User "susan_super" is already active.', $output);
} }
public function testWithMissingUsername() public function testWithMissingUsername(): void
{ {
$this->expectException(RuntimeException::class); $this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Not enough arguments (missing: "username").'); $this->expectExceptionMessage('Not enough arguments (missing: "username").');

View File

@@ -30,7 +30,7 @@ class BundleInstallerCommandTest extends KernelTestCase
/** /**
* @param class-string $className * @param class-string $className
*/ */
protected function getCommand(string $className): Command private function getCommand(string $className): Command
{ {
$kernel = self::bootKernel(); $kernel = self::bootKernel();
$this->application = new Application($kernel); $this->application = new Application($kernel);

View File

@@ -48,7 +48,7 @@ class ChangePasswordCommandTest extends KernelTestCase
self::assertInstanceOf(ChangePasswordCommand::class, $command); self::assertInstanceOf(ChangePasswordCommand::class, $command);
} }
protected function callCommand(?string $username, ?string $password): CommandTester private function callCommand(?string $username, ?string $password): CommandTester
{ {
$command = $this->application->find('kimai:user:password'); $command = $this->application->find('kimai:user:password');
$input = [ $input = [

View File

@@ -24,10 +24,7 @@ use Symfony\Component\Console\Tester\CommandTester;
*/ */
class DeactivateUserCommandTest extends KernelTestCase class DeactivateUserCommandTest extends KernelTestCase
{ {
/** private Application $application;
* @var Application
*/
private $application;
protected function setUp(): void protected function setUp(): void
{ {
@@ -41,7 +38,7 @@ class DeactivateUserCommandTest extends KernelTestCase
$this->application->add(new DeactivateUserCommand($userService)); $this->application->add(new DeactivateUserCommand($userService));
} }
public function testCommandName() public function testCommandName(): void
{ {
$application = $this->application; $application = $this->application;
@@ -49,7 +46,7 @@ class DeactivateUserCommandTest extends KernelTestCase
self::assertInstanceOf(DeactivateUserCommand::class, $command); self::assertInstanceOf(DeactivateUserCommand::class, $command);
} }
protected function callCommand(?string $username) protected function callCommand(?string $username): CommandTester
{ {
$command = $this->application->find('kimai:user:deactivate'); $command = $this->application->find('kimai:user:deactivate');
$input = [ $input = [
@@ -66,7 +63,7 @@ class DeactivateUserCommandTest extends KernelTestCase
return $commandTester; return $commandTester;
} }
public function testDeactivate() public function testDeactivate(): void
{ {
$commandTester = $this->callCommand('john_user'); $commandTester = $this->callCommand('john_user');
@@ -81,7 +78,7 @@ class DeactivateUserCommandTest extends KernelTestCase
self::assertFalse($user->isEnabled()); self::assertFalse($user->isEnabled());
} }
public function testDeactivateOnDeactivatedUser() public function testDeactivateOnDeactivatedUser(): void
{ {
$commandTester = $this->callCommand('chris_user'); $commandTester = $this->callCommand('chris_user');
@@ -89,7 +86,7 @@ class DeactivateUserCommandTest extends KernelTestCase
$this->assertStringContainsString('[WARNING] User "chris_user" is already deactivated.', $output); $this->assertStringContainsString('[WARNING] User "chris_user" is already deactivated.', $output);
} }
public function testWithMissingUsername() public function testWithMissingUsername(): void
{ {
$this->expectException(RuntimeException::class); $this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Not enough arguments (missing: "username").'); $this->expectExceptionMessage('Not enough arguments (missing: "username").');

View File

@@ -25,10 +25,7 @@ use Symfony\Component\Console\Tester\CommandTester;
*/ */
class DemoteUserCommandTest extends KernelTestCase class DemoteUserCommandTest extends KernelTestCase
{ {
/** private Application $application;
* @var Application
*/
private $application;
protected function setUp(): void protected function setUp(): void
{ {
@@ -42,7 +39,7 @@ class DemoteUserCommandTest extends KernelTestCase
$this->application->add(new DemoteUserCommand($userService)); $this->application->add(new DemoteUserCommand($userService));
} }
public function testCommandName() public function testCommandName(): void
{ {
$application = $this->application; $application = $this->application;
@@ -50,7 +47,7 @@ class DemoteUserCommandTest extends KernelTestCase
self::assertInstanceOf(DemoteUserCommand::class, $command); self::assertInstanceOf(DemoteUserCommand::class, $command);
} }
protected function callCommand(?string $username, ?string $role, bool $super = false) private function callCommand(?string $username, ?string $role, bool $super = false): CommandTester
{ {
$command = $this->application->find('kimai:user:demote'); $command = $this->application->find('kimai:user:demote');
$input = [ $input = [
@@ -75,7 +72,7 @@ class DemoteUserCommandTest extends KernelTestCase
return $commandTester; return $commandTester;
} }
public function testDemoteRole() public function testDemoteRole(): void
{ {
$commandTester = $this->callCommand('tony_teamlead', 'ROLE_TEAMLEAD'); $commandTester = $this->callCommand('tony_teamlead', 'ROLE_TEAMLEAD');
@@ -90,7 +87,7 @@ class DemoteUserCommandTest extends KernelTestCase
self::assertFalse($user->hasTeamleadRole()); self::assertFalse($user->hasTeamleadRole());
} }
public function testDemoteSuper() public function testDemoteSuper(): void
{ {
$commandTester = $this->callCommand('susan_super', null, true); $commandTester = $this->callCommand('susan_super', null, true);
@@ -105,7 +102,7 @@ class DemoteUserCommandTest extends KernelTestCase
self::assertFalse($user->isSuperAdmin()); self::assertFalse($user->isSuperAdmin());
} }
public function testDemoteSuperFailsOnTeamlead() public function testDemoteSuperFailsOnTeamlead(): void
{ {
$commandTester = $this->callCommand('tony_teamlead', null, true); $commandTester = $this->callCommand('tony_teamlead', null, true);
@@ -113,7 +110,7 @@ class DemoteUserCommandTest extends KernelTestCase
$this->assertStringContainsString('[WARNING] User "tony_teamlead" doesn\'t have the super administrator role.', $output); $this->assertStringContainsString('[WARNING] User "tony_teamlead" doesn\'t have the super administrator role.', $output);
} }
public function testDemoteAdminFailsOnTeamlead() public function testDemoteAdminFailsOnTeamlead(): void
{ {
$commandTester = $this->callCommand('tony_teamlead', 'ROLE_ADMIN', false); $commandTester = $this->callCommand('tony_teamlead', 'ROLE_ADMIN', false);
@@ -121,7 +118,7 @@ class DemoteUserCommandTest extends KernelTestCase
$this->assertStringContainsString('[WARNING] User "tony_teamlead" didn\'t have "ROLE_ADMIN" role.', $output); $this->assertStringContainsString('[WARNING] User "tony_teamlead" didn\'t have "ROLE_ADMIN" role.', $output);
} }
public function testDemoteRoleAndSuperFails() public function testDemoteRoleAndSuperFails(): void
{ {
$this->expectException(\InvalidArgumentException::class); $this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('You can pass either the role or the --super option (but not both simultaneously).'); $this->expectExceptionMessage('You can pass either the role or the --super option (but not both simultaneously).');
@@ -129,7 +126,7 @@ class DemoteUserCommandTest extends KernelTestCase
$this->callCommand('john_user', 'ROLE_TEAMLEAD', true); $this->callCommand('john_user', 'ROLE_TEAMLEAD', true);
} }
public function testWithMissingUsername() public function testWithMissingUsername(): void
{ {
$this->expectException(RuntimeException::class); $this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Not enough arguments (missing: "username").'); $this->expectExceptionMessage('Not enough arguments (missing: "username").');

View File

@@ -37,9 +37,9 @@ class ExportCreateCommandTest extends KernelTestCase
{ {
use KernelTestTrait; use KernelTestTrait;
protected Application $application; private Application $application;
private function clearExportFiles() private function clearExportFiles(): void
{ {
$path = __DIR__ . '/../_data/export/'; $path = __DIR__ . '/../_data/export/';
@@ -103,7 +103,7 @@ class ExportCreateCommandTest extends KernelTestCase
* @param array $options * @param array $options
* @return CommandTester * @return CommandTester
*/ */
protected function createExport(array $options = []) protected function createExport(array $options = []): CommandTester
{ {
$command = $this->application->find('kimai:export:create'); $command = $this->application->find('kimai:export:create');
$commandTester = new CommandTester($command); $commandTester = new CommandTester($command);
@@ -114,7 +114,7 @@ class ExportCreateCommandTest extends KernelTestCase
return $commandTester; return $commandTester;
} }
protected function assertCommandErrors(array $options = [], string $errorMessage = '') protected function assertCommandErrors(array $options = [], string $errorMessage = ''): void
{ {
$commandTester = $this->createExport($options); $commandTester = $this->createExport($options);
@@ -122,7 +122,7 @@ class ExportCreateCommandTest extends KernelTestCase
$this->assertStringContainsString('[ERROR] ' . $errorMessage, $output); $this->assertStringContainsString('[ERROR] ' . $errorMessage, $output);
} }
protected function assertCommandResult(array $options = [], string $message = '') protected function assertCommandResult(array $options = [], string $message = ''): void
{ {
$commandTester = $this->createExport($options); $commandTester = $this->createExport($options);
@@ -130,47 +130,47 @@ class ExportCreateCommandTest extends KernelTestCase
$this->assertStringContainsString('[OK] ' . $message, $output); $this->assertStringContainsString('[OK] ' . $message, $output);
} }
public function testCreateWithUnknownExportFilter() public function testCreateWithUnknownExportFilter(): void
{ {
$this->assertCommandErrors(['--exported' => 'foo'], 'Unknown "exported" filter given'); $this->assertCommandErrors(['--exported' => 'foo'], 'Unknown "exported" filter given');
} }
public function testCreateWithUnknownTemplate() public function testCreateWithUnknownTemplate(): void
{ {
$this->assertCommandErrors(['--template' => 'foo'], 'Unknown export "template", available are:'); $this->assertCommandErrors(['--template' => 'foo'], 'Unknown export "template", available are:');
} }
public function testCreateWithMissingTemplate() public function testCreateWithMissingTemplate(): void
{ {
$this->assertCommandErrors([], 'You must pass the "template" option'); $this->assertCommandErrors([], 'You must pass the "template" option');
} }
public function testCreateWithInvalidStart() public function testCreateWithInvalidStart(): void
{ {
$this->assertCommandErrors(['--template' => 'csv', '--start' => '202ß-ä1-01'], 'Invalid start date given'); $this->assertCommandErrors(['--template' => 'csv', '--start' => '202ß-ä1-01'], 'Invalid start date given');
} }
public function testCreateWithInvalidEnd() public function testCreateWithInvalidEnd(): void
{ {
$this->assertCommandErrors(['--template' => 'csv', '--end' => '202ß-ä1-01'], 'Invalid end date given'); $this->assertCommandErrors(['--template' => 'csv', '--end' => '202ß-ä1-01'], 'Invalid end date given');
} }
public function testCreateWithInvalidDirectory() public function testCreateWithInvalidDirectory(): void
{ {
$this->assertCommandErrors(['--template' => 'csv', '--directory' => '/tzuikmnbgtz/'], 'Invalid "directory" given: /tzuikmnbgtz/'); $this->assertCommandErrors(['--template' => 'csv', '--directory' => '/tzuikmnbgtz/'], 'Invalid "directory" given: /tzuikmnbgtz/');
} }
public function testCreateWithInvalidEmail() public function testCreateWithInvalidEmail(): void
{ {
$this->assertCommandErrors(['--template' => 'csv', '--email' => ['tzuikmnbgtz']], 'Invalid "email" given: tzuikmnbgtz'); $this->assertCommandErrors(['--template' => 'csv', '--email' => ['tzuikmnbgtz']], 'Invalid "email" given: tzuikmnbgtz');
} }
public function testCreateWithInvalidEmails() public function testCreateWithInvalidEmails(): void
{ {
$this->assertCommandErrors(['--template' => 'csv', '--email' => ['foo@example.com', 'foo@1']], 'Invalid "email" given: foo@1'); $this->assertCommandErrors(['--template' => 'csv', '--email' => ['foo@example.com', 'foo@1']], 'Invalid "email" given: foo@1');
} }
public function testCreateWithMissingEntries() public function testCreateWithMissingEntries(): void
{ {
$options = ['--set-exported' => null, '--customer' => [1], '--template' => 'csv', '--start' => '2020-01-01', '--end' => '2020-03-01']; $options = ['--set-exported' => null, '--customer' => [1], '--template' => 'csv', '--start' => '2020-01-01', '--end' => '2020-03-01'];
$commandTester = $this->createExport($options); $commandTester = $this->createExport($options);
@@ -179,7 +179,11 @@ class ExportCreateCommandTest extends KernelTestCase
$this->assertStringContainsString('[OK] No entries found, skipping', $output); $this->assertStringContainsString('[OK] No entries found, skipping', $output);
} }
protected function prepareFixtures(\DateTime $start) /**
* @param \DateTime $start
* @return array{0: Customer, 1: array<Project>}
*/
private function prepareFixtures(\DateTime $start): array
{ {
$fixture = new CustomerFixtures(); $fixture = new CustomerFixtures();
$fixture->setAmount(1); $fixture->setAmount(1);
@@ -200,7 +204,7 @@ class ExportCreateCommandTest extends KernelTestCase
return [$customer, $project]; return [$customer, $project];
} }
public function testCreateExportByCustomer() public function testCreateExportByCustomer(): void
{ {
$start = new \DateTime('-2 months'); $start = new \DateTime('-2 months');
$end = new \DateTime(); $end = new \DateTime();
@@ -213,7 +217,7 @@ class ExportCreateCommandTest extends KernelTestCase
$this->assertStringContainsString('Saved export to: ', $output); $this->assertStringContainsString('Saved export to: ', $output);
} }
public function testCreateExportByProject() public function testCreateExportByProject(): void
{ {
$start = new \DateTime('-2 months'); $start = new \DateTime('-2 months');
$end = new \DateTime(); $end = new \DateTime();
@@ -226,7 +230,7 @@ class ExportCreateCommandTest extends KernelTestCase
$this->assertStringContainsString('Saved export to: ', $output); $this->assertStringContainsString('Saved export to: ', $output);
} }
public function testCreateExportWithEmail() public function testCreateExportWithEmail(): void
{ {
$start = new \DateTime('-2 months'); $start = new \DateTime('-2 months');
$end = new \DateTime(); $end = new \DateTime();

View File

@@ -19,10 +19,7 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
*/ */
class InstallCommandTest extends KernelTestCase class InstallCommandTest extends KernelTestCase
{ {
/** private Application $application;
* @var Application
*/
protected $application;
protected function setUp(): void protected function setUp(): void
{ {
@@ -37,7 +34,7 @@ class InstallCommandTest extends KernelTestCase
)); ));
} }
public function testCommandName() public function testCommandName(): void
{ {
$command = $this->application->find('kimai:install'); $command = $this->application->find('kimai:install');
self::assertInstanceOf(InstallCommand::class, $command); self::assertInstanceOf(InstallCommand::class, $command);

View File

@@ -35,7 +35,7 @@ class InvoiceCreateCommandTest extends KernelTestCase
{ {
use KernelTestTrait; use KernelTestTrait;
protected Application $application; private Application $application;
private function clearInvoiceFiles(): void private function clearInvoiceFiles(): void
{ {

View File

@@ -22,16 +22,13 @@ use Symfony\Component\Console\Tester\CommandTester;
*/ */
class PluginCommandTest extends KernelTestCase class PluginCommandTest extends KernelTestCase
{ {
/** private Application $application;
* @var Application
*/
protected $application;
public function testWithPlugins() public function testWithPlugins(): void
{ {
$plugin1 = $this->getMockBuilder(PluginInterface::class)->onlyMethods(['getName', 'getPath'])->getMock(); $plugin1 = $this->getMockBuilder(PluginInterface::class)->onlyMethods(['getName', 'getPath'])->getMock();
$plugin1->expects($this->any())->method('getName')->willReturn('TestBundle'); $plugin1->expects($this->any())->method('getName')->willReturn('TestBundle');
$plugin1->expects($this->once())->method('getPath')->willReturn(__DIR__ . '/../Plugin/Fixtures/TestPlugin'); $plugin1->expects($this->exactly(2))->method('getPath')->willReturn(__DIR__ . '/../Plugin/Fixtures/TestPlugin');
$commandTester = $this->getCommandTester([$plugin1], []); $commandTester = $this->getCommandTester([$plugin1], []);
$output = $commandTester->getDisplay(); $output = $commandTester->getDisplay();
@@ -40,7 +37,7 @@ class PluginCommandTest extends KernelTestCase
$this->assertStringContainsString('TestPlugin from composer.json', $output); $this->assertStringContainsString('TestPlugin from composer.json', $output);
} }
protected function getCommandTester(array $plugins, array $options = []) private function getCommandTester(array $plugins, array $options = []): CommandTester
{ {
$kernel = self::bootKernel(); $kernel = self::bootKernel();
$this->application = new Application($kernel); $this->application = new Application($kernel);

View File

@@ -25,10 +25,7 @@ use Symfony\Component\Console\Tester\CommandTester;
*/ */
class PromoteUserCommandTest extends KernelTestCase class PromoteUserCommandTest extends KernelTestCase
{ {
/** private Application $application;
* @var Application
*/
private $application;
protected function setUp(): void protected function setUp(): void
{ {
@@ -42,7 +39,7 @@ class PromoteUserCommandTest extends KernelTestCase
$this->application->add(new PromoteUserCommand($userService)); $this->application->add(new PromoteUserCommand($userService));
} }
public function testCommandName() public function testCommandName(): void
{ {
$application = $this->application; $application = $this->application;
@@ -50,7 +47,7 @@ class PromoteUserCommandTest extends KernelTestCase
self::assertInstanceOf(PromoteUserCommand::class, $command); self::assertInstanceOf(PromoteUserCommand::class, $command);
} }
protected function callCommand(?string $username, ?string $role, bool $super = false) protected function callCommand(?string $username, ?string $role, bool $super = false): CommandTester
{ {
$command = $this->application->find('kimai:user:promote'); $command = $this->application->find('kimai:user:promote');
$input = [ $input = [
@@ -75,7 +72,7 @@ class PromoteUserCommandTest extends KernelTestCase
return $commandTester; return $commandTester;
} }
public function testPromoteRole() public function testPromoteRole(): void
{ {
$commandTester = $this->callCommand('john_user', 'ROLE_TEAMLEAD'); $commandTester = $this->callCommand('john_user', 'ROLE_TEAMLEAD');
@@ -90,7 +87,7 @@ class PromoteUserCommandTest extends KernelTestCase
self::assertTrue($user->hasTeamleadRole()); self::assertTrue($user->hasTeamleadRole());
} }
public function testPromoteSuper() public function testPromoteSuper(): void
{ {
$commandTester = $this->callCommand('john_user', null, true); $commandTester = $this->callCommand('john_user', null, true);
@@ -105,7 +102,7 @@ class PromoteUserCommandTest extends KernelTestCase
self::assertTrue($user->isSuperAdmin()); self::assertTrue($user->isSuperAdmin());
} }
public function testPromoteSuperFailsOnSuperAdmin() public function testPromoteSuperFailsOnSuperAdmin(): void
{ {
$commandTester = $this->callCommand('susan_super', null, true); $commandTester = $this->callCommand('susan_super', null, true);
@@ -113,7 +110,7 @@ class PromoteUserCommandTest extends KernelTestCase
$this->assertStringContainsString('[WARNING] User "susan_super" does already have the super administrator role.', $output); $this->assertStringContainsString('[WARNING] User "susan_super" does already have the super administrator role.', $output);
} }
public function testPromoteTeamleadFailsOnTeamlead() public function testPromoteTeamleadFailsOnTeamlead(): void
{ {
$commandTester = $this->callCommand('tony_teamlead', 'ROLE_TEAMLEAD', false); $commandTester = $this->callCommand('tony_teamlead', 'ROLE_TEAMLEAD', false);
@@ -121,7 +118,7 @@ class PromoteUserCommandTest extends KernelTestCase
$this->assertStringContainsString('[WARNING] User "tony_teamlead" did already have "ROLE_TEAMLEAD" role.', $output); $this->assertStringContainsString('[WARNING] User "tony_teamlead" did already have "ROLE_TEAMLEAD" role.', $output);
} }
public function testPromoteRoleAndSuperFails() public function testPromoteRoleAndSuperFails(): void
{ {
$this->expectException(\InvalidArgumentException::class); $this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('You can pass either the role or the --super option (but not both simultaneously).'); $this->expectExceptionMessage('You can pass either the role or the --super option (but not both simultaneously).');
@@ -129,7 +126,7 @@ class PromoteUserCommandTest extends KernelTestCase
$this->callCommand('john_user', 'ROLE_TEAMLEAD', true); $this->callCommand('john_user', 'ROLE_TEAMLEAD', true);
} }
public function testWithMissingUsername() public function testWithMissingUsername(): void
{ {
$this->expectException(RuntimeException::class); $this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Not enough arguments (missing: "username").'); $this->expectExceptionMessage('Not enough arguments (missing: "username").');

View File

@@ -19,10 +19,7 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
*/ */
class ReloadCommandTest extends KernelTestCase class ReloadCommandTest extends KernelTestCase
{ {
/** protected Application $application;
* @var Application
*/
protected $application;
protected function setUp(): void protected function setUp(): void
{ {

View File

@@ -21,10 +21,7 @@ use Symfony\Component\Console\Tester\CommandTester;
*/ */
class TimesheetStopAllCommandTest extends KernelTestCase class TimesheetStopAllCommandTest extends KernelTestCase
{ {
/** private Application $application;
* @var Application
*/
protected $application;
protected function setUp(): void protected function setUp(): void
{ {

View File

@@ -22,10 +22,7 @@ use Symfony\Component\Console\Tester\CommandTester;
*/ */
class UpdateCommandTest extends KernelTestCase class UpdateCommandTest extends KernelTestCase
{ {
/** private Application $application;
* @var Application
*/
protected $application;
protected function getCommand(): Command protected function getCommand(): Command
{ {
@@ -41,7 +38,7 @@ class UpdateCommandTest extends KernelTestCase
return $this->application->find('kimai:update'); return $this->application->find('kimai:update');
} }
public function testFullRun() public function testFullRun(): void
{ {
$command = $this->getCommand(); $command = $this->getCommand();
$commandTester = new CommandTester($command); $commandTester = new CommandTester($command);

View File

@@ -21,10 +21,7 @@ use Symfony\Component\Console\Tester\CommandTester;
*/ */
class VersionCommandTest extends KernelTestCase class VersionCommandTest extends KernelTestCase
{ {
/** private Application $application;
* @var Application
*/
protected $application;
protected function setUp(): void protected function setUp(): void
{ {
@@ -38,14 +35,14 @@ class VersionCommandTest extends KernelTestCase
/** /**
* @dataProvider getTestData * @dataProvider getTestData
*/ */
public function testVersion(array $options, $result) public function testVersion(array $options, $result): void
{ {
$commandTester = $this->getCommandTester($options); $commandTester = $this->getCommandTester($options);
$output = $commandTester->getDisplay(); $output = $commandTester->getDisplay();
$this->assertEquals($result . PHP_EOL, $output); $this->assertEquals($result . PHP_EOL, $output);
} }
public function getTestData() public function getTestData(): array // @phpstan-ignore-line
{ {
return [ return [
[[], 'Kimai ' . Constants::VERSION . ' by Kevin Papst.'], [[], 'Kimai ' . Constants::VERSION . ' by Kevin Papst.'],
@@ -54,7 +51,7 @@ class VersionCommandTest extends KernelTestCase
]; ];
} }
protected function getCommandTester(array $options = []) protected function getCommandTester(array $options = []): CommandTester
{ {
$command = $this->application->find('kimai:version'); $command = $this->application->find('kimai:version');
$commandTester = new CommandTester($command); $commandTester = new CommandTester($command);

View File

@@ -10,13 +10,15 @@
namespace App\Tests\DependencyInjection\Compiler; namespace App\Tests\DependencyInjection\Compiler;
use App\DependencyInjection\Compiler\ExportServiceCompilerPass; use App\DependencyInjection\Compiler\ExportServiceCompilerPass;
use App\Export\ExportRepositoryInterface;
use App\Export\Renderer\CsvRenderer; use App\Export\Renderer\CsvRenderer;
use App\Export\Renderer\HtmlRenderer; use App\Export\Renderer\HtmlRenderer;
use App\Export\RendererInterface;
use App\Export\ServiceExport; use App\Export\ServiceExport;
use App\Export\Timesheet\PDFRenderer; use App\Export\Timesheet\PDFRenderer;
use App\Export\Timesheet\XlsxRenderer; use App\Export\Timesheet\XlsxRenderer;
use App\Export\TimesheetExportInterface;
use App\Export\TimesheetExportRepository; use App\Export\TimesheetExportRepository;
use App\Kernel;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Definition;
@@ -38,17 +40,17 @@ class ExportServiceCompilerPassTest extends TestCase
$renderers = [CsvRenderer::class, HtmlRenderer::class]; $renderers = [CsvRenderer::class, HtmlRenderer::class];
foreach ($renderers as $renderer) { foreach ($renderers as $renderer) {
$container->register($renderer)->addTag(Kernel::TAG_EXPORT_RENDERER); $container->register($renderer)->addTag(RendererInterface::class);
} }
$exporters = [PDFRenderer::class, XlsxRenderer::class]; $exporters = [PDFRenderer::class, XlsxRenderer::class];
foreach ($exporters as $exporter) { foreach ($exporters as $exporter) {
$container->register($exporter)->addTag(Kernel::TAG_TIMESHEET_EXPORTER); $container->register($exporter)->addTag(TimesheetExportInterface::class);
} }
$repositories = [TimesheetExportRepository::class]; $repositories = [TimesheetExportRepository::class];
foreach ($repositories as $repository) { foreach ($repositories as $repository) {
$container->register($repository)->addTag(Kernel::TAG_EXPORT_REPOSITORY); $container->register($repository)->addTag(ExportRepositoryInterface::class);
} }
return $container; return $container;

View File

@@ -13,11 +13,14 @@ use App\DependencyInjection\Compiler\InvoiceServiceCompilerPass;
use App\Invoice\Calculator\DefaultCalculator; use App\Invoice\Calculator\DefaultCalculator;
use App\Invoice\Calculator\ShortInvoiceCalculator; use App\Invoice\Calculator\ShortInvoiceCalculator;
use App\Invoice\Calculator\UserInvoiceCalculator; use App\Invoice\Calculator\UserInvoiceCalculator;
use App\Invoice\CalculatorInterface;
use App\Invoice\InvoiceItemRepositoryInterface;
use App\Invoice\NumberGenerator\ConfigurableNumberGenerator; use App\Invoice\NumberGenerator\ConfigurableNumberGenerator;
use App\Invoice\NumberGenerator\DateNumberGenerator; use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Invoice\NumberGeneratorInterface;
use App\Invoice\Renderer\DocxRenderer; use App\Invoice\Renderer\DocxRenderer;
use App\Invoice\RendererInterface;
use App\Invoice\ServiceInvoice; use App\Invoice\ServiceInvoice;
use App\Kernel;
use App\Repository\TimesheetInvoiceItemRepository; use App\Repository\TimesheetInvoiceItemRepository;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -37,22 +40,22 @@ class InvoiceServiceCompilerPassTest extends TestCase
$renderers = [DocxRenderer::class]; $renderers = [DocxRenderer::class];
foreach ($renderers as $renderer) { foreach ($renderers as $renderer) {
$container->register($renderer)->addTag(Kernel::TAG_INVOICE_RENDERER); $container->register($renderer)->addTag(RendererInterface::class);
} }
$numberGenerators = [DateNumberGenerator::class, ConfigurableNumberGenerator::class]; $numberGenerators = [DateNumberGenerator::class, ConfigurableNumberGenerator::class];
foreach ($numberGenerators as $numberGenerator) { foreach ($numberGenerators as $numberGenerator) {
$container->register($numberGenerator)->addTag(Kernel::TAG_INVOICE_NUMBER_GENERATOR); $container->register($numberGenerator)->addTag(NumberGeneratorInterface::class);
} }
$calculators = [DefaultCalculator::class, UserInvoiceCalculator::class, ShortInvoiceCalculator::class]; $calculators = [DefaultCalculator::class, UserInvoiceCalculator::class, ShortInvoiceCalculator::class];
foreach ($calculators as $calculator) { foreach ($calculators as $calculator) {
$container->register($calculator)->addTag(Kernel::TAG_INVOICE_CALCULATOR); $container->register($calculator)->addTag(CalculatorInterface::class);
} }
$repositories = [TimesheetInvoiceItemRepository::class]; $repositories = [TimesheetInvoiceItemRepository::class];
foreach ($repositories as $repository) { foreach ($repositories as $repository) {
$container->register($repository)->addTag(Kernel::TAG_INVOICE_REPOSITORY); $container->register($repository)->addTag(InvoiceItemRepositoryInterface::class);
} }
return $container; return $container;

View File

@@ -10,9 +10,9 @@
namespace App\Tests\DependencyInjection\Compiler; namespace App\Tests\DependencyInjection\Compiler;
use App\DependencyInjection\Compiler\WidgetCompilerPass; use App\DependencyInjection\Compiler\WidgetCompilerPass;
use App\Kernel;
use App\Widget\Type\ActiveTimesheets; use App\Widget\Type\ActiveTimesheets;
use App\Widget\Type\ActiveUsersMonth; use App\Widget\Type\ActiveUsersMonth;
use App\Widget\WidgetInterface;
use App\Widget\WidgetService; use App\Widget\WidgetService;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -32,7 +32,7 @@ class WidgetCompilerPassTest extends TestCase
$widgets = [ActiveTimesheets::class, ActiveUsersMonth::class]; $widgets = [ActiveTimesheets::class, ActiveUsersMonth::class];
foreach ($widgets as $widget) { foreach ($widgets as $widget) {
$container->register($widget)->addTag(Kernel::TAG_WIDGET); $container->register($widget)->addTag(WidgetInterface::class);
} }
return $container; return $container;

View File

@@ -12,29 +12,27 @@ namespace App\Tests\Plugin;
use App\Plugin\Plugin; use App\Plugin\Plugin;
use App\Plugin\PluginInterface; use App\Plugin\PluginInterface;
use App\Plugin\PluginManager; use App\Plugin\PluginManager;
use App\Plugin\PluginMetadata;
use App\Tests\Plugin\Fixtures\TestPlugin\TestPlugin; use App\Tests\Plugin\Fixtures\TestPlugin\TestPlugin;
use App\Tests\Plugin\Fixtures\TestPlugin2\TestPlugin2; use App\Tests\Plugin\Fixtures\TestPlugin2\TestPlugin2;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
* @covers \App\Plugin\PluginManager * @covers \App\Plugin\PluginManager
* @covers \App\Plugin\Plugin
* @covers \App\Plugin\PluginMetadata
*/ */
class PluginManagerTest extends TestCase class PluginManagerTest extends TestCase
{ {
public function testEmptyObject() public function testEmptyObject(): void
{ {
$sut = new PluginManager([]); $sut = new PluginManager([]);
$this->assertEmpty($sut->getPlugins()); $this->assertEmpty($sut->getPlugins());
$this->assertNull($sut->getPlugin('foo')); $this->assertNull($sut->getPlugin('foo'));
$this->assertFalse($sut->hasPlugin('foo'));
} }
public function testUnknownRendererReturnsNull() public function testAdd(): void
{
$sut = new PluginManager([]);
$this->assertNull($sut->getPlugin('foo'));
}
public function testAdd()
{ {
$plugin = $this->createMock(PluginInterface::class); $plugin = $this->createMock(PluginInterface::class);
$plugin->expects($this->any())->method('getName')->willReturn('foo'); $plugin->expects($this->any())->method('getName')->willReturn('foo');
@@ -52,27 +50,23 @@ class PluginManagerTest extends TestCase
// make sure a plugin with the same name is not added twice, the first one wins! // make sure a plugin with the same name is not added twice, the first one wins!
$this->assertEquals(2, \count($sut->getPlugins())); $this->assertEquals(2, \count($sut->getPlugins()));
$this->assertFalse($sut->hasPlugin('bar'));
$this->assertTrue($sut->hasPlugin('foo'));
$foo = $sut->getPlugin('foo'); $foo = $sut->getPlugin('foo');
$this->assertInstanceOf(Plugin::class, $foo); $this->assertInstanceOf(Plugin::class, $foo);
$this->assertEquals('foo', $foo->getName()); $this->assertEquals('foo', $foo->getId());
$this->assertEquals('bar', $foo->getPath()); $this->assertEquals('bar', $foo->getPath());
$test = $sut->getPlugin('TestPlugin'); $test = $sut->getPlugin('TestPlugin');
$this->assertInstanceOf(Plugin::class, $test); $this->assertInstanceOf(Plugin::class, $test);
$this->assertEquals('TestPlugin', $test->getName()); $this->assertEquals('TestPlugin', $test->getId());
$this->assertNull($test->getMetadata()); $this->assertEquals('TestPlugin from composer.json', $test->getName());
} $this->assertInstanceOf(PluginMetadata::class, $test->getMetadata());
public function testLoadMetadata() $meta = $test->getMetadata();
{
$sut = new PluginManager([new TestPlugin()]);
$plugin = $sut->getPlugin('TestPlugin');
$sut->loadMetadata($plugin);
$meta = $plugin->getMetadata();
$this->assertEquals(10000, $meta->getKimaiVersion()); $this->assertEquals(10000, $meta->getKimaiVersion());
$this->assertEquals('1.0', $meta->getVersion()); $this->assertEquals('1.0', $meta->getVersion());
$this->assertEquals('TestPlugin', $plugin->getId()); $this->assertEquals('TestPlugin', $test->getId());
$this->assertEquals('TestPlugin from composer.json', $meta->getName()); $this->assertEquals('TestPlugin from composer.json', $meta->getName());
$this->assertEquals('Just a test fixture for the PluginManager', $meta->getDescription()); $this->assertEquals('Just a test fixture for the PluginManager', $meta->getDescription());
$this->assertEquals('https://github.com/kimai/kimai', $meta->getHomepage()); $this->assertEquals('https://github.com/kimai/kimai', $meta->getHomepage());

View File

@@ -17,12 +17,11 @@ use PHPUnit\Framework\TestCase;
*/ */
class PluginMetadataTest extends TestCase class PluginMetadataTest extends TestCase
{ {
public function testEmptyObject() public function testNonExistingDirectoryThrowsException(): void
{ {
$sut = new PluginMetadata(); $this->expectException(\Exception::class);
$this->assertNull($sut->getDescription()); $this->expectExceptionMessage('Bundle "Plugin" does not ship composer.json, which is required since 2.0.');
$this->assertNull($sut->getHomepage());
$this->assertNull($sut->getVersion()); new PluginMetadata(__DIR__);
$this->assertNull($sut->getKimaiVersion());
} }
} }

View File

@@ -11,7 +11,6 @@ namespace App\Tests\Plugin;
use App\Plugin\Plugin; use App\Plugin\Plugin;
use App\Plugin\PluginInterface; use App\Plugin\PluginInterface;
use App\Plugin\PluginMetadata;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
@@ -19,22 +18,10 @@ use PHPUnit\Framework\TestCase;
*/ */
class PluginTest extends TestCase class PluginTest extends TestCase
{ {
public function testEmptyObject() public function testEmptyObject(): void
{ {
$plugin = new Plugin($this->createMock(PluginInterface::class)); $plugin = new Plugin($this->createMock(PluginInterface::class));
$this->assertEquals('', $plugin->getId()); $this->assertEquals('', $plugin->getId());
$this->assertEquals('', $plugin->getName());
$this->assertEquals('', $plugin->getPath()); $this->assertEquals('', $plugin->getPath());
$this->assertNull($plugin->getMetadata());
}
public function testGetterAndSetter()
{
$metadata = new PluginMetadata();
$plugin = new Plugin($this->createMock(PluginInterface::class));
$plugin->setMetadata($metadata);
$this->assertEquals($metadata, $plugin->getMetadata());
} }
} }

View File

@@ -1067,36 +1067,6 @@ parameters:
count: 1 count: 1
path: API/Serializer/ValidationFailedExceptionErrorHandlerTest.php path: API/Serializer/ValidationFailedExceptionErrorHandlerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\StatusControllerTest\\:\\:testIsSecurePing\\(\\) has no return type specified\\.$#"
count: 1
path: API/StatusControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\StatusControllerTest\\:\\:testIsSecurePlugins\\(\\) has no return type specified\\.$#"
count: 1
path: API/StatusControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\StatusControllerTest\\:\\:testIsSecureVersion\\(\\) has no return type specified\\.$#"
count: 1
path: API/StatusControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\StatusControllerTest\\:\\:testPing\\(\\) has no return type specified\\.$#"
count: 1
path: API/StatusControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\StatusControllerTest\\:\\:testPlugins\\(\\) has no return type specified\\.$#"
count: 1
path: API/StatusControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\StatusControllerTest\\:\\:testVersion\\(\\) has no return type specified\\.$#"
count: 1
path: API/StatusControllerTest.php
- -
message: "#^Parameter \\#1 \\$json of function json_decode expects string, string\\|false given\\.$#" message: "#^Parameter \\#1 \\$json of function json_decode expects string, string\\|false given\\.$#"
count: 3 count: 3
@@ -1307,31 +1277,6 @@ parameters:
count: 1 count: 1
path: Command/ActivateUserCommandTest.php path: Command/ActivateUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ActivateUserCommandTest\\:\\:callCommand\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ActivateUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ActivateUserCommandTest\\:\\:testActivate\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ActivateUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ActivateUserCommandTest\\:\\:testActivateOnActiveUser\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ActivateUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ActivateUserCommandTest\\:\\:testCommandName\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ActivateUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ActivateUserCommandTest\\:\\:testWithMissingUsername\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ActivateUserCommandTest.php
- -
message: "#^Parameter \\#1 \\$userService of class App\\\\Command\\\\ActivateUserCommand constructor expects App\\\\User\\\\UserService, object\\|null given\\.$#" message: "#^Parameter \\#1 \\$userService of class App\\\\Command\\\\ActivateUserCommand constructor expects App\\\\User\\\\UserService, object\\|null given\\.$#"
count: 1 count: 1
@@ -1392,31 +1337,6 @@ parameters:
count: 1 count: 1
path: Command/DeactivateUserCommandTest.php path: Command/DeactivateUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\DeactivateUserCommandTest\\:\\:callCommand\\(\\) has no return type specified\\.$#"
count: 1
path: Command/DeactivateUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\DeactivateUserCommandTest\\:\\:testCommandName\\(\\) has no return type specified\\.$#"
count: 1
path: Command/DeactivateUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\DeactivateUserCommandTest\\:\\:testDeactivate\\(\\) has no return type specified\\.$#"
count: 1
path: Command/DeactivateUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\DeactivateUserCommandTest\\:\\:testDeactivateOnDeactivatedUser\\(\\) has no return type specified\\.$#"
count: 1
path: Command/DeactivateUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\DeactivateUserCommandTest\\:\\:testWithMissingUsername\\(\\) has no return type specified\\.$#"
count: 1
path: Command/DeactivateUserCommandTest.php
- -
message: "#^Parameter \\#1 \\$userService of class App\\\\Command\\\\DeactivateUserCommand constructor expects App\\\\User\\\\UserService, object\\|null given\\.$#" message: "#^Parameter \\#1 \\$userService of class App\\\\Command\\\\DeactivateUserCommand constructor expects App\\\\User\\\\UserService, object\\|null given\\.$#"
count: 1 count: 1
@@ -1427,46 +1347,6 @@ parameters:
count: 2 count: 2
path: Command/DemoteUserCommandTest.php path: Command/DemoteUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\DemoteUserCommandTest\\:\\:callCommand\\(\\) has no return type specified\\.$#"
count: 1
path: Command/DemoteUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\DemoteUserCommandTest\\:\\:testCommandName\\(\\) has no return type specified\\.$#"
count: 1
path: Command/DemoteUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\DemoteUserCommandTest\\:\\:testDemoteAdminFailsOnTeamlead\\(\\) has no return type specified\\.$#"
count: 1
path: Command/DemoteUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\DemoteUserCommandTest\\:\\:testDemoteRole\\(\\) has no return type specified\\.$#"
count: 1
path: Command/DemoteUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\DemoteUserCommandTest\\:\\:testDemoteRoleAndSuperFails\\(\\) has no return type specified\\.$#"
count: 1
path: Command/DemoteUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\DemoteUserCommandTest\\:\\:testDemoteSuper\\(\\) has no return type specified\\.$#"
count: 1
path: Command/DemoteUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\DemoteUserCommandTest\\:\\:testDemoteSuperFailsOnTeamlead\\(\\) has no return type specified\\.$#"
count: 1
path: Command/DemoteUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\DemoteUserCommandTest\\:\\:testWithMissingUsername\\(\\) has no return type specified\\.$#"
count: 1
path: Command/DemoteUserCommandTest.php
- -
message: "#^Parameter \\#1 \\$userService of class App\\\\Command\\\\DemoteUserCommand constructor expects App\\\\User\\\\UserService, object\\|null given\\.$#" message: "#^Parameter \\#1 \\$userService of class App\\\\Command\\\\DemoteUserCommand constructor expects App\\\\User\\\\UserService, object\\|null given\\.$#"
count: 1 count: 1
@@ -1477,31 +1357,16 @@ parameters:
count: 1 count: 1
path: Command/ExportCreateCommandTest.php path: Command/ExportCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:assertCommandErrors\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ExportCreateCommandTest.php
- -
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:assertCommandErrors\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:assertCommandErrors\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1 count: 1
path: Command/ExportCreateCommandTest.php path: Command/ExportCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:assertCommandResult\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ExportCreateCommandTest.php
- -
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:assertCommandResult\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:assertCommandResult\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1 count: 1
path: Command/ExportCreateCommandTest.php path: Command/ExportCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:clearExportFiles\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ExportCreateCommandTest.php
- -
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:createApplication\\(\\) has parameter \\$mailer with no type specified\\.$#" message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:createApplication\\(\\) has parameter \\$mailer with no type specified\\.$#"
count: 1 count: 1
@@ -1517,71 +1382,6 @@ parameters:
count: 1 count: 1
path: Command/ExportCreateCommandTest.php path: Command/ExportCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:prepareFixtures\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ExportCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:testCreateExportByCustomer\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ExportCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:testCreateExportByProject\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ExportCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:testCreateExportWithEmail\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ExportCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:testCreateWithInvalidDirectory\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ExportCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:testCreateWithInvalidEmail\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ExportCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:testCreateWithInvalidEmails\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ExportCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:testCreateWithInvalidEnd\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ExportCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:testCreateWithInvalidStart\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ExportCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:testCreateWithMissingEntries\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ExportCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:testCreateWithMissingTemplate\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ExportCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:testCreateWithUnknownExportFilter\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ExportCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:testCreateWithUnknownTemplate\\(\\) has no return type specified\\.$#"
count: 1
path: Command/ExportCreateCommandTest.php
- -
message: "#^Parameter \\#1 \\$serviceExport of class App\\\\Command\\\\ExportCreateCommand constructor expects App\\\\Export\\\\ServiceExport, object\\|null given\\.$#" message: "#^Parameter \\#1 \\$serviceExport of class App\\\\Command\\\\ExportCreateCommand constructor expects App\\\\Export\\\\ServiceExport, object\\|null given\\.$#"
count: 1 count: 1
@@ -1617,11 +1417,6 @@ parameters:
count: 1 count: 1
path: Command/InstallCommandTest.php path: Command/InstallCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\InstallCommandTest\\:\\:testCommandName\\(\\) has no return type specified\\.$#"
count: 1
path: Command/InstallCommandTest.php
- -
message: "#^Argument of an invalid type array\\<int, string\\>\\|false supplied for foreach, only iterables are supported\\.$#" message: "#^Argument of an invalid type array\\<int, string\\>\\|false supplied for foreach, only iterables are supported\\.$#"
count: 1 count: 1
@@ -1672,11 +1467,6 @@ parameters:
count: 1 count: 1
path: Command/InvoiceCreateCommandTest.php path: Command/InvoiceCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\PluginCommandTest\\:\\:getCommandTester\\(\\) has no return type specified\\.$#"
count: 1
path: Command/PluginCommandTest.php
- -
message: "#^Method App\\\\Tests\\\\Command\\\\PluginCommandTest\\:\\:getCommandTester\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Tests\\\\Command\\\\PluginCommandTest\\:\\:getCommandTester\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1 count: 1
@@ -1687,56 +1477,11 @@ parameters:
count: 1 count: 1
path: Command/PluginCommandTest.php path: Command/PluginCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\PluginCommandTest\\:\\:testWithPlugins\\(\\) has no return type specified\\.$#"
count: 1
path: Command/PluginCommandTest.php
- -
message: "#^Cannot call method getRepository\\(\\) on object\\|null\\.$#" message: "#^Cannot call method getRepository\\(\\) on object\\|null\\.$#"
count: 2 count: 2
path: Command/PromoteUserCommandTest.php path: Command/PromoteUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\PromoteUserCommandTest\\:\\:callCommand\\(\\) has no return type specified\\.$#"
count: 1
path: Command/PromoteUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\PromoteUserCommandTest\\:\\:testCommandName\\(\\) has no return type specified\\.$#"
count: 1
path: Command/PromoteUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\PromoteUserCommandTest\\:\\:testPromoteRole\\(\\) has no return type specified\\.$#"
count: 1
path: Command/PromoteUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\PromoteUserCommandTest\\:\\:testPromoteRoleAndSuperFails\\(\\) has no return type specified\\.$#"
count: 1
path: Command/PromoteUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\PromoteUserCommandTest\\:\\:testPromoteSuper\\(\\) has no return type specified\\.$#"
count: 1
path: Command/PromoteUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\PromoteUserCommandTest\\:\\:testPromoteSuperFailsOnSuperAdmin\\(\\) has no return type specified\\.$#"
count: 1
path: Command/PromoteUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\PromoteUserCommandTest\\:\\:testPromoteTeamleadFailsOnTeamlead\\(\\) has no return type specified\\.$#"
count: 1
path: Command/PromoteUserCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\PromoteUserCommandTest\\:\\:testWithMissingUsername\\(\\) has no return type specified\\.$#"
count: 1
path: Command/PromoteUserCommandTest.php
- -
message: "#^Parameter \\#1 \\$userService of class App\\\\Command\\\\PromoteUserCommand constructor expects App\\\\User\\\\UserService, object\\|null given\\.$#" message: "#^Parameter \\#1 \\$userService of class App\\\\Command\\\\PromoteUserCommand constructor expects App\\\\User\\\\UserService, object\\|null given\\.$#"
count: 1 count: 1
@@ -1782,31 +1527,11 @@ parameters:
count: 1 count: 1
path: Command/UpdateCommandTest.php path: Command/UpdateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\UpdateCommandTest\\:\\:testFullRun\\(\\) has no return type specified\\.$#"
count: 1
path: Command/UpdateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\VersionCommandTest\\:\\:getCommandTester\\(\\) has no return type specified\\.$#"
count: 1
path: Command/VersionCommandTest.php
- -
message: "#^Method App\\\\Tests\\\\Command\\\\VersionCommandTest\\:\\:getCommandTester\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Tests\\\\Command\\\\VersionCommandTest\\:\\:getCommandTester\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1 count: 1
path: Command/VersionCommandTest.php path: Command/VersionCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\VersionCommandTest\\:\\:getTestData\\(\\) has no return type specified\\.$#"
count: 1
path: Command/VersionCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\VersionCommandTest\\:\\:testVersion\\(\\) has no return type specified\\.$#"
count: 1
path: Command/VersionCommandTest.php
- -
message: "#^Method App\\\\Tests\\\\Command\\\\VersionCommandTest\\:\\:testVersion\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Tests\\\\Command\\\\VersionCommandTest\\:\\:testVersion\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1 count: 1
@@ -6937,81 +6662,6 @@ parameters:
count: 1 count: 1
path: Pdf/PdfContextTest.php path: Pdf/PdfContextTest.php
-
message: "#^Cannot call method getDescription\\(\\) on App\\\\Plugin\\\\PluginMetadata\\|null\\.$#"
count: 1
path: Plugin/PluginManagerTest.php
-
message: "#^Cannot call method getHomepage\\(\\) on App\\\\Plugin\\\\PluginMetadata\\|null\\.$#"
count: 1
path: Plugin/PluginManagerTest.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Plugin\\\\Plugin\\|null\\.$#"
count: 1
path: Plugin/PluginManagerTest.php
-
message: "#^Cannot call method getKimaiVersion\\(\\) on App\\\\Plugin\\\\PluginMetadata\\|null\\.$#"
count: 1
path: Plugin/PluginManagerTest.php
-
message: "#^Cannot call method getMetadata\\(\\) on App\\\\Plugin\\\\Plugin\\|null\\.$#"
count: 1
path: Plugin/PluginManagerTest.php
-
message: "#^Cannot call method getName\\(\\) on App\\\\Plugin\\\\PluginMetadata\\|null\\.$#"
count: 1
path: Plugin/PluginManagerTest.php
-
message: "#^Cannot call method getVersion\\(\\) on App\\\\Plugin\\\\PluginMetadata\\|null\\.$#"
count: 1
path: Plugin/PluginManagerTest.php
-
message: "#^Method App\\\\Tests\\\\Plugin\\\\PluginManagerTest\\:\\:testAdd\\(\\) has no return type specified\\.$#"
count: 1
path: Plugin/PluginManagerTest.php
-
message: "#^Method App\\\\Tests\\\\Plugin\\\\PluginManagerTest\\:\\:testEmptyObject\\(\\) has no return type specified\\.$#"
count: 1
path: Plugin/PluginManagerTest.php
-
message: "#^Method App\\\\Tests\\\\Plugin\\\\PluginManagerTest\\:\\:testLoadMetadata\\(\\) has no return type specified\\.$#"
count: 1
path: Plugin/PluginManagerTest.php
-
message: "#^Method App\\\\Tests\\\\Plugin\\\\PluginManagerTest\\:\\:testUnknownRendererReturnsNull\\(\\) has no return type specified\\.$#"
count: 1
path: Plugin/PluginManagerTest.php
-
message: "#^Parameter \\#1 \\$plugin of method App\\\\Plugin\\\\PluginManager\\:\\:loadMetadata\\(\\) expects App\\\\Plugin\\\\Plugin, App\\\\Plugin\\\\Plugin\\|null given\\.$#"
count: 1
path: Plugin/PluginManagerTest.php
-
message: "#^Method App\\\\Tests\\\\Plugin\\\\PluginMetadataTest\\:\\:testEmptyObject\\(\\) has no return type specified\\.$#"
count: 1
path: Plugin/PluginMetadataTest.php
-
message: "#^Method App\\\\Tests\\\\Plugin\\\\PluginTest\\:\\:testEmptyObject\\(\\) has no return type specified\\.$#"
count: 1
path: Plugin/PluginTest.php
-
message: "#^Method App\\\\Tests\\\\Plugin\\\\PluginTest\\:\\:testGetterAndSetter\\(\\) has no return type specified\\.$#"
count: 1
path: Plugin/PluginTest.php
- -
message: "#^Method App\\\\Tests\\\\Project\\\\ProjectServiceTest\\:\\:testCannotSavePersistedProjectAsNew\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Tests\\\\Project\\\\ProjectServiceTest\\:\\:testCannotSavePersistedProjectAsNew\\(\\) has no return type specified\\.$#"
count: 1 count: 1

View File

@@ -1685,6 +1685,10 @@
<source>Custom content</source> <source>Custom content</source>
<target>Eigene Inhalte</target> <target>Eigene Inhalte</target>
</trans-unit> </trans-unit>
<trans-unit id="q1EATp1" resname="days">
<source>days</source>
<target>Tage</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -1685,6 +1685,10 @@
<source>Custom content</source> <source>Custom content</source>
<target>Custom content</target> <target>Custom content</target>
</trans-unit> </trans-unit>
<trans-unit id="q1EATp1" resname="days">
<source>days</source>
<target>Days</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -150,6 +150,10 @@
<source>You can book a maximum of {{ value }} days at once.</source> <source>You can book a maximum of {{ value }} days at once.</source>
<target>Sie können maximal {{ value }} Tage auf einmal buchen.</target> <target>Sie können maximal {{ value }} Tage auf einmal buchen.</target>
</trans-unit> </trans-unit>
<trans-unit id="BztgSSP" resname="The period of absence must not extend beyond the turn of the year.">
<source>The period of absence must not extend beyond the turn of the year.</source>
<target>Der Zeitraum der Abwesenheit darf nicht über einen Jahreswechsel hinweg gehen.</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -150,6 +150,10 @@
<source>You can book a maximum of {{ value }} days at once.</source> <source>You can book a maximum of {{ value }} days at once.</source>
<target>You can book a maximum of {{ value }} days at once.</target> <target>You can book a maximum of {{ value }} days at once.</target>
</trans-unit> </trans-unit>
<trans-unit id="BztgSSP" resname="The period of absence must not extend beyond the turn of the year.">
<source>The period of absence must not extend beyond the turn of the year.</source>
<target>The period of absence must not extend beyond the turn of the year.</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>