configurable csv/xlsx export templates (#5531)

This commit is contained in:
Kevin Papst
2025-06-11 13:06:33 +02:00
committed by GitHub
parent 46129c7ab9
commit 05aaa1950a
83 changed files with 2632 additions and 412 deletions

View File

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

View File

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

View File

@@ -119,8 +119,8 @@ kimai:
roles: roles:
ROLE_USER: ['view_team_member','time_team_project','create_tag','view_reporting'] 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_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'] 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'] 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']
# -------------------------------------------------------------------------------- # --------------------------------------------------------------------------------

View File

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

View File

@@ -0,0 +1,51 @@
<?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

@@ -4,7 +4,12 @@
backupGlobals="false" backupGlobals="false"
colors="true" colors="true"
bootstrap="tests/bootstrap.php" bootstrap="tests/bootstrap.php"
cacheDirectory="var/cache/phpunit/"> cacheDirectory="var/cache/phpunit/"
displayDetailsOnTestsThatTriggerDeprecations="true"
displayDetailsOnTestsThatTriggerErrors="true"
displayDetailsOnTestsThatTriggerNotices="true"
displayDetailsOnTestsThatTriggerWarnings="true"
displayDetailsOnPhpunitDeprecations="true">
<php> <php>
<ini name="error_reporting" value="-1"/> <ini name="error_reporting" value="-1"/>
<ini name="max_execution_time" value="-1"/> <ini name="max_execution_time" value="-1"/>
@@ -34,14 +39,15 @@
--> -->
<env name="BOOTSTRAP_RESET_DATABASE" value="true"/> <env name="BOOTSTRAP_RESET_DATABASE" value="true"/>
</php> </php>
<testsuites> <testsuites>
<testsuite name="Kimai"> <testsuite name="Kimai">
<directory>tests/</directory> <directory>tests/</directory>
</testsuite> </testsuite>
</testsuites> </testsuites>
<extensions>
<coverage> <bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
<source>
<include> <include>
<directory suffix=".php">src/</directory> <directory suffix=".php">src/</directory>
<directory suffix=".php">templates/</directory> <directory suffix=".php">templates/</directory>
@@ -58,10 +64,5 @@
<directory suffix=".php">var/</directory> <directory suffix=".php">var/</directory>
<directory suffix=".php">vendor/</directory> <directory suffix=".php">vendor/</directory>
</exclude> </exclude>
</coverage> </source>
<extensions>
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
</phpunit> </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": { "app": {
"js": [ "js": [
"/build/runtime.6c399d29.js", "/build/runtime.6c399d29.js",
"/build/app.6bb53337.js" "/build/app.d4ea12cd.js"
], ],
"css": [ "css": [
"/build/app.0416ea92.css" "/build/app.6e7b2701.css"
] ]
}, },
"app-rtl": { "app-rtl": {
@@ -15,7 +15,7 @@
"/build/app-rtl.7a875ca7.js" "/build/app-rtl.7a875ca7.js"
], ],
"css": [ "css": [
"/build/app-rtl.0848906b.css" "/build/app-rtl.bfe8d111.css"
] ]
}, },
"export-pdf": { "export-pdf": {
@@ -72,10 +72,10 @@
}, },
"integrity": { "integrity": {
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC", "/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
"/build/app.6bb53337.js": "sha384-ZcwEys2VjhW4jwH/pU+/W06vG7rBounyirdkC+TCA4PyMn2QsENR6FIcNoJEpF5X", "/build/app.d4ea12cd.js": "sha384-vv5bzRvK0YN29MXeYe0uKv/LdRlwzMBu+RMvBXpNGu6btx1WxFmDOF6QM+CDoDPB",
"/build/app.0416ea92.css": "sha384-JAIO6+B/vmV8IlpsmQ+zkfO4JsvdKTMsZZaDg6EMlZxkMZ5K3cpEK0mo24D/Wgkh", "/build/app.6e7b2701.css": "sha384-rrrlE5ISpDF53P81i2ce7AKONdm+b+I3mFacFPiJUohuMbMuHnUINac7YDkKD0/p",
"/build/app-rtl.7a875ca7.js": "sha384-T7gLI61h9dGeMgzo63vKu4GiDOeLPct9zSUHrceNbhSwIdUmSSNoZ1+d7fKhJJ4/", "/build/app-rtl.7a875ca7.js": "sha384-T7gLI61h9dGeMgzo63vKu4GiDOeLPct9zSUHrceNbhSwIdUmSSNoZ1+d7fKhJJ4/",
"/build/app-rtl.0848906b.css": "sha384-O26Xw3P/NSea5iT6lt5v2RaZ6+jP06hf9vYD2pUJbkFSCLGU1iGXnobP25dWjUs/", "/build/app-rtl.bfe8d111.css": "sha384-Ty9pGDTW2pxsBS7vfmjpTNFTJS/9jM5V4QfVgLXjIXuuJo5vi8x4sKKBTlwnZuqo",
"/build/export-pdf.395749ab.js": "sha384-3Hjvmu4FC/0dhHnR8kyRBU7k2xMNy1lxBpGgOkrw8PxXnwyQDM8/5bQmkJbjVT1+", "/build/export-pdf.395749ab.js": "sha384-3Hjvmu4FC/0dhHnR8kyRBU7k2xMNy1lxBpGgOkrw8PxXnwyQDM8/5bQmkJbjVT1+",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg", "/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.773af9c4.js": "sha384-QmMqYJ0RP2WOYU7D4lLzKCBFDL6vCvZHQc7wf8K8N+hdCuTJwq1P0GuY81f30/SY", "/build/invoice.773af9c4.js": "sha384-QmMqYJ0RP2WOYU7D4lLzKCBFDL6vCvZHQc7wf8K8N+hdCuTJwq1P0GuY81f30/SY",

View File

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

View File

@@ -0,0 +1,47 @@
<?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,10 +10,13 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\ExportableItem; use App\Entity\ExportableItem;
use App\Entity\ExportTemplate;
use App\Export\Base\DispositionInlineInterface; use App\Export\Base\DispositionInlineInterface;
use App\Export\ServiceExport; use App\Export\ServiceExport;
use App\Export\TooManyItemsExportException; use App\Export\TooManyItemsExportException;
use App\Form\ExportTemplateSpreadsheetForm;
use App\Form\Toolbar\ExportToolbarForm; use App\Form\Toolbar\ExportToolbarForm;
use App\Repository\ExportTemplateRepository;
use App\Repository\Query\ExportQuery; use App\Repository\Query\ExportQuery;
use App\Utils\PageSetup; use App\Utils\PageSetup;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
@@ -29,7 +32,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('create_export')] #[IsGranted('create_export')]
final class ExportController extends AbstractController final class ExportController extends AbstractController
{ {
public function __construct(private ServiceExport $export) public function __construct(private readonly ServiceExport $export)
{ {
} }
@@ -79,6 +82,23 @@ final class ExportController extends AbstractController
$page = new PageSetup('export'); $page = new PageSetup('export');
$page->setHelp('export.html'); $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', [ return $this->render('export/index.html.twig', [
'page_setup' => $page, 'page_setup' => $page,
'too_many' => $tooManyResults, 'too_many' => $tooManyResults,
@@ -86,10 +106,10 @@ final class ExportController extends AbstractController
'query' => $query, 'query' => $query,
'entries' => $entries, 'entries' => $entries,
'form' => $form->createView(), 'form' => $form->createView(),
'renderer' => $this->export->getRenderer(), 'buttons' => $buttons,
'preview_limit' => $maxItemsPreview, 'preview_limit' => $maxItemsPreview,
'preview_show' => $showPreview, 'preview_show' => $showPreview,
'decimal' => $this->getUser()->isExportDecimal(), 'show_rates' => $showRates,
]); ]);
} }
@@ -141,7 +161,6 @@ final class ExportController extends AbstractController
} }
/** /**
* @param ExportQuery $query
* @return ExportableItem[] * @return ExportableItem[]
* @throws TooManyItemsExportException * @throws TooManyItemsExportException
*/ */
@@ -158,8 +177,6 @@ final class ExportController extends AbstractController
} }
/** /**
* @param ExportQuery $query
* @param string $method
* @return FormInterface<ExportQuery> * @return FormInterface<ExportQuery>
*/ */
private function getToolbarForm(ExportQuery $query, string $method): FormInterface private function getToolbarForm(ExportQuery $query, string $method): FormInterface
@@ -175,4 +192,40 @@ 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

