Compare commits

..

2 Commits

Author SHA1 Message Date
Kevin Papst
821000f143 fix test 2025-06-02 14:55:59 +02:00
Kevin Papst
e5ab1f9e78 adds a route to download generated invoice documents 2025-06-01 13:12:26 +02:00
146 changed files with 864 additions and 3682 deletions

View File

@@ -62,10 +62,13 @@ export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
plugins.push('remove_button');
}
if (node.dataset['order'] !== undefined && node.dataset['order'] === '1') {
//plugins.push('caret_position');
/*
const isOrdering = false;
if (isOrdering) {
plugins.push('caret_position');
plugins.push('drag_drop');
}
*/
let options = {
// see https://github.com/orchidjs/tom-select/issues/543#issuecomment-1664342257

View File

@@ -55,19 +55,4 @@ fieldset > .mb-3.row:last-child {
.form-fieldset-light:last-child {
border:none;
}
.dropdown-item {
.dropdown-action {
visibility: hidden;
opacity: 0.5;
}
&:hover {
.dropdown-action {
visibility: visible;
&:hover {
opacity: 1.0;
}
}
}
}

383
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -119,8 +119,8 @@ kimai:
roles:
ROLE_USER: ['view_team_member','time_team_project','create_tag','view_reporting']
ROLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member','hours_other_profile']
ROLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member','view_all_data','contract_other_profile','hours_other_profile','create_export_template']
ROLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','supervisor_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data','contract_other_profile','hours_other_profile','create_export_template']
ROLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member','view_all_data','contract_other_profile','hours_other_profile']
ROLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','supervisor_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data','contract_other_profile','hours_other_profile']
# --------------------------------------------------------------------------------

View File

@@ -206,11 +206,6 @@ services:
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\AccessToken']
App\Repository\ExportTemplateRepository:
class: App\Repository\ExportTemplateRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\ExportTemplate']
monolog.formatter.kimai:
class: Monolog\Formatter\LineFormatter
arguments:

View File

@@ -1,51 +0,0 @@
<?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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* @version 2.36.0
*/
final class Version20250608143244 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create the export template table';
}
public function up(Schema $schema): void
{
$table = $schema->createTable('kimai2_export_templates');
$table->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$table->addColumn('title', 'string', ['notnull' => true, 'length' => 100]);
$table->addColumn('renderer', 'string', ['notnull' => true, 'length' => 20]);
$table->addColumn('language', 'string', ['notnull' => false, 'length' => 6]);
$table->addColumn('columns', 'json', ['notnull' => true]);
$table->addColumn('options', 'json', ['notnull' => true]);
$table->setPrimaryKey(['id']);
$table->addUniqueIndex(['title'], 'UNIQ_2F0CA26F2B36786B');
}
public function down(Schema $schema): void
{
if ($schema->hasTable('kimai2_export_templates')) {
$schema->dropTable('kimai2_export_templates');
}
}
public function isTransactional(): bool
{
return false;
}
}

View File

@@ -2010,7 +2010,7 @@ parameters:
-
message: "#^Cannot clone mixed\\.$#"
count: 1
count: 2
path: src/Form/QuickEntryForm.php
-
@@ -2838,6 +2838,21 @@ parameters:
count: 1
path: src/Form/Type/QuickEntryTimesheetType.php
-
message: "#^Cannot access offset 'activity' on mixed\\.$#"
count: 1
path: src/Form/Type/QuickEntryWeekType.php
-
message: "#^Cannot access offset 'project' on mixed\\.$#"
count: 1
path: src/Form/Type/QuickEntryWeekType.php
-
message: "#^Cannot clone mixed\\.$#"
count: 1
path: src/Form/Type/QuickEntryWeekType.php
-
message: "#^Method App\\\\Form\\\\Type\\\\SearchTermType\\:\\:buildForm\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1

View File

@@ -4,12 +4,7 @@
backupGlobals="false"
colors="true"
bootstrap="tests/bootstrap.php"
cacheDirectory="var/cache/phpunit/"
displayDetailsOnTestsThatTriggerDeprecations="true"
displayDetailsOnTestsThatTriggerErrors="true"
displayDetailsOnTestsThatTriggerNotices="true"
displayDetailsOnTestsThatTriggerWarnings="true"
displayDetailsOnPhpunitDeprecations="true">
cacheDirectory="var/cache/phpunit/">
<php>
<ini name="error_reporting" value="-1"/>
<ini name="max_execution_time" value="-1"/>
@@ -39,15 +34,14 @@
-->
<env name="BOOTSTRAP_RESET_DATABASE" value="true"/>
</php>
<testsuites>
<testsuite name="Kimai">
<directory>tests/</directory>
</testsuite>
</testsuites>
<extensions>
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
<source>
<coverage>
<include>
<directory suffix=".php">src/</directory>
<directory suffix=".php">templates/</directory>
@@ -64,5 +58,10 @@
<directory suffix=".php">var/</directory>
<directory suffix=".php">vendor/</directory>
</exclude>
</source>
</coverage>
<extensions>
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
</phpunit>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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,10 +3,10 @@
"app": {
"js": [
"/build/runtime.6c399d29.js",
"/build/app.d4ea12cd.js"
"/build/app.6bb53337.js"
],
"css": [
"/build/app.6e7b2701.css"
"/build/app.0416ea92.css"
]
},
"app-rtl": {
@@ -15,7 +15,7 @@
"/build/app-rtl.7a875ca7.js"
],
"css": [
"/build/app-rtl.bfe8d111.css"
"/build/app-rtl.0848906b.css"
]
},
"export-pdf": {
@@ -72,10 +72,10 @@
},
"integrity": {
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
"/build/app.d4ea12cd.js": "sha384-vv5bzRvK0YN29MXeYe0uKv/LdRlwzMBu+RMvBXpNGu6btx1WxFmDOF6QM+CDoDPB",
"/build/app.6e7b2701.css": "sha384-rrrlE5ISpDF53P81i2ce7AKONdm+b+I3mFacFPiJUohuMbMuHnUINac7YDkKD0/p",
"/build/app.6bb53337.js": "sha384-ZcwEys2VjhW4jwH/pU+/W06vG7rBounyirdkC+TCA4PyMn2QsENR6FIcNoJEpF5X",
"/build/app.0416ea92.css": "sha384-JAIO6+B/vmV8IlpsmQ+zkfO4JsvdKTMsZZaDg6EMlZxkMZ5K3cpEK0mo24D/Wgkh",
"/build/app-rtl.7a875ca7.js": "sha384-T7gLI61h9dGeMgzo63vKu4GiDOeLPct9zSUHrceNbhSwIdUmSSNoZ1+d7fKhJJ4/",
"/build/app-rtl.bfe8d111.css": "sha384-Ty9pGDTW2pxsBS7vfmjpTNFTJS/9jM5V4QfVgLXjIXuuJo5vi8x4sKKBTlwnZuqo",
"/build/app-rtl.0848906b.css": "sha384-O26Xw3P/NSea5iT6lt5v2RaZ6+jP06hf9vYD2pUJbkFSCLGU1iGXnobP25dWjUs/",
"/build/export-pdf.395749ab.js": "sha384-3Hjvmu4FC/0dhHnR8kyRBU7k2xMNy1lxBpGgOkrw8PxXnwyQDM8/5bQmkJbjVT1+",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.773af9c4.js": "sha384-QmMqYJ0RP2WOYU7D4lLzKCBFDL6vCvZHQc7wf8K8N+hdCuTJwq1P0GuY81f30/SY",

View File

@@ -1,7 +1,7 @@
{
"build/app.css": "/build/app.6e7b2701.css",
"build/app.js": "/build/app.d4ea12cd.js",
"build/app-rtl.css": "/build/app-rtl.bfe8d111.css",
"build/app.css": "/build/app.0416ea92.css",
"build/app.js": "/build/app.6bb53337.js",
"build/app-rtl.css": "/build/app-rtl.0848906b.css",
"build/app-rtl.js": "/build/app-rtl.7a875ca7.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.395749ab.js",

View File

@@ -1,47 +0,0 @@
<?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\API;
use App\Entity\ExportTemplate;
use App\Repository\ExportTemplateRepository;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/export')]
#[IsGranted('API')]
#[OA\Tag(name: 'Export')]
final class ExportController extends BaseApiController
{
public function __construct(
private readonly ViewHandlerInterface $viewHandler,
private readonly ExportTemplateRepository $repository,
) {
}
/**
* Delete export template
*/
#[IsGranted('create_export_template')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete export template')], x: ['internal' => true])]
#[OA\Parameter(name: 'id', description: 'Export template ID to delete', in: 'path', required: true)]
#[Route(path: '/{id}', name: 'delete_export_template', requirements: ['id' => '\d+'], methods: ['DELETE'])]
public function deleteTemplate(ExportTemplate $exportTemplate): Response
{
$this->repository->removeExportTemplate($exportTemplate);
$view = new View(null, Response::HTTP_NO_CONTENT);
return $this->viewHandler->handle($view);
}
}

View File

@@ -10,6 +10,7 @@
namespace App\API;
use App\Entity\Invoice;
use App\Invoice\ServiceInvoice;
use App\Repository\CustomerRepository;
use App\Repository\InvoiceRepository;
use App\Repository\Query\InvoiceArchiveQuery;
@@ -18,6 +19,7 @@ use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -25,6 +27,7 @@ use Symfony\Component\Validator\Constraints;
#[Route(path: '/invoices')]
#[IsGranted('API')]
#[IsGranted('view_invoice')]
#[OA\Tag(name: 'Invoice')]
final class InvoiceController extends BaseApiController
{
@@ -40,7 +43,6 @@ final class InvoiceController extends BaseApiController
/**
* Fetch invoices
*/
#[IsGranted('view_invoice')]
#[OA\Response(response: 200, description: 'Returns a collection of invoices', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/InvoiceCollection')))]
#[Route(methods: ['GET'], path: '', name: 'get_invoices')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
@@ -89,7 +91,6 @@ final class InvoiceController extends BaseApiController
/**
* Fetch invoice
*/
#[IsGranted('view_invoice')]
#[OA\Response(response: 200, description: 'Returns one invoice', content: new OA\JsonContent(ref: '#/components/schemas/Invoice'))]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_invoice', requirements: ['id' => '\d+'])]
public function getAction(Invoice $invoice): Response
@@ -99,4 +100,28 @@ final class InvoiceController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Download invoice
*
* The returned `content-type` depends on the type of invoice document.
* Could be anything from `application/pdf` to `application/vnd.openxmlformats-officedocument.wordprocessingml.document`.
*
* Use the response header to detect the filename, e.g. `content-disposition: attachment; filename=2025-001-acmme_company.pdf`.
*/
#[OA\Response(response: 200, description: 'Returns the (binary) invoice document', content: new OA\MediaType(mediaType: 'application/octet-stream', schema: new OA\Schema(type: 'string', format: 'binary')))]
#[Route(path: '/{id}/download', requirements: ['id' => '\d+'], methods: ['GET'])]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
public function download(Invoice $invoice, ServiceInvoice $service): Response
{
$file = $service->getInvoiceFile($invoice);
if (null === $file) {
throw $this->createNotFoundException(
\sprintf('Invoice file "%s" could not be found for invoice ID "%s"', $invoice->getInvoiceFilename(), $invoice->getId())
);
}
return $this->file($file->getRealPath(), $file->getBasename());
}
}

View File

@@ -63,6 +63,24 @@ final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerI
}
public function serializeValidationExceptionToJson(JsonSerializationVisitor $visitor, ValidationFailedException $exception, array $type, Context $context)
{
$errors = [];
/** @var ConstraintViolationInterface $error */
foreach (iterator_to_array($exception->getViolations()) as $error) {
$errors[$error->getPropertyPath()]['errors'][] = $this->getErrorMessage($error);
}
return [
'code' => '400',
'message' => $this->translator->trans($exception->getMessage(), [], 'validators'),
'errors' => [
'children' => $errors
],
];
}
private function getErrorMessage(ConstraintViolationInterface $error): string
{
$locale = \Locale::getDefault();
/** @var User $user */
@@ -72,24 +90,6 @@ final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerI
$locale = $user->getLanguage();
}
$errors = [];
/** @var ConstraintViolationInterface $error */
foreach (iterator_to_array($exception->getViolations()) as $error) {
$errors[$error->getPropertyPath()]['errors'][] = $this->getErrorMessage($error, $locale);
}
return [
'code' => '400',
'message' => $this->translator->trans($exception->getMessage(), [], 'validators', $locale),
'errors' => [
'children' => $errors
],
];
}
private function getErrorMessage(ConstraintViolationInterface $error, string $locale): string
{
if (null !== $error->getPlural()) {
return $this->translator->trans($error->getMessageTemplate(), ['%count%' => $error->getPlural()] + $error->getParameters(), 'validators', $locale);
}

View File

@@ -315,7 +315,7 @@ final class TimesheetController extends BaseApiController
if ($form->isValid()) {
try {
$this->service->saveTimesheet($timesheet);
$this->service->saveNewTimesheet($timesheet);
$view = new View($timesheet, 200);
@@ -372,7 +372,7 @@ final class TimesheetController extends BaseApiController
return $this->viewHandler->handle($view);
}
$this->service->saveTimesheet($timesheet);
$this->service->updateTimesheet($timesheet);
$view = new View($timesheet, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -547,7 +547,7 @@ final class TimesheetController extends BaseApiController
$copyTimesheet = clone $timesheet;
$this->dispatcher->dispatch(new TimesheetDuplicatePreEvent($copyTimesheet, $timesheet));
$this->service->saveTimesheet($copyTimesheet);
$this->service->saveNewTimesheet($copyTimesheet);
$this->dispatcher->dispatch(new TimesheetDuplicatePostEvent($copyTimesheet, $timesheet));
$view = new View($copyTimesheet, 200);
@@ -571,7 +571,7 @@ final class TimesheetController extends BaseApiController
$timesheet->setExported(!$timesheet->isExported());
$this->service->saveTimesheet($timesheet);
$this->service->updateTimesheet($timesheet);
$view = new View($timesheet, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -601,7 +601,7 @@ final class TimesheetController extends BaseApiController
$meta->setValue($paramFetcher->get('value'));
$this->service->saveTimesheet($timesheet);
$this->service->updateTimesheet($timesheet);
$view = new View($timesheet, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);

View File

@@ -97,7 +97,7 @@ class ActivityService
$errors = $this->validator->validate($activity, null, $groups);
if ($errors->count() > 0) {
throw new ValidationFailedException($errors);
throw new ValidationFailedException($errors, 'Validation Failed');
}
}

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.36.0';
public const VERSION = '2.35.1';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 23600;
public const VERSION_ID = 23501;
/**
* The software name
*/

View File

@@ -10,13 +10,10 @@
namespace App\Controller;
use App\Entity\ExportableItem;
use App\Entity\ExportTemplate;
use App\Export\Base\DispositionInlineInterface;
use App\Export\ServiceExport;
use App\Export\TooManyItemsExportException;
use App\Form\ExportTemplateSpreadsheetForm;
use App\Form\Toolbar\ExportToolbarForm;
use App\Repository\ExportTemplateRepository;
use App\Repository\Query\ExportQuery;
use App\Utils\PageSetup;
use Symfony\Component\Form\FormInterface;
@@ -32,7 +29,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('create_export')]
final class ExportController extends AbstractController
{
public function __construct(private readonly ServiceExport $export)
public function __construct(private ServiceExport $export)
{
}
@@ -82,23 +79,6 @@ final class ExportController extends AbstractController
$page = new PageSetup('export');
$page->setHelp('export.html');
$buttons = [];
foreach ($this->export->getRenderer() as $renderer) {
$class = \get_class($renderer);
$pos = strrpos($class, '\\');
if ($pos !== false) {
$class = substr($class, $pos + 1);
}
$class = strtolower(str_replace('Renderer', '', $class));
$buttons[$class][$renderer->getId()] = $renderer->getTitle();
}
if ($this->isGranted('view_other_timesheet')) {
$showRates = $this->isGranted('view_rate_other_timesheet');
} else {
$showRates = $this->isGranted('view_rate_own_timesheet');
}
return $this->render('export/index.html.twig', [
'page_setup' => $page,
'too_many' => $tooManyResults,
@@ -106,10 +86,10 @@ final class ExportController extends AbstractController
'query' => $query,
'entries' => $entries,
'form' => $form->createView(),
'buttons' => $buttons,
'renderer' => $this->export->getRenderer(),
'preview_limit' => $maxItemsPreview,
'preview_show' => $showPreview,
'show_rates' => $showRates,
'decimal' => $this->getUser()->isExportDecimal(),
]);
}
@@ -161,6 +141,7 @@ final class ExportController extends AbstractController
}
/**
* @param ExportQuery $query
* @return ExportableItem[]
* @throws TooManyItemsExportException
*/
@@ -177,6 +158,8 @@ final class ExportController extends AbstractController
}
/**
* @param ExportQuery $query
* @param string $method
* @return FormInterface<ExportQuery>
*/
private function getToolbarForm(ExportQuery $query, string $method): FormInterface
@@ -192,40 +175,4 @@ final class ExportController extends AbstractController
]
]);
}
#[Route(path: '/template-create', name: 'export_template_create', methods: ['GET', 'POST'])]
public function createExportTemplate(Request $request, ExportTemplateRepository $repository): Response
{
return $this->editExportForm($this->generateUrl('export_template_create'), $request, $repository, new ExportTemplate());
}
#[Route(path: '/template-edit/{exportTemplate}', name: 'export_template_edit', methods: ['GET', 'POST'])]
public function editExportTemplate(ExportTemplate $exportTemplate, Request $request, ExportTemplateRepository $repository): Response
{
return $this->editExportForm($this->generateUrl('export_template_edit', ['exportTemplate' => $exportTemplate->getId()]), $request, $repository, $exportTemplate);
}
private function editExportForm(string $url, Request $request, ExportTemplateRepository $repository, ExportTemplate $exportTemplate): Response
{
$form = $this->createForm(ExportTemplateSpreadsheetForm::class, $exportTemplate, [
'action' => $url,
'method' => 'POST',
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$repository->saveExportTemplate($exportTemplate);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('export');
} catch (\Exception $ex) {
$this->handleFormUpdateException($ex, $form);
}
}
return $this->render('export/template.html.twig', [
'form' => $form->createView()
]);
}
}

