configurable csv/xlsx export templates (#5531)
This commit is contained in:
@@ -62,13 +62,10 @@ export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
|
||||
plugins.push('remove_button');
|
||||
}
|
||||
|
||||
/*
|
||||
const isOrdering = false;
|
||||
if (isOrdering) {
|
||||
plugins.push('caret_position');
|
||||
if (node.dataset['order'] !== undefined && node.dataset['order'] === '1') {
|
||||
//plugins.push('caret_position');
|
||||
plugins.push('drag_drop');
|
||||
}
|
||||
*/
|
||||
|
||||
let options = {
|
||||
// see https://github.com/orchidjs/tom-select/issues/543#issuecomment-1664342257
|
||||
|
||||
@@ -56,3 +56,18 @@ fieldset > .mb-3.row:last-child {
|
||||
.form-fieldset-light:last-child {
|
||||
border:none;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
.dropdown-action {
|
||||
visibility: hidden;
|
||||
opacity: 0.5;
|
||||
}
|
||||
&:hover {
|
||||
.dropdown-action {
|
||||
visibility: visible;
|
||||
&:hover {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,8 +119,8 @@ kimai:
|
||||
roles:
|
||||
ROLE_USER: ['view_team_member','time_team_project','create_tag','view_reporting']
|
||||
ROLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member','hours_other_profile']
|
||||
ROLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member','view_all_data','contract_other_profile','hours_other_profile']
|
||||
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_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member','view_all_data','contract_other_profile','hours_other_profile','create_export_template']
|
||||
ROLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','supervisor_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data','contract_other_profile','hours_other_profile','create_export_template']
|
||||
# --------------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -206,6 +206,11 @@ services:
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\AccessToken']
|
||||
|
||||
App\Repository\ExportTemplateRepository:
|
||||
class: App\Repository\ExportTemplateRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\ExportTemplate']
|
||||
|
||||
monolog.formatter.kimai:
|
||||
class: Monolog\Formatter\LineFormatter
|
||||
arguments:
|
||||
|
||||
51
migrations/Version20250608143244.php
Normal file
51
migrations/Version20250608143244.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,12 @@
|
||||
backupGlobals="false"
|
||||
colors="true"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
cacheDirectory="var/cache/phpunit/">
|
||||
cacheDirectory="var/cache/phpunit/"
|
||||
displayDetailsOnTestsThatTriggerDeprecations="true"
|
||||
displayDetailsOnTestsThatTriggerErrors="true"
|
||||
displayDetailsOnTestsThatTriggerNotices="true"
|
||||
displayDetailsOnTestsThatTriggerWarnings="true"
|
||||
displayDetailsOnPhpunitDeprecations="true">
|
||||
<php>
|
||||
<ini name="error_reporting" value="-1"/>
|
||||
<ini name="max_execution_time" value="-1"/>
|
||||
@@ -34,14 +39,15 @@
|
||||
-->
|
||||
<env name="BOOTSTRAP_RESET_DATABASE" value="true"/>
|
||||
</php>
|
||||
|
||||
<testsuites>
|
||||
<testsuite name="Kimai">
|
||||
<directory>tests/</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<coverage>
|
||||
<extensions>
|
||||
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
|
||||
</extensions>
|
||||
<source>
|
||||
<include>
|
||||
<directory suffix=".php">src/</directory>
|
||||
<directory suffix=".php">templates/</directory>
|
||||
@@ -58,10 +64,5 @@
|
||||
<directory suffix=".php">var/</directory>
|
||||
<directory suffix=".php">vendor/</directory>
|
||||
</exclude>
|
||||
</coverage>
|
||||
|
||||
<extensions>
|
||||
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
|
||||
</extensions>
|
||||
|
||||
</source>
|
||||
</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
2
public/build/app.d4ea12cd.js
Normal file
2
public/build/app.d4ea12cd.js
Normal file
File diff suppressed because one or more lines are too long
@@ -3,10 +3,10 @@
|
||||
"app": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/app.6bb53337.js"
|
||||
"/build/app.d4ea12cd.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app.0416ea92.css"
|
||||
"/build/app.6e7b2701.css"
|
||||
]
|
||||
},
|
||||
"app-rtl": {
|
||||
@@ -15,7 +15,7 @@
|
||||
"/build/app-rtl.7a875ca7.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app-rtl.0848906b.css"
|
||||
"/build/app-rtl.bfe8d111.css"
|
||||
]
|
||||
},
|
||||
"export-pdf": {
|
||||
@@ -72,10 +72,10 @@
|
||||
},
|
||||
"integrity": {
|
||||
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
|
||||
"/build/app.6bb53337.js": "sha384-ZcwEys2VjhW4jwH/pU+/W06vG7rBounyirdkC+TCA4PyMn2QsENR6FIcNoJEpF5X",
|
||||
"/build/app.0416ea92.css": "sha384-JAIO6+B/vmV8IlpsmQ+zkfO4JsvdKTMsZZaDg6EMlZxkMZ5K3cpEK0mo24D/Wgkh",
|
||||
"/build/app.d4ea12cd.js": "sha384-vv5bzRvK0YN29MXeYe0uKv/LdRlwzMBu+RMvBXpNGu6btx1WxFmDOF6QM+CDoDPB",
|
||||
"/build/app.6e7b2701.css": "sha384-rrrlE5ISpDF53P81i2ce7AKONdm+b+I3mFacFPiJUohuMbMuHnUINac7YDkKD0/p",
|
||||
"/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.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
|
||||
"/build/invoice.773af9c4.js": "sha384-QmMqYJ0RP2WOYU7D4lLzKCBFDL6vCvZHQc7wf8K8N+hdCuTJwq1P0GuY81f30/SY",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"build/app.css": "/build/app.0416ea92.css",
|
||||
"build/app.js": "/build/app.6bb53337.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.0848906b.css",
|
||||
"build/app.css": "/build/app.6e7b2701.css",
|
||||
"build/app.js": "/build/app.d4ea12cd.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.bfe8d111.css",
|
||||
"build/app-rtl.js": "/build/app-rtl.7a875ca7.js",
|
||||
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
|
||||
"build/export-pdf.js": "/build/export-pdf.395749ab.js",
|
||||
|
||||
47
src/API/ExportController.php
Normal file
47
src/API/ExportController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,13 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Entity\ExportTemplate;
|
||||
use App\Export\Base\DispositionInlineInterface;
|
||||
use App\Export\ServiceExport;
|
||||
use App\Export\TooManyItemsExportException;
|
||||
use App\Form\ExportTemplateSpreadsheetForm;
|
||||
use App\Form\Toolbar\ExportToolbarForm;
|
||||
use App\Repository\ExportTemplateRepository;
|
||||
use App\Repository\Query\ExportQuery;
|
||||
use App\Utils\PageSetup;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
@@ -29,7 +32,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[IsGranted('create_export')]
|
||||
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->setHelp('export.html');
|
||||
|
||||
$buttons = [];
|
||||
foreach ($this->export->getRenderer() as $renderer) {
|
||||
$class = \get_class($renderer);
|
||||
$pos = strrpos($class, '\\');
|
||||
if ($pos !== false) {
|
||||
$class = substr($class, $pos + 1);
|
||||
}
|
||||
$class = strtolower(str_replace('Renderer', '', $class));
|
||||
$buttons[$class][$renderer->getId()] = $renderer->getTitle();
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_other_timesheet')) {
|
||||
$showRates = $this->isGranted('view_rate_other_timesheet');
|
||||
} else {
|
||||
$showRates = $this->isGranted('view_rate_own_timesheet');
|
||||
}
|
||||
|
||||
return $this->render('export/index.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'too_many' => $tooManyResults,
|
||||
@@ -86,10 +106,10 @@ final class ExportController extends AbstractController
|
||||
'query' => $query,
|
||||
'entries' => $entries,
|
||||
'form' => $form->createView(),
|
||||
'renderer' => $this->export->getRenderer(),
|
||||
'buttons' => $buttons,
|
||||
'preview_limit' => $maxItemsPreview,
|
||||
'preview_show' => $showPreview,
|
||||
'decimal' => $this->getUser()->isExportDecimal(),
|
||||
'show_rates' => $showRates,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -141,7 +161,6 @@ final class ExportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportQuery $query
|
||||
* @return ExportableItem[]
|
||||
* @throws TooManyItemsExportException
|
||||
*/
|
||||
@@ -158,8 +177,6 @@ final class ExportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportQuery $query
|
||||
* @param string $method
|
||||
* @return FormInterface<ExportQuery>
|
||||
*/
|
||||
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()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
136
src/Entity/ExportTemplate.php
Normal file
136
src/Entity/ExportTemplate.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
* @var Collection<UserPreference>|null
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'user', targetEntity: UserPreference::class, cascade: ['persist'])]
|
||||
private ?Collection $preferences;
|
||||
private ?Collection $preferences = null;
|
||||
/**
|
||||
* List of all team memberships.
|
||||
*
|
||||
@@ -504,10 +504,6 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return $allowNull ? $value : ($value ?? $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param UserPreference $preference
|
||||
* @return User
|
||||
*/
|
||||
public function addPreference(UserPreference $preference): User
|
||||
{
|
||||
if (null === $this->preferences) {
|
||||
@@ -586,9 +582,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is member of any team.
|
||||
*
|
||||
* @return bool
|
||||
* Checks if the user is a member of any team.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* @see User::hasTeamleadRole()
|
||||
* @return 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.
|
||||
*
|
||||
* @param User $user
|
||||
* @return 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.
|
||||
*
|
||||
* @param Team $team
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* @param Team $team
|
||||
*/
|
||||
public function removeTeam(Team $team): void
|
||||
{
|
||||
|
||||
@@ -26,6 +26,10 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
|
||||
{
|
||||
use ExportTrait;
|
||||
|
||||
private string $id = 'csv';
|
||||
private string $title = 'default';
|
||||
private ?string $locale = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly SpreadsheetRenderer $spreadsheetRenderer,
|
||||
private readonly TranslatorInterface $translator
|
||||
@@ -33,14 +37,29 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
|
||||
{
|
||||
}
|
||||
|
||||
public function setId(string $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
return 'csv';
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,7 +77,7 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
|
||||
/**
|
||||
* @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');
|
||||
if (false === $filename) {
|
||||
@@ -68,11 +87,12 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
|
||||
$options = new Options();
|
||||
$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);
|
||||
|
||||
$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);
|
||||
|
||||
return new \SplFileInfo($filename);
|
||||
|
||||
@@ -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));
|
||||
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT));
|
||||
@@ -81,7 +81,7 @@ class HtmlRenderer implements ExportRendererInterface
|
||||
$this->dispatcher->dispatch($event);
|
||||
$userPreferences = $event->getPreferences();
|
||||
|
||||
$summary = $this->calculateSummary($timesheets);
|
||||
$summary = $this->calculateSummary($exportItems);
|
||||
|
||||
// enable basic security measures
|
||||
$sandbox = new SandboxExtension(new ExportPolicy());
|
||||
@@ -89,11 +89,11 @@ class HtmlRenderer implements ExportRendererInterface
|
||||
$this->twig->addExtension($sandbox);
|
||||
|
||||
$content = $this->twig->render($this->getTemplate(), array_merge([
|
||||
'entries' => $timesheets,
|
||||
'entries' => $exportItems,
|
||||
'query' => $query,
|
||||
'summaries' => $summary,
|
||||
'budgets' => $this->calculateProjectBudget($timesheets, $query, $this->projectStatisticService),
|
||||
'activity_budgets' => $this->calculateActivityBudget($timesheets, $query, $this->activityStatisticService),
|
||||
'budgets' => $this->calculateProjectBudget($exportItems, $query, $this->projectStatisticService),
|
||||
'activity_budgets' => $this->calculateActivityBudget($exportItems, $query, $this->activityStatisticService),
|
||||
'timesheetMetaFields' => $timesheetMetaFields,
|
||||
'customerMetaFields' => $customerMetaFields,
|
||||
'projectMetaFields' => $projectMetaFields,
|
||||
|
||||
@@ -29,6 +29,7 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
use PDFRendererTrait;
|
||||
|
||||
private string $id = 'pdf';
|
||||
private string $title = 'pdf';
|
||||
private string $template = 'default.pdf.twig';
|
||||
private array $pdfOptions = [];
|
||||
|
||||
@@ -42,7 +43,7 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'pdf';
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
protected function getTemplate(): string
|
||||
@@ -75,20 +76,18 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportableItem[] $timesheets
|
||||
* @param TimesheetQuery $query
|
||||
* @return Response
|
||||
* @param ExportableItem[] $exportItems
|
||||
* @throws \Twig\Error\LoaderError
|
||||
* @throws \Twig\Error\RuntimeError
|
||||
* @throws \Twig\Error\SyntaxError
|
||||
*/
|
||||
public function render(array $timesheets, TimesheetQuery $query): Response
|
||||
public function render(array $exportItems, TimesheetQuery $query): Response
|
||||
{
|
||||
$filename = new ExportFilename($query);
|
||||
$context = new PdfContext();
|
||||
$context->setOption('filename', $filename->getFilename());
|
||||
|
||||
$summary = $this->calculateSummary($timesheets);
|
||||
$summary = $this->calculateSummary($exportItems);
|
||||
|
||||
// enable basic security measures
|
||||
$sandbox = new SandboxExtension(new ExportPolicy());
|
||||
@@ -96,10 +95,10 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
$this->twig->addExtension($sandbox);
|
||||
|
||||
$content = $this->twig->render($this->getTemplate(), array_merge([
|
||||
'entries' => $timesheets,
|
||||
'entries' => $exportItems,
|
||||
'query' => $query,
|
||||
'summaries' => $summary,
|
||||
'budgets' => $this->calculateProjectBudget($timesheets, $query, $this->projectStatisticService),
|
||||
'budgets' => $this->calculateProjectBudget($exportItems, $query, $this->projectStatisticService),
|
||||
'decimal' => false,
|
||||
'pdfContext' => $context
|
||||
], $this->getOptions($query)));
|
||||
@@ -111,18 +110,19 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
return $this->createPdfResponse($content, $context);
|
||||
}
|
||||
|
||||
public function setTemplate(string $filename): PDFRenderer
|
||||
public function setTemplate(string $filename): void
|
||||
{
|
||||
$this->template = $filename;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setId(string $id): PDFRenderer
|
||||
public function setId(string $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
}
|
||||
|
||||
return $this;
|
||||
public function setTitle(string $title): void
|
||||
{
|
||||
$this->title = $title;
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
|
||||
@@ -31,11 +31,14 @@ use App\Export\Package\CellFormatter\TimeFormatter;
|
||||
use App\Export\Package\Column;
|
||||
use App\Export\Package\ColumnWidth;
|
||||
use App\Export\Package\SpreadsheetPackage;
|
||||
use App\Export\Template;
|
||||
use App\Export\TemplateInterface;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
@@ -48,12 +51,32 @@ final class SpreadsheetRenderer
|
||||
*/
|
||||
private array $formatter = [];
|
||||
|
||||
private ?TemplateInterface $template = null;
|
||||
|
||||
public function __construct(
|
||||
protected EventDispatcherInterface $dispatcher,
|
||||
protected Security $voter
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
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
|
||||
{
|
||||
if ($this->voter->getUser() === null) {
|
||||
@@ -73,7 +96,7 @@ final class SpreadsheetRenderer
|
||||
*/
|
||||
private function findMetaColumns(MetaDisplayEventInterface $event): array
|
||||
{
|
||||
$this->dispatcher->dispatch($event);
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
|
||||
return $event->getFields();
|
||||
}
|
||||
@@ -98,7 +121,12 @@ final class SpreadsheetRenderer
|
||||
|
||||
if ($currentRow > 1) {
|
||||
$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 = [];
|
||||
$totalColumn = 1;
|
||||
foreach ($columns as $column) {
|
||||
@@ -131,8 +159,9 @@ final class SpreadsheetRenderer
|
||||
return match ($name) {
|
||||
'date' => new DateFormatter(),
|
||||
'time' => new TimeFormatter(),
|
||||
'duration' => new DurationFormatter(),
|
||||
'duration' => new DurationFormatter('[hh]:mm'),
|
||||
'duration_decimal' => new DurationDecimalFormatter(),
|
||||
'duration_seconds' => new DurationFormatter('[hh]:mm:ss'),
|
||||
default => new DefaultFormatter()
|
||||
};
|
||||
}
|
||||
@@ -144,98 +173,214 @@ final class SpreadsheetRenderer
|
||||
{
|
||||
$showRates = $this->isRenderRate($query);
|
||||
|
||||
$timesheetMeta = [];
|
||||
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$timesheetMeta['timesheet.meta.' . $metaField->getName()] = (new Column('timesheet.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$customerMeta = [];
|
||||
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$customerMeta['customer.meta.' . $metaField->getName()] = (new Column('customer.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getProject()?->getCustomer()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$projectMeta = [];
|
||||
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$projectMeta['project.meta.' . $metaField->getName()] = (new Column('project.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getProject()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$activityMeta = [];
|
||||
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent($query->copyTo(new ActivityQuery()), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$activityMeta['activity.meta.' . $metaField->getName()] = (new Column('activity.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getActivity()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$userMeta = [];
|
||||
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
foreach ($event->getPreferences() as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$userMeta['user.meta.' . $metaField->getName()] = (new Column('user.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getUser()?->getPreference($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$template = $this->getTemplate();
|
||||
|
||||
$columns = [];
|
||||
|
||||
$rateColumns = ['currency', 'rate', 'internal_rate', 'hourly_rate', 'fixed_rate'];
|
||||
|
||||
foreach ($template->getColumns() as $column) {
|
||||
if ($column === 'date') {
|
||||
$columns[] = (new Column('date', $this->getFormatter('date')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin());
|
||||
} elseif ($column === 'begin') {
|
||||
$columns[] = (new Column('begin', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'end') {
|
||||
$columns[] = (new Column('end', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getEnd())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'duration') {
|
||||
$columns[] = (new Column('duration', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'duration_decimal') {
|
||||
$columns[] = (new Column('duration', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'duration_seconds') {
|
||||
$columns[] = (new Column('duration', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'currency' && $showRates) {
|
||||
$columns[] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'rate' && $showRates) {
|
||||
$columns[] = (new Column('rate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getRate());
|
||||
} elseif ($column === 'internal_rate' && $showRates) {
|
||||
$columns[] = (new Column('internalRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getInternalRate());
|
||||
} elseif ($column === 'hourly_rate' && $showRates) {
|
||||
$columns[] = (new Column('hourlyRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getHourlyRate());
|
||||
} elseif ($column === 'fixed_rate' && $showRates) {
|
||||
$columns[] = (new Column('fixedRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getFixedRate());
|
||||
} elseif ($column === 'user.alias') {
|
||||
$columns[] = (new Column('alias', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'user.name') {
|
||||
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getUserIdentifier())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'user.account_number') {
|
||||
$columns[] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber());
|
||||
} elseif ($column === 'customer.name') {
|
||||
$columns[] = (new Column('customer', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'project.name') {
|
||||
$columns[] = (new Column('project', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'activity.name') {
|
||||
$columns[] = (new Column('activity', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'description') {
|
||||
$columns[] = (new Column('description', new TextFormatter(true)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDescription())->withColumnWidth(ColumnWidth::LARGE);
|
||||
} elseif ($column === 'exported') {
|
||||
$columns[] = (new Column('exported', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isExported());
|
||||
} elseif ($column === 'billable') {
|
||||
$columns[] = (new Column('billable', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isBillable())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'tags') {
|
||||
$columns[] = (new Column('tags', new ArrayFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getTagsAsArray());
|
||||
} elseif ($column === 'type') {
|
||||
$columns[] = (new Column('type', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getType());
|
||||
} elseif ($column === 'category') {
|
||||
$columns[] = (new Column('category', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getCategory());
|
||||
} elseif ($column === 'customer.number') {
|
||||
$columns[] = (new Column('number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getNumber());
|
||||
} elseif ($column === 'project.number') {
|
||||
$columns[] = (new Column('project_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getNumber());
|
||||
} elseif ($column === 'activity.number') {
|
||||
$columns[] = (new Column('activity_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getNumber());
|
||||
} elseif ($column === 'customer.vat_id') {
|
||||
$columns[] = (new Column('vat_id', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getVatId());
|
||||
} elseif ($column === 'project.order_number') {
|
||||
$columns[] = (new Column('orderNumber', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getOrderNumber());
|
||||
} elseif (str_starts_with($column, 'timesheet.meta.') && \array_key_exists($column, $timesheetMeta)) {
|
||||
$columns[] = $timesheetMeta[$column];
|
||||
} elseif (str_starts_with($column, 'customer.meta.') && \array_key_exists($column, $customerMeta)) {
|
||||
$columns[] = $customerMeta[$column];
|
||||
} elseif (str_starts_with($column, 'project.meta.') && \array_key_exists($column, $projectMeta)) {
|
||||
$columns[] = $projectMeta[$column];
|
||||
} elseif (str_starts_with($column, 'activity.meta.') && \array_key_exists($column, $activityMeta)) {
|
||||
$columns[] = $activityMeta[$column];
|
||||
} elseif (str_starts_with($column, 'user.meta.') && \array_key_exists($column, $userMeta)) {
|
||||
$columns[] = $userMeta[$column];
|
||||
} else {
|
||||
if ($this->logger !== null && ($showRates || !\in_array($column, $rateColumns, true))) {
|
||||
$this->logger->warning(\sprintf('Unknown column "%s" used in exporter template "%s".', $column, $template->getTitle()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function getDefaultColumns(): array
|
||||
{
|
||||
// @deprecated since 2.36 - will be removed with 3.0
|
||||
$durationFormatter = 'duration';
|
||||
if (($user = $this->voter->getUser()) instanceof User) {
|
||||
$durationFormatter = $user->isExportDecimal() ? 'duration_decimal' : 'duration';
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
$columns = [
|
||||
'date',
|
||||
'begin',
|
||||
'end',
|
||||
$durationFormatter,
|
||||
'currency',
|
||||
'rate',
|
||||
'internal_rate',
|
||||
'hourly_rate',
|
||||
'fixed_rate',
|
||||
'user.alias',
|
||||
'user.name',
|
||||
'user.account_number',
|
||||
'customer.name',
|
||||
'project.name',
|
||||
'activity.name',
|
||||
'description',
|
||||
'billable',
|
||||
'tags',
|
||||
'type',
|
||||
'category',
|
||||
'customer.number',
|
||||
'project.number',
|
||||
'customer.vat_id',
|
||||
'project.order_number',
|
||||
];
|
||||
|
||||
$columns[] = (new Column('date', $this->getFormatter('date')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin());
|
||||
$columns[] = (new Column('begin', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin())->withColumnWidth(ColumnWidth::SMALL);
|
||||
$columns[] = (new Column('end', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getEnd())->withColumnWidth(ColumnWidth::SMALL);
|
||||
$columns[] = (new Column('duration', $this->getFormatter($durationFormatter)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
|
||||
|
||||
if ($showRates) {
|
||||
$columns[] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL);
|
||||
$columns[] = (new Column('rate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getRate());
|
||||
$columns[] = (new Column('internalRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getInternalRate());
|
||||
$columns[] = (new Column('hourlyRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getHourlyRate());
|
||||
$columns[] = (new Column('fixedRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getFixedRate());
|
||||
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent(new TimesheetQuery(), TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'timesheet.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
$columns[] = (new Column('alias', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getUserIdentifier())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
$columns[] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber());
|
||||
$columns[] = (new Column('customer', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
$columns[] = (new Column('project', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
$columns[] = (new Column('activity', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
$columns[] = (new Column('description', new TextFormatter(true)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDescription())->withColumnWidth(ColumnWidth::LARGE);
|
||||
//$columns[] = (new Column('exported', new BooleanFormatter()))->withExtractor(fn(ExportableItem $exportableItem) => $exportableItem->isExported());
|
||||
$columns[] = (new Column('billable', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isBillable())->withColumnWidth(ColumnWidth::SMALL);
|
||||
$columns[] = (new Column('tags', new ArrayFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getTagsAsArray());
|
||||
$columns[] = (new Column('type', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getType());
|
||||
$columns[] = (new Column('category', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getCategory());
|
||||
$columns[] = (new Column('number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getNumber());
|
||||
$columns[] = (new Column('project_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getNumber());
|
||||
$columns[] = (new Column('vat_id', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getVatId());
|
||||
$columns[] = (new Column('orderNumber', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getOrderNumber());
|
||||
|
||||
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() === null) {
|
||||
continue;
|
||||
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent(new CustomerQuery(), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'customer.meta.' . $metaField->getName();
|
||||
}
|
||||
$columns[] = (new Column('timesheet.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() === null) {
|
||||
continue;
|
||||
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent(new ProjectQuery(), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'project.meta.' . $metaField->getName();
|
||||
}
|
||||
$columns[] = (new Column('customer.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getProject()?->getCustomer()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() === null) {
|
||||
continue;
|
||||
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent(new ActivityQuery(), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'activity.meta.' . $metaField->getName();
|
||||
}
|
||||
$columns[] = (new Column('project.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getProject()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent($query->copyTo(new ActivityQuery()), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() === null) {
|
||||
continue;
|
||||
}
|
||||
$columns[] = (new Column('activity.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getActivity()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
|
||||
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
foreach ($event->getPreferences() as $metaField) {
|
||||
if ($metaField->getName() === null) {
|
||||
continue;
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'user.meta.' . $metaField->getName();
|
||||
}
|
||||
$columns[] = (new Column('user.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getUser()?->getPreference($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
|
||||
return $columns;
|
||||
|
||||
@@ -23,6 +23,10 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
|
||||
{
|
||||
use ExportTrait;
|
||||
|
||||
private string $id = 'xlsx';
|
||||
private string $title = 'default';
|
||||
private ?string $locale = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly SpreadsheetRenderer $spreadsheetRenderer,
|
||||
private readonly TranslatorInterface $translator,
|
||||
@@ -30,14 +34,29 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
|
||||
{
|
||||
}
|
||||
|
||||
public function setId(string $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
return 'xlsx';
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,14 +74,14 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
|
||||
/**
|
||||
* @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');
|
||||
if (false === $filename) {
|
||||
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);
|
||||
|
||||
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);
|
||||
|
||||
@@ -17,18 +17,10 @@ interface ExportRendererInterface
|
||||
{
|
||||
/**
|
||||
* @param ExportableItem[] $exportItems
|
||||
* @param TimesheetQuery $query
|
||||
* @return Response
|
||||
*/
|
||||
public function render(array $exportItems, TimesheetQuery $query): Response;
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getId(): string;
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getTitle(): string;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\Utils\Duration;
|
||||
|
||||
final class DurationDecimalFormatter implements CellFormatterInterface
|
||||
{
|
||||
private Duration $duration;
|
||||
private readonly Duration $duration;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
|
||||
@@ -11,6 +11,10 @@ namespace App\Export\Package\CellFormatter;
|
||||
|
||||
final class DurationFormatter implements CellFormatterInterface, CellWithFormatInterface
|
||||
{
|
||||
public function __construct(private readonly string $format)
|
||||
{
|
||||
}
|
||||
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if (!is_numeric($value) || (int) $value === 0) {
|
||||
@@ -37,6 +41,6 @@ final class DurationFormatter implements CellFormatterInterface, CellWithFormatI
|
||||
|
||||
public function getFormat(): string
|
||||
{
|
||||
return '[hh]:mm:ss';
|
||||
return $this->format;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,18 @@ namespace App\Export\Package\CellFormatter;
|
||||
|
||||
final class DurationPlainFormatter implements CellFormatterInterface
|
||||
{
|
||||
public function __construct(private readonly bool $withSeconds = true)
|
||||
{
|
||||
}
|
||||
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if (!is_numeric($value) || (int) $value === 0) {
|
||||
return '0:00:00';
|
||||
if ($this->withSeconds) {
|
||||
return '0:00:00';
|
||||
}
|
||||
|
||||
return '0:00';
|
||||
}
|
||||
|
||||
$value = (int) $value;
|
||||
@@ -32,6 +40,10 @@ final class DurationPlainFormatter implements CellFormatterInterface
|
||||
$interval->invert = 1;
|
||||
}
|
||||
|
||||
return $interval->format('%r%h:%I:%S');
|
||||
if ($this->withSeconds) {
|
||||
return $interval->format('%r%h:%I:%S');
|
||||
}
|
||||
|
||||
return $interval->format('%r%h:%I');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ class SpoutSpreadsheet implements SpreadsheetPackage
|
||||
|
||||
public function __construct(
|
||||
private readonly WriterInterface $writer,
|
||||
private readonly TranslatorInterface $translator
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly ?string $locale = null,
|
||||
)
|
||||
{
|
||||
$this->writer->setCreator(Constants::SOFTWARE);
|
||||
@@ -50,11 +51,11 @@ class SpoutSpreadsheet implements SpreadsheetPackage
|
||||
$tmp = [];
|
||||
$i = 0;
|
||||
foreach ($columns as $column) {
|
||||
$title = $this->translator->trans($column->getHeader());
|
||||
$title = $this->translator->trans($column->getHeader(), [], null, $this->locale);
|
||||
$tmp[] = Cell::fromValue($title);
|
||||
$style = null;
|
||||
if ($column->getFormat() !== null) {
|
||||
$style = (new Style())->setFormat($column->getFormat());
|
||||
if (($format = $column->getFormat()) !== null) {
|
||||
$style = (new Style())->setFormat($format);
|
||||
}
|
||||
$this->styles[$i++] = $style;
|
||||
}
|
||||
@@ -120,8 +121,10 @@ class SpoutSpreadsheet implements SpreadsheetPackage
|
||||
|
||||
if ($this->writer instanceof AbstractWriterMultiSheets) {
|
||||
$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);
|
||||
}
|
||||
|
||||
42
src/Export/Renderer/CsvRendererFactory.php
Normal file
42
src/Export/Renderer/CsvRendererFactory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ final class PdfRendererFactory
|
||||
{
|
||||
$renderer = new PDFRenderer($this->twig, $this->converter, $this->projectStatisticService);
|
||||
$renderer->setId($id);
|
||||
$renderer->setTitle($id);
|
||||
$renderer->setTemplate($template);
|
||||
|
||||
return $renderer;
|
||||
|
||||
42
src/Export/Renderer/XlsxRendererFactory.php
Normal file
42
src/Export/Renderer/XlsxRendererFactory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,14 @@ namespace App\Export;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Event\ExportItemsQueryEvent;
|
||||
use App\Export\Renderer\CsvRendererFactory;
|
||||
use App\Export\Renderer\HtmlRendererFactory;
|
||||
use App\Export\Renderer\PdfRendererFactory;
|
||||
use App\Export\Renderer\XlsxRendererFactory;
|
||||
use App\Repository\ExportTemplateRepository;
|
||||
use App\Repository\Query\ExportQuery;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class ServiceExport
|
||||
{
|
||||
@@ -36,9 +40,13 @@ final class ServiceExport
|
||||
private array $repositories = [];
|
||||
|
||||
public function __construct(
|
||||
private EventDispatcherInterface $eventDispatcher,
|
||||
private HtmlRendererFactory $htmlRendererFactory,
|
||||
private PdfRendererFactory $pdfRendererFactory
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
private readonly HtmlRendererFactory $htmlRendererFactory,
|
||||
private readonly PdfRendererFactory $pdfRendererFactory,
|
||||
private readonly CsvRendererFactory $csvRendererFactory,
|
||||
private readonly XlsxRendererFactory $xlsxRendererFactory,
|
||||
private readonly ExportTemplateRepository $exportTemplateRepository,
|
||||
private readonly LoggerInterface $logger,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -73,6 +81,26 @@ final class ServiceExport
|
||||
{
|
||||
$renderer = [];
|
||||
|
||||
foreach ($this->exportTemplateRepository->findAll() as $template) {
|
||||
$tpl = new Template((string) $template->getId(), $template->getTitle()); // @phpstan-ignore argument.type
|
||||
$tpl->setColumns($template->getColumns());
|
||||
$tpl->setLocale($template->getLanguage());
|
||||
|
||||
switch ($template->getRenderer()) {
|
||||
case 'csv':
|
||||
$renderer[] = $this->csvRendererFactory->create($tpl);
|
||||
break;
|
||||
|
||||
case 'xlsx':
|
||||
$renderer[] = $this->xlsxRendererFactory->create($tpl);
|
||||
break;
|
||||
|
||||
default:
|
||||
$this->logger->error('Unknown export template type: ' . $template->getRenderer());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->documentDirs as $exportPath) {
|
||||
if (!is_dir($exportPath)) {
|
||||
continue;
|
||||
@@ -132,7 +160,7 @@ final class ServiceExport
|
||||
|
||||
public function getTimesheetExporterById(string $id): ?TimesheetExportInterface
|
||||
{
|
||||
foreach ($this->timesheetExporter as $exporter) {
|
||||
foreach ($this->getTimesheetExporter() as $exporter) {
|
||||
if ($exporter->getId() === $id) {
|
||||
return $exporter;
|
||||
}
|
||||
@@ -147,7 +175,6 @@ final class ServiceExport
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportQuery $query
|
||||
* @return ExportableItem[]
|
||||
* @throws TooManyItemsExportException
|
||||
*/
|
||||
|
||||
79
src/Export/Template.php
Normal file
79
src/Export/Template.php
Normal 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;
|
||||
}
|
||||
}
|
||||
29
src/Export/TemplateInterface.php
Normal file
29
src/Export/TemplateInterface.php
Normal 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;
|
||||
}
|
||||
43
src/Form/ExportTemplateSpreadsheetForm.php
Normal file
43
src/Form/ExportTemplateSpreadsheetForm.php
Normal 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'
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -40,18 +40,22 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
|
||||
|
||||
$extendedOptions = ['class' => 'selectpicker'];
|
||||
|
||||
if ($options['multiple']) {
|
||||
if (\array_key_exists('multiple', $options) && $options['multiple']) {
|
||||
$extendedOptions['size'] = 1;
|
||||
}
|
||||
|
||||
if (false !== $options['width']) {
|
||||
if (\array_key_exists('width', $options) && false !== $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;
|
||||
}
|
||||
|
||||
if (\array_key_exists('order', $options) && true === $options['order']) {
|
||||
$extendedOptions['data-order'] = 1;
|
||||
}
|
||||
|
||||
// there is a very weird logic in vendor/symfony/twig-bridge/Resources/views/Form/form_div_layout.html.twig
|
||||
// in block "block choice_widget_collapsed" that resets "{% set required = false %}", so we fake it into the select
|
||||
if (true === $options['required'] && \is_array($options['attr']) && (!\array_key_exists('size', $options['attr']) || $options['attr']['size'] <= 1)) {
|
||||
@@ -75,5 +79,9 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
|
||||
$resolver->setDefined(['search']);
|
||||
$resolver->setAllowedTypes('search', 'boolean');
|
||||
$resolver->setDefault('search', true);
|
||||
|
||||
$resolver->setDefined(['order']);
|
||||
$resolver->setAllowedTypes('order', 'boolean');
|
||||
$resolver->setDefault('order', false);
|
||||
}
|
||||
}
|
||||
|
||||
166
src/Form/Type/ExportColumnsType.php
Normal file
166
src/Form/Type/ExportColumnsType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
29
src/Form/Type/ExportRendererType.php
Normal file
29
src/Form/Type/ExportRendererType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
31
src/Repository/ExportTemplateRepository.php
Normal file
31
src/Repository/ExportTemplateRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
24
src/Validator/Constraints/ExportRenderer.php
Normal file
24
src/Validator/Constraints/ExportRenderer.php
Normal 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.';
|
||||
}
|
||||
41
src/Validator/Constraints/ExportRendererValidator.php
Normal file
41
src/Validator/Constraints/ExportRendererValidator.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,18 @@
|
||||
'activity': {'class': 'd-none', 'orderBy': false},
|
||||
'description': {'class': 'd-none d-xl-table-cell timesheet-description', 'orderBy': false},
|
||||
'tags': {'class': 'd-none d-xl-table-cell', 'orderBy': false},
|
||||
'unit_price': {'class': 'd-none text-nowrap text-end', 'orderBy': false},
|
||||
'duration': {'class': 'text-end text-nowrap', 'orderBy': false},
|
||||
'internalRate': {'class': 'text-end d-none text-nowrap', 'orderBy': false},
|
||||
'total_rate': {'class': 'text-end text-nowrap', 'orderBy': false},
|
||||
'actions': {'class': 'actions alwaysVisible', 'orderBy': false},
|
||||
} %}
|
||||
{% if show_rates %}
|
||||
{% set columns = columns|merge({
|
||||
'unit_price': {'class': 'd-none text-nowrap text-end', 'orderBy': false},
|
||||
'internalRate': {'class': 'text-end d-none text-nowrap', 'orderBy': false},
|
||||
'total_rate': {'class': 'text-end text-nowrap', 'orderBy': false},
|
||||
}) %}
|
||||
{% endif %}
|
||||
{% set columns = columns|merge({
|
||||
'actions': {'class': 'actions alwaysVisible', 'orderBy': false},
|
||||
}) %}
|
||||
|
||||
{% set tableName = 'export' %}
|
||||
{% set editExported = is_granted('edit_exported_timesheet') %}
|
||||
@@ -45,9 +51,11 @@
|
||||
{% if totalDuration > 0 %}
|
||||
{{ status_duration(totalDuration|duration) }}
|
||||
{% endif %}
|
||||
{% for totalCurrency, totalRate in totalRates %}
|
||||
{{ status_money(totalRate|money(totalCurrency)) }}
|
||||
{% endfor %}
|
||||
{% if show_rates %}
|
||||
{% for totalCurrency, totalRate in totalRates %}
|
||||
{{ status_money(totalRate|money(totalCurrency)) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block main_before %}
|
||||
@@ -102,16 +110,6 @@
|
||||
{% block box_attributes %}id="preview_export"{% endblock %}
|
||||
{% block box_body_class %}p-0{% endblock %}
|
||||
{% block box_footer %}
|
||||
{% set buttons = {} %}
|
||||
{% for button in renderer %}
|
||||
{% set title = button.title %}
|
||||
{% set group = [] %}
|
||||
{% if buttons[(title)] is defined %}
|
||||
{% set group = buttons[(title)] %}
|
||||
{% endif %}
|
||||
{% set group = group|merge([button]) %}
|
||||
{% set buttons = buttons|merge({(title): group}) %}
|
||||
{% endfor %}
|
||||
{% if showMarkAsExportedButton %}
|
||||
<div class="d-flex">
|
||||
<div class="form-check form-switch">
|
||||
@@ -122,33 +120,51 @@
|
||||
{% endif %}
|
||||
<div class="d-flex">
|
||||
<div class="btn-group me-auto" id="export-buttons" role="group">
|
||||
{% for group in buttons %}
|
||||
{% set button = group.0 %}
|
||||
{% set btnTitle = ('button.' ~ button.title)|trans %}
|
||||
{% if btnTitle == ('button.' ~ button.title) %}
|
||||
{% set btnTitle = button.title %}
|
||||
{% set allowCreate = is_granted('create_export_template') %}
|
||||
{% for type, group in buttons %}
|
||||
{% set btnTitle = ('button.' ~ type)|trans %}
|
||||
{% if btnTitle == ('button.' ~ type) %}
|
||||
{% set btnTitle = type|trans %}
|
||||
{% endif %}
|
||||
{% if group|length == 1 %}
|
||||
<button type="button" id="export-{{ button.id }}-button" class="btn btn-success startExportBtn" data-type="{{ button.id }}">
|
||||
{{ btnTitle }}
|
||||
</button>
|
||||
{% elseif group|length > 1 %}
|
||||
{% for id, title in group %}
|
||||
{% set btnTitle2 = ('button.' ~ title)|trans %}
|
||||
{% if btnTitle2 != ('button.' ~ title) %}
|
||||
{% set btnTitle = btnTitle2 %}
|
||||
{% endif %}
|
||||
<button type="button" id="export-{{ id }}-button" class="btn btn-success startExportBtn" data-type="{{ id }}">
|
||||
{{ btnTitle }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-success dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
{{ btnTitle }}
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
{% for button in group %}
|
||||
{% set btnTitle = (button.id)|trans({}, 'export') %}
|
||||
{% if btnTitle == button.id %}
|
||||
{% set btnTitle = button.id|split('.')|first|replace({'-': ' ', '_': ' '})|split(' ')|map(t => t|capitalize)|join(' ') %}
|
||||
{% endif %}
|
||||
<a href="#" class="dropdown-item startExportBtn" data-type="{{ button.id }}">{{ btnTitle }}</a>
|
||||
{% for id, title in group %}
|
||||
{% set btnTitle = id|trans({}, 'export') %}
|
||||
{% if btnTitle == id %}
|
||||
{% set btnTitle = title|trans %}
|
||||
{% endif %}
|
||||
{% if btnTitle == id%}
|
||||
{% set btnTitle = id|split('.')|first|replace({'-': ' ', '_': ' '})|split(' ')|map(t => t|capitalize)|join(' ') %}
|
||||
{% endif %}
|
||||
<a href="#" class="dropdown-item startExportBtn" data-type="{{ id }}">
|
||||
{{ btnTitle }}
|
||||
{% if allowCreate and loop.index0 != 0 and (type == 'csv' or type == 'xlsx') %}
|
||||
<span class="dropdown-action modal-ajax-form open-edit ps-2 ms-auto" data-href="{{ path('export_template_edit', {exportTemplate: id}) }}">{{ icon('edit') }}</span>
|
||||
<span class="dropdown-action api-link text-red dd-ts-trash text-danger ms-2" href="{{ path('delete_export_template', {id: id}) }}" data-event="kimai.exportTemplate" data-method="DELETE" data-question="confirm.delete" data-msg-error="action.delete.error" data-msg-success="action.delete.success">{{ icon('delete') }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if allowCreate %}
|
||||
{{ widgets.action_button('create', {'url': path('export_template_create'), 'class': 'btn modal-ajax-form open-edit', 'title': 'create'|trans}) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if showToggleButton %}
|
||||
<button id="toggle-button" class="btn ms-auto d-none d-sm-inline-flex">
|
||||
@@ -169,7 +185,9 @@
|
||||
<tr>
|
||||
<th>{{ 'customer'|trans }}</th>
|
||||
<th class="w-min text-end d-none d-sm-table-cell">{{ 'duration'|trans }}</th>
|
||||
{% if show_rates %}
|
||||
<th class="w-min text-end">{{ 'total_rate'|trans }}</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -180,11 +198,13 @@
|
||||
{{ widgets.label_customer(row.customer) }}
|
||||
</td>
|
||||
<td class="w-min text-end d-none d-sm-table-cell">
|
||||
{{ row.duration|duration(decimal) }}
|
||||
{{ row.duration|duration }}
|
||||
</td>
|
||||
{% if show_rates %}
|
||||
<td class="w-min text-end">
|
||||
{{ row.rate|money(currency) }}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -235,18 +255,20 @@
|
||||
{{ widgets.tag_list(entry.tags) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}" data-duration="{{ entry.duration }}">
|
||||
{{ entry.duration|duration }}
|
||||
</td>
|
||||
{% if show_rates %}
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }}">
|
||||
{{ rate|money(currency) }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}" data-duration="{{ entry.duration }}">
|
||||
{{ entry.duration|duration(decimal) }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'internalRate') }}">
|
||||
{{ entry.internalRate|money(currency) }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'total_rate') }}">
|
||||
{{ entry.rate|money(currency) }}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'actions') }}">
|
||||
{% if is_granted('edit_export', entry) %}
|
||||
{% if entry.exported %}
|
||||
@@ -288,6 +310,8 @@
|
||||
{% block javascripts %}
|
||||
{{ parent() }}
|
||||
<script type="text/javascript">
|
||||
KimaiReloadPageWidget.create('kimai.exportTemplate', true);
|
||||
|
||||
function updateTimesheetExportState(node, id)
|
||||
{
|
||||
/** @type {KimaiAlert} */
|
||||
|
||||
24
templates/export/template.html.twig
Normal file
24
templates/export/template.html.twig
Normal 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 %}
|
||||
@@ -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);
|
||||
|
||||
sort($actual);
|
||||
@@ -73,6 +73,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
|
||||
'/api/customers/{id}/meta',
|
||||
'/api/customers/{id}/rates',
|
||||
'/api/customers/{id}/rates/{rateId}',
|
||||
'/api/export/{id}',
|
||||
'/api/invoices',
|
||||
'/api/invoices/{id}',
|
||||
'/api/projects',
|
||||
|
||||
80
tests/API/ExportControllerTest.php
Normal file
80
tests/API/ExportControllerTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,14 @@
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Entity\ExportTemplate;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Tests\DataFixtures\ExportTemplateFixtures;
|
||||
use App\Tests\DataFixtures\TimesheetFixtures;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Symfony\Component\DomCrawler\Field\FormField;
|
||||
|
||||
/**
|
||||
* @group integration
|
||||
@@ -25,7 +28,7 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
|
||||
$this->assertUrlIsSecured('/export/');
|
||||
}
|
||||
|
||||
public function testIsSecureForrole(): void
|
||||
public function testIsSecureForRole(): void
|
||||
{
|
||||
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/export/');
|
||||
}
|
||||
@@ -90,6 +93,16 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
|
||||
// +1 row for summary
|
||||
$this->assertDataTableRowCount($client, 'datatable_export', 22);
|
||||
|
||||
$header = $client->getCrawler()->filter('section.content div.datatable_export table.dataTable thead th');
|
||||
$titles = [];
|
||||
/** @var \DOMElement $th */
|
||||
foreach ($header as $th) {
|
||||
$titles[] = trim($th->textContent);
|
||||
}
|
||||
self::assertEquals([
|
||||
'', 'Date', 'User', 'Project', 'Activity', 'Description', 'Tags', 'Duration', 'Unit price', 'Internal price', 'Total price', '',
|
||||
], $titles);
|
||||
|
||||
// assert export type buttons are available
|
||||
$expected = [
|
||||
'csv' => 'csv',
|
||||
@@ -246,4 +259,71 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
|
||||
self::assertTrue($timesheet->isExported());
|
||||
}
|
||||
}
|
||||
|
||||
public function testCreateTemplateIsSecure(): void
|
||||
{
|
||||
$this->assertUrlIsSecured('/export/template-create');
|
||||
}
|
||||
|
||||
public function testCreateTemplateIsSecureForRole(): void
|
||||
{
|
||||
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/export/template-create');
|
||||
}
|
||||
|
||||
public function testCreateTemplateAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->assertAccessIsGranted($client, '/export/template-create');
|
||||
$form = $client->getCrawler()->filter('form[name=export_template_spreadsheet_form]')->form();
|
||||
$client->submit($form, [
|
||||
'export_template_spreadsheet_form' => [
|
||||
'title' => 'My temaplte name',
|
||||
'renderer' => 'xlsx',
|
||||
'language' => 'de',
|
||||
'columns' => 'date',
|
||||
]
|
||||
]);
|
||||
|
||||
$this->assertIsRedirect($client, $this->createUrl('/export/'));
|
||||
|
||||
$templates = $this->getEntityManager()->getRepository(ExportTemplate::class)->findAll();
|
||||
self::assertCount(1, $templates);
|
||||
$template = array_pop($templates);
|
||||
$id = $template->getId();
|
||||
|
||||
$this->request($client, $this->createUrl('/export/template-edit/' . $id));
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
$editForm = $client->getCrawler()->filter('form[name=export_template_spreadsheet_form]')->form();
|
||||
$field = $editForm->get('export_template_spreadsheet_form[title]');
|
||||
self::assertInstanceOf(FormField::class, $field);
|
||||
self::assertEquals('My temaplte name', $field->getValue());
|
||||
}
|
||||
|
||||
public function testEditTemplateAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
/** @var ExportTemplate[] $templates */
|
||||
$templates = $this->importFixture(new ExportTemplateFixtures());
|
||||
|
||||
$id = $templates[0]->getId();
|
||||
|
||||
$this->request($client, $this->createUrl('/export/template-edit/' . $id));
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
$form = $client->getCrawler()->filter('form[name=export_template_spreadsheet_form]')->form();
|
||||
$field = $form->get('export_template_spreadsheet_form[title]');
|
||||
self::assertInstanceOf(FormField::class, $field);
|
||||
self::assertEquals('CSV Test', $field->getValue());
|
||||
|
||||
$client->submit($form, [
|
||||
'export_template_spreadsheet_form' => [
|
||||
'title' => 'My temaplte name',
|
||||
]
|
||||
]);
|
||||
|
||||
$this->assertIsRedirect($client, $this->createUrl('/export/'));
|
||||
|
||||
/** @var ExportTemplate $template */
|
||||
$template = $this->getEntityManager()->getRepository(ExportTemplate::class)->find($id);
|
||||
self::assertEquals('My temaplte name', $template->getTitle());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ class PermissionControllerTest extends AbstractControllerBaseTestCase
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||
$this->assertAccessIsGranted($client, '/admin/permissions');
|
||||
$this->assertHasDataTable($client);
|
||||
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 135);
|
||||
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 136);
|
||||
$this->assertPageActions($client, [
|
||||
'create modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),
|
||||
]);
|
||||
|
||||
@@ -35,12 +35,9 @@ class CustomerServiceTest extends TestCase
|
||||
private function getSut(
|
||||
?EventDispatcherInterface $dispatcher = null,
|
||||
?ValidatorInterface $validator = null,
|
||||
?CustomerRepository $repository = null,
|
||||
?SystemConfiguration $configuration = null
|
||||
): CustomerService {
|
||||
if ($repository === null) {
|
||||
$repository = $this->createMock(CustomerRepository::class);
|
||||
}
|
||||
$repository = $this->createMock(CustomerRepository::class);
|
||||
|
||||
if ($dispatcher === null) {
|
||||
$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');
|
||||
|
||||
self::assertEquals((string) $expected, $customer->getNumber());
|
||||
|
||||
40
tests/DataFixtures/ExportTemplateFixtures.php
Normal file
40
tests/DataFixtures/ExportTemplateFixtures.php
Normal 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];
|
||||
}
|
||||
}
|
||||
73
tests/Entity/ExportTemplateTest.php
Normal file
73
tests/Entity/ExportTemplateTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ class UserTest extends TestCase
|
||||
|
||||
$user->setUserIdentifier('foo');
|
||||
self::assertEquals('foo', $user->getUserIdentifier());
|
||||
self::assertEquals('foo', $user->getIdentifier());
|
||||
self::assertEquals('foo', $user->getDisplayName());
|
||||
$user->setAlias('BAR');
|
||||
self::assertEquals('BAR', $user->getDisplayName());
|
||||
@@ -89,6 +90,7 @@ class UserTest extends TestCase
|
||||
public function testWorkContract(): void
|
||||
{
|
||||
$user = new User();
|
||||
self::assertFalse($user->hasContractSettings());
|
||||
|
||||
self::assertEquals(0, $user->getWorkHoursMonday());
|
||||
self::assertEquals(0, $user->getWorkHoursTuesday());
|
||||
@@ -127,6 +129,7 @@ class UserTest extends TestCase
|
||||
$user->setWorkHoursSunday(7800);
|
||||
$user->setHolidaysPerYear(10.7);
|
||||
self::assertTrue($user->hasWorkHourConfiguration());
|
||||
self::assertTrue($user->hasContractSettings());
|
||||
|
||||
self::assertEquals(7200, $user->getWorkHoursMonday());
|
||||
self::assertEquals(7300, $user->getWorkHoursTuesday());
|
||||
@@ -158,6 +161,16 @@ class UserTest extends TestCase
|
||||
|
||||
$user->setPublicHolidayGroup('DE-NRW');
|
||||
self::assertEquals('DE-NRW', $user->getPublicHolidayGroup());
|
||||
|
||||
self::assertNull($user->getWorkStartingDay());
|
||||
$workStart = new \DateTimeImmutable('2018-07-23');
|
||||
$user->setWorkStartingDay($workStart);
|
||||
self::assertEquals($workStart, $user->getWorkStartingDay());
|
||||
|
||||
self::assertNull($user->getLastWorkingDay());
|
||||
$workStart = new \DateTimeImmutable('2021-02-13');
|
||||
$user->setLastWorkingDay($workStart);
|
||||
self::assertEquals($workStart, $user->getLastWorkingDay());
|
||||
}
|
||||
|
||||
public function testColor(): void
|
||||
@@ -248,6 +261,21 @@ class UserTest extends TestCase
|
||||
|
||||
$user->setPreferenceValue('export_decimal', true);
|
||||
self::assertTrue($user->isExportDecimal());
|
||||
|
||||
$prefs = $user->getPreferences();
|
||||
self::assertCount(3, $prefs);
|
||||
|
||||
self::assertInstanceOf(UserPreference::class, $prefs[0]);
|
||||
self::assertEquals('test', $prefs[0]->getName());
|
||||
|
||||
self::assertInstanceOf(UserPreference::class, $prefs[1]);
|
||||
self::assertEquals('test2', $prefs[1]->getName());
|
||||
|
||||
self::assertInstanceOf(UserPreference::class, $prefs[2]);
|
||||
self::assertEquals('export_decimal', $prefs[2]->getName());
|
||||
|
||||
$user->setPreferences(new ArrayCollection([]));
|
||||
self::assertCount(0, $user->getPreferences());
|
||||
}
|
||||
|
||||
public function testDisplayName(): void
|
||||
@@ -332,6 +360,12 @@ class UserTest extends TestCase
|
||||
self::assertTrue($sut->isTeamleadOf($team2));
|
||||
self::assertTrue($sut->isInTeam($team2));
|
||||
|
||||
self::assertTrue($sut->isTeamleadOf($team2));
|
||||
$user2 = new User();
|
||||
self::assertFalse($sut->isTeamleadOfUser($user2));
|
||||
$team2->addUser($user2);
|
||||
self::assertTrue($sut->isTeamleadOfUser($user2));
|
||||
|
||||
self::assertCount(2, $sut->getTeams());
|
||||
$sut->removeMembership(new TeamMember());
|
||||
self::assertCount(2, $sut->getTeams());
|
||||
@@ -624,4 +658,21 @@ class UserTest extends TestCase
|
||||
self::assertNotNull($user->getSupervisor());
|
||||
self::assertSame($supervisor, $user->getSupervisor());
|
||||
}
|
||||
|
||||
public function testLastLogin(): void
|
||||
{
|
||||
$dateTime = new \DateTime('now', new \DateTimeZone('UTC'));
|
||||
|
||||
$user = new User();
|
||||
$user->setTimezone('Europe/Berlin');
|
||||
|
||||
$lastLogin = $user->getLastLogin();
|
||||
self::assertNull($lastLogin);
|
||||
$user->setLastLogin($dateTime);
|
||||
|
||||
$lastLogin = $user->getLastLogin();
|
||||
self::assertNotNull($lastLogin);
|
||||
self::assertInstanceOf(\DateTime::class, $lastLogin);
|
||||
self::assertEquals('Europe/Berlin', $lastLogin->getTimezone()->getName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ use App\Entity\User;
|
||||
use App\Export\Base\CsvRenderer;
|
||||
use App\Export\Base\SpreadsheetRenderer;
|
||||
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\Component\EventDispatcher\EventDispatcher;
|
||||
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('isGranted')->willReturn(true);
|
||||
|
||||
$translator = $this->createMock(TranslatorInterface::class);
|
||||
$translator = $this->getContainer()->get(TranslatorInterface::class);
|
||||
self::assertInstanceOf(TranslatorInterface::class, $translator);
|
||||
|
||||
$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
|
||||
@@ -50,23 +52,43 @@ class CsvRendererTest extends AbstractRendererTestCase
|
||||
$sut = $this->getAbstractRenderer();
|
||||
|
||||
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
|
||||
{
|
||||
$en = [
|
||||
'Date', 'From', 'To', 'Duration', 'Currency', 'Price', 'Internal price', 'Hourly price', 'Fixed price', 'Name',
|
||||
'User', 'Staff number', 'Customer', 'Project', 'Activity', 'Description', 'Billable', 'Tags',
|
||||
'Type', 'category', 'Account', 'Project number', 'VAT-ID', 'Order number',
|
||||
'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'mypref',
|
||||
];
|
||||
$de = [
|
||||
'Datum', 'Von', 'Bis', 'Dauer', 'Währung', 'Preis', 'Interner Preis', 'Preis pro Stunde', 'Festpreis', 'Name',
|
||||
'Benutzer', 'Personalnummer', 'Kunde', 'Projekt', 'Tätigkeit', 'Beschreibung', 'Abrechenbar', 'Schlagworte',
|
||||
'Typ', 'category', 'Kundennummer', 'Projektnummer', 'Umsatzsteuer-ID', 'Bestellnummer',
|
||||
'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'mypref',
|
||||
];
|
||||
|
||||
return [
|
||||
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, false],
|
||||
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, true]
|
||||
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, false, null, $en],
|
||||
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, true, 'de', $de]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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->setLocale($locale);
|
||||
|
||||
/** @var BinaryFileResponse $response */
|
||||
$response = $this->render($sut);
|
||||
@@ -102,11 +124,13 @@ class CsvRendererTest extends AbstractRendererTestCase
|
||||
$all[] = str_getcsv($row);
|
||||
}
|
||||
|
||||
self::assertEquals($header, $all[0]);
|
||||
|
||||
$expected = [
|
||||
'2019-06-16',
|
||||
'12:00',
|
||||
'12:06',
|
||||
($exportDecimal ? '0.11' : '0:06:40'),
|
||||
($exportDecimal ? '0.11' : '0:06'),
|
||||
//'0.11',
|
||||
'EUR',
|
||||
'0',
|
||||
@@ -134,13 +158,14 @@ class CsvRendererTest extends AbstractRendererTestCase
|
||||
'',
|
||||
'project-foo2',
|
||||
'activity-bar',
|
||||
'',
|
||||
];
|
||||
|
||||
$expected2 = [
|
||||
'2019-06-16',
|
||||
'12:00',
|
||||
'12:06',
|
||||
($exportDecimal ? '0.11' : '0:06:40'),
|
||||
($exportDecimal ? '0.11' : '0:06'),
|
||||
//'0.11',
|
||||
'EUR',
|
||||
'0',
|
||||
@@ -168,6 +193,7 @@ class CsvRendererTest extends AbstractRendererTestCase
|
||||
'',
|
||||
'project-foo2',
|
||||
'activity-bar',
|
||||
'',
|
||||
];
|
||||
|
||||
self::assertEquals(7, \count($all));
|
||||
|
||||
56
tests/Export/Base/PdfRendererTest.php
Normal file
56
tests/Export/Base/PdfRendererTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,15 @@ namespace App\Tests\Export\Base;
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\Base\SpreadsheetRenderer;
|
||||
use App\Export\Package\SpreadsheetPackage;
|
||||
use App\Export\Template;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Tests\Export\Package\MemoryPackage;
|
||||
use App\Tests\Export\Renderer\AbstractRendererTestCase;
|
||||
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
@@ -31,7 +36,7 @@ class SpreadsheetRendererTest extends AbstractRendererTestCase
|
||||
$spreadsheetPackage = $this->createMock(SpreadsheetPackage::class);
|
||||
$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());
|
||||
}
|
||||
|
||||
@@ -60,7 +65,7 @@ class SpreadsheetRendererTest extends AbstractRendererTestCase
|
||||
$exportItem->method('getType')->willReturn('type');
|
||||
$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());
|
||||
}
|
||||
|
||||
@@ -93,4 +98,96 @@ class SpreadsheetRendererTest extends AbstractRendererTestCase
|
||||
$renderer = new SpreadsheetRenderer($dispatcher, $security);
|
||||
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem, $exportItem], new TimesheetQuery());
|
||||
}
|
||||
|
||||
public static function getTestData(): iterable
|
||||
{
|
||||
yield [null, [
|
||||
'date' => 'date',
|
||||
'begin' => 'begin',
|
||||
'end' => 'end',
|
||||
'duration' => 'duration',
|
||||
'currency' => 'currency',
|
||||
'rate' => 'rate',
|
||||
'internalRate' => 'internalRate',
|
||||
'hourlyRate' => 'hourlyRate',
|
||||
'fixedRate' => 'fixedRate',
|
||||
'alias' => 'alias',
|
||||
'username' => 'username',
|
||||
'account_number' => 'account_number',
|
||||
'customer' => 'customer',
|
||||
'project' => 'project',
|
||||
'activity' => 'activity',
|
||||
'description' => 'description',
|
||||
'billable' => 'billable',
|
||||
'tags' => 'tags',
|
||||
'type' => 'type',
|
||||
'category' => 'category',
|
||||
'number' => 'number',
|
||||
'project_number' => 'project_number',
|
||||
'vat_id' => 'vat_id',
|
||||
'orderNumber' => 'orderNumber',
|
||||
'timesheet.meta.foo' => 'Working place',
|
||||
'timesheet.meta.foo2' => 'Working place',
|
||||
'customer.meta.customer-foo' => 'Working place',
|
||||
'project.meta.project-foo' => 'Working place',
|
||||
'project.meta.project-foo2' => 'Working place',
|
||||
'activity.meta.activity-foo' => 'Working place',
|
||||
'user.meta.mypref' => 'mypref',
|
||||
]];
|
||||
|
||||
$template = new Template('test', 'Testing');
|
||||
$template->setLocale('de');
|
||||
$template->setColumns(['date', 'user.name', 'duration_decimal', 'customer.name', 'exported', 'user.meta.mypref']);
|
||||
|
||||
yield [$template, [
|
||||
'date' => 'date',
|
||||
'duration' => 'duration',
|
||||
'username' => 'username',
|
||||
'customer' => 'customer',
|
||||
'exported' => 'exported',
|
||||
'user.meta.mypref' => 'mypref',
|
||||
]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getTestData
|
||||
*/
|
||||
public function testWriteSpreadsheetCsv(?Template $template, array $expectedColumns): void
|
||||
{
|
||||
$dispatcher = new EventDispatcher();
|
||||
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$spreadsheetPackage = new MemoryPackage();
|
||||
|
||||
$exportItem = $this->createMock(ExportableItem::class);
|
||||
$exportItem->method('getBegin')->willReturn(new \DateTime());
|
||||
$exportItem->method('getEnd')->willReturn(new \DateTime());
|
||||
$exportItem->method('getDuration')->willReturn(3600);
|
||||
$exportItem->method('getRate')->willReturn(100.0);
|
||||
$exportItem->method('getInternalRate')->willReturn(80.0);
|
||||
$exportItem->method('getHourlyRate')->willReturn(50.0);
|
||||
$exportItem->method('getFixedRate')->willReturn(200.0);
|
||||
$exportItem->method('getUser')->willReturn(null);
|
||||
$exportItem->method('getProject')->willReturn(null);
|
||||
$exportItem->method('getActivity')->willReturn(null);
|
||||
$exportItem->method('getDescription')->willReturn('Test description');
|
||||
$exportItem->method('isBillable')->willReturn(true);
|
||||
$exportItem->method('getTagsAsArray')->willReturn(['tag1', 'tag2']);
|
||||
$exportItem->method('getType')->willReturn('type');
|
||||
$exportItem->method('getCategory')->willReturn('category');
|
||||
|
||||
$renderer = new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class));
|
||||
$renderer->setTemplate($template);
|
||||
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem], new TimesheetQuery());
|
||||
|
||||
$columnNames = [];
|
||||
foreach ($spreadsheetPackage->getColumns() as $column) {
|
||||
$columnNames[$column->getName()] = $column->getHeader();
|
||||
}
|
||||
|
||||
self::assertEquals(null, $spreadsheetPackage->getFilename());
|
||||
self::assertEquals($expectedColumns, $columnNames);
|
||||
self::assertCount(2, $spreadsheetPackage->getRows());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\Entity\User;
|
||||
use App\Export\Base\SpreadsheetRenderer;
|
||||
use App\Export\Base\XlsxRenderer;
|
||||
use App\Tests\Export\Renderer\AbstractRendererTestCase;
|
||||
use App\Tests\Export\Renderer\MetaFieldColumnSubscriber;
|
||||
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
@@ -38,7 +38,7 @@ class XlsxRendererTest extends AbstractRendererTestCase
|
||||
$translator->method('trans')->willReturnArgument(0);
|
||||
|
||||
$dispatcher = new EventDispatcher();
|
||||
$dispatcher->addSubscriber(new MetaFieldColumnSubscriber());
|
||||
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
|
||||
|
||||
return new XlsxRenderer(new SpreadsheetRenderer($dispatcher, $security), $translator);
|
||||
}
|
||||
@@ -46,9 +46,16 @@ class XlsxRendererTest extends AbstractRendererTestCase
|
||||
public function testConfiguration(): void
|
||||
{
|
||||
$sut = $this->getAbstractRenderer();
|
||||
$sut->setLocale('de');
|
||||
|
||||
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
|
||||
|
||||
@@ -19,13 +19,13 @@ class DurationFormatterTest extends TestCase
|
||||
{
|
||||
public function testGetFormat(): void
|
||||
{
|
||||
$formatter = new DurationFormatter();
|
||||
$formatter = new DurationFormatter('[hh]:mm:ss');
|
||||
self::assertEquals('[hh]:mm:ss', $formatter->getFormat());
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsFormattedDurationQuiteLong(): void
|
||||
{
|
||||
$formatter = new DurationFormatter();
|
||||
$formatter = new DurationFormatter('[hh]:mm:ss');
|
||||
$result = $formatter->formatValue(701213);
|
||||
self::assertInstanceOf(\DateInterval::class, $result);
|
||||
self::assertEquals('194:46:53', $result->format('%r%H:%I:%S'));
|
||||
@@ -33,7 +33,7 @@ class DurationFormatterTest extends TestCase
|
||||
|
||||
public function testFormatValueReturnsFormattedDurationForNumericValue(): void
|
||||
{
|
||||
$formatter = new DurationFormatter();
|
||||
$formatter = new DurationFormatter('[hh]:mm:ss');
|
||||
$result = $formatter->formatValue(7213);
|
||||
self::assertInstanceOf(\DateInterval::class, $result);
|
||||
self::assertEquals('02:00:13', $result->format('%r%H:%I:%S'));
|
||||
@@ -41,7 +41,7 @@ class DurationFormatterTest extends TestCase
|
||||
|
||||
public function testFormatValueReturnsZeroForNonNumericValue(): void
|
||||
{
|
||||
$formatter = new DurationFormatter();
|
||||
$formatter = new DurationFormatter('[hh]:mm:ss');
|
||||
$result = $formatter->formatValue('not a number');
|
||||
self::assertInstanceOf(\DateInterval::class, $result);
|
||||
self::assertEquals('00:00:00', $result->format('%r%H:%I:%S'));
|
||||
@@ -49,7 +49,7 @@ class DurationFormatterTest extends TestCase
|
||||
|
||||
public function testFormatValueReturnsFormattedDurationForFloatValue(): void
|
||||
{
|
||||
$formatter = new DurationFormatter();
|
||||
$formatter = new DurationFormatter('[hh]:mm:ss');
|
||||
$result = $formatter->formatValue(4521.5);
|
||||
self::assertInstanceOf(\DateInterval::class, $result);
|
||||
self::assertEquals('01:15:21', $result->format('%r%H:%I:%S'));
|
||||
@@ -57,7 +57,7 @@ class DurationFormatterTest extends TestCase
|
||||
|
||||
public function testFormatValueReturnsZeroForNullValue(): void
|
||||
{
|
||||
$formatter = new DurationFormatter();
|
||||
$formatter = new DurationFormatter('[hh]:mm:ss');
|
||||
$result = $formatter->formatValue(null);
|
||||
self::assertInstanceOf(\DateInterval::class, $result);
|
||||
self::assertEquals('00:00:00', $result->format('%r%H:%I:%S'));
|
||||
@@ -65,7 +65,7 @@ class DurationFormatterTest extends TestCase
|
||||
|
||||
public function testFormatValueReturnsFormattedDurationForNegativeValue(): void
|
||||
{
|
||||
$formatter = new DurationFormatter();
|
||||
$formatter = new DurationFormatter('[hh]:mm:ss');
|
||||
$result = $formatter->formatValue(-3600);
|
||||
self::assertInstanceOf(\DateInterval::class, $result);
|
||||
self::assertEquals('-01:00:00', $result->format('%r%H:%I:%S'));
|
||||
|
||||
@@ -19,42 +19,50 @@ class DurationPlainFormatterTest extends TestCase
|
||||
{
|
||||
public function testFormatValueReturnsFormattedDurationQuiteLong(): void
|
||||
{
|
||||
$formatter = new DurationPlainFormatter();
|
||||
$formatter = new DurationPlainFormatter(true);
|
||||
$result = $formatter->formatValue(701213);
|
||||
self::assertEquals('194:46:53', $result);
|
||||
|
||||
$formatter = new DurationPlainFormatter(false);
|
||||
$result = $formatter->formatValue(701213);
|
||||
self::assertEquals('194:46', $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsFormattedDurationForNumericValue(): void
|
||||
{
|
||||
$formatter = new DurationPlainFormatter();
|
||||
$formatter = new DurationPlainFormatter(true);
|
||||
$result = $formatter->formatValue(8246);
|
||||
self::assertEquals('2:17:26', $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsZeroForNonNumericValue(): void
|
||||
{
|
||||
$formatter = new DurationPlainFormatter();
|
||||
$formatter = new DurationPlainFormatter(true);
|
||||
$result = $formatter->formatValue('not a number');
|
||||
self::assertEquals('0:00:00', $result);
|
||||
|
||||
$formatter = new DurationPlainFormatter(false);
|
||||
$result = $formatter->formatValue('not a number');
|
||||
self::assertEquals('0:00', $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsFormattedDurationForFloatValue(): void
|
||||
{
|
||||
$formatter = new DurationPlainFormatter();
|
||||
$formatter = new DurationPlainFormatter(true);
|
||||
$result = $formatter->formatValue(44513.5);
|
||||
self::assertEquals('12:21:53', $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsZeroForNullValue(): void
|
||||
{
|
||||
$formatter = new DurationPlainFormatter();
|
||||
$formatter = new DurationPlainFormatter(true);
|
||||
$result = $formatter->formatValue(null);
|
||||
self::assertEquals('0:00:00', $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsFormattedDurationForNegativeValue(): void
|
||||
{
|
||||
$formatter = new DurationPlainFormatter();
|
||||
$formatter = new DurationPlainFormatter(true);
|
||||
$result = $formatter->formatValue(-3600);
|
||||
self::assertEquals('-1:00:00', $result);
|
||||
}
|
||||
|
||||
71
tests/Export/Package/MemoryPackage.php
Normal file
71
tests/Export/Package/MemoryPackage.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -13,22 +13,15 @@ use App\Entity\Activity;
|
||||
use App\Entity\ActivityMeta;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\CustomerMeta;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\ProjectMeta;
|
||||
use App\Entity\Tag;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\TimesheetMeta;
|
||||
use App\Entity\User;
|
||||
use App\Event\ActivityMetaDisplayEvent;
|
||||
use App\Event\CustomerMetaDisplayEvent;
|
||||
use App\Event\ProjectMetaDisplayEvent;
|
||||
use App\Event\TimesheetMetaDisplayEvent;
|
||||
use App\Export\ExportRendererInterface;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
abstract class AbstractRendererTestCase extends KernelTestCase
|
||||
@@ -145,47 +138,3 @@ abstract class AbstractRendererTestCase extends KernelTestCase
|
||||
return $renderer->render($entries, $query);
|
||||
}
|
||||
}
|
||||
|
||||
class MetaFieldColumnSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
TimesheetMetaDisplayEvent::class => ['loadTimesheetField', 200],
|
||||
CustomerMetaDisplayEvent::class => ['loadCustomerField', 200],
|
||||
ProjectMetaDisplayEvent::class => ['loadProjectField', 200],
|
||||
ActivityMetaDisplayEvent::class => ['loadActivityField', 200],
|
||||
];
|
||||
}
|
||||
|
||||
public function loadTimesheetField(TimesheetMetaDisplayEvent $event): void
|
||||
{
|
||||
$event->addField($this->prepareEntity(new TimesheetMeta(), 'foo'));
|
||||
$event->addField($this->prepareEntity(new TimesheetMeta(), 'foo2'));
|
||||
}
|
||||
|
||||
public function loadCustomerField(CustomerMetaDisplayEvent $event): void
|
||||
{
|
||||
$event->addField($this->prepareEntity(new CustomerMeta(), 'customer-foo'));
|
||||
}
|
||||
|
||||
public function loadProjectField(ProjectMetaDisplayEvent $event): void
|
||||
{
|
||||
$event->addField($this->prepareEntity(new ProjectMeta(), 'project-foo'));
|
||||
$event->addField($this->prepareEntity(new ProjectMeta(), 'project-foo2')->setIsVisible(false));
|
||||
}
|
||||
|
||||
public function loadActivityField(ActivityMetaDisplayEvent $event): void
|
||||
{
|
||||
$event->addField($this->prepareEntity(new ActivityMeta(), 'activity-foo'));
|
||||
}
|
||||
|
||||
private function prepareEntity(MetaTableTypeInterface $meta, string $name): MetaTableTypeInterface
|
||||
{
|
||||
return $meta
|
||||
->setLabel('Working place')
|
||||
->setName($name)
|
||||
->setType(TextType::class)
|
||||
->setIsVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
44
tests/Export/Renderer/CsvRendererFactoryTest.php
Normal file
44
tests/Export/Renderer/CsvRendererFactoryTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
44
tests/Export/Renderer/XlsxRendererFactoryTest.php
Normal file
44
tests/Export/Renderer/XlsxRendererFactoryTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,22 @@
|
||||
namespace App\Tests\Export;
|
||||
|
||||
use App\Activity\ActivityStatisticService;
|
||||
use App\Entity\ExportTemplate;
|
||||
use App\Export\Base\CsvRenderer;
|
||||
use App\Export\Base\HtmlRenderer;
|
||||
use App\Export\Base\XlsxRenderer;
|
||||
use App\Export\ExportRepositoryInterface;
|
||||
use App\Export\ServiceExport;
|
||||
use App\Export\Timesheet\HtmlRenderer as HtmlExporter;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use App\Repository\ExportTemplateRepository;
|
||||
use App\Repository\Query\ExportQuery;
|
||||
use App\Tests\Mocks\Export\CsvRendererFactoryMock;
|
||||
use App\Tests\Mocks\Export\HtmlRendererFactoryMock;
|
||||
use App\Tests\Mocks\Export\PdfRendererFactoryMock;
|
||||
use App\Tests\Mocks\Export\XlsxRendererFactoryMock;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
use Twig\Environment;
|
||||
@@ -28,12 +35,48 @@ use Twig\Environment;
|
||||
*/
|
||||
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(
|
||||
$this->createMock(EventDispatcherInterface::class),
|
||||
(new HtmlRendererFactoryMock($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);
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
||||
63
tests/Export/TemplateTest.php
Normal file
63
tests/Export/TemplateTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
61
tests/Form/ExportTemplateSpreadsheetFormTest.php
Normal file
61
tests/Form/ExportTemplateSpreadsheetFormTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ namespace App\Tests\Form\Extension;
|
||||
use App\Form\Extension\DocumentationLinkExtension;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
@@ -43,4 +45,18 @@ class DocumentationLinkExtensionTest extends TestCase
|
||||
$this->expectException(InvalidOptionsException::class);
|
||||
$resolver->resolve(['docu_chapter' => true]);
|
||||
}
|
||||
|
||||
public function testBuildView(): void
|
||||
{
|
||||
$sut = new DocumentationLinkExtension();
|
||||
$form = $this->createMock(FormInterface::class);
|
||||
|
||||
$view = new FormView();
|
||||
$sut->buildView($view, $form, ['docu_chapter' => null]);
|
||||
self::assertEquals(['attr' => [], 'value' => null, 'docu_chapter' => null], $view->vars);
|
||||
|
||||
$view = new FormView();
|
||||
$sut->buildView($view, $form, ['docu_chapter' => 'customers']);
|
||||
self::assertEquals(['attr' => [], 'value' => null, 'docu_chapter' => 'customers'], $view->vars);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ use App\Form\Extension\EnhancedChoiceTypeExtension;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
@@ -30,15 +32,52 @@ class EnhancedChoiceTypeExtensionTest extends TestCase
|
||||
$resolver = new OptionsResolver();
|
||||
$sut = new EnhancedChoiceTypeExtension();
|
||||
$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('width'));
|
||||
self::assertTrue($resolver->hasDefault('search'));
|
||||
self::assertTrue($resolver->hasDefault('order'));
|
||||
self::assertFalse($resolver->isRequired('selectpicker'));
|
||||
self::assertFalse($resolver->isRequired('width'));
|
||||
self::assertFalse($resolver->isRequired('search'));
|
||||
|
||||
$result = $resolver->resolve([]);
|
||||
self::assertEquals(['selectpicker' => true, 'width' => '100%', 'search' => true], $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);
|
||||
}
|
||||
}
|
||||
|
||||
72
tests/Form/Type/ExportColumnsTypeTest.php
Normal file
72
tests/Form/Type/ExportColumnsTypeTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
51
tests/Form/Type/ExportRendererTypeTest.php
Normal file
51
tests/Form/Type/ExportRendererTypeTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ namespace App\Tests\Form\Type;
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
30
tests/Mocks/Export/CsvRendererFactoryMock.php
Normal file
30
tests/Mocks/Export/CsvRendererFactoryMock.php
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
30
tests/Mocks/Export/XlsxRendererFactoryMock.php
Normal file
30
tests/Mocks/Export/XlsxRendererFactoryMock.php
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
74
tests/Mocks/MetaFieldColumnSubscriberMock.php
Normal file
74
tests/Mocks/MetaFieldColumnSubscriberMock.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ class ColorChoicesValidatorTest extends ConstraintValidatorTestCase
|
||||
return new ColorChoicesValidator();
|
||||
}
|
||||
|
||||
public static function getValidColors()
|
||||
public static function getValidColors(): iterable
|
||||
{
|
||||
yield ['#000000'];
|
||||
yield ['#fff000'];
|
||||
@@ -57,7 +57,7 @@ class ColorChoicesValidatorTest extends ConstraintValidatorTestCase
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public static function getInvalidColors()
|
||||
public static function getInvalidColors(): iterable
|
||||
{
|
||||
yield ['sdf_sdf|#000000', null, 'sdf_sdf', '#000000'];
|
||||
yield ['sdfghjklöß.|#aaabbb', null, 'sdfghjklöß.', '#aaabbb'];
|
||||
|
||||
@@ -27,7 +27,7 @@ class DateTimeFormatValidatorTest extends ConstraintValidatorTestCase
|
||||
return new DateTimeFormatValidator();
|
||||
}
|
||||
|
||||
public static function getValidData()
|
||||
public static function getValidData(): array
|
||||
{
|
||||
return [
|
||||
['10:00'],
|
||||
@@ -56,7 +56,7 @@ class DateTimeFormatValidatorTest extends ConstraintValidatorTestCase
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public static function getInvalidData()
|
||||
public static function getInvalidData(): array
|
||||
{
|
||||
return [
|
||||
['13-13'],
|
||||
|
||||
92
tests/Validator/Constraints/ExportRendererValidatorTest.php
Normal file
92
tests/Validator/Constraints/ExportRendererValidatorTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ class HexColorValidatorTest extends ConstraintValidatorTestCase
|
||||
return new HexColorValidator();
|
||||
}
|
||||
|
||||
public static function getValidColors()
|
||||
public static function getValidColors(): iterable
|
||||
{
|
||||
yield ['#000'];
|
||||
yield ['#aaa'];
|
||||
@@ -56,7 +56,7 @@ class HexColorValidatorTest extends ConstraintValidatorTestCase
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public static function getInvalidColors()
|
||||
public static function getInvalidColors(): iterable
|
||||
{
|
||||
yield ['string'];
|
||||
yield ['000'];
|
||||
|
||||
@@ -32,7 +32,7 @@ class RoleValidatorTest extends ConstraintValidatorTestCase
|
||||
return new RoleValidator($roleService);
|
||||
}
|
||||
|
||||
public static function getValidRoles()
|
||||
public static function getValidRoles(): array
|
||||
{
|
||||
return [
|
||||
[User::ROLE_USER],
|
||||
@@ -69,7 +69,7 @@ class RoleValidatorTest extends ConstraintValidatorTestCase
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public static function getInvalidRoles()
|
||||
public static function getInvalidRoles(): array
|
||||
{
|
||||
return [
|
||||
['foo'],
|
||||
|
||||
@@ -52,7 +52,7 @@ class TimeFormatValidatorTest extends ConstraintValidatorTestCase
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public static function getValidTimes()
|
||||
public static function getValidTimes(): array
|
||||
{
|
||||
return [
|
||||
[''],
|
||||
@@ -79,7 +79,7 @@ class TimeFormatValidatorTest extends ConstraintValidatorTestCase
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public static function getInvalidTimes()
|
||||
public static function getInvalidTimes(): array
|
||||
{
|
||||
return [
|
||||
['a'],
|
||||
|
||||
@@ -143,7 +143,7 @@ class TimesheetBasicValidatorTest extends ConstraintValidatorTestCase
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public static function getProjectStartEndTestData()
|
||||
public static function getProjectStartEndTestData(): iterable
|
||||
{
|
||||
yield [new \DateTime(), new \DateTime(), [
|
||||
['begin_date', TimesheetBasic::PROJECT_NOT_STARTED, 'The project has not started at that time.'],
|
||||
|
||||
@@ -195,7 +195,7 @@ class TimesheetBudgetUsedValidatorTest extends ConstraintValidatorTestCase
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public static function getViolationTestData()
|
||||
public static function getViolationTestData(): array
|
||||
{
|
||||
return [
|
||||
// activity: violations ----------------------------------------------------------------------
|
||||
@@ -294,7 +294,7 @@ class TimesheetBudgetUsedValidatorTest extends ConstraintValidatorTestCase
|
||||
string $duration,
|
||||
array $rawData = [],
|
||||
?Rate $rate = null
|
||||
) {
|
||||
): void {
|
||||
$activityStatistic = new ActivityStatistic();
|
||||
if ($activityDuration !== null) {
|
||||
$activityStatistic->setDuration($activityDuration);
|
||||
|
||||
@@ -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
|
||||
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, '+5 days', null, null, false];
|
||||
|
||||
@@ -94,7 +94,7 @@ class TimesheetRestartValidatorTest extends ConstraintValidatorTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public static function getTestData()
|
||||
public static function getTestData(): iterable
|
||||
{
|
||||
yield [false, 'end_date', 'default'];
|
||||
yield [true, null, 'default'];
|
||||
|
||||
@@ -58,7 +58,7 @@ class TimesheetZeroDurationValidatorTest extends ConstraintValidatorTestCase
|
||||
$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
|
||||
$begin = new \DateTime();
|
||||
|
||||
@@ -2816,116 +2816,31 @@ parameters:
|
||||
count: 6
|
||||
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\\.$#"
|
||||
count: 1
|
||||
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\\.$#"
|
||||
count: 1
|
||||
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\\.$#"
|
||||
count: 1
|
||||
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\\.$#"
|
||||
count: 1
|
||||
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\\.$#"
|
||||
count: 1
|
||||
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\\.$#"
|
||||
count: 1
|
||||
|
||||
Reference in New Issue
Block a user