@@ -0,0 +1,136 @@
<?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

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

View File

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

View File

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

View File

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

View File

@@ -31,11 +31,14 @@ use App\Export\Package\CellFormatter\TimeFormatter;
use App\Export\Package\Column; use App\Export\Package\Column;
use App\Export\Package\ColumnWidth; use App\Export\Package\ColumnWidth;
use App\Export\Package\SpreadsheetPackage; use App\Export\Package\SpreadsheetPackage;
use App\Export\Template;
use App\Export\TemplateInterface;
use App\Repository\Query\ActivityQuery; use App\Repository\Query\ActivityQuery;
use App\Repository\Query\CustomerQuery; use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery; use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
/** /**
@@ -48,12 +51,32 @@ final class SpreadsheetRenderer
*/ */
private array $formatter = []; private array $formatter = [];
private ?TemplateInterface $template = null;
public function __construct( public function __construct(
protected EventDispatcherInterface $dispatcher, private readonly EventDispatcherInterface $eventDispatcher,
protected Security $voter private readonly Security $voter,
private readonly ?LoggerInterface $logger = null,
) { ) {
} }
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 private function isRenderRate(TimesheetQuery $query): bool
{ {
if ($this->voter->getUser() === null) { if ($this->voter->getUser() === null) {
@@ -73,7 +96,7 @@ final class SpreadsheetRenderer
*/ */
private function findMetaColumns(MetaDisplayEventInterface $event): array private function findMetaColumns(MetaDisplayEventInterface $event): array
{ {
$this->dispatcher->dispatch($event); $this->eventDispatcher->dispatch($event);
return $event->getFields(); return $event->getFields();
} }
@@ -98,7 +121,12 @@ final class SpreadsheetRenderer
if ($currentRow > 1) { if ($currentRow > 1) {
$totalColumns = ['duration', 'rate', 'internalRate']; $totalColumns = ['duration', 'rate', 'internalRate'];
$columnNames = range('A', 'Z'); // 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',
];
$totalRow = []; $totalRow = [];
$totalColumn = 1; $totalColumn = 1;
foreach ($columns as $column) { foreach ($columns as $column) {
@@ -131,8 +159,9 @@ final class SpreadsheetRenderer
return match ($name) { return match ($name) {
'date' => new DateFormatter(), 'date' => new DateFormatter(),
'time' => new TimeFormatter(), 'time' => new TimeFormatter(),
'duration' => new DurationFormatter(), 'duration' => new DurationFormatter('[hh]:mm'),
'duration_decimal' => new DurationDecimalFormatter(), 'duration_decimal' => new DurationDecimalFormatter(),
'duration_seconds' => new DurationFormatter('[hh]:mm:ss'),
default => new DefaultFormatter() default => new DefaultFormatter()
}; };
} }
@@ -144,99 +173,215 @@ final class SpreadsheetRenderer
{ {
$showRates = $this->isRenderRate($query); $showRates = $this->isRenderRate($query);
$durationFormatter = 'duration'; $timesheetMeta = [];
if (($user = $this->voter->getUser()) instanceof User) {
$durationFormatter = $user->isExportDecimal() ? 'duration_decimal' : 'duration';
}
$columns = [];
$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());
}
$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) { foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() === null) { if ($metaField->getName() !== null) {
continue; $timesheetMeta['timesheet.meta.' . $metaField->getName()] = (new Column('timesheet.meta.' . $metaField->getName(), $this->getFormatter('default')))
}
$columns[] = (new Column('timesheet.meta.' . $metaField->getName(), $this->getFormatter('default')))
->withHeader($metaField->getLabel()) ->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) { ->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getMetaField($metaField->getName())?->getValue(); return $exportableItem->getMetaField($metaField->getName())?->getValue();
}); });
} }
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')))
$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()) ->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) { ->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getProject()?->getCustomer()?->getMetaField($metaField->getName())?->getValue(); return $exportableItem->getProject()?->getCustomer()?->getMetaField($metaField->getName())?->getValue();
}); });
} }
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')))
$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()) ->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) { ->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getProject()?->getMetaField($metaField->getName())?->getValue(); 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')))
$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()) ->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) { ->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getActivity()?->getMetaField($metaField->getName())?->getValue(); return $exportableItem->getActivity()?->getMetaField($metaField->getName())?->getValue();
}); });
} }
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
$this->dispatcher->dispatch($event);
foreach ($event->getPreferences() as $metaField) {
if ($metaField->getName() === null) {
continue;
} }
$columns[] = (new Column('user.meta.' . $metaField->getName(), $this->getFormatter('default')))
$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()) ->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) { ->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getUser()?->getPreference($metaField->getName())?->getValue(); 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',
];
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent(new TimesheetQuery(), TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns[] = 'timesheet.meta.' . $metaField->getName();
}
}
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent(new CustomerQuery(), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns[] = 'customer.meta.' . $metaField->getName();
}
}
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent(new ProjectQuery(), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns[] = 'project.meta.' . $metaField->getName();
}
}
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent(new ActivityQuery(), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns[] = 'activity.meta.' . $metaField->getName();
}
}
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
$this->eventDispatcher->dispatch($event);
foreach ($event->getPreferences() as $metaField) {
if ($metaField->getName() !== null) {
$columns[] = 'user.meta.' . $metaField->getName();
}
}
return $columns; return $columns;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
<?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,6 +27,7 @@ final class PdfRendererFactory
{ {
$renderer = new PDFRenderer($this->twig, $this->converter, $this->projectStatisticService); $renderer = new PDFRenderer($this->twig, $this->converter, $this->projectStatisticService);
$renderer->setId($id); $renderer->setId($id);
$renderer->setTitle($id);
$renderer->setTemplate($template); $renderer->setTemplate($template);
return $renderer; return $renderer;

View File

@@ -0,0 +1,42 @@
<?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,10 +11,14 @@ namespace App\Export;
use App\Entity\ExportableItem; use App\Entity\ExportableItem;
use App\Event\ExportItemsQueryEvent; use App\Event\ExportItemsQueryEvent;
use App\Export\Renderer\CsvRendererFactory;
use App\Export\Renderer\HtmlRendererFactory; use App\Export\Renderer\HtmlRendererFactory;
use App\Export\Renderer\PdfRendererFactory; use App\Export\Renderer\PdfRendererFactory;
use App\Export\Renderer\XlsxRendererFactory;
use App\Repository\ExportTemplateRepository;
use App\Repository\Query\ExportQuery; use App\Repository\Query\ExportQuery;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
final class ServiceExport final class ServiceExport
{ {
@@ -36,9 +40,13 @@ final class ServiceExport
private array $repositories = []; private array $repositories = [];
public function __construct( public function __construct(
private EventDispatcherInterface $eventDispatcher, private readonly EventDispatcherInterface $eventDispatcher,
private HtmlRendererFactory $htmlRendererFactory, private readonly HtmlRendererFactory $htmlRendererFactory,
private PdfRendererFactory $pdfRendererFactory private readonly PdfRendererFactory $pdfRendererFactory,
private readonly CsvRendererFactory $csvRendererFactory,
private readonly XlsxRendererFactory $xlsxRendererFactory,
private readonly ExportTemplateRepository $exportTemplateRepository,
private readonly LoggerInterface $logger,
) )
{ {
} }
@@ -73,6 +81,26 @@ final class ServiceExport
{ {
$renderer = []; $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) { foreach ($this->documentDirs as $exportPath) {
if (!is_dir($exportPath)) { if (!is_dir($exportPath)) {
continue; continue;
@@ -132,7 +160,7 @@ final class ServiceExport
public function getTimesheetExporterById(string $id): ?TimesheetExportInterface public function getTimesheetExporterById(string $id): ?TimesheetExportInterface
{ {
foreach ($this->timesheetExporter as $exporter) { foreach ($this->getTimesheetExporter() as $exporter) {
if ($exporter->getId() === $id) { if ($exporter->getId() === $id) {
return $exporter; return $exporter;
} }
@@ -147,7 +175,6 @@ final class ServiceExport
} }
/** /**
* @param ExportQuery $query
* @return ExportableItem[] * @return ExportableItem[]
* @throws TooManyItemsExportException * @throws TooManyItemsExportException
*/ */

79
src/Export/Template.php Normal file
View File

@@ -0,0 +1,79 @@
<?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

@@ -0,0 +1,29 @@
<?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

@@ -0,0 +1,43 @@
<?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,18 +40,22 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
$extendedOptions = ['class' => 'selectpicker']; $extendedOptions = ['class' => 'selectpicker'];
if ($options['multiple']) { if (\array_key_exists('multiple', $options) && $options['multiple']) {
$extendedOptions['size'] = 1; $extendedOptions['size'] = 1;
} }
if (false !== $options['width']) { if (\array_key_exists('width', $options) && false !== $options['width']) {
$extendedOptions['data-width'] = $options['width']; $extendedOptions['data-width'] = $options['width'];
} }
if (false === $options['search']) { if (\array_key_exists('search', $options) && false === $options['search']) {
$extendedOptions['data-disable-search'] = 1; $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 // 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 // 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)) { if (true === $options['required'] && \is_array($options['attr']) && (!\array_key_exists('size', $options['attr']) || $options['attr']['size'] <= 1)) {
@@ -75,5 +79,9 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
$resolver->setDefined(['search']); $resolver->setDefined(['search']);
$resolver->setAllowedTypes('search', 'boolean'); $resolver->setAllowedTypes('search', 'boolean');
$resolver->setDefault('search', true); $resolver->setDefault('search', true);
$resolver->setDefined(['order']);
$resolver->setAllowedTypes('order', 'boolean');
$resolver->setDefault('order', false);
} }
} }

View File

@@ -0,0 +1,166 @@
<?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

@@ -0,0 +1,29 @@
<?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

@@ -0,0 +1,31 @@
<?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

@@ -0,0 +1,24 @@
<?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

@@ -0,0 +1,41 @@
<?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

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

View File

@@ -0,0 +1,24 @@
{% 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

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

View File

@@ -0,0 +1,80 @@
<?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

@@ -9,11 +9,14 @@
namespace App\Tests\Controller; namespace App\Tests\Controller;
use App\Entity\ExportTemplate;
use App\Entity\Team; use App\Entity\Team;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Tests\DataFixtures\ExportTemplateFixtures;
use App\Tests\DataFixtures\TimesheetFixtures; use App\Tests\DataFixtures\TimesheetFixtures;
use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManager;
use Symfony\Component\DomCrawler\Field\FormField;
/** /**
* @group integration * @group integration
@@ -25,7 +28,7 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
$this->assertUrlIsSecured('/export/'); $this->assertUrlIsSecured('/export/');
} }
public function testIsSecureForrole(): void public function testIsSecureForRole(): void
{ {
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/export/'); $this->assertUrlIsSecuredForRole(User::ROLE_USER, '/export/');
} }
@@ -90,6 +93,16 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
// +1 row for summary // +1 row for summary
$this->assertDataTableRowCount($client, 'datatable_export', 22); $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 // assert export type buttons are available
$expected = [ $expected = [
'csv' => 'csv', 'csv' => 'csv',
@@ -246,4 +259,71 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
self::assertTrue($timesheet->isExported()); 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); $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/permissions'); $this->assertAccessIsGranted($client, '/admin/permissions');
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 135); $this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 136);
$this->assertPageActions($client, [ $this->assertPageActions($client, [
'create modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'), 'create modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),
]); ]);

View File

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

View File

@@ -0,0 +1,40 @@
<?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

@@ -0,0 +1,73 @@
<?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

@@ -53,6 +53,7 @@ class UserTest extends TestCase
$user->setUserIdentifier('foo'); $user->setUserIdentifier('foo');
self::assertEquals('foo', $user->getUserIdentifier()); self::assertEquals('foo', $user->getUserIdentifier());
self::assertEquals('foo', $user->getIdentifier());
self::assertEquals('foo', $user->getDisplayName()); self::assertEquals('foo', $user->getDisplayName());
$user->setAlias('BAR'); $user->setAlias('BAR');
self::assertEquals('BAR', $user->getDisplayName()); self::assertEquals('BAR', $user->getDisplayName());
@@ -89,6 +90,7 @@ class UserTest extends TestCase
public function testWorkContract(): void public function testWorkContract(): void
{ {
$user = new User(); $user = new User();
self::assertFalse($user->hasContractSettings());
self::assertEquals(0, $user->getWorkHoursMonday()); self::assertEquals(0, $user->getWorkHoursMonday());
self::assertEquals(0, $user->getWorkHoursTuesday()); self::assertEquals(0, $user->getWorkHoursTuesday());
@@ -127,6 +129,7 @@ class UserTest extends TestCase
$user->setWorkHoursSunday(7800); $user->setWorkHoursSunday(7800);
$user->setHolidaysPerYear(10.7); $user->setHolidaysPerYear(10.7);
self::assertTrue($user->hasWorkHourConfiguration()); self::assertTrue($user->hasWorkHourConfiguration());
self::assertTrue($user->hasContractSettings());
self::assertEquals(7200, $user->getWorkHoursMonday()); self::assertEquals(7200, $user->getWorkHoursMonday());
self::assertEquals(7300, $user->getWorkHoursTuesday()); self::assertEquals(7300, $user->getWorkHoursTuesday());
@@ -158,6 +161,16 @@ class UserTest extends TestCase
$user->setPublicHolidayGroup('DE-NRW'); $user->setPublicHolidayGroup('DE-NRW');
self::assertEquals('DE-NRW', $user->getPublicHolidayGroup()); 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 public function testColor(): void
@@ -248,6 +261,21 @@ class UserTest extends TestCase
$user->setPreferenceValue('export_decimal', true); $user->setPreferenceValue('export_decimal', true);
self::assertTrue($user->isExportDecimal()); 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 public function testDisplayName(): void
@@ -332,6 +360,12 @@ class UserTest extends TestCase
self::assertTrue($sut->isTeamleadOf($team2)); self::assertTrue($sut->isTeamleadOf($team2));
self::assertTrue($sut->isInTeam($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()); self::assertCount(2, $sut->getTeams());
$sut->removeMembership(new TeamMember()); $sut->removeMembership(new TeamMember());
self::assertCount(2, $sut->getTeams()); self::assertCount(2, $sut->getTeams());
@@ -624,4 +658,21 @@ class UserTest extends TestCase
self::assertNotNull($user->getSupervisor()); self::assertNotNull($user->getSupervisor());
self::assertSame($supervisor, $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,7 +13,8 @@ use App\Entity\User;
use App\Export\Base\CsvRenderer; use App\Export\Base\CsvRenderer;
use App\Export\Base\SpreadsheetRenderer; use App\Export\Base\SpreadsheetRenderer;
use App\Tests\Export\Renderer\AbstractRendererTestCase; use App\Tests\Export\Renderer\AbstractRendererTestCase;
use App\Tests\Export\Renderer\MetaFieldColumnSubscriber; use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -37,12 +38,13 @@ class CsvRendererTest extends AbstractRendererTestCase
$security->expects($this->any())->method('getUser')->willReturn($user); $security->expects($this->any())->method('getUser')->willReturn($user);
$security->expects($this->any())->method('isGranted')->willReturn(true); $security->expects($this->any())->method('isGranted')->willReturn(true);
$translator = $this->createMock(TranslatorInterface::class); $translator = $this->getContainer()->get(TranslatorInterface::class);
self::assertInstanceOf(TranslatorInterface::class, $translator);
$dispatcher = new EventDispatcher(); $dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriber()); $dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
return new CsvRenderer(new SpreadsheetRenderer($dispatcher, $security), $translator); return new CsvRenderer(new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class)), $translator);
} }
public function testConfiguration(): void public function testConfiguration(): void
@@ -50,23 +52,43 @@ class CsvRendererTest extends AbstractRendererTestCase
$sut = $this->getAbstractRenderer(); $sut = $this->getAbstractRenderer();
self::assertEquals('csv', $sut->getId()); self::assertEquals('csv', $sut->getId());
self::assertEquals('csv', $sut->getTitle()); 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());
} }
public static function getTestModel(): array 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 [ return [
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, false], ['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, false, null, $en],
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, true] ['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, true, 'de', $de]
]; ];
} }
/** /**
* @dataProvider getTestModel * @dataProvider getTestModel
*/ */
public function testRender(string $totalDuration, string $totalRate, string $expectedRate, int $expectedRows, int $expectedDescriptions, int $expectedUser1, int $expectedUser2, int $expectedUser3, bool $exportDecimal): void 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
{ {
$sut = $this->getAbstractRenderer($exportDecimal); $sut = $this->getAbstractRenderer($exportDecimal);
$sut->setLocale($locale);
/** @var BinaryFileResponse $response */ /** @var BinaryFileResponse $response */
$response = $this->render($sut); $response = $this->render($sut);
@@ -102,11 +124,13 @@ class CsvRendererTest extends AbstractRendererTestCase
$all[] = str_getcsv($row); $all[] = str_getcsv($row);
} }
self::assertEquals($header, $all[0]);
$expected = [ $expected = [
'2019-06-16', '2019-06-16',
'12:00', '12:00',
'12:06', '12:06',
($exportDecimal ? '0.11' : '0:06:40'), ($exportDecimal ? '0.11' : '0:06'),
//'0.11', //'0.11',
'EUR', 'EUR',
'0', '0',
@@ -134,13 +158,14 @@ class CsvRendererTest extends AbstractRendererTestCase
'', '',
'project-foo2', 'project-foo2',
'activity-bar', 'activity-bar',
'',
]; ];
$expected2 = [ $expected2 = [
'2019-06-16', '2019-06-16',
'12:00', '12:00',
'12:06', '12:06',
($exportDecimal ? '0.11' : '0:06:40'), ($exportDecimal ? '0.11' : '0:06'),
//'0.11', //'0.11',
'EUR', 'EUR',
'0', '0',
@@ -168,6 +193,7 @@ class CsvRendererTest extends AbstractRendererTestCase
'', '',
'project-foo2', 'project-foo2',
'activity-bar', 'activity-bar',
'',
]; ];
self::assertEquals(7, \count($all)); self::assertEquals(7, \count($all));

View File

@@ -0,0 +1,56 @@
<?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,10 +12,15 @@ namespace App\Tests\Export\Base;
use App\Entity\ExportableItem; use App\Entity\ExportableItem;
use App\Export\Base\SpreadsheetRenderer; use App\Export\Base\SpreadsheetRenderer;
use App\Export\Package\SpreadsheetPackage; use App\Export\Package\SpreadsheetPackage;
use App\Export\Template;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use App\Tests\Export\Package\MemoryPackage;
use App\Tests\Export\Renderer\AbstractRendererTestCase; use App\Tests\Export\Renderer\AbstractRendererTestCase;
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
/** /**
@@ -31,7 +36,7 @@ class SpreadsheetRendererTest extends AbstractRendererTestCase
$spreadsheetPackage = $this->createMock(SpreadsheetPackage::class); $spreadsheetPackage = $this->createMock(SpreadsheetPackage::class);
$spreadsheetPackage->expects(self::once())->method('setColumns'); $spreadsheetPackage->expects(self::once())->method('setColumns');
$renderer = new SpreadsheetRenderer($dispatcher, $security); $renderer = new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class));
$renderer->writeSpreadsheet($spreadsheetPackage, [], new TimesheetQuery()); $renderer->writeSpreadsheet($spreadsheetPackage, [], new TimesheetQuery());
} }
@@ -60,7 +65,7 @@ class SpreadsheetRendererTest extends AbstractRendererTestCase
$exportItem->method('getType')->willReturn('type'); $exportItem->method('getType')->willReturn('type');
$exportItem->method('getCategory')->willReturn('category'); $exportItem->method('getCategory')->willReturn('category');
$renderer = new SpreadsheetRenderer($dispatcher, $security); $renderer = new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class));
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem], new TimesheetQuery()); $renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem], new TimesheetQuery());
} }
@@ -93,4 +98,96 @@ class SpreadsheetRendererTest extends AbstractRendererTestCase
$renderer = new SpreadsheetRenderer($dispatcher, $security); $renderer = new SpreadsheetRenderer($dispatcher, $security);
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem, $exportItem], new TimesheetQuery()); $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\SpreadsheetRenderer;
use App\Export\Base\XlsxRenderer; use App\Export\Base\XlsxRenderer;
use App\Tests\Export\Renderer\AbstractRendererTestCase; use App\Tests\Export\Renderer\AbstractRendererTestCase;
use App\Tests\Export\Renderer\MetaFieldColumnSubscriber; use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -38,7 +38,7 @@ class XlsxRendererTest extends AbstractRendererTestCase
$translator->method('trans')->willReturnArgument(0); $translator->method('trans')->willReturnArgument(0);
$dispatcher = new EventDispatcher(); $dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriber()); $dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
return new XlsxRenderer(new SpreadsheetRenderer($dispatcher, $security), $translator); return new XlsxRenderer(new SpreadsheetRenderer($dispatcher, $security), $translator);
} }
@@ -46,9 +46,16 @@ class XlsxRendererTest extends AbstractRendererTestCase
public function testConfiguration(): void public function testConfiguration(): void
{ {
$sut = $this->getAbstractRenderer(); $sut = $this->getAbstractRenderer();
$sut->setLocale('de');
self::assertEquals('xlsx', $sut->getId()); self::assertEquals('xlsx', $sut->getId());
self::assertEquals('xlsx', $sut->getTitle()); 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());
} }
public function testRender(): void public function testRender(): void

View File

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

View File

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

View File

@@ -0,0 +1,71 @@
<?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,22 +13,15 @@ use App\Entity\Activity;
use App\Entity\ActivityMeta; use App\Entity\ActivityMeta;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\CustomerMeta; use App\Entity\CustomerMeta;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\ProjectMeta; use App\Entity\ProjectMeta;
use App\Entity\Tag; use App\Entity\Tag;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\TimesheetMeta; use App\Entity\TimesheetMeta;
use App\Entity\User; 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\Export\ExportRendererInterface;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
abstract class AbstractRendererTestCase extends KernelTestCase abstract class AbstractRendererTestCase extends KernelTestCase
@@ -145,47 +138,3 @@ abstract class AbstractRendererTestCase extends KernelTestCase
return $renderer->render($entries, $query); 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

@@ -0,0 +1,44 @@
<?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

@@ -0,0 +1,44 @@
<?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,15 +10,22 @@
namespace App\Tests\Export; namespace App\Tests\Export;
use App\Activity\ActivityStatisticService; use App\Activity\ActivityStatisticService;
use App\Entity\ExportTemplate;
use App\Export\Base\CsvRenderer;
use App\Export\Base\HtmlRenderer; use App\Export\Base\HtmlRenderer;
use App\Export\Base\XlsxRenderer;
use App\Export\ExportRepositoryInterface; use App\Export\ExportRepositoryInterface;
use App\Export\ServiceExport; use App\Export\ServiceExport;
use App\Export\Timesheet\HtmlRenderer as HtmlExporter; use App\Export\Timesheet\HtmlRenderer as HtmlExporter;
use App\Project\ProjectStatisticService; use App\Project\ProjectStatisticService;
use App\Repository\ExportTemplateRepository;
use App\Repository\Query\ExportQuery; use App\Repository\Query\ExportQuery;
use App\Tests\Mocks\Export\CsvRendererFactoryMock;
use App\Tests\Mocks\Export\HtmlRendererFactoryMock; use App\Tests\Mocks\Export\HtmlRendererFactoryMock;
use App\Tests\Mocks\Export\PdfRendererFactoryMock; use App\Tests\Mocks\Export\PdfRendererFactoryMock;
use App\Tests\Mocks\Export\XlsxRendererFactoryMock;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Twig\Environment; use Twig\Environment;
@@ -28,12 +35,48 @@ use Twig\Environment;
*/ */
class ServiceExportTest extends TestCase class ServiceExportTest extends TestCase
{ {
private function createSut(): ServiceExport private function createSut(bool $withTemplates = false, int $failureCount = 1): 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( return new ServiceExport(
$this->createMock(EventDispatcherInterface::class), $this->createMock(EventDispatcherInterface::class),
(new HtmlRendererFactoryMock($this))->create(), (new HtmlRendererFactoryMock($this))->create(),
(new PdfRendererFactoryMock($this))->create(), (new PdfRendererFactoryMock($this))->create(),
(new CsvRendererFactoryMock($this))->create(),
(new XlsxRendererFactoryMock($this))->create(),
$repository,
$logger,
); );
} }
@@ -88,4 +131,18 @@ class ServiceExportTest extends TestCase
self::assertEquals([], $items); 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

@@ -0,0 +1,63 @@
<?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

@@ -0,0 +1,61 @@
<?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,6 +12,8 @@ namespace App\Tests\Form\Extension;
use App\Form\Extension\DocumentationLinkExtension; use App\Form\Extension\DocumentationLinkExtension;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\Extension\Core\Type\FormType; 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\Exception\InvalidOptionsException;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -43,4 +45,18 @@ class DocumentationLinkExtensionTest extends TestCase
$this->expectException(InvalidOptionsException::class); $this->expectException(InvalidOptionsException::class);
$resolver->resolve(['docu_chapter' => true]); $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,6 +13,8 @@ use App\Form\Extension\EnhancedChoiceTypeExtension;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
/** /**
@@ -30,15 +32,52 @@ class EnhancedChoiceTypeExtensionTest extends TestCase
$resolver = new OptionsResolver(); $resolver = new OptionsResolver();
$sut = new EnhancedChoiceTypeExtension(); $sut = new EnhancedChoiceTypeExtension();
$sut->configureOptions($resolver); $sut->configureOptions($resolver);
self::assertEquals(['selectpicker', 'width', 'search'], $resolver->getDefinedOptions()); self::assertEquals(['selectpicker', 'width', 'search', 'order'], $resolver->getDefinedOptions());
self::assertTrue($resolver->hasDefault('selectpicker')); self::assertTrue($resolver->hasDefault('selectpicker'));
self::assertTrue($resolver->hasDefault('width')); self::assertTrue($resolver->hasDefault('width'));
self::assertTrue($resolver->hasDefault('search')); self::assertTrue($resolver->hasDefault('search'));
self::assertTrue($resolver->hasDefault('order'));
self::assertFalse($resolver->isRequired('selectpicker')); self::assertFalse($resolver->isRequired('selectpicker'));
self::assertFalse($resolver->isRequired('width')); self::assertFalse($resolver->isRequired('width'));
self::assertFalse($resolver->isRequired('search')); self::assertFalse($resolver->isRequired('search'));
$result = $resolver->resolve([]); $result = $resolver->resolve([]);
self::assertEquals(['selectpicker' => true, 'width' => '100%', 'search' => true], $result); 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);
} }
} }

View File

@@ -0,0 +1,72 @@
<?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

@@ -0,0 +1,51 @@
<?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 class TypeTestModel extends \ArrayObject
{ {
public function __set(string $name, string|int|null $value) public function __set(string $name, string|int|null|array $value)
{ {
$this->offsetSet($name, $value); $this->offsetSet($name, $value);
} }

View File

@@ -0,0 +1,30 @@
<?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\Mocks\Export;
use App\Export\Renderer\CsvRendererFactory;
use App\Tests\Mocks\AbstractMockFactory;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
class CsvRendererFactoryMock extends AbstractMockFactory
{
public function create(): CsvRendererFactory
{
return new CsvRendererFactory(
$this->createMock(EventDispatcherInterface::class),
$this->createMock(Security::class),
$this->createMock(TranslatorInterface::class),
$this->createMock(LoggerInterface::class),
);
}
}

View File

@@ -0,0 +1,30 @@
<?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\Mocks\Export;
use App\Export\Renderer\XlsxRendererFactory;
use App\Tests\Mocks\AbstractMockFactory;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
class XlsxRendererFactoryMock extends AbstractMockFactory
{
public function create(): XlsxRendererFactory
{
return new XlsxRendererFactory(
$this->createMock(EventDispatcherInterface::class),
$this->createMock(Security::class),
$this->createMock(TranslatorInterface::class),
$this->createMock(LoggerInterface::class),
);
}
}

View File

@@ -0,0 +1,74 @@
<?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\Mocks;
use App\Entity\ActivityMeta;
use App\Entity\CustomerMeta;
use App\Entity\MetaTableTypeInterface;
use App\Entity\ProjectMeta;
use App\Entity\TimesheetMeta;
use App\Entity\UserPreference;
use App\Event\ActivityMetaDisplayEvent;
use App\Event\CustomerMetaDisplayEvent;
use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Event\UserPreferenceDisplayEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
class MetaFieldColumnSubscriberMock implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
TimesheetMetaDisplayEvent::class => ['loadTimesheetField', 200],
CustomerMetaDisplayEvent::class => ['loadCustomerField', 200],
ProjectMetaDisplayEvent::class => ['loadProjectField', 200],
ActivityMetaDisplayEvent::class => ['loadActivityField', 200],
UserPreferenceDisplayEvent::class => ['loadUserField', 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'));
}
public function loadUserField(UserPreferenceDisplayEvent $event): void
{
$event->addPreference(new UserPreference('mypref', 'hello world'));
}
private function prepareEntity(MetaTableTypeInterface $meta, string $name): MetaTableTypeInterface
{
return $meta
->setLabel('Working place')
->setName($name)
->setType(TextType::class)
->setIsVisible(true);
}
}

View File

@@ -27,7 +27,7 @@ class ColorChoicesValidatorTest extends ConstraintValidatorTestCase
return new ColorChoicesValidator(); return new ColorChoicesValidator();
} }
public static function getValidColors() public static function getValidColors(): iterable
{ {
yield ['#000000']; yield ['#000000'];
yield ['#fff000']; yield ['#fff000'];
@@ -57,7 +57,7 @@ class ColorChoicesValidatorTest extends ConstraintValidatorTestCase
$this->assertNoViolation(); $this->assertNoViolation();
} }
public static function getInvalidColors() public static function getInvalidColors(): iterable
{ {
yield ['sdf_sdf|#000000', null, 'sdf_sdf', '#000000']; yield ['sdf_sdf|#000000', null, 'sdf_sdf', '#000000'];
yield ['sdfghjklöß.|#aaabbb', null, 'sdfghjklöß.', '#aaabbb']; yield ['sdfghjklöß.|#aaabbb', null, 'sdfghjklöß.', '#aaabbb'];

View File

@@ -27,7 +27,7 @@ class DateTimeFormatValidatorTest extends ConstraintValidatorTestCase
return new DateTimeFormatValidator(); return new DateTimeFormatValidator();
} }
public static function getValidData() public static function getValidData(): array
{ {
return [ return [
['10:00'], ['10:00'],
@@ -56,7 +56,7 @@ class DateTimeFormatValidatorTest extends ConstraintValidatorTestCase
$this->assertNoViolation(); $this->assertNoViolation();
} }
public static function getInvalidData() public static function getInvalidData(): array
{ {
return [ return [
['13-13'], ['13-13'],

View File

@@ -0,0 +1,92 @@
<?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\Validator\Constraints;
use App\Validator\Constraints\ExportRenderer;
use App\Validator\Constraints\ExportRendererValidator;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\ExportRenderer
* @covers \App\Validator\Constraints\ExportRendererValidator
* @extends ConstraintValidatorTestCase<ExportRendererValidator>
*/
class ExportRendererValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): ExportRendererValidator
{
return new ExportRendererValidator();
}
public static function getValidColors(): iterable
{
yield ['csv'];
yield ['xlsx'];
yield [null];
}
public function testConstraintIsInvalid(): void
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate('#000', new NotBlank());
}
/**
* @dataProvider getValidColors
*/
public function testConstraintWithValidColor(?string $color): void
{
$constraint = new ExportRenderer();
$this->validator->validate($color, $constraint);
$this->assertNoViolation();
}
public static function getInvalidColors(): iterable
{
yield ['CSV'];
yield ['XLSX'];
yield ['PDF'];
yield ['HTML'];
yield ['fff000'];
yield ['000aaa'];
yield ['fffaaa'];
yield ['#f'];
yield ['#ff'];
yield ['#ffdd'];
yield ['#ffddd'];
yield ['#ffddddd'];
yield [new \stdClass(), 'object'];
yield [[], 'array'];
}
/**
* @dataProvider getInvalidColors
*/
public function testValidationError(mixed $color, ?string $parameterType = null): void
{
$constraint = new ExportRenderer();
$this->validator->validate($color, $constraint);
if (\is_string($color)) {
$expectedFormat = '"' . $color . '"';
} else {
$expectedFormat = $parameterType ?? '';
}
$this->buildViolation('Unknown exporter type.')
->setParameter('{{ value }}', $expectedFormat)
->setCode(ExportRenderer::UNKNOWN_TYPE)
->assertRaised();
}
}

View File

@@ -27,7 +27,7 @@ class HexColorValidatorTest extends ConstraintValidatorTestCase
return new HexColorValidator(); return new HexColorValidator();
} }
public static function getValidColors() public static function getValidColors(): iterable
{ {
yield ['#000']; yield ['#000'];
yield ['#aaa']; yield ['#aaa'];
@@ -56,7 +56,7 @@ class HexColorValidatorTest extends ConstraintValidatorTestCase
$this->assertNoViolation(); $this->assertNoViolation();
} }
public static function getInvalidColors() public static function getInvalidColors(): iterable
{ {
yield ['string']; yield ['string'];
yield ['000']; yield ['000'];

View File

@@ -32,7 +32,7 @@ class RoleValidatorTest extends ConstraintValidatorTestCase
return new RoleValidator($roleService); return new RoleValidator($roleService);
} }
public static function getValidRoles() public static function getValidRoles(): array
{ {
return [ return [
[User::ROLE_USER], [User::ROLE_USER],
@@ -69,7 +69,7 @@ class RoleValidatorTest extends ConstraintValidatorTestCase
->assertRaised(); ->assertRaised();
} }
public static function getInvalidRoles() public static function getInvalidRoles(): array
{ {
return [ return [
['foo'], ['foo'],

View File

@@ -52,7 +52,7 @@ class TimeFormatValidatorTest extends ConstraintValidatorTestCase
$this->assertNoViolation(); $this->assertNoViolation();
} }
public static function getValidTimes() public static function getValidTimes(): array
{ {
return [ return [
[''], [''],
@@ -79,7 +79,7 @@ class TimeFormatValidatorTest extends ConstraintValidatorTestCase
->assertRaised(); ->assertRaised();
} }
public static function getInvalidTimes() public static function getInvalidTimes(): array
{ {
return [ return [
['a'], ['a'],

View File

@@ -143,7 +143,7 @@ class TimesheetBasicValidatorTest extends ConstraintValidatorTestCase
->assertRaised(); ->assertRaised();
} }
public static function getProjectStartEndTestData() public static function getProjectStartEndTestData(): iterable
{ {
yield [new \DateTime(), new \DateTime(), [ yield [new \DateTime(), new \DateTime(), [
['begin_date', TimesheetBasic::PROJECT_NOT_STARTED, 'The project has not started at that time.'], ['begin_date', TimesheetBasic::PROJECT_NOT_STARTED, 'The project has not started at that time.'],

View File

@@ -195,7 +195,7 @@ class TimesheetBudgetUsedValidatorTest extends ConstraintValidatorTestCase
$this->assertNoViolation(); $this->assertNoViolation();
} }
public static function getViolationTestData() public static function getViolationTestData(): array
{ {
return [ return [
// activity: violations ---------------------------------------------------------------------- // activity: violations ----------------------------------------------------------------------
@@ -294,7 +294,7 @@ class TimesheetBudgetUsedValidatorTest extends ConstraintValidatorTestCase
string $duration, string $duration,
array $rawData = [], array $rawData = [],
?Rate $rate = null ?Rate $rate = null
) { ): void {
$activityStatistic = new ActivityStatistic(); $activityStatistic = new ActivityStatistic();
if ($activityDuration !== null) { if ($activityDuration !== null) {
$activityStatistic->setDuration($activityDuration); $activityStatistic->setDuration($activityDuration);

View File

@@ -171,7 +171,7 @@ class TimesheetLockdownValidatorTest extends ConstraintValidatorTestCase
} }
} }
public static function getTestData() public static function getTestData(): iterable
{ {
// changing before last dockdown period is not allowed // changing before last dockdown period is not allowed
yield [false, false, '-5 days', '+5 days', true]; yield [false, false, '-5 days', '+5 days', true];
@@ -219,7 +219,7 @@ class TimesheetLockdownValidatorTest extends ConstraintValidatorTestCase
} }
} }
public static function getConfigTestData() public static function getConfigTestData(): iterable
{ {
yield [false, false, null, null, null, false]; yield [false, false, null, null, null, false];
yield [false, false, '+5 days', null, null, false]; yield [false, false, '+5 days', null, null, false];

View File

@@ -94,7 +94,7 @@ class TimesheetRestartValidatorTest extends ConstraintValidatorTestCase
} }
} }
public static function getTestData() public static function getTestData(): iterable
{ {
yield [false, 'end_date', 'default']; yield [false, 'end_date', 'default'];
yield [true, null, 'default']; yield [true, null, 'default'];

View File

@@ -58,7 +58,7 @@ class TimesheetZeroDurationValidatorTest extends ConstraintValidatorTestCase
$this->validator->validate(new NotBlank(), new TimesheetZeroDuration(['message' => 'Duration cannot be zero.'])); $this->validator->validate(new NotBlank(), new TimesheetZeroDuration(['message' => 'Duration cannot be zero.']));
} }
private function prepareTimesheet() private function prepareTimesheet(): Timesheet
{ {
// creates Timesheet with same begin and endtime // creates Timesheet with same begin and endtime
$begin = new \DateTime(); $begin = new \DateTime();

View File

@@ -2816,116 +2816,31 @@ parameters:
count: 6 count: 6
path: Utils/StringHelperTest.php path: Utils/StringHelperTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\ColorChoicesValidatorTest\\:\\:getInvalidColors\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/ColorChoicesValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\ColorChoicesValidatorTest\\:\\:getValidColors\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/ColorChoicesValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\DateTimeFormatValidatorTest\\:\\:getInvalidData\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/DateTimeFormatValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\DateTimeFormatValidatorTest\\:\\:getValidData\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/DateTimeFormatValidatorTest.php
- -
message: "#^Parameter \\#1 \\$string of function strtoupper expects string, int\\|string given\\.$#" message: "#^Parameter \\#1 \\$string of function strtoupper expects string, int\\|string given\\.$#"
count: 1 count: 1
path: Validator/Constraints/DurationValidatorTest.php path: Validator/Constraints/DurationValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\HexColorValidatorTest\\:\\:getInvalidColors\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/HexColorValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\HexColorValidatorTest\\:\\:getValidColors\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/HexColorValidatorTest.php
- -
message: "#^Cannot cast mixed to string\\.$#" message: "#^Cannot cast mixed to string\\.$#"
count: 1 count: 1
path: Validator/Constraints/RoleValidatorTest.php path: Validator/Constraints/RoleValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\RoleValidatorTest\\:\\:getInvalidRoles\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/RoleValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\RoleValidatorTest\\:\\:getValidRoles\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/RoleValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimeFormatValidatorTest\\:\\:getInvalidTimes\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/TimeFormatValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimeFormatValidatorTest\\:\\:getValidTimes\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/TimeFormatValidatorTest.php
- -
message: "#^Cannot call method assertRaised\\(\\) on Symfony\\\\Component\\\\Validator\\\\Test\\\\ConstraintViolationAssertion\\|null\\.$#" message: "#^Cannot call method assertRaised\\(\\) on Symfony\\\\Component\\\\Validator\\\\Test\\\\ConstraintViolationAssertion\\|null\\.$#"
count: 1 count: 1
path: Validator/Constraints/TimesheetBasicValidatorTest.php path: Validator/Constraints/TimesheetBasicValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetBasicValidatorTest\\:\\:getProjectStartEndTestData\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/TimesheetBasicValidatorTest.php
- -
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetBasicValidatorTest\\:\\:testEndBeforeWithProjectStartAndEnd\\(\\) has parameter \\$violations with no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetBasicValidatorTest\\:\\:testEndBeforeWithProjectStartAndEnd\\(\\) has parameter \\$violations with no value type specified in iterable type array\\.$#"
count: 1 count: 1
path: Validator/Constraints/TimesheetBasicValidatorTest.php path: Validator/Constraints/TimesheetBasicValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetBudgetUsedValidatorTest\\:\\:getViolationTestData\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/TimesheetBudgetUsedValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetBudgetUsedValidatorTest\\:\\:testWithActivityTimeBudget\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/TimesheetBudgetUsedValidatorTest.php
- -
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetBudgetUsedValidatorTest\\:\\:testWithActivityTimeBudget\\(\\) has parameter \\$rawData with no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetBudgetUsedValidatorTest\\:\\:testWithActivityTimeBudget\\(\\) has parameter \\$rawData with no value type specified in iterable type array\\.$#"
count: 1 count: 1
path: Validator/Constraints/TimesheetBudgetUsedValidatorTest.php path: Validator/Constraints/TimesheetBudgetUsedValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetLockdownValidatorTest\\:\\:getConfigTestData\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/TimesheetLockdownValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetLockdownValidatorTest\\:\\:getTestData\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/TimesheetLockdownValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetRestartValidatorTest\\:\\:getTestData\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/TimesheetRestartValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetZeroDurationValidatorTest\\:\\:prepareTimesheet\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/TimesheetZeroDurationValidatorTest.php
- -
message: "#^Parameter \\#1 \\$objectOrClass of class ReflectionClass constructor expects class\\-string\\<T of object\\>\\|T of object, string given\\.$#" message: "#^Parameter \\#1 \\$objectOrClass of class ReflectionClass constructor expects class\\-string\\<T of object\\>\\|T of object, string given\\.$#"
count: 1 count: 1