View File

@@ -20,7 +20,6 @@ use App\Repository\TimesheetRepository;
use App\Timesheet\FavoriteRecordService;
use App\Timesheet\TimesheetService;
use App\Utils\PageSetup;
use App\WorkingTime\WorkingTimeService;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -39,7 +38,6 @@ final class QuickEntryController extends AbstractController
private readonly TimesheetRepository $repository,
private readonly FavoriteRecordService $favoriteRecordService,
private readonly EventDispatcherInterface $dispatcher,
private readonly WorkingTimeService $workingTimeService,
)
{
}
@@ -116,46 +114,37 @@ final class QuickEntryController extends AbstractController
ksort($rows);
// this should also check via lock service
$locked = $this->workingTimeService->isApproved($user, $endWeek);
// attach recent activities
$amount = $this->configuration->getQuickEntriesRecentAmount();
if ($amount > 0) {
$takeOverWeeks = $this->configuration->find('quick_entry.recent_activity_weeks');
$startFrom = null;
if ($takeOverWeeks !== null && \intval($takeOverWeeks) > 0) {
$startFrom = clone $startWeek;
$startFrom->modify(\sprintf('-%s weeks', $takeOverWeeks));
}
if (!$locked) {
// attach recent activities
$amount = $this->configuration->getQuickEntriesRecentAmount();
if ($amount > 0) {
$takeOverWeeks = $this->configuration->find('quick_entry.recent_activity_weeks');
$startFrom = null;
if ($takeOverWeeks !== null && \intval($takeOverWeeks) > 0) {
$startFrom = clone $startWeek;
$startFrom->modify(\sprintf('-%s weeks', $takeOverWeeks));
$favorites = $this->favoriteRecordService->favoriteEntries($user, $amount);
foreach ($favorites as $favorite) {
$timesheet = $favorite->getTimesheet();
if ($startFrom !== null && !$favorite->isFavorite() && $startFrom > $timesheet->getBegin()) {
continue;
}
$favorites = $this->favoriteRecordService->favoriteEntries($user, $amount);
foreach ($favorites as $favorite) {
$timesheet = $favorite->getTimesheet();
if ($startFrom !== null && !$favorite->isFavorite() && $startFrom > $timesheet->getBegin()) {
continue;
}
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
if (\array_key_exists($id, $rows)) {
continue;
}
// edge case: a project that starts and ends between the start and end date allows to select it from the dropdown,
// but it is better to hide a row than displaying already ended projects
if ($timesheet->getProject() !== null && (!$timesheet->getProject()->isVisibleAtDate($startWeek) && !$timesheet->getProject()->isVisibleAtDate($endWeek))) {
continue;
}
// make sure no invisible entries are included
if (!$this->isGranted('start', $timesheet)) {
continue;
}
$rows[$id] = [
'days' => $week,
'project' => $timesheet->getProject(),
'activity' => $timesheet->getActivity()
];
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
if (\array_key_exists($id, $rows)) {
continue;
}
// there is an edge case possible with a project that starts and ends between the start and end date
// user could still select it from the dropdown, but it is better to hide a row than displaying already ended projects
if ($timesheet->getProject() !== null && (!$timesheet->getProject()->isVisibleAtDate($startWeek) && !$timesheet->getProject()->isVisibleAtDate($endWeek))) {
continue;
}
$rows[$id] = [
'days' => $week,
'project' => $timesheet->getProject(),
'activity' => $timesheet->getActivity()
];
}
}
@@ -176,7 +165,6 @@ final class QuickEntryController extends AbstractController
if (!\array_key_exists('entry', $day)) {
// fill all rows and columns to make sure we do not have missing records
$tmp = $this->timesheetService->createNewTimesheet($user);
$tmp->setDuration(null);
$tmp->setProject($row['project']);
$tmp->setActivity($row['activity']);
$newTime = \DateTime::createFromInterface($day['day']);
@@ -197,7 +185,6 @@ final class QuickEntryController extends AbstractController
$empty->markAsPrototype();
foreach ($week as $dayId => $day) {
$tmp = $this->timesheetService->createNewTimesheet($user);
$tmp->setDuration(null);
$newTime = \DateTime::createFromInterface($day['day']);
$newTime = $newTime->setTime($defaultHour, $defaultMinute, 0, 0);
$tmp->setBegin($newTime);
@@ -207,20 +194,18 @@ final class QuickEntryController extends AbstractController
// add empty rows for simpler starting
$minRows = \intval($this->configuration->find('quick_entry.minimum_rows'));
if (!$locked && $formModel->countRows() < $minRows) {
if ($formModel->countRows() < $minRows) {
$newRows = $minRows - $formModel->countRows();
for ($a = 0; $a < $newRows; $a++) {
$model = $formModel->addRow($user);
foreach ($week as $dayId => $day) {
$tmp = $this->timesheetService->createNewTimesheet($user);
$tmp->setDuration(null);
$newTime = \DateTime::createFromInterface($day['day']);
$newTime = $newTime->setTime($defaultHour, $defaultMinute, 0, 0);
$tmp->setBegin($newTime);
$this->timesheetService->prepareNewTimesheet($tmp);
$model->addTimesheet($tmp);
}
$model->setMetaFields($metaFields);
}
}
@@ -244,14 +229,12 @@ final class QuickEntryController extends AbstractController
foreach ($tmpModel->getTimesheets() as $timesheet) {
if ($timesheet->getId() !== null) {
$duration = $timesheet->getDuration(false);
// running timesheets also have a empty duration.
// we distinguish them from temporary ones, to make sure they will not be deleted
// previously running timesheets were deleted, which was wrong
// so now we distinguish between running timesheets and null duration
if ($timesheet->isRunning()) {
$saveTimesheets[] = $timesheet;
} elseif ($duration === null) {
if ($this->isGranted('delete', $timesheet)) {
$deleteTimesheets[] = $timesheet;
}
$deleteTimesheets[] = $timesheet;
} else {
$saveTimesheets[] = $timesheet;
}
@@ -265,22 +248,14 @@ final class QuickEntryController extends AbstractController
try {
$saved = false;
if (\count($deleteTimesheets) > 0) {
if (\count($deleteTimesheets) > 0 && $this->isGranted('delete_own_timesheet')) {
$this->timesheetService->deleteMultipleTimesheets($deleteTimesheets);
$saved = true;
}
if (\count($saveTimesheets) > 0) {
$saveMe = [];
foreach ($saveTimesheets as $timesheet) {
if ($timesheet->getId() === null || $this->isGranted('edit', $timesheet)) {
$saveMe[] = $timesheet;
}
}
if (\count($saveMe) > 0) {
$this->timesheetService->updateMultipleTimesheets($saveMe);
$saved = true;
}
$this->timesheetService->updateMultipleTimesheets($saveTimesheets);
$saved = true;
}
if ($saved) {
@@ -303,7 +278,6 @@ final class QuickEntryController extends AbstractController
'days' => $week,
'form' => $form->createView(),
'metaColumns' => $metaFields,
'locked' => $locked,
]);
}
}

View File

@@ -152,7 +152,7 @@ abstract class TimesheetAbstractController extends AbstractController
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->service->saveTimesheet($entry);
$this->service->updateTimesheet($entry);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute($this->getTimesheetRoute());
@@ -184,7 +184,7 @@ abstract class TimesheetAbstractController extends AbstractController
if ($createForm->isSubmitted() && $createForm->isValid()) {
try {
$this->service->saveTimesheet($entry);
$this->service->saveNewTimesheet($entry);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute($this->getTimesheetRoute());
@@ -216,7 +216,7 @@ abstract class TimesheetAbstractController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->dispatcher->dispatch(new TimesheetDuplicatePreEvent($copyTimesheet, $timesheet));
$this->service->saveTimesheet($copyTimesheet);
$this->service->saveNewTimesheet($copyTimesheet);
$this->dispatcher->dispatch(new TimesheetDuplicatePostEvent($copyTimesheet, $timesheet));
$this->flashSuccess('action.update.success');

View File

@@ -126,7 +126,7 @@ final class TimesheetTeamController extends TimesheetAbstractController
}
foreach ($newTimesheets as $newTimesheet) {
$this->service->saveTimesheet($newTimesheet);
$this->service->saveNewTimesheet($newTimesheet);
}
$this->flashSuccess('action.update.success');

View File

@@ -101,7 +101,7 @@ final class CustomerService
$errors = $this->validator->validate($customer, null, $groups);
if ($errors->count() > 0) {
throw new ValidationFailedException($errors);
throw new ValidationFailedException($errors, 'Validation Failed');
}
}

View File

@@ -1,136 +0,0 @@
<?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\Entity;
use App\Repository\ExportTemplateRepository;
use App\Validator\Constraints\ExportRenderer;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_export_templates')]
#[ORM\UniqueConstraint(columns: ['title'])]
#[ORM\Entity(repositoryClass: ExportTemplateRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('title')]
class ExportTemplate
{
#[ORM\Column(name: 'id', type: Types::INTEGER)]
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
private ?int $id = null;
#[ORM\Column(name: 'title', type: Types::STRING, length: 100, nullable: false)]
#[Assert\NotBlank]
#[Assert\Length(min: 1, max: 100)]
private ?string $title = null;
#[ORM\Column(name: 'renderer', type: Types::STRING, length: 20, nullable: false)]
#[ExportRenderer]
#[Assert\NotBlank]
private string $renderer = 'csv';
/**
* Used for header column translation.
*/
#[ORM\Column(name: 'language', type: Types::STRING, length: 6, nullable: true)]
#[Assert\Locale]
private ?string $language = null;
/**
* @var array<int, string>
*/
#[ORM\Column(name: 'columns', type: Types::JSON, nullable: false)]
#[Assert\Count(min: 1)]
#[Assert\NotNull]
private array $columns = [];
/**
* @var array<string, mixed>
*/
#[ORM\Column(name: 'options', type: Types::JSON, nullable: false)]
#[Assert\NotNull]
private array $options = [];
public function getId(): ?int
{
return $this->id;
}
public function setTitle(?string $title): void
{
$this->title = $title;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setRenderer(string $renderer): void
{
$this->renderer = $renderer;
}
public function getRenderer(): string
{
return $this->renderer;
}
public function getLanguage(): ?string
{
return $this->language;
}
public function setLanguage(?string $language): void
{
$this->language = $language;
}
/**
* @return array<int, string>
*/
public function getColumns(): array
{
return $this->columns;
}
/**
* @param array<int, string>|null $columns
*/
public function setColumns(?array $columns): void
{
$this->columns = $columns ?? [];
}
/**
* @return array<string, mixed>
*/
public function getOptions(): array
{
return $this->options;
}
/**
* @param array<string, mixed> $options
*/
public function setOptions(?array $options): void
{
$this->options = $options ?? [];
}
public function __toString(): string
{
return $this->title ?? 'New';
}
public function __clone()
{
if ($this->id) {
$this->id = null;
}
}
}

View File

@@ -126,6 +126,8 @@ class Invoice implements EntityWithMetaFields
#[ORM\Column(name: 'invoice_filename', type: Types::STRING, length: 150, nullable: false)]
#[Assert\NotNull]
#[Assert\Length(min: 1, max: 150)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'file', type: 'string')]
private ?string $invoiceFilename = null;
private bool $localized = false;

View File

@@ -131,7 +131,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
* @var Collection<UserPreference>|null
*/
#[ORM\OneToMany(mappedBy: 'user', targetEntity: UserPreference::class, cascade: ['persist'])]
private ?Collection $preferences = null;
private ?Collection $preferences;
/**
* List of all team memberships.
*
@@ -504,6 +504,10 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return $allowNull ? $value : ($value ?? $default);
}
/**
* @param UserPreference $preference
* @return User
*/
public function addPreference(UserPreference $preference): User
{
if (null === $this->preferences) {
@@ -582,7 +586,9 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
}
/**
* Checks if the user is a member of any team.
* Checks if the user is member of any team.
*
* @return bool
*/
public function hasTeamAssignment(): bool
{
@@ -593,6 +599,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
* Checks is the user is teamlead in any of the assigned teams.
*
* @see User::hasTeamleadRole()
* @return bool
*/
public function isTeamlead(): bool
{
@@ -607,6 +614,9 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
/**
* Checks if the given user is a team member.
*
* @param User $user
* @return bool
*/
public function hasTeamMember(User $user): bool
{
@@ -670,6 +680,8 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
/**
* Required in the User profile screen to edit his teams.
*
* @param Team $team
*/
public function addTeam(Team $team): void
{
@@ -688,6 +700,8 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
/**
* Required in the User profile screen to edit his teams.
*
* @param Team $team
*/
public function removeTeam(Team $team): void
{

View File

@@ -26,10 +26,6 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
{
use ExportTrait;
private string $id = 'csv';
private string $title = 'default';
private ?string $locale = null;
public function __construct(
private readonly SpreadsheetRenderer $spreadsheetRenderer,
private readonly TranslatorInterface $translator
@@ -37,29 +33,14 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
{
}
public function setId(string $id): void
{
$this->id = $id;
}
public function getId(): string
{
return $this->id;
}
public function setTitle(string $title): void
{
$this->title = $title;
}
public function setLocale(?string $locale): void
{
$this->locale = $locale;
return 'csv';
}
public function getTitle(): string
{
return $this->title;
return 'csv';
}
/**
@@ -77,7 +58,7 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
/**
* @param ExportableItem[] $exportItems
*/
private function renderFile(array $exportItems, TimesheetQuery $query): \SplFileInfo
public function renderFile(array $exportItems, TimesheetQuery $query): \SplFileInfo
{
$filename = @tempnam(sys_get_temp_dir(), 'kimai-export-csv');
if (false === $filename) {
@@ -87,12 +68,11 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
$options = new Options();
$options->SHOULD_ADD_BOM = false;
$spreadsheet = new SpoutSpreadsheet(new Writer($options), $this->translator, $this->locale);
$spreadsheet = new SpoutSpreadsheet(new Writer($options), $this->translator);
$spreadsheet->open($filename);
$this->spreadsheetRenderer->registerFormatter('date', new DateStringFormatter());
$this->spreadsheetRenderer->registerFormatter('duration', new DurationPlainFormatter(false));
$this->spreadsheetRenderer->registerFormatter('duration_seconds', new DurationPlainFormatter(true));
$this->spreadsheetRenderer->registerFormatter('duration', new DurationPlainFormatter());
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);
return new \SplFileInfo($filename);

View File

@@ -68,9 +68,9 @@ class HtmlRenderer implements ExportRendererInterface
}
/**
* @param ExportableItem[] $exportItems
* @param ExportableItem[] $timesheets
*/
public function render(array $exportItems, TimesheetQuery $query): Response
public function render(array $timesheets, TimesheetQuery $query): Response
{
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT));
@@ -81,7 +81,7 @@ class HtmlRenderer implements ExportRendererInterface
$this->dispatcher->dispatch($event);
$userPreferences = $event->getPreferences();
$summary = $this->calculateSummary($exportItems);
$summary = $this->calculateSummary($timesheets);
// enable basic security measures
$sandbox = new SandboxExtension(new ExportPolicy());
@@ -89,11 +89,11 @@ class HtmlRenderer implements ExportRendererInterface
$this->twig->addExtension($sandbox);
$content = $this->twig->render($this->getTemplate(), array_merge([
'entries' => $exportItems,
'entries' => $timesheets,
'query' => $query,
'summaries' => $summary,
'budgets' => $this->calculateProjectBudget($exportItems, $query, $this->projectStatisticService),
'activity_budgets' => $this->calculateActivityBudget($exportItems, $query, $this->activityStatisticService),
'budgets' => $this->calculateProjectBudget($timesheets, $query, $this->projectStatisticService),
'activity_budgets' => $this->calculateActivityBudget($timesheets, $query, $this->activityStatisticService),
'timesheetMetaFields' => $timesheetMetaFields,
'customerMetaFields' => $customerMetaFields,
'projectMetaFields' => $projectMetaFields,

View File

@@ -29,7 +29,6 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
use PDFRendererTrait;
private string $id = 'pdf';
private string $title = 'pdf';
private string $template = 'default.pdf.twig';
private array $pdfOptions = [];
@@ -43,7 +42,7 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
public function getTitle(): string
{
return $this->title;
return 'pdf';
}
protected function getTemplate(): string
@@ -76,18 +75,20 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
}
/**
* @param ExportableItem[] $exportItems
* @param ExportableItem[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
public function render(array $exportItems, TimesheetQuery $query): Response
public function render(array $timesheets, TimesheetQuery $query): Response
{
$filename = new ExportFilename($query);
$context = new PdfContext();
$context->setOption('filename', $filename->getFilename());
$summary = $this->calculateSummary($exportItems);
$summary = $this->calculateSummary($timesheets);
// enable basic security measures
$sandbox = new SandboxExtension(new ExportPolicy());
@@ -95,10 +96,10 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
$this->twig->addExtension($sandbox);
$content = $this->twig->render($this->getTemplate(), array_merge([
'entries' => $exportItems,
'entries' => $timesheets,
'query' => $query,
'summaries' => $summary,
'budgets' => $this->calculateProjectBudget($exportItems, $query, $this->projectStatisticService),
'budgets' => $this->calculateProjectBudget($timesheets, $query, $this->projectStatisticService),
'decimal' => false,
'pdfContext' => $context
], $this->getOptions($query)));
@@ -110,19 +111,18 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
return $this->createPdfResponse($content, $context);
}
public function setTemplate(string $filename): void
public function setTemplate(string $filename): PDFRenderer
{
$this->template = $filename;
return $this;
}
public function setId(string $id): void
public function setId(string $id): PDFRenderer
{
$this->id = $id;
}
public function setTitle(string $title): void
{
$this->title = $title;
return $this;
}
public function getId(): string

View File

@@ -31,14 +31,11 @@ use App\Export\Package\CellFormatter\TimeFormatter;
use App\Export\Package\Column;
use App\Export\Package\ColumnWidth;
use App\Export\Package\SpreadsheetPackage;
use App\Export\Template;
use App\Export\TemplateInterface;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TimesheetQuery;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
@@ -51,32 +48,12 @@ final class SpreadsheetRenderer
*/
private array $formatter = [];
private ?TemplateInterface $template = null;
public function __construct(
private readonly EventDispatcherInterface $eventDispatcher,
private readonly Security $voter,
private readonly ?LoggerInterface $logger = null,
protected EventDispatcherInterface $dispatcher,
protected Security $voter
) {
}
public function setTemplate(?TemplateInterface $template): void
{
$this->template = $template;
}
private function getTemplate(): TemplateInterface
{
if ($this->template === null) {
$template = new Template('default', 'default');
$template->setColumns($this->getDefaultColumns());
return $template;
}
return $this->template;
}
private function isRenderRate(TimesheetQuery $query): bool
{
if ($this->voter->getUser() === null) {
@@ -96,7 +73,7 @@ final class SpreadsheetRenderer
*/
private function findMetaColumns(MetaDisplayEventInterface $event): array
{
$this->eventDispatcher->dispatch($event);
$this->dispatcher->dispatch($event);
return $event->getFields();
}
@@ -121,12 +98,7 @@ final class SpreadsheetRenderer
if ($currentRow > 1) {
$totalColumns = ['duration', 'rate', 'internalRate'];
// that should be enough for the near future: the number of array entries must cover the max number of columns
$columnNames = [
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
'AA', 'AB', 'AC', 'AD', 'AE', 'AF', 'AG', 'AH', 'AI', 'AJ', 'AK', 'AL', 'AM', 'AN', 'AO', 'AP', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AV', 'AW', 'AX', 'AY', 'AZ',
'BA', 'BB', 'BC', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BK', 'BL', 'BM', 'BN', 'BO', 'BP', 'BQ', 'BR', 'BS', 'BT', 'BU', 'BV', 'BW', 'BX', 'BY', 'BZ',
];
$columnNames = range('A', 'Z');
$totalRow = [];
$totalColumn = 1;
foreach ($columns as $column) {
@@ -159,9 +131,8 @@ final class SpreadsheetRenderer
return match ($name) {
'date' => new DateFormatter(),
'time' => new TimeFormatter(),
'duration' => new DurationFormatter('[hh]:mm'),
'duration' => new DurationFormatter(),
'duration_decimal' => new DurationDecimalFormatter(),
'duration_seconds' => new DurationFormatter('[hh]:mm:ss'),
default => new DefaultFormatter()
};
}
@@ -173,214 +144,98 @@ final class SpreadsheetRenderer
{
$showRates = $this->isRenderRate($query);
$timesheetMeta = [];
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$timesheetMeta['timesheet.meta.' . $metaField->getName()] = (new Column('timesheet.meta.' . $metaField->getName(), $this->getFormatter('default')))
->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getMetaField($metaField->getName())?->getValue();
});
}
}
$customerMeta = [];
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$customerMeta['customer.meta.' . $metaField->getName()] = (new Column('customer.meta.' . $metaField->getName(), $this->getFormatter('default')))
->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getProject()?->getCustomer()?->getMetaField($metaField->getName())?->getValue();
});
}
}
$projectMeta = [];
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$projectMeta['project.meta.' . $metaField->getName()] = (new Column('project.meta.' . $metaField->getName(), $this->getFormatter('default')))
->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getProject()?->getMetaField($metaField->getName())?->getValue();
});
}
}
$activityMeta = [];
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent($query->copyTo(new ActivityQuery()), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$activityMeta['activity.meta.' . $metaField->getName()] = (new Column('activity.meta.' . $metaField->getName(), $this->getFormatter('default')))
->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getActivity()?->getMetaField($metaField->getName())?->getValue();
});
}
}
$userMeta = [];
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
$this->eventDispatcher->dispatch($event);
foreach ($event->getPreferences() as $metaField) {
if ($metaField->getName() !== null) {
$userMeta['user.meta.' . $metaField->getName()] = (new Column('user.meta.' . $metaField->getName(), $this->getFormatter('default')))
->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getUser()?->getPreference($metaField->getName())?->getValue();
});
}
}
$template = $this->getTemplate();
$columns = [];
$rateColumns = ['currency', 'rate', 'internal_rate', 'hourly_rate', 'fixed_rate'];
foreach ($template->getColumns() as $column) {
if ($column === 'date') {
$columns[] = (new Column('date', $this->getFormatter('date')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin());
} elseif ($column === 'begin') {
$columns[] = (new Column('begin', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin())->withColumnWidth(ColumnWidth::SMALL);
} elseif ($column === 'end') {
$columns[] = (new Column('end', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getEnd())->withColumnWidth(ColumnWidth::SMALL);
} elseif ($column === 'duration') {
$columns[] = (new Column('duration', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
} elseif ($column === 'duration_decimal') {
$columns[] = (new Column('duration', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
} elseif ($column === 'duration_seconds') {
$columns[] = (new Column('duration', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
} elseif ($column === 'currency' && $showRates) {
$columns[] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL);
} elseif ($column === 'rate' && $showRates) {
$columns[] = (new Column('rate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getRate());
} elseif ($column === 'internal_rate' && $showRates) {
$columns[] = (new Column('internalRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getInternalRate());
} elseif ($column === 'hourly_rate' && $showRates) {
$columns[] = (new Column('hourlyRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getHourlyRate());
} elseif ($column === 'fixed_rate' && $showRates) {
$columns[] = (new Column('fixedRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getFixedRate());
} elseif ($column === 'user.alias') {
$columns[] = (new Column('alias', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
} elseif ($column === 'user.name') {
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getUserIdentifier())->withColumnWidth(ColumnWidth::MEDIUM);
} elseif ($column === 'user.account_number') {
$columns[] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber());
} elseif ($column === 'customer.name') {
$columns[] = (new Column('customer', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
} elseif ($column === 'project.name') {
$columns[] = (new Column('project', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
} elseif ($column === 'activity.name') {
$columns[] = (new Column('activity', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
} elseif ($column === 'description') {
$columns[] = (new Column('description', new TextFormatter(true)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDescription())->withColumnWidth(ColumnWidth::LARGE);
} elseif ($column === 'exported') {
$columns[] = (new Column('exported', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isExported());
} elseif ($column === 'billable') {
$columns[] = (new Column('billable', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isBillable())->withColumnWidth(ColumnWidth::SMALL);
} elseif ($column === 'tags') {
$columns[] = (new Column('tags', new ArrayFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getTagsAsArray());
} elseif ($column === 'type') {
$columns[] = (new Column('type', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getType());
} elseif ($column === 'category') {
$columns[] = (new Column('category', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getCategory());
} elseif ($column === 'customer.number') {
$columns[] = (new Column('number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getNumber());
} elseif ($column === 'project.number') {
$columns[] = (new Column('project_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getNumber());
} elseif ($column === 'activity.number') {
$columns[] = (new Column('activity_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getNumber());
} elseif ($column === 'customer.vat_id') {
$columns[] = (new Column('vat_id', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getVatId());
} elseif ($column === 'project.order_number') {
$columns[] = (new Column('orderNumber', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getOrderNumber());
} elseif (str_starts_with($column, 'timesheet.meta.') && \array_key_exists($column, $timesheetMeta)) {
$columns[] = $timesheetMeta[$column];
} elseif (str_starts_with($column, 'customer.meta.') && \array_key_exists($column, $customerMeta)) {
$columns[] = $customerMeta[$column];
} elseif (str_starts_with($column, 'project.meta.') && \array_key_exists($column, $projectMeta)) {
$columns[] = $projectMeta[$column];
} elseif (str_starts_with($column, 'activity.meta.') && \array_key_exists($column, $activityMeta)) {
$columns[] = $activityMeta[$column];
} elseif (str_starts_with($column, 'user.meta.') && \array_key_exists($column, $userMeta)) {
$columns[] = $userMeta[$column];
} else {
if ($this->logger !== null && ($showRates || !\in_array($column, $rateColumns, true))) {
$this->logger->warning(\sprintf('Unknown column "%s" used in exporter template "%s".', $column, $template->getTitle()));
}
}
}
return $columns;
}
/**
* @return array<int, string>
*/
private function getDefaultColumns(): array
{
// @deprecated since 2.36 - will be removed with 3.0
$durationFormatter = 'duration';
if (($user = $this->voter->getUser()) instanceof User) {
$durationFormatter = $user->isExportDecimal() ? 'duration_decimal' : 'duration';
}
$columns = [
'date',
'begin',
'end',
$durationFormatter,
'currency',
'rate',
'internal_rate',
'hourly_rate',
'fixed_rate',
'user.alias',
'user.name',
'user.account_number',
'customer.name',
'project.name',
'activity.name',
'description',
'billable',
'tags',
'type',
'category',
'customer.number',
'project.number',
'customer.vat_id',
'project.order_number',
];
$columns = [];
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent(new TimesheetQuery(), TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns[] = 'timesheet.meta.' . $metaField->getName();
}
$columns[] = (new Column('date', $this->getFormatter('date')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin());
$columns[] = (new Column('begin', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin())->withColumnWidth(ColumnWidth::SMALL);
$columns[] = (new Column('end', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getEnd())->withColumnWidth(ColumnWidth::SMALL);
$columns[] = (new Column('duration', $this->getFormatter($durationFormatter)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
if ($showRates) {
$columns[] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL);
$columns[] = (new Column('rate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getRate());
$columns[] = (new Column('internalRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getInternalRate());
$columns[] = (new Column('hourlyRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getHourlyRate());
$columns[] = (new Column('fixedRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getFixedRate());
}
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent(new CustomerQuery(), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns[] = 'customer.meta.' . $metaField->getName();
$columns[] = (new Column('alias', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getUserIdentifier())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber());
$columns[] = (new Column('customer', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('project', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('activity', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('description', new TextFormatter(true)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDescription())->withColumnWidth(ColumnWidth::LARGE);
//$columns[] = (new Column('exported', new BooleanFormatter()))->withExtractor(fn(ExportableItem $exportableItem) => $exportableItem->isExported());
$columns[] = (new Column('billable', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isBillable())->withColumnWidth(ColumnWidth::SMALL);
$columns[] = (new Column('tags', new ArrayFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getTagsAsArray());
$columns[] = (new Column('type', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getType());
$columns[] = (new Column('category', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getCategory());
$columns[] = (new Column('number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getNumber());
$columns[] = (new Column('project_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getNumber());
$columns[] = (new Column('vat_id', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getVatId());
$columns[] = (new Column('orderNumber', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getOrderNumber());
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() === null) {
continue;
}
$columns[] = (new Column('timesheet.meta.' . $metaField->getName(), $this->getFormatter('default')))
->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getMetaField($metaField->getName())?->getValue();
});
}
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent(new ProjectQuery(), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns[] = 'project.meta.' . $metaField->getName();
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() === null) {
continue;
}
$columns[] = (new Column('customer.meta.' . $metaField->getName(), $this->getFormatter('default')))
->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getProject()?->getCustomer()?->getMetaField($metaField->getName())?->getValue();
});
}
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent(new ActivityQuery(), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns[] = 'activity.meta.' . $metaField->getName();
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() === null) {
continue;
}
$columns[] = (new Column('project.meta.' . $metaField->getName(), $this->getFormatter('default')))
->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getProject()?->getMetaField($metaField->getName())?->getValue();
});
}
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent($query->copyTo(new ActivityQuery()), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() === null) {
continue;
}
$columns[] = (new Column('activity.meta.' . $metaField->getName(), $this->getFormatter('default')))
->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getActivity()?->getMetaField($metaField->getName())?->getValue();
});
}
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
$this->eventDispatcher->dispatch($event);
$this->dispatcher->dispatch($event);
foreach ($event->getPreferences() as $metaField) {
if ($metaField->getName() !== null) {
$columns[] = 'user.meta.' . $metaField->getName();
if ($metaField->getName() === null) {
continue;
}
$columns[] = (new Column('user.meta.' . $metaField->getName(), $this->getFormatter('default')))
->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getUser()?->getPreference($metaField->getName())?->getValue();
});
}
return $columns;

View File

@@ -23,10 +23,6 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
{
use ExportTrait;
private string $id = 'xlsx';
private string $title = 'default';
private ?string $locale = null;
public function __construct(
private readonly SpreadsheetRenderer $spreadsheetRenderer,
private readonly TranslatorInterface $translator,
@@ -34,29 +30,14 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
{
}
public function setId(string $id): void
{
$this->id = $id;
}
public function getId(): string
{
return $this->id;
}
public function setTitle(string $title): void
{
$this->title = $title;
}
public function setLocale(?string $locale): void
{
$this->locale = $locale;
return 'xlsx';
}
public function getTitle(): string
{
return $this->title;
return 'xlsx';
}
/**
@@ -74,14 +55,14 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
/**
* @param ExportableItem[] $exportItems
*/
private function renderFile(array $exportItems, TimesheetQuery $query): \SplFileInfo
public function renderFile(array $exportItems, TimesheetQuery $query): \SplFileInfo
{
$filename = @tempnam(sys_get_temp_dir(), 'kimai-export-xlsx');
if (false === $filename) {
throw new \Exception('Could not open temporary file');
}
$spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator, $this->locale);
$spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator);
$spreadsheet->open($filename);
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);

View File

@@ -17,10 +17,18 @@ interface ExportRendererInterface
{
/**
* @param ExportableItem[] $exportItems
* @param TimesheetQuery $query
* @return Response
*/
public function render(array $exportItems, TimesheetQuery $query): Response;
/**
* @return string
*/
public function getId(): string;
/**
* @return string
*/
public function getTitle(): string;
}

View File

@@ -13,7 +13,7 @@ use App\Utils\Duration;
final class DurationDecimalFormatter implements CellFormatterInterface
{
private readonly Duration $duration;
private Duration $duration;
public function __construct()
{

View File

@@ -11,10 +11,6 @@ namespace App\Export\Package\CellFormatter;
final class DurationFormatter implements CellFormatterInterface, CellWithFormatInterface
{
public function __construct(private readonly string $format)
{
}
public function formatValue(mixed $value): mixed
{
if (!is_numeric($value) || (int) $value === 0) {
@@ -41,6 +37,6 @@ final class DurationFormatter implements CellFormatterInterface, CellWithFormatI
public function getFormat(): string
{
return $this->format;
return '[hh]:mm:ss';
}
}

View File

@@ -11,18 +11,10 @@ namespace App\Export\Package\CellFormatter;
final class DurationPlainFormatter implements CellFormatterInterface
{
public function __construct(private readonly bool $withSeconds = true)
{
}
public function formatValue(mixed $value): mixed
{
if (!is_numeric($value) || (int) $value === 0) {
if ($this->withSeconds) {
return '0:00:00';
}
return '0:00';
return '0:00:00';
}
$value = (int) $value;
@@ -40,10 +32,6 @@ final class DurationPlainFormatter implements CellFormatterInterface
$interval->invert = 1;
}
if ($this->withSeconds) {
return $interval->format('%r%h:%I:%S');
}
return $interval->format('%r%h:%I');
return $interval->format('%r%h:%I:%S');
}
}

View File

@@ -31,8 +31,7 @@ class SpoutSpreadsheet implements SpreadsheetPackage
public function __construct(
private readonly WriterInterface $writer,
private readonly TranslatorInterface $translator,
private readonly ?string $locale = null,
private readonly TranslatorInterface $translator
)
{
$this->writer->setCreator(Constants::SOFTWARE);
@@ -51,11 +50,11 @@ class SpoutSpreadsheet implements SpreadsheetPackage
$tmp = [];
$i = 0;
foreach ($columns as $column) {
$title = $this->translator->trans($column->getHeader(), [], null, $this->locale);
$title = $this->translator->trans($column->getHeader());
$tmp[] = Cell::fromValue($title);
$style = null;
if (($format = $column->getFormat()) !== null) {
$style = (new Style())->setFormat($format);
if ($column->getFormat() !== null) {
$style = (new Style())->setFormat($column->getFormat());
}
$this->styles[$i++] = $style;
}
@@ -121,10 +120,8 @@ class SpoutSpreadsheet implements SpreadsheetPackage
if ($this->writer instanceof AbstractWriterMultiSheets) {
$sheetView = new SheetView();
// deactivated, because the column order is now configurable
//$sheetView->setFreezeColumn('D');
//$sheetView->setFreezeRow(2);
$sheetView->setFreezeColumn('D');
$sheetView->setFreezeRow(2);
$this->writer->getCurrentSheet()->setSheetView($sheetView);
}

View File

@@ -1,42 +0,0 @@
<?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\Export\Renderer;
use App\Export\Base\CsvRenderer;
use App\Export\Base\SpreadsheetRenderer;
use App\Export\TemplateInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
final class CsvRendererFactory
{
public function __construct(
private readonly EventDispatcherInterface $dispatcher,
private readonly Security $voter,
private readonly TranslatorInterface $translator,
private readonly ?LoggerInterface $logger = null,
) {
}
public function create(TemplateInterface $template): CsvRenderer
{
$renderer = new SpreadsheetRenderer($this->dispatcher, $this->voter, $this->logger);
$renderer->setTemplate($template);
$renderer = new CsvRenderer($renderer, $this->translator);
$renderer->setId($template->getId());
$renderer->setTitle($template->getTitle());
$renderer->setLocale($template->getLocale());
return $renderer;
}
}

View File

@@ -27,7 +27,6 @@ final class PdfRendererFactory
{
$renderer = new PDFRenderer($this->twig, $this->converter, $this->projectStatisticService);
$renderer->setId($id);
$renderer->setTitle($id);
$renderer->setTemplate($template);
return $renderer;

View File

@@ -1,42 +0,0 @@
<?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\Export\Renderer;
use App\Export\Base\SpreadsheetRenderer;
use App\Export\Base\XlsxRenderer;
use App\Export\TemplateInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
final class XlsxRendererFactory
{
public function __construct(
private readonly EventDispatcherInterface $dispatcher,
private readonly Security $voter,
private readonly TranslatorInterface $translator,
private readonly ?LoggerInterface $logger = null,
) {
}
public function create(TemplateInterface $template): XlsxRenderer
{
$renderer = new SpreadsheetRenderer($this->dispatcher, $this->voter, $this->logger);
$renderer->setTemplate($template);
$renderer = new XlsxRenderer($renderer, $this->translator);
$renderer->setId($template->getId());
$renderer->setTitle($template->getTitle());
$renderer->setLocale($template->getLocale());
return $renderer;
}
}

View File

@@ -11,14 +11,10 @@ namespace App\Export;
use App\Entity\ExportableItem;
use App\Event\ExportItemsQueryEvent;
use App\Export\Renderer\CsvRendererFactory;
use App\Export\Renderer\HtmlRendererFactory;
use App\Export\Renderer\PdfRendererFactory;
use App\Export\Renderer\XlsxRendererFactory;
use App\Repository\ExportTemplateRepository;
use App\Repository\Query\ExportQuery;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
final class ServiceExport
{
@@ -40,13 +36,9 @@ final class ServiceExport
private array $repositories = [];
public function __construct(
private readonly EventDispatcherInterface $eventDispatcher,
private readonly HtmlRendererFactory $htmlRendererFactory,
private readonly PdfRendererFactory $pdfRendererFactory,
private readonly CsvRendererFactory $csvRendererFactory,
private readonly XlsxRendererFactory $xlsxRendererFactory,
private readonly ExportTemplateRepository $exportTemplateRepository,
private readonly LoggerInterface $logger,
private EventDispatcherInterface $eventDispatcher,
private HtmlRendererFactory $htmlRendererFactory,
private PdfRendererFactory $pdfRendererFactory
)
{
}
@@ -81,26 +73,6 @@ final class ServiceExport
{
$renderer = [];
foreach ($this->exportTemplateRepository->findAll() as $template) {
$tpl = new Template((string) $template->getId(), $template->getTitle()); // @phpstan-ignore argument.type
$tpl->setColumns($template->getColumns());
$tpl->setLocale($template->getLanguage());
switch ($template->getRenderer()) {
case 'csv':
$renderer[] = $this->csvRendererFactory->create($tpl);
break;
case 'xlsx':
$renderer[] = $this->xlsxRendererFactory->create($tpl);
break;
default:
$this->logger->error('Unknown export template type: ' . $template->getRenderer());
break;
}
}
foreach ($this->documentDirs as $exportPath) {
if (!is_dir($exportPath)) {
continue;
@@ -160,7 +132,7 @@ final class ServiceExport
public function getTimesheetExporterById(string $id): ?TimesheetExportInterface
{
foreach ($this->getTimesheetExporter() as $exporter) {
foreach ($this->timesheetExporter as $exporter) {
if ($exporter->getId() === $id) {
return $exporter;
}
@@ -175,6 +147,7 @@ final class ServiceExport
}
/**
* @param ExportQuery $query
* @return ExportableItem[]
* @throws TooManyItemsExportException
*/

View File

@@ -1,79 +0,0 @@
<?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\Export;
final class Template implements TemplateInterface
{
private ?string $locale = null;
/**
* @var array<int, string>
*/
private array $columns = [];
/**
* @var array<string, mixed>
*/
private array $options = [];
public function __construct(private readonly string $id, private readonly string $title)
{
}
/**
* @param array<int, string> $columns
*/
public function setColumns(array $columns): void
{
$this->columns = $columns;
}
/**
* @return array<string, mixed>
*/
public function getOptions(): array
{
return $this->options;
}
/**
* @param array<string, mixed> $options
*/
public function setOptions(array $options): void
{
$this->options = $options;
}
public function getLocale(): ?string
{
return $this->locale;
}
public function setLocale(?string $locale): void
{
$this->locale = $locale;
}
public function getId(): string
{
return $this->id;
}
public function getTitle(): string
{
return $this->title;
}
/**
* @return array<int, string>
*/
public function getColumns(): array
{
return $this->columns;
}
}

View File

@@ -1,29 +0,0 @@
<?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\Export;
interface TemplateInterface
{
public function getId(): string;
public function getTitle(): string;
/**
* @return array<int, string>
*/
public function getColumns(): array;
public function getLocale(): ?string;
/**
* @return array<string, mixed>
*/
public function getOptions(): array;
}

View File

@@ -1,43 +0,0 @@
<?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\Form;
use App\Entity\ExportTemplate;
use App\Form\Type\ExportColumnsType;
use App\Form\Type\ExportRendererType;
use App\Form\Type\LanguageType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ExportTemplateSpreadsheetForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('title', TextType::class);
$builder->add('renderer', ExportRendererType::class, ['label' => 'type']);
$builder->add('language', LanguageType::class, ['required' => false]);
$builder->add('columns', ExportColumnsType::class, ['required' => true]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ExportTemplate::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'export_template_create',
'attr' => [
'data-form-event' => 'kimai.exportTemplate'
],
]);
}
}

View File

@@ -40,22 +40,18 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
$extendedOptions = ['class' => 'selectpicker'];
if (\array_key_exists('multiple', $options) && $options['multiple']) {
if ($options['multiple']) {
$extendedOptions['size'] = 1;
}
if (\array_key_exists('width', $options) && false !== $options['width']) {
if (false !== $options['width']) {
$extendedOptions['data-width'] = $options['width'];
}
if (\array_key_exists('search', $options) && false === $options['search']) {
if (false === $options['search']) {
$extendedOptions['data-disable-search'] = 1;
}
if (\array_key_exists('order', $options) && true === $options['order']) {
$extendedOptions['data-order'] = 1;
}
// there is a very weird logic in vendor/symfony/twig-bridge/Resources/views/Form/form_div_layout.html.twig
// in block "block choice_widget_collapsed" that resets "{% set required = false %}", so we fake it into the select
if (true === $options['required'] && \is_array($options['attr']) && (!\array_key_exists('size', $options['attr']) || $options['attr']['size'] <= 1)) {
@@ -79,9 +75,5 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
$resolver->setDefined(['search']);
$resolver->setAllowedTypes('search', 'boolean');
$resolver->setDefault('search', true);
$resolver->setDefined(['order']);
$resolver->setAllowedTypes('order', 'boolean');
$resolver->setDefault('order', false);
}
}

View File

@@ -18,7 +18,6 @@ use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\All;
use Symfony\Component\Validator\Constraints\Valid;
@@ -93,16 +92,13 @@ final class QuickEntryForm extends AbstractType
'entry_options' => [
'label' => false,
'duration_minutes' => $this->configuration->getTimesheetIncrementDuration(),
// this is NOT the start_date, because it would prevent projects from appearing in the
// first days of the week if the project ends at the end of the week.
// the validation still triggers if the user selects days outside the project range.
// this is NOT the start_date, because it would prevent projects from appearing
// in the first days of the week, if the projects ends at the end of the week
// the validation still triggers if the user selects days outside the project range
'start_date' => $options['end_date'],
'end_date' => $options['end_date'],
'empty_data' => function (FormInterface $form) use ($options) {
if ($options['prototype_data'] instanceof QuickEntryModel) {
return clone $options['prototype_data'];
}
throw new \Exception('Invalid Prototype given');
return clone $options['prototype_data'];
},
'prototype_data' => clone $options['prototype_data'],
],
@@ -115,25 +111,6 @@ final class QuickEntryForm extends AbstractType
]);
}
public function finishView(FormView $view, FormInterface $form, array $options): void
{
usort($view['rows']->children, function (FormView $a, FormView $b) {
/** @var \App\Model\QuickEntryModel $objectA */
$objectA = $a->vars['data'];
/** @var \App\Model\QuickEntryModel $objectB */
$objectB = $b->vars['data'];
$existingA = $objectA->hasExistingTimesheet();
$existingB = $objectB->hasExistingTimesheet();
if ($existingA === $existingB) {
return 0;
}
return ($existingA && !$existingB) ? -1 : 1;
});
}
public function configureOptions(OptionsResolver $resolver): void
{
$start = new \DateTime();

View File

@@ -59,7 +59,8 @@ class TimesheetEditForm extends AbstractType
$timezone = $options['timezone'];
$isNew = true;
if (isset($options['data']) && $options['data'] instanceof Timesheet) {
if (isset($options['data'])) {
/** @var Timesheet $entry */
$entry = $options['data'];
$activity = $entry->getActivity();
@@ -337,7 +338,7 @@ class TimesheetEditForm extends AbstractType
function (FormEvent $event) {
/** @var Timesheet|null $timesheet */
$timesheet = $event->getData();
if (null === $timesheet || ($timesheet instanceof Timesheet && $timesheet->isRunning())) {
if (null === $timesheet || $timesheet->isRunning()) {
$event->getForm()->get('duration')->setData(null);
}
}

View File

@@ -1,166 +0,0 @@
<?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\Form\Type;
use App\Entity\MetaTableTypeInterface;
use App\Event\ActivityMetaDisplayEvent;
use App\Event\CustomerMetaDisplayEvent;
use App\Event\MetaDisplayEventInterface;
use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Event\UserPreferenceDisplayEvent;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TimesheetQuery;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
final class ExportColumnsType extends AbstractType
{
/**
* @var array<int, string>
*/
private array $ordered = [];
public function __construct(
private readonly EventDispatcherInterface $dispatcher,
private readonly TranslatorInterface $translator,
)
{
}
public function configureOptions(OptionsResolver $resolver): void
{
$columns = [
'timesheet' => [
'date' => 'date',
'begin' => 'begin',
'end' => 'end',
$this->translator->trans('duration') . ' (1:30)' => 'duration',
$this->translator->trans('duration') . ' (1:30:00)' => 'duration_seconds',
$this->translator->trans('duration') . ' (1.5)' => 'duration_decimal',
'currency' => 'currency',
'rate' => 'rate',
'internalRate' => 'internal_rate',
'hourlyRate' => 'hourly_rate',
'fixedRate' => 'fixed_rate',
'description' => 'description',
'exported' => 'exported',
'billable' => 'billable',
'tags' => 'tags',
'type' => 'type',
'category' => 'category',
],
'user' => [
'alias' => 'user.alias',
'username' => 'user.name',
'account_number' => 'user.account_number',
],
'customer' => [
'customer' => 'customer.name',
'number' => 'customer.number',
'vat_id' => 'customer.vat_id',
],
'project' => [
'project' => 'project.name',
'project_number' => 'project.number',
'orderNumber' => 'project.order_number',
],
'activity' => [
'activity' => 'activity.name',
'activity_number' => 'activity.number',
],
];
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent(new TimesheetQuery(), TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns['timesheet'][$metaField->getLabel()] = 'timesheet.meta.' . $metaField->getName();
}
}
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent(new CustomerQuery(), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns['customer'][$metaField->getLabel()] = 'customer.meta.' . $metaField->getName();
}
}
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent(new ProjectQuery(), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns['project'][$metaField->getLabel()] = 'project.meta.' . $metaField->getName();
}
}
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent(new ActivityQuery(), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns['activity'][$metaField->getLabel()] = 'activity.meta.' . $metaField->getName();
}
}
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
$this->dispatcher->dispatch($event);
foreach ($event->getPreferences() as $metaField) {
if ($metaField->getName() !== null) {
$columns['user'][$metaField->getLabel()] = 'user.meta.' . $metaField->getName();
}
}
$resolver->setDefaults([
'choices' => $columns,
'label' => 'modal.columns.label',
'multiple' => true,
// does not work in the frontend
//'order' => true,
]);
}
/**
* @param array<string, mixed> $options
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) {
$data = $event->getData();
if (\is_array($data)) {
$this->ordered = $data; // @phpstan-ignore assign.propertyType
}
}
);
$builder->addEventListener(
FormEvents::SUBMIT,
function (FormEvent $event) {
$event->setData($this->ordered);
}
);
}
/**
* @return array<MetaTableTypeInterface>
*/
private function findMetaColumns(MetaDisplayEventInterface $event): array
{
$this->dispatcher->dispatch($event);
return $event->getFields();
}
public function getParent(): string
{
return ChoiceType::class;
}
}

View File

@@ -1,29 +0,0 @@
<?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\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class ExportRendererType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'choices' => ['button.csv' => 'csv', 'button.xlsx' => 'xlsx'],
]);
}
public function getParent(): string
{
return ChoiceType::class;
}
}

View File

@@ -20,7 +20,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
final class QuickEntryTimesheetType extends AbstractType
{
public function __construct(private readonly Security $security)
public function __construct(private Security $security)
{
}
@@ -60,28 +60,8 @@ final class QuickEntryTimesheetType extends AbstractType
$event->getForm()->get('duration')->setData(null);
}
if ($data instanceof Timesheet && !$this->security->isGranted('edit', $data)) {
$event->getForm()->remove('duration');
if (null !== $data && !$this->security->isGranted('edit', $data)) {
$event->getForm()->add('duration', DurationType::class, array_merge(['disabled' => true], $durationOptions));
$mainForm = $event->getForm()->getParent()?->getParent();
if ($mainForm === null) {
return;
}
$isNew = $data->getId() === null;
foreach($mainForm->all() as $key => $child) {
if ($key === 'timesheets') {
continue;
}
if ($child->isDisabled() || $isNew) {
continue;
}
$type = \get_class($child->getConfig()->getType()->getInnerType());
$mainForm->remove($key);
$mainForm->add($key, $type, array_merge($child->getConfig()->getOptions(), ['disabled' => true]));
}
}
}
);

View File

@@ -9,6 +9,7 @@
namespace App\Form\Type;
use App\Entity\User;
use App\Model\QuickEntryModel;
use App\Validator\Constraints\QuickEntryTimesheet;
use DateTime;
@@ -76,6 +77,21 @@ final class QuickEntryWeekType extends AbstractType
};
$builder->addEventListener(FormEvents::PRE_SET_DATA, $activityFunction);
$activityPreSubmitFunction = function (FormEvent $event) use ($activityOptions) {
$data = $event->getData();
if (isset($data['project']) && !empty($data['project'])) {
$activityOptions['projects'] = [$data['project']];
}
if (isset($data['activity']) && !empty($data['activity'])) {
$activityOptions['activities'] = [$data['activity']];
}
$event->getForm()->add('activity', ActivityType::class, $activityOptions);
};
$builder->addEventListener(FormEvents::PRE_SUBMIT, $activityPreSubmitFunction);
$builder->add('metaFields', MetaFieldsCollectionType::class);
$builder->add('timesheets', CollectionType::class, [
@@ -96,13 +112,13 @@ final class QuickEntryWeekType extends AbstractType
]);
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options) {
if ($event->getData() === null && $options['prototype_data'] instanceof QuickEntryModel) {
if ($event->getData() === null) {
$event->setData(clone $options['prototype_data']);
}
});
$builder->addModelTransformer(new CallbackTransformer(
function ($transformValue) {
function ($transformValue) use ($options) {
/** @var QuickEntryModel|null $transformValue */
if ($transformValue === null || $transformValue->isPrototype()) {
return $transformValue;
@@ -117,6 +133,9 @@ final class QuickEntryWeekType extends AbstractType
}
$user = $transformValue->getUser();
if ($user === null && $options['user'] instanceof User) {
$user = $options['user'];
}
foreach ($transformValue->getTimesheets() as $timesheet) {
$timesheet->setUser($user);
$timesheet->setProject($project);
@@ -143,7 +162,9 @@ final class QuickEntryWeekType extends AbstractType
$activity = $data->getActivity();
foreach ($newRecords as $record) {
$record->setUser($user);
if ($user !== null) {
$record->setUser($user);
}
if ($project !== null) {
$record->setProject($project);
}

View File

@@ -218,7 +218,9 @@ final class InvoiceModel
}
/**
* Returns the user currently creating the invoice.
* Returns the user who is currently creating the invoice.
*
* @return User|null
*/
public function getUser(): ?User
{

View File

@@ -21,10 +21,7 @@ class LdapDriver
{
private ?Ldap $driver = null;
public function __construct(
private readonly LdapConfiguration $config,
private readonly LoggerInterface $logger
)
public function __construct(private LdapConfiguration $config, private ?LoggerInterface $logger = null)
{
}
@@ -44,8 +41,10 @@ class LdapDriver
}
/**
* @param array<int, string> $attributes
* @return array{'count': int, array<string, string|array<int, string>>}
* @param string $baseDn
* @param string $filter
* @param array $attributes
* @return array
* @throws LdapDriverException
*/
public function search(string $baseDn, string $filter, array $attributes = []): array
@@ -54,7 +53,7 @@ class LdapDriver
$attributes = array_unique(array_merge($attributes, ['+', '*']));
$this->logger->debug('{action}({base_dn}, {filter}, {attributes})', [
$this->logDebug('{action}({base_dn}, {filter}, {attributes})', [
'action' => 'ldap_search',
'base_dn' => $baseDn,
'filter' => $filter,
@@ -68,7 +67,7 @@ class LdapDriver
// searchEntries don't return 'count' key as specified by php native function ldap_get_entries()
$entries['count'] = \count($entries);
} catch (LdapException $exception) {
$this->logger->error(\sprintf('Failed to search LDAP: %s', $exception->getMessage()), ['exception' => $exception]);
$this->ldapExceptionHandler($exception);
throw new LdapDriverException('An error occurred with the search operation.');
}
@@ -81,7 +80,7 @@ class LdapDriver
$driver = $this->getDriver();
try {
$this->logger->debug('{action}({bindDn}, ****)', [
$this->logDebug('{action}({bindDn}, ****)', [
'action' => 'ldap_bind',
'bindDn' => $bindDn,
]);
@@ -89,9 +88,36 @@ class LdapDriver
return $bind instanceof Ldap;
} catch (LdapException $exception) {
$this->logger->error(\sprintf('Failed binding to LDAP at %s: %s', $bindDn, $exception->getMessage()), ['exception' => new SanitizingException($exception, $password)]);
$this->ldapExceptionHandler($exception, $password);
}
return false;
}
private function ldapExceptionHandler(LdapException $exception, string $password = null): void
{
$sanitizedException = null !== $password ? new SanitizingException($exception, $password) : $exception;
switch ($exception->getCode()) {
// Error level codes
case LdapException::LDAP_SERVER_DOWN:
if ($this->logger) {
$this->logger->error('{exception}', ['exception' => $sanitizedException]);
}
break;
// Other level codes
default:
$this->logDebug('{exception}', ['exception' => $sanitizedException]);
break;
}
}
private function logDebug(string $message, array $context = []): void
{
if (null === $this->logger) {
return;
}
$this->logger->debug($message, $context);
}
}

View File

@@ -105,7 +105,7 @@ final class ProjectService
$errors = $this->validator->validate($project, null, $groups);
if ($errors->count() > 0) {
throw new ValidationFailedException($errors);
throw new ValidationFailedException($errors, 'Validation Failed');
}
}

View File

@@ -1,31 +0,0 @@
<?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\Repository;
use App\Entity\ExportTemplate;
use Doctrine\ORM\EntityRepository;
/**
* @extends \Doctrine\ORM\EntityRepository<ExportTemplate>
*/
class ExportTemplateRepository extends EntityRepository
{
public function saveExportTemplate(ExportTemplate $template): void
{
$this->getEntityManager()->persist($template);
$this->getEntityManager()->flush();
}
public function removeExportTemplate(ExportTemplate $template): void
{
$this->getEntityManager()->remove($template);
$this->getEntityManager()->flush();
}
}

View File

@@ -12,7 +12,6 @@ namespace App\Saml;
use App\Configuration\SamlConfigurationInterface;
use App\Saml\Security\SamlAuthenticationFailureHandler;
use App\Saml\Security\SamlAuthenticationSuccessHandler;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@@ -41,8 +40,7 @@ class SamlAuthenticator extends AbstractAuthenticator
private readonly SamlAuthenticationFailureHandler $failureHandler,
private readonly SamlAuthFactory $samlAuthFactory,
private readonly SamlProvider $samlProvider,
private readonly SamlConfigurationInterface $configuration,
private readonly LoggerInterface $logger
private readonly SamlConfigurationInterface $configuration
) {
}
@@ -88,7 +86,6 @@ class SamlAuthenticator extends AbstractAuthenticator
// file_put_contents(__DIR__ . '/../../var/log/saml.xml', $oneLoginAuth->getLastResponseXML());
if (\count($oneLoginAuth->getErrors()) > 0) {
$this->logger->critical('SAML login failed: ' . $oneLoginAuth->getLastErrorReason());
throw new AuthenticationException($oneLoginAuth->getLastErrorReason());
}
@@ -105,9 +102,7 @@ class SamlAuthenticator extends AbstractAuthenticator
if (isset($this->options['username_attribute'])) {
if (!\array_key_exists($this->options['username_attribute'], $attributes)) {
$errorMessage = \sprintf("Attribute '%s' not found in SAML data", $this->options['username_attribute']);
$this->logger->critical($errorMessage);
throw new \Exception($errorMessage);
throw new \Exception(\sprintf("Attribute '%s' not found in SAML data", $this->options['username_attribute']));
}
$username = $attributes[$this->options['username_attribute']][0];

View File

@@ -17,7 +17,7 @@ final class LockdownService
{
private ?bool $isActive = null;
public function __construct(private readonly SystemConfiguration $configuration)
public function __construct(private SystemConfiguration $configuration)
{
}
@@ -225,7 +225,7 @@ final class LockdownService
return true;
}
// further validate entries inside the most recent lockdown
// further validate entries inside of the most recent lockdown
if ($timesheetStart >= $lockdownStart) {
// if grace period is still in effect, validation succeeds
if ($now <= $lockdownGrace) {

View File

@@ -45,12 +45,12 @@ final class TimesheetService
private array $doNotValidateCodes = [];
public function __construct(
private readonly SystemConfiguration $configuration,
private readonly TimesheetRepository $repository,
private readonly TrackingModeService $trackingModeService,
private readonly EventDispatcherInterface $dispatcher,
private readonly AuthorizationCheckerInterface $auth,
private readonly ValidatorInterface $validator
private SystemConfiguration $configuration,
private TimesheetRepository $repository,
private TrackingModeService $trackingModeService,
private EventDispatcherInterface $dispatcher,
private AuthorizationCheckerInterface $auth,
private ValidatorInterface $validator
) {
}
@@ -101,7 +101,7 @@ final class TimesheetService
public function restartTimesheet(Timesheet $timesheet, Timesheet $copyFrom): Timesheet
{
$this->dispatcher->dispatch(new TimesheetRestartPreEvent($timesheet, $copyFrom));
$this->saveNewTimesheet($timesheet); // @phpstan-ignore method.deprecated
$this->saveNewTimesheet($timesheet);
$this->dispatcher->dispatch(new TimesheetRestartPostEvent($timesheet, $copyFrom));
return $timesheet;
@@ -111,7 +111,6 @@ final class TimesheetService
* @throws ValidationFailedException for invalid timesheets or running timesheets that should be stopped
* @throws InvalidArgumentException for already persisted timesheets
* @throws AccessDeniedException if user is not allowed to start timesheet
* @deprecated since 2.36.0 - use saveTimesheet() instead
*/
public function saveNewTimesheet(Timesheet $timesheet): Timesheet
{
@@ -151,19 +150,12 @@ final class TimesheetService
return $timesheet;
}
public function saveTimesheet(Timesheet $timesheet): Timesheet
{
if ($timesheet->getId() === null) {
return $this->saveNewTimesheet($timesheet); // @phpstan-ignore method.deprecated
} else {
return $this->updateTimesheet($timesheet); // @phpstan-ignore method.deprecated
}
}
/**
* Does NOT validate the given timesheet.
* Does NOT validate the given timesheet!
*
* @deprecated since 2.36.0 - use saveTimesheet() instead
* @param Timesheet $timesheet
* @return Timesheet
* @throws \Exception
*/
public function updateTimesheet(Timesheet $timesheet): Timesheet
{
@@ -253,7 +245,7 @@ final class TimesheetService
continue;
}
throw new ValidationFailedException($errors);
throw new ValidationFailedException($errors, 'Validation Failed');
}
}
}

View File

@@ -112,7 +112,7 @@ class UserService
$errors = $this->validator->validate($user, null, $groups);
if ($errors->count() > 0) {
throw new ValidationFailedException($errors);
throw new ValidationFailedException($errors, 'Validation Failed');
}
}

View File

@@ -1,24 +0,0 @@
<?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\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class ExportRenderer extends Constraint
{
public const UNKNOWN_TYPE = 'kimai-export-type-00';
protected const ERROR_NAMES = [
self::UNKNOWN_TYPE => 'Unknown exporter type.',
];
public string $message = 'Unknown exporter type.';
}

View File

@@ -1,41 +0,0 @@
<?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\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class ExportRendererValidator extends ConstraintValidator
{
/**
* @param string|mixed $value
*/
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof ExportRenderer)) {
throw new UnexpectedTypeException($constraint, ExportRenderer::class);
}
if ($value === null) {
return;
}
$ids = ['csv', 'xlsx'];
if (!\is_string($value) || !\in_array($value, $ids, true)) {
$this->context->buildViolation(ExportRenderer::getErrorName(ExportRenderer::UNKNOWN_TYPE))
->setParameter('{{ value }}', $this->formatValue($value))
->setTranslationDomain('validators')
->setCode(ExportRenderer::UNKNOWN_TYPE)
->addViolation();
}
}
}

View File

@@ -1,26 +0,0 @@
<?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\Validator\Constraints;
final class TimesheetNegativeDuration extends TimesheetConstraint
{
public const NEGATIVE_DURATION_ERROR = 'kimai-timesheet-negative-duration-01';
protected const ERROR_NAMES = [
self::NEGATIVE_DURATION_ERROR => 'Duration cannot be negative.',
];
public string $message = 'Duration cannot be negative.';
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}

View File

@@ -1,43 +0,0 @@
<?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\Validator\Constraints;
use App\Entity\Timesheet as TimesheetEntity;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetNegativeDurationValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof TimesheetNegativeDuration)) {
throw new UnexpectedTypeException($constraint, TimesheetNegativeDuration::class);
}
if (!\is_object($value) || !($value instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($value, TimesheetEntity::class);
}
if ($value->isRunning()) {
return;
}
$duration = $value->getCalculatedDuration();
if ($duration !== null && $duration < 0) {
$this->context->buildViolation($constraint->message)
->atPath('duration')
->setTranslationDomain('validators')
->setCode(TimesheetNegativeDuration::NEGATIVE_DURATION_ERROR)
->addViolation();
}
}
}

View File

@@ -44,7 +44,7 @@ final class TimesheetZeroDurationValidator extends ConstraintValidator
$duration = $value->getCalculatedDuration();
}
if ($duration === 0) {
if ($duration <= 0) {
$this->context->buildViolation($constraint->message)
->atPath('duration')
->setTranslationDomain('validators')

View File

@@ -14,7 +14,7 @@ final class ValidationException extends \RuntimeException
public function __construct(string $message = null)
{
if ($message === null) {
$message = 'Validation Failed';
$message = 'Validation failed';
}
parent::__construct($message, 400);
}

View File

@@ -13,10 +13,10 @@ use Symfony\Component\Validator\ConstraintViolationListInterface;
final class ValidationFailedException extends \RuntimeException
{
public function __construct(private readonly ConstraintViolationListInterface $violations, ?string $message = null)
public function __construct(private ConstraintViolationListInterface $violations, ?string $message = null)
{
if ($message === null) {
$message = 'Validation Failed';
$message = 'Validation failed';
}
parent::__construct($message, 400);
}

View File

@@ -24,18 +24,12 @@
'activity': {'class': 'd-none', 'orderBy': false},
'description': {'class': 'd-none d-xl-table-cell timesheet-description', 'orderBy': false},
'tags': {'class': 'd-none d-xl-table-cell', 'orderBy': false},
'unit_price': {'class': 'd-none text-nowrap text-end', 'orderBy': false},
'duration': {'class': 'text-end text-nowrap', 'orderBy': false},
} %}
{% if show_rates %}
{% set columns = columns|merge({
'unit_price': {'class': 'd-none text-nowrap text-end', 'orderBy': false},
'internalRate': {'class': 'text-end d-none text-nowrap', 'orderBy': false},
'total_rate': {'class': 'text-end text-nowrap', 'orderBy': false},
}) %}
{% endif %}
{% set columns = columns|merge({
'internalRate': {'class': 'text-end d-none text-nowrap', 'orderBy': false},
'total_rate': {'class': 'text-end text-nowrap', 'orderBy': false},
'actions': {'class': 'actions alwaysVisible', 'orderBy': false},
}) %}
} %}
{% set tableName = 'export' %}
{% set editExported = is_granted('edit_exported_timesheet') %}
@@ -51,11 +45,9 @@
{% if totalDuration > 0 %}
{{ status_duration(totalDuration|duration) }}
{% endif %}
{% if show_rates %}
{% for totalCurrency, totalRate in totalRates %}
{{ status_money(totalRate|money(totalCurrency)) }}
{% endfor %}
{% endif %}
{% for totalCurrency, totalRate in totalRates %}
{{ status_money(totalRate|money(totalCurrency)) }}
{% endfor %}
{% endblock %}
{% block main_before %}
@@ -110,6 +102,16 @@
{% block box_attributes %}id="preview_export"{% endblock %}
{% block box_body_class %}p-0{% endblock %}
{% block box_footer %}
{% set buttons = {} %}
{% for button in renderer %}
{% set title = button.title %}
{% set group = [] %}
{% if buttons[(title)] is defined %}
{% set group = buttons[(title)] %}
{% endif %}
{% set group = group|merge([button]) %}
{% set buttons = buttons|merge({(title): group}) %}
{% endfor %}
{% if showMarkAsExportedButton %}
<div class="d-flex">
<div class="form-check form-switch">
@@ -120,51 +122,33 @@
{% endif %}
<div class="d-flex">
<div class="btn-group me-auto" id="export-buttons" role="group">
{% set allowCreate = is_granted('create_export_template') %}
{% for type, group in buttons %}
{% set btnTitle = ('button.' ~ type)|trans %}
{% if btnTitle == ('button.' ~ type) %}
{% set btnTitle = type|trans %}
{% for group in buttons %}
{% set button = group.0 %}
{% set btnTitle = ('button.' ~ button.title)|trans %}
{% if btnTitle == ('button.' ~ button.title) %}
{% set btnTitle = button.title %}
{% endif %}
{% if group|length == 1 %}
{% for id, title in group %}
{% set btnTitle2 = ('button.' ~ title)|trans %}
{% if btnTitle2 != ('button.' ~ title) %}
{% set btnTitle = btnTitle2 %}
{% endif %}
<button type="button" id="export-{{ id }}-button" class="btn btn-success startExportBtn" data-type="{{ id }}">
{{ btnTitle }}
</button>
{% endfor %}
{% else %}
<button type="button" id="export-{{ button.id }}-button" class="btn btn-success startExportBtn" data-type="{{ button.id }}">
{{ btnTitle }}
</button>
{% elseif group|length > 1 %}
<div class="btn-group">
<button type="button" class="btn btn-success dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ btnTitle }}
</button>
<div class="dropdown-menu">
{% for id, title in group %}
{% set btnTitle = id|trans({}, 'export') %}
{% if btnTitle == id %}
{% set btnTitle = title|trans %}
{% endif %}
{% if btnTitle == id%}
{% set btnTitle = id|split('.')|first|replace({'-': ' ', '_': ' '})|split(' ')|map(t => t|capitalize)|join(' ') %}
{% endif %}
<a href="#" class="dropdown-item startExportBtn" data-type="{{ id }}">
{{ btnTitle }}
{% if allowCreate and loop.index0 != 0 and (type == 'csv' or type == 'xlsx') %}
<span class="dropdown-action modal-ajax-form open-edit ps-2 ms-auto" data-href="{{ path('export_template_edit', {exportTemplate: id}) }}">{{ icon('edit') }}</span>
<span class="dropdown-action api-link text-red dd-ts-trash text-danger ms-2" href="{{ path('delete_export_template', {id: id}) }}" data-event="kimai.exportTemplate" data-method="DELETE" data-question="confirm.delete" data-msg-error="action.delete.error" data-msg-success="action.delete.success">{{ icon('delete') }}</span>
{% endif %}
</a>
{% for button in group %}
{% set btnTitle = (button.id)|trans({}, 'export') %}
{% if btnTitle == button.id %}
{% set btnTitle = button.id|split('.')|first|replace({'-': ' ', '_': ' '})|split(' ')|map(t => t|capitalize)|join(' ') %}
{% endif %}
<a href="#" class="dropdown-item startExportBtn" data-type="{{ button.id }}">{{ btnTitle }}</a>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
{% if allowCreate %}
{{ widgets.action_button('create', {'url': path('export_template_create'), 'class': 'btn modal-ajax-form open-edit', 'title': 'create'|trans}) }}
{% endif %}
</div>
{% if showToggleButton %}
<button id="toggle-button" class="btn ms-auto d-none d-sm-inline-flex">
@@ -185,9 +169,7 @@
<tr>
<th>{{ 'customer'|trans }}</th>
<th class="w-min text-end d-none d-sm-table-cell">{{ 'duration'|trans }}</th>
{% if show_rates %}
<th class="w-min text-end">{{ 'total_rate'|trans }}</th>
{% endif %}
</tr>
</thead>
<tbody>
@@ -198,13 +180,11 @@
{{ widgets.label_customer(row.customer) }}
</td>
<td class="w-min text-end d-none d-sm-table-cell">
{{ row.duration|duration }}
{{ row.duration|duration(decimal) }}
</td>
{% if show_rates %}
<td class="w-min text-end">
{{ row.rate|money(currency) }}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
@@ -255,20 +235,18 @@
{{ widgets.tag_list(entry.tags) }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}" data-duration="{{ entry.duration }}">
{{ entry.duration|duration }}
</td>
{% if show_rates %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }}">
{{ rate|money(currency) }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}" data-duration="{{ entry.duration }}">
{{ entry.duration|duration(decimal) }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'internalRate') }}">
{{ entry.internalRate|money(currency) }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'total_rate') }}">
{{ entry.rate|money(currency) }}
</td>
{% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'actions') }}">
{% if is_granted('edit_export', entry) %}
{% if entry.exported %}
@@ -310,8 +288,6 @@
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
KimaiReloadPageWidget.create('kimai.exportTemplate', true);
function updateTimesheetExportState(node, id)
{
/** @type {KimaiAlert} */

View File

@@ -1,24 +0,0 @@
{% extends kimai_context.modalRequest ? 'form.html.twig' : 'base.html.twig' %}
{% block main %}
{% set formEditTemplate = kimai_context.modalRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig' %}
{% set formOptions = {
'title': 'create'|trans,
'form': form,
'back': path('export_template_create')
} %}
{% embed formEditTemplate with formOptions %}
{% block form_body %}
<div class="row">
<div class="col-md-9">
{{ form_row(form.title) }}
</div>
<div class="col-md-3">
{{ form_row(form.renderer) }}
</div>
</div>
{{ form_row(form.language) }}
{{ form_rest(form) }}
{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -13,15 +13,11 @@
{{ form_errors(form) }}
{% endblock %}
{% block box_after %}
{% if locked %}
{{ widgets.alert('warning', 'The chosen date is already locked.'|trans({}, 'validators')) }}
{% else %}
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
<button type="button" class="btn btn-success add-item-link" data-collection-prototype="{{ form.rows.vars.id }}" data-collection-holder="ts-collection">
{{ icon('create', true) }}
{{ 'action.add'|trans }}
</button>
{% endif %}
{{ form_end(form) }}
{% endblock %}
{# "table-responsive" does not work, because that would render dropdowns at the bottom behind the container #}

View File

@@ -303,6 +303,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'user' => ['result' => 'object', 'type' => '@User'],
'dueDays' => 'int',
'invoiceNumber' => 'string',
'invoiceFilename' => 'string',
'metaFields' => 'array',
'paymentDate' => '@datetime',
'status' => 'string',

View File

@@ -48,7 +48,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
}
}
$expectedKeys = ['Actions', 'Activity', 'Default', 'Customer', 'Project', 'Tag', 'Team', 'Timesheet', 'User', 'Invoice', 'Export'];
$expectedKeys = ['Actions', 'Activity', 'Default', 'Customer', 'Project', 'Tag', 'Team', 'Timesheet', 'User', 'Invoice'];
$actual = array_keys($tags);
sort($actual);
@@ -73,9 +73,9 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
'/api/customers/{id}/meta',
'/api/customers/{id}/rates',
'/api/customers/{id}/rates/{rateId}',
'/api/export/{id}',
'/api/invoices',
'/api/invoices/{id}',
'/api/invoices/{id}/download',
'/api/projects',
'/api/projects/{id}',
'/api/projects/{id}/meta',

View File

@@ -1,80 +0,0 @@
<?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\Tests\API;
use App\Entity\ExportTemplate;
use App\Entity\User;
use App\Repository\ExportTemplateRepository;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* @group integration
*/
class ExportControllerTest extends APIControllerBaseTestCase
{
private function importExportTemplate(): ExportTemplate
{
/** @var ExportTemplateRepository $repository */
$repository = $this->getEntityManager()->getRepository(ExportTemplate::class);
$template = new ExportTemplate();
$template->setRenderer('csv');
$template->setTitle('csv');
$template->setColumns(['activity.name', 'project.number', 'project.name', 'customer.name', 'user.account_number', 'duration', 'date', 'rate', 'currency']);
$template->setLanguage('en');
$template->setLanguage('en');
$repository->saveExportTemplate($template);
return $template;
}
public function testDeleteIsSecure(): void
{
$this->assertUrlIsSecured('/api/export/1', Request::METHOD_DELETE);
}
public function testDeleteActionWithUnknownTemplate(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertNotFoundForDelete($client, '/api/export/' . PHP_INT_MAX);
}
public function testDeleteEntityIsSecure(): void
{
$client = $this->createClient();
$template = $this->importExportTemplate();
$this->assertRequestIsSecured($client, '/api/export/' . $template->getId(), Request::METHOD_DELETE);
}
public function testDeleteActionWithoutAuthorization(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$template = $this->importExportTemplate();
$this->request($client, '/api/export/' . $template->getId(), Request::METHOD_DELETE);
$response = $client->getResponse();
$this->assertApiResponseAccessDenied($response);
}
public function testDeleteAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$template = $this->importExportTemplate();
$this->request($client, '/api/export/' . $template->getId(), Request::METHOD_DELETE);
self::assertTrue($client->getResponse()->isSuccessful());
self::assertEquals(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode());
self::assertEmpty($client->getResponse()->getContent());
}
}

View File

@@ -120,6 +120,11 @@ class InvoiceControllerTest extends APIControllerBaseTestCase
self::assertApiResponseTypeStructure('Invoice', $result);
}
public function testDownloadInvoice(): void
{
$this->fail('Missing test');
}
public function testNotFound(): void
{
$this->assertEntityNotFound(User::ROLE_USER, '/api/invoices/' . PHP_INT_MAX);

View File

@@ -9,14 +9,11 @@
namespace App\Tests\Controller;
use App\Entity\ExportTemplate;
use App\Entity\Team;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Tests\DataFixtures\ExportTemplateFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
use Doctrine\ORM\EntityManager;
use Symfony\Component\DomCrawler\Field\FormField;
/**
* @group integration
@@ -28,7 +25,7 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
$this->assertUrlIsSecured('/export/');
}
public function testIsSecureForRole(): void
public function testIsSecureForrole(): void
{
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/export/');
}
@@ -93,16 +90,6 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
// +1 row for summary
$this->assertDataTableRowCount($client, 'datatable_export', 22);
$header = $client->getCrawler()->filter('section.content div.datatable_export table.dataTable thead th');
$titles = [];
/** @var \DOMElement $th */
foreach ($header as $th) {
$titles[] = trim($th->textContent);
}
self::assertEquals([
'', 'Date', 'User', 'Project', 'Activity', 'Description', 'Tags', 'Duration', 'Unit price', 'Internal price', 'Total price', '',
], $titles);
// assert export type buttons are available
$expected = [
'csv' => 'csv',
@@ -259,71 +246,4 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
self::assertTrue($timesheet->isExported());
}
}
public function testCreateTemplateIsSecure(): void
{
$this->assertUrlIsSecured('/export/template-create');
}
public function testCreateTemplateIsSecureForRole(): void
{
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/export/template-create');
}
public function testCreateTemplateAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/export/template-create');
$form = $client->getCrawler()->filter('form[name=export_template_spreadsheet_form]')->form();
$client->submit($form, [
'export_template_spreadsheet_form' => [
'title' => 'My temaplte name',
'renderer' => 'xlsx',
'language' => 'de',
'columns' => 'date',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/export/'));
$templates = $this->getEntityManager()->getRepository(ExportTemplate::class)->findAll();
self::assertCount(1, $templates);
$template = array_pop($templates);
$id = $template->getId();
$this->request($client, $this->createUrl('/export/template-edit/' . $id));
self::assertTrue($client->getResponse()->isSuccessful());
$editForm = $client->getCrawler()->filter('form[name=export_template_spreadsheet_form]')->form();
$field = $editForm->get('export_template_spreadsheet_form[title]');
self::assertInstanceOf(FormField::class, $field);
self::assertEquals('My temaplte name', $field->getValue());
}
public function testEditTemplateAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
/** @var ExportTemplate[] $templates */
$templates = $this->importFixture(new ExportTemplateFixtures());
$id = $templates[0]->getId();
$this->request($client, $this->createUrl('/export/template-edit/' . $id));
self::assertTrue($client->getResponse()->isSuccessful());
$form = $client->getCrawler()->filter('form[name=export_template_spreadsheet_form]')->form();
$field = $form->get('export_template_spreadsheet_form[title]');
self::assertInstanceOf(FormField::class, $field);
self::assertEquals('CSV Test', $field->getValue());
$client->submit($form, [
'export_template_spreadsheet_form' => [
'title' => 'My temaplte name',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/export/'));
/** @var ExportTemplate $template */
$template = $this->getEntityManager()->getRepository(ExportTemplate::class)->find($id);
self::assertEquals('My temaplte name', $template->getTitle());
}
}

View File

@@ -34,7 +34,7 @@ class PermissionControllerTest extends AbstractControllerBaseTestCase
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/permissions');
$this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 136);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 135);
$this->assertPageActions($client, [
'create modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),
]);

View File

@@ -35,9 +35,12 @@ class CustomerServiceTest extends TestCase
private function getSut(
?EventDispatcherInterface $dispatcher = null,
?ValidatorInterface $validator = null,
?CustomerRepository $repository = null,
?SystemConfiguration $configuration = null
): CustomerService {
$repository = $this->createMock(CustomerRepository::class);
if ($repository === null) {
$repository = $this->createMock(CustomerRepository::class);
}
if ($dispatcher === null) {
$dispatcher = $this->createMock(EventDispatcherInterface::class);
@@ -160,7 +163,7 @@ class CustomerServiceTest extends TestCase
]
]);
$sut = $this->getSut(null, null, $configuration);
$sut = $this->getSut(null, null, null, $configuration);
$customer = $sut->createNewCustomer('Test');
self::assertEquals((string) $expected, $customer->getNumber());

View File

@@ -1,40 +0,0 @@
<?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\Tests\DataFixtures;
use App\Entity\ExportTemplate;
use Doctrine\Persistence\ObjectManager;
final class ExportTemplateFixtures implements TestFixture
{
/**
* @return ExportTemplate[]
*/
public function load(ObjectManager $manager): array
{
$template1 = new ExportTemplate();
$template1->setRenderer('csv');
$template1->setLanguage('de');
$template1->setTitle('CSV Test');
$template1->setColumns(['date', 'user.name', 'duration', 'customer.name']);
$manager->persist($template1);
$template2 = new ExportTemplate();
$template2->setRenderer('xlsx');
$template2->setLanguage('en');
$template2->setTitle('Excel Test');
$template2->setColumns(['date', 'user.name', 'duration_seconds', 'project.name']);
$manager->persist($template2);
$manager->flush();
return [$template1, $template2];
}
}

View File

@@ -1,73 +0,0 @@
<?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\Tests\Entity;
use App\Entity\ExportTemplate;
/**
* @covers \App\Entity\ExportTemplate
*/
class ExportTemplateTest extends AbstractEntityTestCase
{
public function testDefaultValues(): void
{
$sut = new ExportTemplate();
self::assertNull($sut->getId());
self::assertNull($sut->getTitle());
self::assertEquals('csv', $sut->getRenderer());
self::assertNull($sut->getLanguage());
self::assertEquals([], $sut->getColumns());
self::assertEquals([], $sut->getOptions());
}
public function testSetter(): void
{
$sut = new ExportTemplate();
self::assertEquals('New', (string) $sut);
$sut->setTitle('foo');
self::assertEquals('foo', $sut->getTitle());
self::assertEquals('foo', (string) $sut);
$sut->setTitle(null);
self::assertNull($sut->getTitle());
self::assertEquals('New', (string) $sut);
$sut->setRenderer('xlsx');
self::assertEquals('xlsx', $sut->getRenderer());
$sut->setLanguage('de');
self::assertEquals('de', $sut->getLanguage());
$sut->setLanguage(null);
self::assertNull($sut->getLanguage());
$sut->setColumns(['foo', 'bar', 'WORLD']);
self::assertEquals(['foo', 'bar', 'WORLD'], $sut->getColumns());
$sut->setColumns(null);
self::assertEquals([], $sut->getColumns());
$sut->setOptions(['foo' => 1, 'bar' => true, 'WORLD' => 'HELLO']);
self::assertEquals(['foo' => 1, 'bar' => true, 'WORLD' => 'HELLO'], $sut->getOptions());
$sut->setOptions(null);
self::assertEquals([], $sut->getOptions());
}
public function testClone(): void
{
$sut = new ExportTemplate();
$r = new \ReflectionObject($sut);
$p = $r->getProperty('id');
$p->setAccessible(true);
$p->setValue($sut, 13);
self::assertEquals(13, $sut->getId());
$sut2 = clone $sut;
self::assertNull($sut2->getId());
}
}

View File

@@ -251,7 +251,7 @@ class TimesheetValidationTest extends KernelTestCase
$entity->setBegin($begin);
$entity->setEnd($end);
$this->assertHasViolationForField($entity, ['end_date', 'duration']);
$this->assertHasViolationForField($entity, 'end_date');
// allow same begin and end
$entity = $this->getEntity();

View File

@@ -53,7 +53,6 @@ class UserTest extends TestCase
$user->setUserIdentifier('foo');
self::assertEquals('foo', $user->getUserIdentifier());
self::assertEquals('foo', $user->getIdentifier());
self::assertEquals('foo', $user->getDisplayName());
$user->setAlias('BAR');
self::assertEquals('BAR', $user->getDisplayName());
@@ -90,7 +89,6 @@ class UserTest extends TestCase
public function testWorkContract(): void
{
$user = new User();
self::assertFalse($user->hasContractSettings());
self::assertEquals(0, $user->getWorkHoursMonday());
self::assertEquals(0, $user->getWorkHoursTuesday());
@@ -129,7 +127,6 @@ class UserTest extends TestCase
$user->setWorkHoursSunday(7800);
$user->setHolidaysPerYear(10.7);
self::assertTrue($user->hasWorkHourConfiguration());
self::assertTrue($user->hasContractSettings());
self::assertEquals(7200, $user->getWorkHoursMonday());
self::assertEquals(7300, $user->getWorkHoursTuesday());
@@ -161,16 +158,6 @@ class UserTest extends TestCase
$user->setPublicHolidayGroup('DE-NRW');
self::assertEquals('DE-NRW', $user->getPublicHolidayGroup());
self::assertNull($user->getWorkStartingDay());
$workStart = new \DateTimeImmutable('2018-07-23');
$user->setWorkStartingDay($workStart);
self::assertEquals($workStart, $user->getWorkStartingDay());
self::assertNull($user->getLastWorkingDay());
$workStart = new \DateTimeImmutable('2021-02-13');
$user->setLastWorkingDay($workStart);
self::assertEquals($workStart, $user->getLastWorkingDay());
}
public function testColor(): void
@@ -261,21 +248,6 @@ class UserTest extends TestCase
$user->setPreferenceValue('export_decimal', true);
self::assertTrue($user->isExportDecimal());
$prefs = $user->getPreferences();
self::assertCount(3, $prefs);
self::assertInstanceOf(UserPreference::class, $prefs[0]);
self::assertEquals('test', $prefs[0]->getName());
self::assertInstanceOf(UserPreference::class, $prefs[1]);
self::assertEquals('test2', $prefs[1]->getName());
self::assertInstanceOf(UserPreference::class, $prefs[2]);
self::assertEquals('export_decimal', $prefs[2]->getName());
$user->setPreferences(new ArrayCollection([]));
self::assertCount(0, $user->getPreferences());
}
public function testDisplayName(): void
@@ -360,12 +332,6 @@ class UserTest extends TestCase
self::assertTrue($sut->isTeamleadOf($team2));
self::assertTrue($sut->isInTeam($team2));
self::assertTrue($sut->isTeamleadOf($team2));
$user2 = new User();
self::assertFalse($sut->isTeamleadOfUser($user2));
$team2->addUser($user2);
self::assertTrue($sut->isTeamleadOfUser($user2));
self::assertCount(2, $sut->getTeams());
$sut->removeMembership(new TeamMember());
self::assertCount(2, $sut->getTeams());
@@ -658,21 +624,4 @@ class UserTest extends TestCase
self::assertNotNull($user->getSupervisor());
self::assertSame($supervisor, $user->getSupervisor());
}
public function testLastLogin(): void
{
$dateTime = new \DateTime('now', new \DateTimeZone('UTC'));
$user = new User();
$user->setTimezone('Europe/Berlin');
$lastLogin = $user->getLastLogin();
self::assertNull($lastLogin);
$user->setLastLogin($dateTime);
$lastLogin = $user->getLastLogin();
self::assertNotNull($lastLogin);
self::assertInstanceOf(\DateTime::class, $lastLogin);
self::assertEquals('Europe/Berlin', $lastLogin->getTimezone()->getName());
}
}

View File

@@ -13,8 +13,7 @@ use App\Entity\User;
use App\Export\Base\CsvRenderer;
use App\Export\Base\SpreadsheetRenderer;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
use Psr\Log\LoggerInterface;
use App\Tests\Export\Renderer\MetaFieldColumnSubscriber;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -38,13 +37,12 @@ class CsvRendererTest extends AbstractRendererTestCase
$security->expects($this->any())->method('getUser')->willReturn($user);
$security->expects($this->any())->method('isGranted')->willReturn(true);
$translator = $this->getContainer()->get(TranslatorInterface::class);
self::assertInstanceOf(TranslatorInterface::class, $translator);
$translator = $this->createMock(TranslatorInterface::class);
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
$dispatcher->addSubscriber(new MetaFieldColumnSubscriber());
return new CsvRenderer(new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class)), $translator);
return new CsvRenderer(new SpreadsheetRenderer($dispatcher, $security), $translator);
}
public function testConfiguration(): void
@@ -52,43 +50,23 @@ class CsvRendererTest extends AbstractRendererTestCase
$sut = $this->getAbstractRenderer();
self::assertEquals('csv', $sut->getId());
self::assertEquals('default', $sut->getTitle());
$sut->setTitle('foo-bar');
self::assertEquals('foo-bar', $sut->getTitle());
$sut->setId('bar-id');
self::assertEquals('bar-id', $sut->getId());
self::assertEquals('csv', $sut->getTitle());
}
public static function getTestModel(): array
{
$en = [
'Date', 'From', 'To', 'Duration', 'Currency', 'Price', 'Internal price', 'Hourly price', 'Fixed price', 'Name',
'User', 'Staff number', 'Customer', 'Project', 'Activity', 'Description', 'Billable', 'Tags',
'Type', 'category', 'Account', 'Project number', 'VAT-ID', 'Order number',
'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'mypref',
];
$de = [
'Datum', 'Von', 'Bis', 'Dauer', 'Währung', 'Preis', 'Interner Preis', 'Preis pro Stunde', 'Festpreis', 'Name',
'Benutzer', 'Personalnummer', 'Kunde', 'Projekt', 'Tätigkeit', 'Beschreibung', 'Abrechenbar', 'Schlagworte',
'Typ', 'category', 'Kundennummer', 'Projektnummer', 'Umsatzsteuer-ID', 'Bestellnummer',
'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'mypref',
];
return [
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, false, null, $en],
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, true, 'de', $de]
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, false],
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, true]
];
}
/**
* @dataProvider getTestModel
*/
public function testRender(string $totalDuration, string $totalRate, string $expectedRate, int $expectedRows, int $expectedDescriptions, int $expectedUser1, int $expectedUser2, int $expectedUser3, bool $exportDecimal, ?string $locale, array $header): void
public function testRender(string $totalDuration, string $totalRate, string $expectedRate, int $expectedRows, int $expectedDescriptions, int $expectedUser1, int $expectedUser2, int $expectedUser3, bool $exportDecimal): void
{
$sut = $this->getAbstractRenderer($exportDecimal);
$sut->setLocale($locale);
/** @var BinaryFileResponse $response */
$response = $this->render($sut);
@@ -124,13 +102,11 @@ class CsvRendererTest extends AbstractRendererTestCase
$all[] = str_getcsv($row);
}
self::assertEquals($header, $all[0]);
$expected = [
'2019-06-16',
'12:00',
'12:06',
($exportDecimal ? '0.11' : '0:06'),
($exportDecimal ? '0.11' : '0:06:40'),
//'0.11',
'EUR',
'0',
@@ -158,14 +134,13 @@ class CsvRendererTest extends AbstractRendererTestCase
'',
'project-foo2',
'activity-bar',
'',
];
$expected2 = [
'2019-06-16',
'12:00',
'12:06',
($exportDecimal ? '0.11' : '0:06'),
($exportDecimal ? '0.11' : '0:06:40'),
//'0.11',
'EUR',
'0',
@@ -193,7 +168,6 @@ class CsvRendererTest extends AbstractRendererTestCase
'',
'project-foo2',
'activity-bar',
'',
];
self::assertEquals(7, \count($all));

View File

@@ -1,56 +0,0 @@
<?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\Tests\Export\Base;
use App\Export\Base\PDFRenderer;
use App\Pdf\HtmlToPdfConverter;
use App\Project\ProjectStatisticService;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use Twig\Environment;
/**
* @covers \App\Export\Base\PDFRenderer
* @covers \App\Export\Base\RendererTrait
* @covers \App\Pdf\PdfRendererTrait
* @group integration
*/
class PdfRendererTest extends AbstractRendererTestCase
{
protected function getAbstractRenderer(bool $exportDecimal = false): PDFRenderer
{
$twig = $this->createMock(Environment::class);
$converter = $this->createMock(HtmlToPdfConverter::class);
$projectStatisticService = $this->createMock(ProjectStatisticService::class);
return new PDFRenderer($twig, $converter, $projectStatisticService);
}
public function testConfiguration(): void
{
$sut = $this->getAbstractRenderer();
self::assertEquals('pdf', $sut->getId());
self::assertEquals('pdf', $sut->getTitle());
$sut->setTitle('foo-bar');
self::assertEquals('foo-bar', $sut->getTitle());
$sut->setId('bar-id');
self::assertEquals('bar-id', $sut->getId());
self::assertEquals([], $sut->getPdfOptions());
$sut->setPdfOption('foo', 'bar');
self::assertEquals(['foo' => 'bar'], $sut->getPdfOptions());
$sut->setPdfOption('foo', 'bar2');
self::assertEquals(['foo' => 'bar2'], $sut->getPdfOptions());
$sut->setPdfOption('hello', 'world');
self::assertEquals(['foo' => 'bar2', 'hello' => 'world'], $sut->getPdfOptions());
}
}

View File

@@ -12,15 +12,10 @@ namespace App\Tests\Export\Base;
use App\Entity\ExportableItem;
use App\Export\Base\SpreadsheetRenderer;
use App\Export\Package\SpreadsheetPackage;
use App\Export\Template;
use App\Repository\Query\TimesheetQuery;
use App\Tests\Export\Package\MemoryPackage;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
@@ -36,7 +31,7 @@ class SpreadsheetRendererTest extends AbstractRendererTestCase
$spreadsheetPackage = $this->createMock(SpreadsheetPackage::class);
$spreadsheetPackage->expects(self::once())->method('setColumns');
$renderer = new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class));
$renderer = new SpreadsheetRenderer($dispatcher, $security);
$renderer->writeSpreadsheet($spreadsheetPackage, [], new TimesheetQuery());
}
@@ -65,7 +60,7 @@ class SpreadsheetRendererTest extends AbstractRendererTestCase
$exportItem->method('getType')->willReturn('type');
$exportItem->method('getCategory')->willReturn('category');
$renderer = new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class));
$renderer = new SpreadsheetRenderer($dispatcher, $security);
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem], new TimesheetQuery());
}
@@ -98,96 +93,4 @@ class SpreadsheetRendererTest extends AbstractRendererTestCase
$renderer = new SpreadsheetRenderer($dispatcher, $security);
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem, $exportItem], new TimesheetQuery());
}
public static function getTestData(): iterable
{
yield [null, [
'date' => 'date',
'begin' => 'begin',
'end' => 'end',
'duration' => 'duration',
'currency' => 'currency',
'rate' => 'rate',
'internalRate' => 'internalRate',
'hourlyRate' => 'hourlyRate',
'fixedRate' => 'fixedRate',
'alias' => 'alias',
'username' => 'username',
'account_number' => 'account_number',
'customer' => 'customer',
'project' => 'project',
'activity' => 'activity',
'description' => 'description',
'billable' => 'billable',
'tags' => 'tags',
'type' => 'type',
'category' => 'category',
'number' => 'number',
'project_number' => 'project_number',
'vat_id' => 'vat_id',
'orderNumber' => 'orderNumber',
'timesheet.meta.foo' => 'Working place',
'timesheet.meta.foo2' => 'Working place',
'customer.meta.customer-foo' => 'Working place',
'project.meta.project-foo' => 'Working place',
'project.meta.project-foo2' => 'Working place',
'activity.meta.activity-foo' => 'Working place',
'user.meta.mypref' => 'mypref',
]];
$template = new Template('test', 'Testing');
$template->setLocale('de');
$template->setColumns(['date', 'user.name', 'duration_decimal', 'customer.name', 'exported', 'user.meta.mypref']);
yield [$template, [
'date' => 'date',
'duration' => 'duration',
'username' => 'username',
'customer' => 'customer',
'exported' => 'exported',
'user.meta.mypref' => 'mypref',
]];
}
/**
* @dataProvider getTestData
*/
public function testWriteSpreadsheetCsv(?Template $template, array $expectedColumns): void
{
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
$security = $this->createMock(Security::class);
$spreadsheetPackage = new MemoryPackage();
$exportItem = $this->createMock(ExportableItem::class);
$exportItem->method('getBegin')->willReturn(new \DateTime());
$exportItem->method('getEnd')->willReturn(new \DateTime());
$exportItem->method('getDuration')->willReturn(3600);
$exportItem->method('getRate')->willReturn(100.0);
$exportItem->method('getInternalRate')->willReturn(80.0);
$exportItem->method('getHourlyRate')->willReturn(50.0);
$exportItem->method('getFixedRate')->willReturn(200.0);
$exportItem->method('getUser')->willReturn(null);
$exportItem->method('getProject')->willReturn(null);
$exportItem->method('getActivity')->willReturn(null);
$exportItem->method('getDescription')->willReturn('Test description');
$exportItem->method('isBillable')->willReturn(true);
$exportItem->method('getTagsAsArray')->willReturn(['tag1', 'tag2']);
$exportItem->method('getType')->willReturn('type');
$exportItem->method('getCategory')->willReturn('category');
$renderer = new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class));
$renderer->setTemplate($template);
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem], new TimesheetQuery());
$columnNames = [];
foreach ($spreadsheetPackage->getColumns() as $column) {
$columnNames[$column->getName()] = $column->getHeader();
}
self::assertEquals(null, $spreadsheetPackage->getFilename());
self::assertEquals($expectedColumns, $columnNames);
self::assertCount(2, $spreadsheetPackage->getRows());
}
}

View File

@@ -13,7 +13,7 @@ use App\Entity\User;
use App\Export\Base\SpreadsheetRenderer;
use App\Export\Base\XlsxRenderer;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
use App\Tests\Export\Renderer\MetaFieldColumnSubscriber;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -38,7 +38,7 @@ class XlsxRendererTest extends AbstractRendererTestCase
$translator->method('trans')->willReturnArgument(0);
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
$dispatcher->addSubscriber(new MetaFieldColumnSubscriber());
return new XlsxRenderer(new SpreadsheetRenderer($dispatcher, $security), $translator);
}
@@ -46,16 +46,9 @@ class XlsxRendererTest extends AbstractRendererTestCase
public function testConfiguration(): void
{
$sut = $this->getAbstractRenderer();
$sut->setLocale('de');
self::assertEquals('xlsx', $sut->getId());
self::assertEquals('default', $sut->getTitle());
$sut->setTitle('foo-bar');
self::assertEquals('foo-bar', $sut->getTitle());
$sut->setId('bar-id');
self::assertEquals('bar-id', $sut->getId());
self::assertEquals('xlsx', $sut->getTitle());
}
public function testRender(): void

View File

@@ -19,13 +19,13 @@ class DurationFormatterTest extends TestCase
{
public function testGetFormat(): void
{
$formatter = new DurationFormatter('[hh]:mm:ss');
$formatter = new DurationFormatter();
self::assertEquals('[hh]:mm:ss', $formatter->getFormat());
}
public function testFormatValueReturnsFormattedDurationQuiteLong(): void
{
$formatter = new DurationFormatter('[hh]:mm:ss');
$formatter = new DurationFormatter();
$result = $formatter->formatValue(701213);
self::assertInstanceOf(\DateInterval::class, $result);
self::assertEquals('194:46:53', $result->format('%r%H:%I:%S'));
@@ -33,7 +33,7 @@ class DurationFormatterTest extends TestCase
public function testFormatValueReturnsFormattedDurationForNumericValue(): void
{
$formatter = new DurationFormatter('[hh]:mm:ss');
$formatter = new DurationFormatter();
$result = $formatter->formatValue(7213);
self::assertInstanceOf(\DateInterval::class, $result);
self::assertEquals('02:00:13', $result->format('%r%H:%I:%S'));
@@ -41,7 +41,7 @@ class DurationFormatterTest extends TestCase
public function testFormatValueReturnsZeroForNonNumericValue(): void
{
$formatter = new DurationFormatter('[hh]:mm:ss');
$formatter = new DurationFormatter();
$result = $formatter->formatValue('not a number');
self::assertInstanceOf(\DateInterval::class, $result);
self::assertEquals('00:00:00', $result->format('%r%H:%I:%S'));
@@ -49,7 +49,7 @@ class DurationFormatterTest extends TestCase
public function testFormatValueReturnsFormattedDurationForFloatValue(): void
{
$formatter = new DurationFormatter('[hh]:mm:ss');
$formatter = new DurationFormatter();
$result = $formatter->formatValue(4521.5);
self::assertInstanceOf(\DateInterval::class, $result);
self::assertEquals('01:15:21', $result->format('%r%H:%I:%S'));
@@ -57,7 +57,7 @@ class DurationFormatterTest extends TestCase
public function testFormatValueReturnsZeroForNullValue(): void
{
$formatter = new DurationFormatter('[hh]:mm:ss');
$formatter = new DurationFormatter();
$result = $formatter->formatValue(null);
self::assertInstanceOf(\DateInterval::class, $result);
self::assertEquals('00:00:00', $result->format('%r%H:%I:%S'));
@@ -65,7 +65,7 @@ class DurationFormatterTest extends TestCase
public function testFormatValueReturnsFormattedDurationForNegativeValue(): void
{
$formatter = new DurationFormatter('[hh]:mm:ss');
$formatter = new DurationFormatter();
$result = $formatter->formatValue(-3600);
self::assertInstanceOf(\DateInterval::class, $result);
self::assertEquals('-01:00:00', $result->format('%r%H:%I:%S'));

View File

@@ -19,50 +19,42 @@ class DurationPlainFormatterTest extends TestCase
{
public function testFormatValueReturnsFormattedDurationQuiteLong(): void
{
$formatter = new DurationPlainFormatter(true);
$formatter = new DurationPlainFormatter();
$result = $formatter->formatValue(701213);
self::assertEquals('194:46:53', $result);
$formatter = new DurationPlainFormatter(false);
$result = $formatter->formatValue(701213);
self::assertEquals('194:46', $result);
}
public function testFormatValueReturnsFormattedDurationForNumericValue(): void
{
$formatter = new DurationPlainFormatter(true);
$formatter = new DurationPlainFormatter();
$result = $formatter->formatValue(8246);
self::assertEquals('2:17:26', $result);
}
public function testFormatValueReturnsZeroForNonNumericValue(): void
{
$formatter = new DurationPlainFormatter(true);
$formatter = new DurationPlainFormatter();
$result = $formatter->formatValue('not a number');
self::assertEquals('0:00:00', $result);
$formatter = new DurationPlainFormatter(false);
$result = $formatter->formatValue('not a number');
self::assertEquals('0:00', $result);
}
public function testFormatValueReturnsFormattedDurationForFloatValue(): void
{
$formatter = new DurationPlainFormatter(true);
$formatter = new DurationPlainFormatter();
$result = $formatter->formatValue(44513.5);
self::assertEquals('12:21:53', $result);
}
public function testFormatValueReturnsZeroForNullValue(): void
{
$formatter = new DurationPlainFormatter(true);
$formatter = new DurationPlainFormatter();
$result = $formatter->formatValue(null);
self::assertEquals('0:00:00', $result);
}
public function testFormatValueReturnsFormattedDurationForNegativeValue(): void
{
$formatter = new DurationPlainFormatter(true);
$formatter = new DurationPlainFormatter();
$result = $formatter->formatValue(-3600);
self::assertEquals('-1:00:00', $result);
}

View File

@@ -1,71 +0,0 @@
<?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\Tests\Export\Package;
use App\Export\Package\Column;
use App\Export\Package\SpreadsheetPackage;
/**
* @covers \App\Export\Package\SpoutSpreadsheet
*/
class MemoryPackage implements SpreadsheetPackage
{
private ?string $filename = null;
/** @var array<Column> */
private array $columns = [];
private array $rows = [];
private bool $saved = false;
public function open(string $filename): void
{
$this->filename = $filename;
}
public function save(): void
{
$this->saved = true;
}
/**
* @param array<Column> $columns
*/
public function setColumns(array $columns): void
{
$this->columns = $columns;
}
public function addRow(array $columns, array $options = []): void
{
$this->rows[] = ['columns' => $columns, 'options' => $options];
}
public function getFilename(): ?string
{
return $this->filename;
}
/**
* @return Column[]
*/
public function getColumns(): array
{
return $this->columns;
}
public function getRows(): array
{
return $this->rows;
}
public function isSaved(): bool
{
return $this->saved;
}
}

View File

@@ -13,15 +13,22 @@ use App\Entity\Activity;
use App\Entity\ActivityMeta;
use App\Entity\Customer;
use App\Entity\CustomerMeta;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Project;
use App\Entity\ProjectMeta;
use App\Entity\Tag;
use App\Entity\Timesheet;
use App\Entity\TimesheetMeta;
use App\Entity\User;
use App\Event\ActivityMetaDisplayEvent;
use App\Event\CustomerMetaDisplayEvent;
use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Export\ExportRendererInterface;
use App\Repository\Query\TimesheetQuery;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Response;
abstract class AbstractRendererTestCase extends KernelTestCase
@@ -138,3 +145,47 @@ abstract class AbstractRendererTestCase extends KernelTestCase
return $renderer->render($entries, $query);
}
}
class MetaFieldColumnSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
TimesheetMetaDisplayEvent::class => ['loadTimesheetField', 200],
CustomerMetaDisplayEvent::class => ['loadCustomerField', 200],
ProjectMetaDisplayEvent::class => ['loadProjectField', 200],
ActivityMetaDisplayEvent::class => ['loadActivityField', 200],
];
}
public function loadTimesheetField(TimesheetMetaDisplayEvent $event): void
{
$event->addField($this->prepareEntity(new TimesheetMeta(), 'foo'));
$event->addField($this->prepareEntity(new TimesheetMeta(), 'foo2'));
}
public function loadCustomerField(CustomerMetaDisplayEvent $event): void
{
$event->addField($this->prepareEntity(new CustomerMeta(), 'customer-foo'));
}
public function loadProjectField(ProjectMetaDisplayEvent $event): void
{
$event->addField($this->prepareEntity(new ProjectMeta(), 'project-foo'));
$event->addField($this->prepareEntity(new ProjectMeta(), 'project-foo2')->setIsVisible(false));
}
public function loadActivityField(ActivityMetaDisplayEvent $event): void
{
$event->addField($this->prepareEntity(new ActivityMeta(), 'activity-foo'));
}
private function prepareEntity(MetaTableTypeInterface $meta, string $name): MetaTableTypeInterface
{
return $meta
->setLabel('Working place')
->setName($name)
->setType(TextType::class)
->setIsVisible(true);
}
}

View File

@@ -1,44 +0,0 @@
<?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\Tests\Export\Renderer;
use App\Export\Base\CsvRenderer;
use App\Export\Renderer\CsvRendererFactory;
use App\Export\Template;
use PHPUnit\Framework\TestCase;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @covers \App\Export\Renderer\CsvRendererFactory
*/
class CsvRendererFactoryTest extends TestCase
{
public function testCreate(): void
{
$sut = new CsvRendererFactory(
$this->createMock(EventDispatcherInterface::class),
$this->createMock(Security::class),
$this->createMock(TranslatorInterface::class),
$this->createMock(LoggerInterface::class)
);
$template = new Template('foo-id', 'bar-title');
$template->setLocale('it_IT');
$renderer = $sut->create($template);
self::assertInstanceOf(CsvRenderer::class, $renderer);
self::assertEquals('foo-id', $renderer->getId());
self::assertEquals('bar-title', $renderer->getTitle());
}
}

View File

@@ -1,44 +0,0 @@
<?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\Tests\Export\Renderer;
use App\Export\Base\XlsxRenderer;
use App\Export\Renderer\XlsxRendererFactory;
use App\Export\Template;
use PHPUnit\Framework\TestCase;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @covers \App\Export\Renderer\XlsxRendererFactory
*/
class XlsxRendererFactoryTest extends TestCase
{
public function testCreate(): void
{
$sut = new XlsxRendererFactory(
$this->createMock(EventDispatcherInterface::class),
$this->createMock(Security::class),
$this->createMock(TranslatorInterface::class),
$this->createMock(LoggerInterface::class)
);
$template = new Template('foo-id', 'bar-title');
$template->setLocale('it_IT');
$renderer = $sut->create($template);
self::assertInstanceOf(XlsxRenderer::class, $renderer);
self::assertEquals('foo-id', $renderer->getId());
self::assertEquals('bar-title', $renderer->getTitle());
}
}

View File

@@ -10,22 +10,15 @@
namespace App\Tests\Export;
use App\Activity\ActivityStatisticService;
use App\Entity\ExportTemplate;
use App\Export\Base\CsvRenderer;
use App\Export\Base\HtmlRenderer;
use App\Export\Base\XlsxRenderer;
use App\Export\ExportRepositoryInterface;
use App\Export\ServiceExport;
use App\Export\Timesheet\HtmlRenderer as HtmlExporter;
use App\Project\ProjectStatisticService;
use App\Repository\ExportTemplateRepository;
use App\Repository\Query\ExportQuery;
use App\Tests\Mocks\Export\CsvRendererFactoryMock;
use App\Tests\Mocks\Export\HtmlRendererFactoryMock;
use App\Tests\Mocks\Export\PdfRendererFactoryMock;
use App\Tests\Mocks\Export\XlsxRendererFactoryMock;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Twig\Environment;
@@ -35,48 +28,12 @@ use Twig\Environment;
*/
class ServiceExportTest extends TestCase
{
private function createSut(bool $withTemplates = false, int $failureCount = 1): ServiceExport
private function createSut(): ServiceExport
{
$repository = $this->createMock(ExportTemplateRepository::class);
$templates = [];
$logger = $this->createMock(LoggerInterface::class);
if ($withTemplates) {
$template1 = $this->createMock(ExportTemplate::class);
$template1->method('getId')->willReturn(1);
$template1->method('getTitle')->willReturn('CSV Test');
$template1->method('getLanguage')->willReturn('de');
$template1->method('getRenderer')->willReturn('csv');
$template1->method('getColumns')->willReturn(['date', 'customer.name', 'duration', 'rate']);
$template2 = $this->createMock(ExportTemplate::class);
$template2->method('getId')->willReturn(2);
$template2->method('getTitle')->willReturn('XLSX Test');
$template2->method('getLanguage')->willReturn('it');
$template2->method('getRenderer')->willReturn('xlsx');
$template2->method('getColumns')->willReturn(['date', 'begin', 'duration', 'rate', 'user.name']);
$template3 = $this->createMock(ExportTemplate::class);
$template3->method('getTitle')->willReturn('XLSX Test');
$template3->method('getLanguage')->willReturn('it');
$template3->method('getRenderer')->willReturn('foo'); // invalid renderer will be ignored
$template3->method('getColumns')->willReturn(['date', 'begin', 'duration', 'rate', 'user.name']);
$logger->expects($this->exactly($failureCount))->method('error')->with('Unknown export template type: ' . $template3->getRenderer());
$templates = [$template1, $template2, $template3];
}
$repository->method('findAll')->willReturn($templates);
return new ServiceExport(
$this->createMock(EventDispatcherInterface::class),
(new HtmlRendererFactoryMock($this))->create(),
(new PdfRendererFactoryMock($this))->create(),
(new CsvRendererFactoryMock($this))->create(),
(new XlsxRendererFactoryMock($this))->create(),
$repository,
$logger,
);
}
@@ -131,18 +88,4 @@ class ServiceExportTest extends TestCase
self::assertEquals([], $items);
}
public function testWithTemplates(): void
{
$sut = $this->createSut(true, 5);
$renderer = $sut->getRenderer();
self::assertCount(2, $renderer);
self::assertInstanceOf(CsvRenderer::class, $renderer[0]);
self::assertInstanceOf(XlsxRenderer::class, $renderer[1]);
self::assertInstanceOf(CsvRenderer::class, $sut->getRendererById('1'));
self::assertNull($sut->getRendererById('default'));
self::assertNull($sut->getRendererById('csv'));
self::assertNull($sut->getRendererById('xlsx'));
}
}

View File

@@ -1,63 +0,0 @@
<?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\Tests\Export;
use App\Export\Template;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Export\Template
*/
class TemplateTest extends TestCase
{
public function testDefaultValues(): void
{
$template = new Template('id', 'title');
self::assertEquals('id', $template->getId());
self::assertEquals('title', $template->getTitle());
self::assertNull($template->getLocale());
self::assertEquals([], $template->getColumns());
self::assertEquals([], $template->getOptions());
}
public function testSetsAndGetsColumnsCorrectly(): void
{
$template = new Template('id', 'title');
$columns = ['Column1', 'Column2'];
$template->setColumns($columns);
self::assertEquals($columns, $template->getColumns());
$template->setColumns([]);
self::assertEquals([], $template->getColumns());
}
public function testSetsAndGetsOptionsCorrectly(): void
{
$template = new Template('id', 'title');
$options = ['key1' => 'value1', 'key2' => 'value2'];
$template->setOptions($options);
self::assertEquals($options, $template->getOptions());
$template->setOptions([]);
self::assertEquals([], $template->getOptions());
}
public function testSetsAndGetsLocaleCorrectly(): void
{
$template = new Template('id', 'title');
$template->setLocale('en_US');
self::assertEquals('en_US', $template->getLocale());
}
public function testHandlesNullLocaleGracefully(): void
{
$template = new Template('id', 'title');
$template->setLocale(null);
self::assertNull($template->getLocale());
}
}

View File

@@ -1,61 +0,0 @@
<?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\Tests\Form;
use App\Configuration\LocaleService;
use App\Entity\ExportTemplate;
use App\Form\ExportTemplateSpreadsheetForm;
use App\Form\Type\ExportColumnsType;
use App\Form\Type\LanguageType;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\Form\Test\TypeTestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @covers \App\Form\ExportTemplateSpreadsheetForm
*/
class ExportTemplateSpreadsheetFormTest extends TypeTestCase
{
/**
* @return FormTypeInterface[]
*/
protected function getTypes(): array // @phpstan-ignore missingType.generics
{
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$translator = $this->createMock(TranslatorInterface::class);
return [
new ExportColumnsType($dispatcher, $translator),
new LanguageType(new LocaleService([]))
];
}
public function testWithGlobalNewActivity(): void
{
$model = new ExportTemplate();
$form = $this->factory->createBuilder(ExportTemplateSpreadsheetForm::class, $model);
$attr = $form->getFormConfig()->getOption('attr');
self::assertIsArray($attr);
self::assertArrayHasKey('data-form-event', $attr);
self::assertEquals('kimai.exportTemplate', $attr['data-form-event']);
self::assertTrue($form->has('title'));
self::assertTrue($form->has('renderer'));
self::assertTrue($form->has('language'));
self::assertTrue($form->has('columns'));
self::assertTrue($form->get('title')->getRequired());
self::assertTrue($form->get('renderer')->getRequired());
self::assertFalse($form->get('language')->getRequired());
self::assertTrue($form->get('columns')->getRequired());
}
}

View File

@@ -12,8 +12,6 @@ namespace App\Tests\Form\Extension;
use App\Form\Extension\DocumentationLinkExtension;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -45,18 +43,4 @@ class DocumentationLinkExtensionTest extends TestCase
$this->expectException(InvalidOptionsException::class);
$resolver->resolve(['docu_chapter' => true]);
}
public function testBuildView(): void
{
$sut = new DocumentationLinkExtension();
$form = $this->createMock(FormInterface::class);
$view = new FormView();
$sut->buildView($view, $form, ['docu_chapter' => null]);
self::assertEquals(['attr' => [], 'value' => null, 'docu_chapter' => null], $view->vars);
$view = new FormView();
$sut->buildView($view, $form, ['docu_chapter' => 'customers']);
self::assertEquals(['attr' => [], 'value' => null, 'docu_chapter' => 'customers'], $view->vars);
}
}

View File

@@ -13,8 +13,6 @@ use App\Form\Extension\EnhancedChoiceTypeExtension;
use PHPUnit\Framework\TestCase;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -32,52 +30,15 @@ class EnhancedChoiceTypeExtensionTest extends TestCase
$resolver = new OptionsResolver();
$sut = new EnhancedChoiceTypeExtension();
$sut->configureOptions($resolver);
self::assertEquals(['selectpicker', 'width', 'search', 'order'], $resolver->getDefinedOptions());
self::assertEquals(['selectpicker', 'width', 'search'], $resolver->getDefinedOptions());
self::assertTrue($resolver->hasDefault('selectpicker'));
self::assertTrue($resolver->hasDefault('width'));
self::assertTrue($resolver->hasDefault('search'));
self::assertTrue($resolver->hasDefault('order'));
self::assertFalse($resolver->isRequired('selectpicker'));
self::assertFalse($resolver->isRequired('width'));
self::assertFalse($resolver->isRequired('search'));
$result = $resolver->resolve([]);
self::assertEquals(['selectpicker' => true, 'width' => '100%', 'search' => true, 'order' => false], $result);
}
public static function getTestData(): iterable
{
yield [
['expanded' => true],
['value' => null, 'attr' => []]
];
yield [
['multiple' => false, 'width' => false, 'search' => true, 'selectpicker' => true, 'order', 'required' => false],
['value' => null, 'attr' => ['class' => 'selectpicker']]
];
yield [
['multiple' => false, 'width' => '100%', 'search' => true, 'required' => false, 'order' => true],
['value' => null, 'attr' => ['class' => 'selectpicker', 'data-width' => '100%', 'data-order' => 1]]
];
yield [
['multiple' => true, 'width' => '50%', 'search' => false, 'required' => true, 'attr' => []],
['value' => null, 'attr' => ['size' => 1, 'class' => 'selectpicker', 'data-width' => '50%', 'data-disable-search' => 1, 'required' => 'required', 'placeholder' => '']]
];
}
/**
* @dataProvider getTestData
*/
public function testBuildView(array $options, array $expected): void
{
$sut = new EnhancedChoiceTypeExtension();
$view = new FormView();
$form = $this->createMock(FormInterface::class);
$sut->buildView($view, $form, $options);
self::assertEquals($expected, $view->vars);
self::assertEquals(['selectpicker' => true, 'width' => '100%', 'search' => true], $result);
}
}

View File

@@ -1,72 +0,0 @@
<?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\Tests\Form\Type;
use App\Form\Type\ExportColumnsType;
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Test\TypeTestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @covers \App\Form\Type\ExportColumnsType
*/
class ExportColumnsTypeTest extends TypeTestCase
{
public static function getTestData(): iterable
{
yield [['foo', 'bar'], []];
yield [
['user.name', 'customer.meta.customer-foo', 'duration', 'hello', 'user.meta.mypref'],
['user.name', 'customer.meta.customer-foo', 'duration', 'user.meta.mypref']
];
}
/**
* @return ExportColumnsType[]
*/
protected function getTypes(): array
{
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
$translator = $this->createMock(TranslatorInterface::class);
return [
new ExportColumnsType($dispatcher, $translator)
];
}
/**
* @param array<mixed> $value
* @param array<mixed> $expected
* @dataProvider getTestData
*/
public function testSubmitValidData(array $value, array $expected): void
{
$data = ['columns' => $value];
$model = new TypeTestModel(['columns' => []]);
$form = $this->factory->createBuilder(FormType::class, $model);
$form->add('columns', ExportColumnsType::class);
$form = $form->getForm();
$expected = new TypeTestModel([
'columns' => $expected
]);
$form->submit($data);
self::assertTrue($form->isSynchronized());
self::assertEquals($expected, $model);
}
}

View File

@@ -1,51 +0,0 @@
<?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\Tests\Form\Type;
use App\Form\Type\ExportRendererType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Test\TypeTestCase;
/**
* @covers \App\Form\Type\ExportRendererType
*/
class ExportRendererTypeTest extends TypeTestCase
{
public static function getTestData(): iterable
{
yield ['foo', null];
yield ['csv', 'csv'];
yield ['csV', null];
yield ['xlsx', 'xlsx'];
yield ['XLSX', null];
}
/**
* @dataProvider getTestData
*/
public function testSubmitValidData(string $value, string|null $expected): void
{
$data = ['renderer' => $value];
$model = new TypeTestModel(['renderer' => null]);
$form = $this->factory->createBuilder(FormType::class, $model);
$form->add('renderer', ExportRendererType::class);
$form = $form->getForm();
$expected = new TypeTestModel([
'renderer' => $expected
]);
$form->submit($data);
self::assertTrue($form->isSynchronized());
self::assertEquals($expected, $model);
}
}

View File

@@ -14,7 +14,7 @@ namespace App\Tests\Form\Type;
*/
class TypeTestModel extends \ArrayObject
{
public function __set(string $name, string|int|null|array $value)
public function __set(string $name, string|int|null $value)
{
$this->offsetSet($name, $value);
}

Some files were not shown because too many files have changed in this diff Show More