Compare commits
2 Commits
2.36.0
...
download-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
821000f143 | ||
|
|
e5ab1f9e78 |
@@ -62,10 +62,13 @@ export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
|
||||
plugins.push('remove_button');
|
||||
}
|
||||
|
||||
if (node.dataset['order'] !== undefined && node.dataset['order'] === '1') {
|
||||
//plugins.push('caret_position');
|
||||
/*
|
||||
const isOrdering = false;
|
||||
if (isOrdering) {
|
||||
plugins.push('caret_position');
|
||||
plugins.push('drag_drop');
|
||||
}
|
||||
*/
|
||||
|
||||
let options = {
|
||||
// see https://github.com/orchidjs/tom-select/issues/543#issuecomment-1664342257
|
||||
|
||||
@@ -55,19 +55,4 @@ fieldset > .mb-3.row:last-child {
|
||||
|
||||
.form-fieldset-light:last-child {
|
||||
border:none;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
.dropdown-action {
|
||||
visibility: hidden;
|
||||
opacity: 0.5;
|
||||
}
|
||||
&:hover {
|
||||
.dropdown-action {
|
||||
visibility: visible;
|
||||
&:hover {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
383
composer.lock
generated
383
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -119,8 +119,8 @@ kimai:
|
||||
roles:
|
||||
ROLE_USER: ['view_team_member','time_team_project','create_tag','view_reporting']
|
||||
ROLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member','hours_other_profile']
|
||||
ROLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member','view_all_data','contract_other_profile','hours_other_profile','create_export_template']
|
||||
ROLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','supervisor_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data','contract_other_profile','hours_other_profile','create_export_template']
|
||||
ROLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member','view_all_data','contract_other_profile','hours_other_profile']
|
||||
ROLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','supervisor_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data','contract_other_profile','hours_other_profile']
|
||||
# --------------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -206,11 +206,6 @@ services:
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\AccessToken']
|
||||
|
||||
App\Repository\ExportTemplateRepository:
|
||||
class: App\Repository\ExportTemplateRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\ExportTemplate']
|
||||
|
||||
monolog.formatter.kimai:
|
||||
class: Monolog\Formatter\LineFormatter
|
||||
arguments:
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Doctrine\AbstractMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
/**
|
||||
* @version 2.36.0
|
||||
*/
|
||||
final class Version20250608143244 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create the export template table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$table = $schema->createTable('kimai2_export_templates');
|
||||
|
||||
$table->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
|
||||
$table->addColumn('title', 'string', ['notnull' => true, 'length' => 100]);
|
||||
$table->addColumn('renderer', 'string', ['notnull' => true, 'length' => 20]);
|
||||
$table->addColumn('language', 'string', ['notnull' => false, 'length' => 6]);
|
||||
$table->addColumn('columns', 'json', ['notnull' => true]);
|
||||
$table->addColumn('options', 'json', ['notnull' => true]);
|
||||
|
||||
$table->setPrimaryKey(['id']);
|
||||
$table->addUniqueIndex(['title'], 'UNIQ_2F0CA26F2B36786B');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
if ($schema->hasTable('kimai2_export_templates')) {
|
||||
$schema->dropTable('kimai2_export_templates');
|
||||
}
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
17
phpstan.neon
17
phpstan.neon
@@ -2010,7 +2010,7 @@ parameters:
|
||||
|
||||
-
|
||||
message: "#^Cannot clone mixed\\.$#"
|
||||
count: 1
|
||||
count: 2
|
||||
path: src/Form/QuickEntryForm.php
|
||||
|
||||
-
|
||||
@@ -2838,6 +2838,21 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/Type/QuickEntryTimesheetType.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'activity' on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Type/QuickEntryWeekType.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'project' on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Type/QuickEntryWeekType.php
|
||||
|
||||
-
|
||||
message: "#^Cannot clone mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Type/QuickEntryWeekType.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Type\\\\SearchTermType\\:\\:buildForm\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
|
||||
@@ -4,12 +4,7 @@
|
||||
backupGlobals="false"
|
||||
colors="true"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
cacheDirectory="var/cache/phpunit/"
|
||||
displayDetailsOnTestsThatTriggerDeprecations="true"
|
||||
displayDetailsOnTestsThatTriggerErrors="true"
|
||||
displayDetailsOnTestsThatTriggerNotices="true"
|
||||
displayDetailsOnTestsThatTriggerWarnings="true"
|
||||
displayDetailsOnPhpunitDeprecations="true">
|
||||
cacheDirectory="var/cache/phpunit/">
|
||||
<php>
|
||||
<ini name="error_reporting" value="-1"/>
|
||||
<ini name="max_execution_time" value="-1"/>
|
||||
@@ -39,15 +34,14 @@
|
||||
-->
|
||||
<env name="BOOTSTRAP_RESET_DATABASE" value="true"/>
|
||||
</php>
|
||||
|
||||
<testsuites>
|
||||
<testsuite name="Kimai">
|
||||
<directory>tests/</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<extensions>
|
||||
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
|
||||
</extensions>
|
||||
<source>
|
||||
|
||||
<coverage>
|
||||
<include>
|
||||
<directory suffix=".php">src/</directory>
|
||||
<directory suffix=".php">templates/</directory>
|
||||
@@ -64,5 +58,10 @@
|
||||
<directory suffix=".php">var/</directory>
|
||||
<directory suffix=".php">vendor/</directory>
|
||||
</exclude>
|
||||
</source>
|
||||
</coverage>
|
||||
|
||||
<extensions>
|
||||
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
|
||||
</extensions>
|
||||
|
||||
</phpunit>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
public/build/app.6bb53337.js
Normal file
2
public/build/app.6bb53337.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3,10 +3,10 @@
|
||||
"app": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/app.d4ea12cd.js"
|
||||
"/build/app.6bb53337.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app.6e7b2701.css"
|
||||
"/build/app.0416ea92.css"
|
||||
]
|
||||
},
|
||||
"app-rtl": {
|
||||
@@ -15,7 +15,7 @@
|
||||
"/build/app-rtl.7a875ca7.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app-rtl.bfe8d111.css"
|
||||
"/build/app-rtl.0848906b.css"
|
||||
]
|
||||
},
|
||||
"export-pdf": {
|
||||
@@ -72,10 +72,10 @@
|
||||
},
|
||||
"integrity": {
|
||||
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
|
||||
"/build/app.d4ea12cd.js": "sha384-vv5bzRvK0YN29MXeYe0uKv/LdRlwzMBu+RMvBXpNGu6btx1WxFmDOF6QM+CDoDPB",
|
||||
"/build/app.6e7b2701.css": "sha384-rrrlE5ISpDF53P81i2ce7AKONdm+b+I3mFacFPiJUohuMbMuHnUINac7YDkKD0/p",
|
||||
"/build/app.6bb53337.js": "sha384-ZcwEys2VjhW4jwH/pU+/W06vG7rBounyirdkC+TCA4PyMn2QsENR6FIcNoJEpF5X",
|
||||
"/build/app.0416ea92.css": "sha384-JAIO6+B/vmV8IlpsmQ+zkfO4JsvdKTMsZZaDg6EMlZxkMZ5K3cpEK0mo24D/Wgkh",
|
||||
"/build/app-rtl.7a875ca7.js": "sha384-T7gLI61h9dGeMgzo63vKu4GiDOeLPct9zSUHrceNbhSwIdUmSSNoZ1+d7fKhJJ4/",
|
||||
"/build/app-rtl.bfe8d111.css": "sha384-Ty9pGDTW2pxsBS7vfmjpTNFTJS/9jM5V4QfVgLXjIXuuJo5vi8x4sKKBTlwnZuqo",
|
||||
"/build/app-rtl.0848906b.css": "sha384-O26Xw3P/NSea5iT6lt5v2RaZ6+jP06hf9vYD2pUJbkFSCLGU1iGXnobP25dWjUs/",
|
||||
"/build/export-pdf.395749ab.js": "sha384-3Hjvmu4FC/0dhHnR8kyRBU7k2xMNy1lxBpGgOkrw8PxXnwyQDM8/5bQmkJbjVT1+",
|
||||
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
|
||||
"/build/invoice.773af9c4.js": "sha384-QmMqYJ0RP2WOYU7D4lLzKCBFDL6vCvZHQc7wf8K8N+hdCuTJwq1P0GuY81f30/SY",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"build/app.css": "/build/app.6e7b2701.css",
|
||||
"build/app.js": "/build/app.d4ea12cd.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.bfe8d111.css",
|
||||
"build/app.css": "/build/app.0416ea92.css",
|
||||
"build/app.js": "/build/app.6bb53337.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.0848906b.css",
|
||||
"build/app-rtl.js": "/build/app-rtl.7a875ca7.js",
|
||||
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
|
||||
"build/export-pdf.js": "/build/export-pdf.395749ab.js",
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\API;
|
||||
|
||||
use App\Entity\ExportTemplate;
|
||||
use App\Repository\ExportTemplateRepository;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route(path: '/export')]
|
||||
#[IsGranted('API')]
|
||||
#[OA\Tag(name: 'Export')]
|
||||
final class ExportController extends BaseApiController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ViewHandlerInterface $viewHandler,
|
||||
private readonly ExportTemplateRepository $repository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete export template
|
||||
*/
|
||||
#[IsGranted('create_export_template')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete export template')], x: ['internal' => true])]
|
||||
#[OA\Parameter(name: 'id', description: 'Export template ID to delete', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}', name: 'delete_export_template', requirements: ['id' => '\d+'], methods: ['DELETE'])]
|
||||
public function deleteTemplate(ExportTemplate $exportTemplate): Response
|
||||
{
|
||||
$this->repository->removeExportTemplate($exportTemplate);
|
||||
|
||||
$view = new View(null, Response::HTTP_NO_CONTENT);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\API;
|
||||
|
||||
use App\Entity\Invoice;
|
||||
use App\Invoice\ServiceInvoice;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\InvoiceRepository;
|
||||
use App\Repository\Query\InvoiceArchiveQuery;
|
||||
@@ -18,6 +19,7 @@ use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
@@ -25,6 +27,7 @@ use Symfony\Component\Validator\Constraints;
|
||||
|
||||
#[Route(path: '/invoices')]
|
||||
#[IsGranted('API')]
|
||||
#[IsGranted('view_invoice')]
|
||||
#[OA\Tag(name: 'Invoice')]
|
||||
final class InvoiceController extends BaseApiController
|
||||
{
|
||||
@@ -40,7 +43,6 @@ final class InvoiceController extends BaseApiController
|
||||
/**
|
||||
* Fetch invoices
|
||||
*/
|
||||
#[IsGranted('view_invoice')]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of invoices', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/InvoiceCollection')))]
|
||||
#[Route(methods: ['GET'], path: '', name: 'get_invoices')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
@@ -89,7 +91,6 @@ final class InvoiceController extends BaseApiController
|
||||
/**
|
||||
* Fetch invoice
|
||||
*/
|
||||
#[IsGranted('view_invoice')]
|
||||
#[OA\Response(response: 200, description: 'Returns one invoice', content: new OA\JsonContent(ref: '#/components/schemas/Invoice'))]
|
||||
#[Route(methods: ['GET'], path: '/{id}', name: 'get_invoice', requirements: ['id' => '\d+'])]
|
||||
public function getAction(Invoice $invoice): Response
|
||||
@@ -99,4 +100,28 @@ final class InvoiceController extends BaseApiController
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download invoice
|
||||
*
|
||||
* The returned `content-type` depends on the type of invoice document.
|
||||
* Could be anything from `application/pdf` to `application/vnd.openxmlformats-officedocument.wordprocessingml.document`.
|
||||
*
|
||||
* Use the response header to detect the filename, e.g. `content-disposition: attachment; filename=2025-001-acmme_company.pdf`.
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns the (binary) invoice document', content: new OA\MediaType(mediaType: 'application/octet-stream', schema: new OA\Schema(type: 'string', format: 'binary')))]
|
||||
#[Route(path: '/{id}/download', requirements: ['id' => '\d+'], methods: ['GET'])]
|
||||
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
|
||||
public function download(Invoice $invoice, ServiceInvoice $service): Response
|
||||
{
|
||||
$file = $service->getInvoiceFile($invoice);
|
||||
|
||||
if (null === $file) {
|
||||
throw $this->createNotFoundException(
|
||||
\sprintf('Invoice file "%s" could not be found for invoice ID "%s"', $invoice->getInvoiceFilename(), $invoice->getId())
|
||||
);
|
||||
}
|
||||
|
||||
return $this->file($file->getRealPath(), $file->getBasename());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,24 @@ final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerI
|
||||
}
|
||||
|
||||
public function serializeValidationExceptionToJson(JsonSerializationVisitor $visitor, ValidationFailedException $exception, array $type, Context $context)
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
/** @var ConstraintViolationInterface $error */
|
||||
foreach (iterator_to_array($exception->getViolations()) as $error) {
|
||||
$errors[$error->getPropertyPath()]['errors'][] = $this->getErrorMessage($error);
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => '400',
|
||||
'message' => $this->translator->trans($exception->getMessage(), [], 'validators'),
|
||||
'errors' => [
|
||||
'children' => $errors
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function getErrorMessage(ConstraintViolationInterface $error): string
|
||||
{
|
||||
$locale = \Locale::getDefault();
|
||||
/** @var User $user */
|
||||
@@ -72,24 +90,6 @@ final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerI
|
||||
$locale = $user->getLanguage();
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
|
||||
/** @var ConstraintViolationInterface $error */
|
||||
foreach (iterator_to_array($exception->getViolations()) as $error) {
|
||||
$errors[$error->getPropertyPath()]['errors'][] = $this->getErrorMessage($error, $locale);
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => '400',
|
||||
'message' => $this->translator->trans($exception->getMessage(), [], 'validators', $locale),
|
||||
'errors' => [
|
||||
'children' => $errors
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function getErrorMessage(ConstraintViolationInterface $error, string $locale): string
|
||||
{
|
||||
if (null !== $error->getPlural()) {
|
||||
return $this->translator->trans($error->getMessageTemplate(), ['%count%' => $error->getPlural()] + $error->getParameters(), 'validators', $locale);
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ final class TimesheetController extends BaseApiController
|
||||
|
||||
if ($form->isValid()) {
|
||||
try {
|
||||
$this->service->saveTimesheet($timesheet);
|
||||
$this->service->saveNewTimesheet($timesheet);
|
||||
|
||||
$view = new View($timesheet, 200);
|
||||
|
||||
@@ -372,7 +372,7 @@ final class TimesheetController extends BaseApiController
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
$this->service->saveTimesheet($timesheet);
|
||||
$this->service->updateTimesheet($timesheet);
|
||||
|
||||
$view = new View($timesheet, Response::HTTP_OK);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
@@ -547,7 +547,7 @@ final class TimesheetController extends BaseApiController
|
||||
$copyTimesheet = clone $timesheet;
|
||||
|
||||
$this->dispatcher->dispatch(new TimesheetDuplicatePreEvent($copyTimesheet, $timesheet));
|
||||
$this->service->saveTimesheet($copyTimesheet);
|
||||
$this->service->saveNewTimesheet($copyTimesheet);
|
||||
$this->dispatcher->dispatch(new TimesheetDuplicatePostEvent($copyTimesheet, $timesheet));
|
||||
|
||||
$view = new View($copyTimesheet, 200);
|
||||
@@ -571,7 +571,7 @@ final class TimesheetController extends BaseApiController
|
||||
|
||||
$timesheet->setExported(!$timesheet->isExported());
|
||||
|
||||
$this->service->saveTimesheet($timesheet);
|
||||
$this->service->updateTimesheet($timesheet);
|
||||
|
||||
$view = new View($timesheet, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
@@ -601,7 +601,7 @@ final class TimesheetController extends BaseApiController
|
||||
|
||||
$meta->setValue($paramFetcher->get('value'));
|
||||
|
||||
$this->service->saveTimesheet($timesheet);
|
||||
$this->service->updateTimesheet($timesheet);
|
||||
|
||||
$view = new View($timesheet, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
@@ -97,7 +97,7 @@ class ActivityService
|
||||
$errors = $this->validator->validate($activity, null, $groups);
|
||||
|
||||
if ($errors->count() > 0) {
|
||||
throw new ValidationFailedException($errors);
|
||||
throw new ValidationFailedException($errors, 'Validation Failed');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@ final class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.36.0';
|
||||
public const VERSION = '2.35.1';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 23600;
|
||||
public const VERSION_ID = 23501;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -10,13 +10,10 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Entity\ExportTemplate;
|
||||
use App\Export\Base\DispositionInlineInterface;
|
||||
use App\Export\ServiceExport;
|
||||
use App\Export\TooManyItemsExportException;
|
||||
use App\Form\ExportTemplateSpreadsheetForm;
|
||||
use App\Form\Toolbar\ExportToolbarForm;
|
||||
use App\Repository\ExportTemplateRepository;
|
||||
use App\Repository\Query\ExportQuery;
|
||||
use App\Utils\PageSetup;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
@@ -32,7 +29,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[IsGranted('create_export')]
|
||||
final class ExportController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly ServiceExport $export)
|
||||
public function __construct(private ServiceExport $export)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -82,23 +79,6 @@ final class ExportController extends AbstractController
|
||||
$page = new PageSetup('export');
|
||||
$page->setHelp('export.html');
|
||||
|
||||
$buttons = [];
|
||||
foreach ($this->export->getRenderer() as $renderer) {
|
||||
$class = \get_class($renderer);
|
||||
$pos = strrpos($class, '\\');
|
||||
if ($pos !== false) {
|
||||
$class = substr($class, $pos + 1);
|
||||
}
|
||||
$class = strtolower(str_replace('Renderer', '', $class));
|
||||
$buttons[$class][$renderer->getId()] = $renderer->getTitle();
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_other_timesheet')) {
|
||||
$showRates = $this->isGranted('view_rate_other_timesheet');
|
||||
} else {
|
||||
$showRates = $this->isGranted('view_rate_own_timesheet');
|
||||
}
|
||||
|
||||
return $this->render('export/index.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'too_many' => $tooManyResults,
|
||||
@@ -106,10 +86,10 @@ final class ExportController extends AbstractController
|
||||
'query' => $query,
|
||||
'entries' => $entries,
|
||||
'form' => $form->createView(),
|
||||
'buttons' => $buttons,
|
||||
'renderer' => $this->export->getRenderer(),
|
||||
'preview_limit' => $maxItemsPreview,
|
||||
'preview_show' => $showPreview,
|
||||
'show_rates' => $showRates,
|
||||
'decimal' => $this->getUser()->isExportDecimal(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -161,6 +141,7 @@ final class ExportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportQuery $query
|
||||
* @return ExportableItem[]
|
||||
* @throws TooManyItemsExportException
|
||||
*/
|
||||
@@ -177,6 +158,8 @@ final class ExportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportQuery $query
|
||||
* @param string $method
|
||||
* @return FormInterface<ExportQuery>
|
||||
*/
|
||||
private function getToolbarForm(ExportQuery $query, string $method): FormInterface
|
||||
@@ -192,40 +175,4 @@ final class ExportController extends AbstractController
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/template-create', name: 'export_template_create', methods: ['GET', 'POST'])]
|
||||
public function createExportTemplate(Request $request, ExportTemplateRepository $repository): Response
|
||||
{
|
||||
return $this->editExportForm($this->generateUrl('export_template_create'), $request, $repository, new ExportTemplate());
|
||||
}
|
||||
|
||||
#[Route(path: '/template-edit/{exportTemplate}', name: 'export_template_edit', methods: ['GET', 'POST'])]
|
||||
public function editExportTemplate(ExportTemplate $exportTemplate, Request $request, ExportTemplateRepository $repository): Response
|
||||
{
|
||||
return $this->editExportForm($this->generateUrl('export_template_edit', ['exportTemplate' => $exportTemplate->getId()]), $request, $repository, $exportTemplate);
|
||||
}
|
||||
|
||||
private function editExportForm(string $url, Request $request, ExportTemplateRepository $repository, ExportTemplate $exportTemplate): Response
|
||||
{
|
||||
$form = $this->createForm(ExportTemplateSpreadsheetForm::class, $exportTemplate, [
|
||||
'action' => $url,
|
||||
'method' => 'POST',
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$repository->saveExportTemplate($exportTemplate);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('export');
|
||||
} catch (\Exception $ex) {
|
||||
$this->handleFormUpdateException($ex, $form);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('export/template.html.twig', [
|
||||
'form' => $form->createView()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ use App\Repository\TimesheetRepository;
|
||||
use App\Timesheet\FavoriteRecordService;
|
||||
use App\Timesheet\TimesheetService;
|
||||
use App\Utils\PageSetup;
|
||||
use App\WorkingTime\WorkingTimeService;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -39,7 +38,6 @@ final class QuickEntryController extends AbstractController
|
||||
private readonly TimesheetRepository $repository,
|
||||
private readonly FavoriteRecordService $favoriteRecordService,
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly WorkingTimeService $workingTimeService,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -116,46 +114,37 @@ final class QuickEntryController extends AbstractController
|
||||
|
||||
ksort($rows);
|
||||
|
||||
// this should also check via lock service
|
||||
$locked = $this->workingTimeService->isApproved($user, $endWeek);
|
||||
// attach recent activities
|
||||
$amount = $this->configuration->getQuickEntriesRecentAmount();
|
||||
if ($amount > 0) {
|
||||
$takeOverWeeks = $this->configuration->find('quick_entry.recent_activity_weeks');
|
||||
$startFrom = null;
|
||||
if ($takeOverWeeks !== null && \intval($takeOverWeeks) > 0) {
|
||||
$startFrom = clone $startWeek;
|
||||
$startFrom->modify(\sprintf('-%s weeks', $takeOverWeeks));
|
||||
}
|
||||
|
||||
if (!$locked) {
|
||||
// attach recent activities
|
||||
$amount = $this->configuration->getQuickEntriesRecentAmount();
|
||||
if ($amount > 0) {
|
||||
$takeOverWeeks = $this->configuration->find('quick_entry.recent_activity_weeks');
|
||||
$startFrom = null;
|
||||
if ($takeOverWeeks !== null && \intval($takeOverWeeks) > 0) {
|
||||
$startFrom = clone $startWeek;
|
||||
$startFrom->modify(\sprintf('-%s weeks', $takeOverWeeks));
|
||||
$favorites = $this->favoriteRecordService->favoriteEntries($user, $amount);
|
||||
foreach ($favorites as $favorite) {
|
||||
$timesheet = $favorite->getTimesheet();
|
||||
if ($startFrom !== null && !$favorite->isFavorite() && $startFrom > $timesheet->getBegin()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$favorites = $this->favoriteRecordService->favoriteEntries($user, $amount);
|
||||
foreach ($favorites as $favorite) {
|
||||
$timesheet = $favorite->getTimesheet();
|
||||
if ($startFrom !== null && !$favorite->isFavorite() && $startFrom > $timesheet->getBegin()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
|
||||
if (\array_key_exists($id, $rows)) {
|
||||
continue;
|
||||
}
|
||||
// edge case: a project that starts and ends between the start and end date allows to select it from the dropdown,
|
||||
// but it is better to hide a row than displaying already ended projects
|
||||
if ($timesheet->getProject() !== null && (!$timesheet->getProject()->isVisibleAtDate($startWeek) && !$timesheet->getProject()->isVisibleAtDate($endWeek))) {
|
||||
continue;
|
||||
}
|
||||
// make sure no invisible entries are included
|
||||
if (!$this->isGranted('start', $timesheet)) {
|
||||
continue;
|
||||
}
|
||||
$rows[$id] = [
|
||||
'days' => $week,
|
||||
'project' => $timesheet->getProject(),
|
||||
'activity' => $timesheet->getActivity()
|
||||
];
|
||||
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
|
||||
if (\array_key_exists($id, $rows)) {
|
||||
continue;
|
||||
}
|
||||
// there is an edge case possible with a project that starts and ends between the start and end date
|
||||
// user could still select it from the dropdown, but it is better to hide a row than displaying already ended projects
|
||||
if ($timesheet->getProject() !== null && (!$timesheet->getProject()->isVisibleAtDate($startWeek) && !$timesheet->getProject()->isVisibleAtDate($endWeek))) {
|
||||
continue;
|
||||
}
|
||||
$rows[$id] = [
|
||||
'days' => $week,
|
||||
'project' => $timesheet->getProject(),
|
||||
'activity' => $timesheet->getActivity()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +165,6 @@ final class QuickEntryController extends AbstractController
|
||||
if (!\array_key_exists('entry', $day)) {
|
||||
// fill all rows and columns to make sure we do not have missing records
|
||||
$tmp = $this->timesheetService->createNewTimesheet($user);
|
||||
$tmp->setDuration(null);
|
||||
$tmp->setProject($row['project']);
|
||||
$tmp->setActivity($row['activity']);
|
||||
$newTime = \DateTime::createFromInterface($day['day']);
|
||||
@@ -197,7 +185,6 @@ final class QuickEntryController extends AbstractController
|
||||
$empty->markAsPrototype();
|
||||
foreach ($week as $dayId => $day) {
|
||||
$tmp = $this->timesheetService->createNewTimesheet($user);
|
||||
$tmp->setDuration(null);
|
||||
$newTime = \DateTime::createFromInterface($day['day']);
|
||||
$newTime = $newTime->setTime($defaultHour, $defaultMinute, 0, 0);
|
||||
$tmp->setBegin($newTime);
|
||||
@@ -207,20 +194,18 @@ final class QuickEntryController extends AbstractController
|
||||
|
||||
// add empty rows for simpler starting
|
||||
$minRows = \intval($this->configuration->find('quick_entry.minimum_rows'));
|
||||
if (!$locked && $formModel->countRows() < $minRows) {
|
||||
if ($formModel->countRows() < $minRows) {
|
||||
$newRows = $minRows - $formModel->countRows();
|
||||
for ($a = 0; $a < $newRows; $a++) {
|
||||
$model = $formModel->addRow($user);
|
||||
foreach ($week as $dayId => $day) {
|
||||
$tmp = $this->timesheetService->createNewTimesheet($user);
|
||||
$tmp->setDuration(null);
|
||||
$newTime = \DateTime::createFromInterface($day['day']);
|
||||
$newTime = $newTime->setTime($defaultHour, $defaultMinute, 0, 0);
|
||||
$tmp->setBegin($newTime);
|
||||
$this->timesheetService->prepareNewTimesheet($tmp);
|
||||
$model->addTimesheet($tmp);
|
||||
}
|
||||
$model->setMetaFields($metaFields);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,14 +229,12 @@ final class QuickEntryController extends AbstractController
|
||||
foreach ($tmpModel->getTimesheets() as $timesheet) {
|
||||
if ($timesheet->getId() !== null) {
|
||||
$duration = $timesheet->getDuration(false);
|
||||
// running timesheets also have a empty duration.
|
||||
// we distinguish them from temporary ones, to make sure they will not be deleted
|
||||
// previously running timesheets were deleted, which was wrong
|
||||
// so now we distinguish between running timesheets and null duration
|
||||
if ($timesheet->isRunning()) {
|
||||
$saveTimesheets[] = $timesheet;
|
||||
} elseif ($duration === null) {
|
||||
if ($this->isGranted('delete', $timesheet)) {
|
||||
$deleteTimesheets[] = $timesheet;
|
||||
}
|
||||
$deleteTimesheets[] = $timesheet;
|
||||
} else {
|
||||
$saveTimesheets[] = $timesheet;
|
||||
}
|
||||
@@ -265,22 +248,14 @@ final class QuickEntryController extends AbstractController
|
||||
|
||||
try {
|
||||
$saved = false;
|
||||
if (\count($deleteTimesheets) > 0) {
|
||||
if (\count($deleteTimesheets) > 0 && $this->isGranted('delete_own_timesheet')) {
|
||||
$this->timesheetService->deleteMultipleTimesheets($deleteTimesheets);
|
||||
$saved = true;
|
||||
}
|
||||
|
||||
if (\count($saveTimesheets) > 0) {
|
||||
$saveMe = [];
|
||||
foreach ($saveTimesheets as $timesheet) {
|
||||
if ($timesheet->getId() === null || $this->isGranted('edit', $timesheet)) {
|
||||
$saveMe[] = $timesheet;
|
||||
}
|
||||
}
|
||||
if (\count($saveMe) > 0) {
|
||||
$this->timesheetService->updateMultipleTimesheets($saveMe);
|
||||
$saved = true;
|
||||
}
|
||||
$this->timesheetService->updateMultipleTimesheets($saveTimesheets);
|
||||
$saved = true;
|
||||
}
|
||||
|
||||
if ($saved) {
|
||||
@@ -303,7 +278,6 @@ final class QuickEntryController extends AbstractController
|
||||
'days' => $week,
|
||||
'form' => $form->createView(),
|
||||
'metaColumns' => $metaFields,
|
||||
'locked' => $locked,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
$this->service->saveTimesheet($entry);
|
||||
$this->service->updateTimesheet($entry);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute($this->getTimesheetRoute());
|
||||
@@ -184,7 +184,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
|
||||
if ($createForm->isSubmitted() && $createForm->isValid()) {
|
||||
try {
|
||||
$this->service->saveTimesheet($entry);
|
||||
$this->service->saveNewTimesheet($entry);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute($this->getTimesheetRoute());
|
||||
@@ -216,7 +216,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$this->dispatcher->dispatch(new TimesheetDuplicatePreEvent($copyTimesheet, $timesheet));
|
||||
$this->service->saveTimesheet($copyTimesheet);
|
||||
$this->service->saveNewTimesheet($copyTimesheet);
|
||||
$this->dispatcher->dispatch(new TimesheetDuplicatePostEvent($copyTimesheet, $timesheet));
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ final class TimesheetTeamController extends TimesheetAbstractController
|
||||
}
|
||||
|
||||
foreach ($newTimesheets as $newTimesheet) {
|
||||
$this->service->saveTimesheet($newTimesheet);
|
||||
$this->service->saveNewTimesheet($newTimesheet);
|
||||
}
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
@@ -101,7 +101,7 @@ final class CustomerService
|
||||
$errors = $this->validator->validate($customer, null, $groups);
|
||||
|
||||
if ($errors->count() > 0) {
|
||||
throw new ValidationFailedException($errors);
|
||||
throw new ValidationFailedException($errors, 'Validation Failed');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\ExportTemplateRepository;
|
||||
use App\Validator\Constraints\ExportRenderer;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_export_templates')]
|
||||
#[ORM\UniqueConstraint(columns: ['title'])]
|
||||
#[ORM\Entity(repositoryClass: ExportTemplateRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('title')]
|
||||
class ExportTemplate
|
||||
{
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
private ?int $id = null;
|
||||
#[ORM\Column(name: 'title', type: Types::STRING, length: 100, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 1, max: 100)]
|
||||
private ?string $title = null;
|
||||
#[ORM\Column(name: 'renderer', type: Types::STRING, length: 20, nullable: false)]
|
||||
#[ExportRenderer]
|
||||
#[Assert\NotBlank]
|
||||
private string $renderer = 'csv';
|
||||
/**
|
||||
* Used for header column translation.
|
||||
*/
|
||||
#[ORM\Column(name: 'language', type: Types::STRING, length: 6, nullable: true)]
|
||||
#[Assert\Locale]
|
||||
private ?string $language = null;
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
#[ORM\Column(name: 'columns', type: Types::JSON, nullable: false)]
|
||||
#[Assert\Count(min: 1)]
|
||||
#[Assert\NotNull]
|
||||
private array $columns = [];
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
#[ORM\Column(name: 'options', type: Types::JSON, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
private array $options = [];
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setTitle(?string $title): void
|
||||
{
|
||||
$this->title = $title;
|
||||
}
|
||||
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setRenderer(string $renderer): void
|
||||
{
|
||||
$this->renderer = $renderer;
|
||||
}
|
||||
|
||||
public function getRenderer(): string
|
||||
{
|
||||
return $this->renderer;
|
||||
}
|
||||
|
||||
public function getLanguage(): ?string
|
||||
{
|
||||
return $this->language;
|
||||
}
|
||||
|
||||
public function setLanguage(?string $language): void
|
||||
{
|
||||
$this->language = $language;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getColumns(): array
|
||||
{
|
||||
return $this->columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string>|null $columns
|
||||
*/
|
||||
public function setColumns(?array $columns): void
|
||||
{
|
||||
$this->columns = $columns ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getOptions(): array
|
||||
{
|
||||
return $this->options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function setOptions(?array $options): void
|
||||
{
|
||||
$this->options = $options ?? [];
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->title ?? 'New';
|
||||
}
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
if ($this->id) {
|
||||
$this->id = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,8 @@ class Invoice implements EntityWithMetaFields
|
||||
#[ORM\Column(name: 'invoice_filename', type: Types::STRING, length: 150, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Length(min: 1, max: 150)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'file', type: 'string')]
|
||||
private ?string $invoiceFilename = null;
|
||||
private bool $localized = false;
|
||||
|
||||
@@ -131,7 +131,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
* @var Collection<UserPreference>|null
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'user', targetEntity: UserPreference::class, cascade: ['persist'])]
|
||||
private ?Collection $preferences = null;
|
||||
private ?Collection $preferences;
|
||||
/**
|
||||
* List of all team memberships.
|
||||
*
|
||||
@@ -504,6 +504,10 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return $allowNull ? $value : ($value ?? $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param UserPreference $preference
|
||||
* @return User
|
||||
*/
|
||||
public function addPreference(UserPreference $preference): User
|
||||
{
|
||||
if (null === $this->preferences) {
|
||||
@@ -582,7 +586,9 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is a member of any team.
|
||||
* Checks if the user is member of any team.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasTeamAssignment(): bool
|
||||
{
|
||||
@@ -593,6 +599,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
* Checks is the user is teamlead in any of the assigned teams.
|
||||
*
|
||||
* @see User::hasTeamleadRole()
|
||||
* @return bool
|
||||
*/
|
||||
public function isTeamlead(): bool
|
||||
{
|
||||
@@ -607,6 +614,9 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
|
||||
/**
|
||||
* Checks if the given user is a team member.
|
||||
*
|
||||
* @param User $user
|
||||
* @return bool
|
||||
*/
|
||||
public function hasTeamMember(User $user): bool
|
||||
{
|
||||
@@ -670,6 +680,8 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
|
||||
/**
|
||||
* Required in the User profile screen to edit his teams.
|
||||
*
|
||||
* @param Team $team
|
||||
*/
|
||||
public function addTeam(Team $team): void
|
||||
{
|
||||
@@ -688,6 +700,8 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
|
||||
/**
|
||||
* Required in the User profile screen to edit his teams.
|
||||
*
|
||||
* @param Team $team
|
||||
*/
|
||||
public function removeTeam(Team $team): void
|
||||
{
|
||||
|
||||
@@ -26,10 +26,6 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
|
||||
{
|
||||
use ExportTrait;
|
||||
|
||||
private string $id = 'csv';
|
||||
private string $title = 'default';
|
||||
private ?string $locale = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly SpreadsheetRenderer $spreadsheetRenderer,
|
||||
private readonly TranslatorInterface $translator
|
||||
@@ -37,29 +33,14 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
|
||||
{
|
||||
}
|
||||
|
||||
public function setId(string $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setTitle(string $title): void
|
||||
{
|
||||
$this->title = $title;
|
||||
}
|
||||
|
||||
public function setLocale(?string $locale): void
|
||||
{
|
||||
$this->locale = $locale;
|
||||
return 'csv';
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
return 'csv';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +58,7 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
|
||||
/**
|
||||
* @param ExportableItem[] $exportItems
|
||||
*/
|
||||
private function renderFile(array $exportItems, TimesheetQuery $query): \SplFileInfo
|
||||
public function renderFile(array $exportItems, TimesheetQuery $query): \SplFileInfo
|
||||
{
|
||||
$filename = @tempnam(sys_get_temp_dir(), 'kimai-export-csv');
|
||||
if (false === $filename) {
|
||||
@@ -87,12 +68,11 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
|
||||
$options = new Options();
|
||||
$options->SHOULD_ADD_BOM = false;
|
||||
|
||||
$spreadsheet = new SpoutSpreadsheet(new Writer($options), $this->translator, $this->locale);
|
||||
$spreadsheet = new SpoutSpreadsheet(new Writer($options), $this->translator);
|
||||
$spreadsheet->open($filename);
|
||||
|
||||
$this->spreadsheetRenderer->registerFormatter('date', new DateStringFormatter());
|
||||
$this->spreadsheetRenderer->registerFormatter('duration', new DurationPlainFormatter(false));
|
||||
$this->spreadsheetRenderer->registerFormatter('duration_seconds', new DurationPlainFormatter(true));
|
||||
$this->spreadsheetRenderer->registerFormatter('duration', new DurationPlainFormatter());
|
||||
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);
|
||||
|
||||
return new \SplFileInfo($filename);
|
||||
|
||||
@@ -68,9 +68,9 @@ class HtmlRenderer implements ExportRendererInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportableItem[] $exportItems
|
||||
* @param ExportableItem[] $timesheets
|
||||
*/
|
||||
public function render(array $exportItems, TimesheetQuery $query): Response
|
||||
public function render(array $timesheets, TimesheetQuery $query): Response
|
||||
{
|
||||
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));
|
||||
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT));
|
||||
@@ -81,7 +81,7 @@ class HtmlRenderer implements ExportRendererInterface
|
||||
$this->dispatcher->dispatch($event);
|
||||
$userPreferences = $event->getPreferences();
|
||||
|
||||
$summary = $this->calculateSummary($exportItems);
|
||||
$summary = $this->calculateSummary($timesheets);
|
||||
|
||||
// enable basic security measures
|
||||
$sandbox = new SandboxExtension(new ExportPolicy());
|
||||
@@ -89,11 +89,11 @@ class HtmlRenderer implements ExportRendererInterface
|
||||
$this->twig->addExtension($sandbox);
|
||||
|
||||
$content = $this->twig->render($this->getTemplate(), array_merge([
|
||||
'entries' => $exportItems,
|
||||
'entries' => $timesheets,
|
||||
'query' => $query,
|
||||
'summaries' => $summary,
|
||||
'budgets' => $this->calculateProjectBudget($exportItems, $query, $this->projectStatisticService),
|
||||
'activity_budgets' => $this->calculateActivityBudget($exportItems, $query, $this->activityStatisticService),
|
||||
'budgets' => $this->calculateProjectBudget($timesheets, $query, $this->projectStatisticService),
|
||||
'activity_budgets' => $this->calculateActivityBudget($timesheets, $query, $this->activityStatisticService),
|
||||
'timesheetMetaFields' => $timesheetMetaFields,
|
||||
'customerMetaFields' => $customerMetaFields,
|
||||
'projectMetaFields' => $projectMetaFields,
|
||||
|
||||
@@ -29,7 +29,6 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
use PDFRendererTrait;
|
||||
|
||||
private string $id = 'pdf';
|
||||
private string $title = 'pdf';
|
||||
private string $template = 'default.pdf.twig';
|
||||
private array $pdfOptions = [];
|
||||
|
||||
@@ -43,7 +42,7 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
protected function getTemplate(): string
|
||||
@@ -76,18 +75,20 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportableItem[] $exportItems
|
||||
* @param ExportableItem[] $timesheets
|
||||
* @param TimesheetQuery $query
|
||||
* @return Response
|
||||
* @throws \Twig\Error\LoaderError
|
||||
* @throws \Twig\Error\RuntimeError
|
||||
* @throws \Twig\Error\SyntaxError
|
||||
*/
|
||||
public function render(array $exportItems, TimesheetQuery $query): Response
|
||||
public function render(array $timesheets, TimesheetQuery $query): Response
|
||||
{
|
||||
$filename = new ExportFilename($query);
|
||||
$context = new PdfContext();
|
||||
$context->setOption('filename', $filename->getFilename());
|
||||
|
||||
$summary = $this->calculateSummary($exportItems);
|
||||
$summary = $this->calculateSummary($timesheets);
|
||||
|
||||
// enable basic security measures
|
||||
$sandbox = new SandboxExtension(new ExportPolicy());
|
||||
@@ -95,10 +96,10 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
$this->twig->addExtension($sandbox);
|
||||
|
||||
$content = $this->twig->render($this->getTemplate(), array_merge([
|
||||
'entries' => $exportItems,
|
||||
'entries' => $timesheets,
|
||||
'query' => $query,
|
||||
'summaries' => $summary,
|
||||
'budgets' => $this->calculateProjectBudget($exportItems, $query, $this->projectStatisticService),
|
||||
'budgets' => $this->calculateProjectBudget($timesheets, $query, $this->projectStatisticService),
|
||||
'decimal' => false,
|
||||
'pdfContext' => $context
|
||||
], $this->getOptions($query)));
|
||||
@@ -110,19 +111,18 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
return $this->createPdfResponse($content, $context);
|
||||
}
|
||||
|
||||
public function setTemplate(string $filename): void
|
||||
public function setTemplate(string $filename): PDFRenderer
|
||||
{
|
||||
$this->template = $filename;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setId(string $id): void
|
||||
public function setId(string $id): PDFRenderer
|
||||
{
|
||||
$this->id = $id;
|
||||
}
|
||||
|
||||
public function setTitle(string $title): void
|
||||
{
|
||||
$this->title = $title;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
|
||||
@@ -31,14 +31,11 @@ use App\Export\Package\CellFormatter\TimeFormatter;
|
||||
use App\Export\Package\Column;
|
||||
use App\Export\Package\ColumnWidth;
|
||||
use App\Export\Package\SpreadsheetPackage;
|
||||
use App\Export\Template;
|
||||
use App\Export\TemplateInterface;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
@@ -51,32 +48,12 @@ final class SpreadsheetRenderer
|
||||
*/
|
||||
private array $formatter = [];
|
||||
|
||||
private ?TemplateInterface $template = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
private readonly Security $voter,
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
protected EventDispatcherInterface $dispatcher,
|
||||
protected Security $voter
|
||||
) {
|
||||
}
|
||||
|
||||
public function setTemplate(?TemplateInterface $template): void
|
||||
{
|
||||
$this->template = $template;
|
||||
}
|
||||
|
||||
private function getTemplate(): TemplateInterface
|
||||
{
|
||||
if ($this->template === null) {
|
||||
$template = new Template('default', 'default');
|
||||
$template->setColumns($this->getDefaultColumns());
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
return $this->template;
|
||||
}
|
||||
|
||||
private function isRenderRate(TimesheetQuery $query): bool
|
||||
{
|
||||
if ($this->voter->getUser() === null) {
|
||||
@@ -96,7 +73,7 @@ final class SpreadsheetRenderer
|
||||
*/
|
||||
private function findMetaColumns(MetaDisplayEventInterface $event): array
|
||||
{
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
return $event->getFields();
|
||||
}
|
||||
@@ -121,12 +98,7 @@ final class SpreadsheetRenderer
|
||||
|
||||
if ($currentRow > 1) {
|
||||
$totalColumns = ['duration', 'rate', 'internalRate'];
|
||||
// that should be enough for the near future: the number of array entries must cover the max number of columns
|
||||
$columnNames = [
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
|
||||
'AA', 'AB', 'AC', 'AD', 'AE', 'AF', 'AG', 'AH', 'AI', 'AJ', 'AK', 'AL', 'AM', 'AN', 'AO', 'AP', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AV', 'AW', 'AX', 'AY', 'AZ',
|
||||
'BA', 'BB', 'BC', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BK', 'BL', 'BM', 'BN', 'BO', 'BP', 'BQ', 'BR', 'BS', 'BT', 'BU', 'BV', 'BW', 'BX', 'BY', 'BZ',
|
||||
];
|
||||
$columnNames = range('A', 'Z');
|
||||
$totalRow = [];
|
||||
$totalColumn = 1;
|
||||
foreach ($columns as $column) {
|
||||
@@ -159,9 +131,8 @@ final class SpreadsheetRenderer
|
||||
return match ($name) {
|
||||
'date' => new DateFormatter(),
|
||||
'time' => new TimeFormatter(),
|
||||
'duration' => new DurationFormatter('[hh]:mm'),
|
||||
'duration' => new DurationFormatter(),
|
||||
'duration_decimal' => new DurationDecimalFormatter(),
|
||||
'duration_seconds' => new DurationFormatter('[hh]:mm:ss'),
|
||||
default => new DefaultFormatter()
|
||||
};
|
||||
}
|
||||
@@ -173,214 +144,98 @@ final class SpreadsheetRenderer
|
||||
{
|
||||
$showRates = $this->isRenderRate($query);
|
||||
|
||||
$timesheetMeta = [];
|
||||
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$timesheetMeta['timesheet.meta.' . $metaField->getName()] = (new Column('timesheet.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$customerMeta = [];
|
||||
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$customerMeta['customer.meta.' . $metaField->getName()] = (new Column('customer.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getProject()?->getCustomer()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$projectMeta = [];
|
||||
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$projectMeta['project.meta.' . $metaField->getName()] = (new Column('project.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getProject()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$activityMeta = [];
|
||||
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent($query->copyTo(new ActivityQuery()), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$activityMeta['activity.meta.' . $metaField->getName()] = (new Column('activity.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getActivity()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$userMeta = [];
|
||||
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
foreach ($event->getPreferences() as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$userMeta['user.meta.' . $metaField->getName()] = (new Column('user.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getUser()?->getPreference($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$template = $this->getTemplate();
|
||||
|
||||
$columns = [];
|
||||
|
||||
$rateColumns = ['currency', 'rate', 'internal_rate', 'hourly_rate', 'fixed_rate'];
|
||||
|
||||
foreach ($template->getColumns() as $column) {
|
||||
if ($column === 'date') {
|
||||
$columns[] = (new Column('date', $this->getFormatter('date')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin());
|
||||
} elseif ($column === 'begin') {
|
||||
$columns[] = (new Column('begin', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'end') {
|
||||
$columns[] = (new Column('end', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getEnd())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'duration') {
|
||||
$columns[] = (new Column('duration', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'duration_decimal') {
|
||||
$columns[] = (new Column('duration', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'duration_seconds') {
|
||||
$columns[] = (new Column('duration', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'currency' && $showRates) {
|
||||
$columns[] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'rate' && $showRates) {
|
||||
$columns[] = (new Column('rate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getRate());
|
||||
} elseif ($column === 'internal_rate' && $showRates) {
|
||||
$columns[] = (new Column('internalRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getInternalRate());
|
||||
} elseif ($column === 'hourly_rate' && $showRates) {
|
||||
$columns[] = (new Column('hourlyRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getHourlyRate());
|
||||
} elseif ($column === 'fixed_rate' && $showRates) {
|
||||
$columns[] = (new Column('fixedRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getFixedRate());
|
||||
} elseif ($column === 'user.alias') {
|
||||
$columns[] = (new Column('alias', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'user.name') {
|
||||
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getUserIdentifier())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'user.account_number') {
|
||||
$columns[] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber());
|
||||
} elseif ($column === 'customer.name') {
|
||||
$columns[] = (new Column('customer', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'project.name') {
|
||||
$columns[] = (new Column('project', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'activity.name') {
|
||||
$columns[] = (new Column('activity', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
} elseif ($column === 'description') {
|
||||
$columns[] = (new Column('description', new TextFormatter(true)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDescription())->withColumnWidth(ColumnWidth::LARGE);
|
||||
} elseif ($column === 'exported') {
|
||||
$columns[] = (new Column('exported', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isExported());
|
||||
} elseif ($column === 'billable') {
|
||||
$columns[] = (new Column('billable', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isBillable())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'tags') {
|
||||
$columns[] = (new Column('tags', new ArrayFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getTagsAsArray());
|
||||
} elseif ($column === 'type') {
|
||||
$columns[] = (new Column('type', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getType());
|
||||
} elseif ($column === 'category') {
|
||||
$columns[] = (new Column('category', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getCategory());
|
||||
} elseif ($column === 'customer.number') {
|
||||
$columns[] = (new Column('number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getNumber());
|
||||
} elseif ($column === 'project.number') {
|
||||
$columns[] = (new Column('project_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getNumber());
|
||||
} elseif ($column === 'activity.number') {
|
||||
$columns[] = (new Column('activity_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getNumber());
|
||||
} elseif ($column === 'customer.vat_id') {
|
||||
$columns[] = (new Column('vat_id', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getVatId());
|
||||
} elseif ($column === 'project.order_number') {
|
||||
$columns[] = (new Column('orderNumber', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getOrderNumber());
|
||||
} elseif (str_starts_with($column, 'timesheet.meta.') && \array_key_exists($column, $timesheetMeta)) {
|
||||
$columns[] = $timesheetMeta[$column];
|
||||
} elseif (str_starts_with($column, 'customer.meta.') && \array_key_exists($column, $customerMeta)) {
|
||||
$columns[] = $customerMeta[$column];
|
||||
} elseif (str_starts_with($column, 'project.meta.') && \array_key_exists($column, $projectMeta)) {
|
||||
$columns[] = $projectMeta[$column];
|
||||
} elseif (str_starts_with($column, 'activity.meta.') && \array_key_exists($column, $activityMeta)) {
|
||||
$columns[] = $activityMeta[$column];
|
||||
} elseif (str_starts_with($column, 'user.meta.') && \array_key_exists($column, $userMeta)) {
|
||||
$columns[] = $userMeta[$column];
|
||||
} else {
|
||||
if ($this->logger !== null && ($showRates || !\in_array($column, $rateColumns, true))) {
|
||||
$this->logger->warning(\sprintf('Unknown column "%s" used in exporter template "%s".', $column, $template->getTitle()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function getDefaultColumns(): array
|
||||
{
|
||||
// @deprecated since 2.36 - will be removed with 3.0
|
||||
$durationFormatter = 'duration';
|
||||
if (($user = $this->voter->getUser()) instanceof User) {
|
||||
$durationFormatter = $user->isExportDecimal() ? 'duration_decimal' : 'duration';
|
||||
}
|
||||
|
||||
$columns = [
|
||||
'date',
|
||||
'begin',
|
||||
'end',
|
||||
$durationFormatter,
|
||||
'currency',
|
||||
'rate',
|
||||
'internal_rate',
|
||||
'hourly_rate',
|
||||
'fixed_rate',
|
||||
'user.alias',
|
||||
'user.name',
|
||||
'user.account_number',
|
||||
'customer.name',
|
||||
'project.name',
|
||||
'activity.name',
|
||||
'description',
|
||||
'billable',
|
||||
'tags',
|
||||
'type',
|
||||
'category',
|
||||
'customer.number',
|
||||
'project.number',
|
||||
'customer.vat_id',
|
||||
'project.order_number',
|
||||
];
|
||||
$columns = [];
|
||||
|
||||
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent(new TimesheetQuery(), TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'timesheet.meta.' . $metaField->getName();
|
||||
}
|
||||
$columns[] = (new Column('date', $this->getFormatter('date')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin());
|
||||
$columns[] = (new Column('begin', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin())->withColumnWidth(ColumnWidth::SMALL);
|
||||
$columns[] = (new Column('end', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getEnd())->withColumnWidth(ColumnWidth::SMALL);
|
||||
$columns[] = (new Column('duration', $this->getFormatter($durationFormatter)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
|
||||
|
||||
if ($showRates) {
|
||||
$columns[] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL);
|
||||
$columns[] = (new Column('rate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getRate());
|
||||
$columns[] = (new Column('internalRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getInternalRate());
|
||||
$columns[] = (new Column('hourlyRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getHourlyRate());
|
||||
$columns[] = (new Column('fixedRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getFixedRate());
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent(new CustomerQuery(), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'customer.meta.' . $metaField->getName();
|
||||
$columns[] = (new Column('alias', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getUserIdentifier())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
$columns[] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber());
|
||||
$columns[] = (new Column('customer', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
$columns[] = (new Column('project', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
$columns[] = (new Column('activity', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
|
||||
$columns[] = (new Column('description', new TextFormatter(true)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDescription())->withColumnWidth(ColumnWidth::LARGE);
|
||||
//$columns[] = (new Column('exported', new BooleanFormatter()))->withExtractor(fn(ExportableItem $exportableItem) => $exportableItem->isExported());
|
||||
$columns[] = (new Column('billable', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isBillable())->withColumnWidth(ColumnWidth::SMALL);
|
||||
$columns[] = (new Column('tags', new ArrayFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getTagsAsArray());
|
||||
$columns[] = (new Column('type', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getType());
|
||||
$columns[] = (new Column('category', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getCategory());
|
||||
$columns[] = (new Column('number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getNumber());
|
||||
$columns[] = (new Column('project_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getNumber());
|
||||
$columns[] = (new Column('vat_id', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getVatId());
|
||||
$columns[] = (new Column('orderNumber', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getOrderNumber());
|
||||
|
||||
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() === null) {
|
||||
continue;
|
||||
}
|
||||
$columns[] = (new Column('timesheet.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent(new ProjectQuery(), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'project.meta.' . $metaField->getName();
|
||||
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() === null) {
|
||||
continue;
|
||||
}
|
||||
$columns[] = (new Column('customer.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getProject()?->getCustomer()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent(new ActivityQuery(), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'activity.meta.' . $metaField->getName();
|
||||
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() === null) {
|
||||
continue;
|
||||
}
|
||||
$columns[] = (new Column('project.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getProject()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent($query->copyTo(new ActivityQuery()), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() === null) {
|
||||
continue;
|
||||
}
|
||||
$columns[] = (new Column('activity.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getActivity()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
|
||||
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
$this->dispatcher->dispatch($event);
|
||||
foreach ($event->getPreferences() as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns[] = 'user.meta.' . $metaField->getName();
|
||||
if ($metaField->getName() === null) {
|
||||
continue;
|
||||
}
|
||||
$columns[] = (new Column('user.meta.' . $metaField->getName(), $this->getFormatter('default')))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getUser()?->getPreference($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
|
||||
return $columns;
|
||||
|
||||
@@ -23,10 +23,6 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
|
||||
{
|
||||
use ExportTrait;
|
||||
|
||||
private string $id = 'xlsx';
|
||||
private string $title = 'default';
|
||||
private ?string $locale = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly SpreadsheetRenderer $spreadsheetRenderer,
|
||||
private readonly TranslatorInterface $translator,
|
||||
@@ -34,29 +30,14 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
|
||||
{
|
||||
}
|
||||
|
||||
public function setId(string $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setTitle(string $title): void
|
||||
{
|
||||
$this->title = $title;
|
||||
}
|
||||
|
||||
public function setLocale(?string $locale): void
|
||||
{
|
||||
$this->locale = $locale;
|
||||
return 'xlsx';
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
return 'xlsx';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,14 +55,14 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
|
||||
/**
|
||||
* @param ExportableItem[] $exportItems
|
||||
*/
|
||||
private function renderFile(array $exportItems, TimesheetQuery $query): \SplFileInfo
|
||||
public function renderFile(array $exportItems, TimesheetQuery $query): \SplFileInfo
|
||||
{
|
||||
$filename = @tempnam(sys_get_temp_dir(), 'kimai-export-xlsx');
|
||||
if (false === $filename) {
|
||||
throw new \Exception('Could not open temporary file');
|
||||
}
|
||||
|
||||
$spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator, $this->locale);
|
||||
$spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator);
|
||||
$spreadsheet->open($filename);
|
||||
|
||||
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);
|
||||
|
||||
@@ -17,10 +17,18 @@ interface ExportRendererInterface
|
||||
{
|
||||
/**
|
||||
* @param ExportableItem[] $exportItems
|
||||
* @param TimesheetQuery $query
|
||||
* @return Response
|
||||
*/
|
||||
public function render(array $exportItems, TimesheetQuery $query): Response;
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getId(): string;
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getTitle(): string;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\Utils\Duration;
|
||||
|
||||
final class DurationDecimalFormatter implements CellFormatterInterface
|
||||
{
|
||||
private readonly Duration $duration;
|
||||
private Duration $duration;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
|
||||
@@ -11,10 +11,6 @@ namespace App\Export\Package\CellFormatter;
|
||||
|
||||
final class DurationFormatter implements CellFormatterInterface, CellWithFormatInterface
|
||||
{
|
||||
public function __construct(private readonly string $format)
|
||||
{
|
||||
}
|
||||
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if (!is_numeric($value) || (int) $value === 0) {
|
||||
@@ -41,6 +37,6 @@ final class DurationFormatter implements CellFormatterInterface, CellWithFormatI
|
||||
|
||||
public function getFormat(): string
|
||||
{
|
||||
return $this->format;
|
||||
return '[hh]:mm:ss';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,18 +11,10 @@ namespace App\Export\Package\CellFormatter;
|
||||
|
||||
final class DurationPlainFormatter implements CellFormatterInterface
|
||||
{
|
||||
public function __construct(private readonly bool $withSeconds = true)
|
||||
{
|
||||
}
|
||||
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if (!is_numeric($value) || (int) $value === 0) {
|
||||
if ($this->withSeconds) {
|
||||
return '0:00:00';
|
||||
}
|
||||
|
||||
return '0:00';
|
||||
return '0:00:00';
|
||||
}
|
||||
|
||||
$value = (int) $value;
|
||||
@@ -40,10 +32,6 @@ final class DurationPlainFormatter implements CellFormatterInterface
|
||||
$interval->invert = 1;
|
||||
}
|
||||
|
||||
if ($this->withSeconds) {
|
||||
return $interval->format('%r%h:%I:%S');
|
||||
}
|
||||
|
||||
return $interval->format('%r%h:%I');
|
||||
return $interval->format('%r%h:%I:%S');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,7 @@ class SpoutSpreadsheet implements SpreadsheetPackage
|
||||
|
||||
public function __construct(
|
||||
private readonly WriterInterface $writer,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly ?string $locale = null,
|
||||
private readonly TranslatorInterface $translator
|
||||
)
|
||||
{
|
||||
$this->writer->setCreator(Constants::SOFTWARE);
|
||||
@@ -51,11 +50,11 @@ class SpoutSpreadsheet implements SpreadsheetPackage
|
||||
$tmp = [];
|
||||
$i = 0;
|
||||
foreach ($columns as $column) {
|
||||
$title = $this->translator->trans($column->getHeader(), [], null, $this->locale);
|
||||
$title = $this->translator->trans($column->getHeader());
|
||||
$tmp[] = Cell::fromValue($title);
|
||||
$style = null;
|
||||
if (($format = $column->getFormat()) !== null) {
|
||||
$style = (new Style())->setFormat($format);
|
||||
if ($column->getFormat() !== null) {
|
||||
$style = (new Style())->setFormat($column->getFormat());
|
||||
}
|
||||
$this->styles[$i++] = $style;
|
||||
}
|
||||
@@ -121,10 +120,8 @@ class SpoutSpreadsheet implements SpreadsheetPackage
|
||||
|
||||
if ($this->writer instanceof AbstractWriterMultiSheets) {
|
||||
$sheetView = new SheetView();
|
||||
|
||||
// deactivated, because the column order is now configurable
|
||||
//$sheetView->setFreezeColumn('D');
|
||||
//$sheetView->setFreezeRow(2);
|
||||
$sheetView->setFreezeColumn('D');
|
||||
$sheetView->setFreezeRow(2);
|
||||
|
||||
$this->writer->getCurrentSheet()->setSheetView($sheetView);
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export\Renderer;
|
||||
|
||||
use App\Export\Base\CsvRenderer;
|
||||
use App\Export\Base\SpreadsheetRenderer;
|
||||
use App\Export\TemplateInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
final class CsvRendererFactory
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly Security $voter,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(TemplateInterface $template): CsvRenderer
|
||||
{
|
||||
$renderer = new SpreadsheetRenderer($this->dispatcher, $this->voter, $this->logger);
|
||||
$renderer->setTemplate($template);
|
||||
|
||||
$renderer = new CsvRenderer($renderer, $this->translator);
|
||||
$renderer->setId($template->getId());
|
||||
$renderer->setTitle($template->getTitle());
|
||||
$renderer->setLocale($template->getLocale());
|
||||
|
||||
return $renderer;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,6 @@ final class PdfRendererFactory
|
||||
{
|
||||
$renderer = new PDFRenderer($this->twig, $this->converter, $this->projectStatisticService);
|
||||
$renderer->setId($id);
|
||||
$renderer->setTitle($id);
|
||||
$renderer->setTemplate($template);
|
||||
|
||||
return $renderer;
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export\Renderer;
|
||||
|
||||
use App\Export\Base\SpreadsheetRenderer;
|
||||
use App\Export\Base\XlsxRenderer;
|
||||
use App\Export\TemplateInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
final class XlsxRendererFactory
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly Security $voter,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(TemplateInterface $template): XlsxRenderer
|
||||
{
|
||||
$renderer = new SpreadsheetRenderer($this->dispatcher, $this->voter, $this->logger);
|
||||
$renderer->setTemplate($template);
|
||||
|
||||
$renderer = new XlsxRenderer($renderer, $this->translator);
|
||||
$renderer->setId($template->getId());
|
||||
$renderer->setTitle($template->getTitle());
|
||||
$renderer->setLocale($template->getLocale());
|
||||
|
||||
return $renderer;
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,10 @@ namespace App\Export;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Event\ExportItemsQueryEvent;
|
||||
use App\Export\Renderer\CsvRendererFactory;
|
||||
use App\Export\Renderer\HtmlRendererFactory;
|
||||
use App\Export\Renderer\PdfRendererFactory;
|
||||
use App\Export\Renderer\XlsxRendererFactory;
|
||||
use App\Repository\ExportTemplateRepository;
|
||||
use App\Repository\Query\ExportQuery;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class ServiceExport
|
||||
{
|
||||
@@ -40,13 +36,9 @@ final class ServiceExport
|
||||
private array $repositories = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
private readonly HtmlRendererFactory $htmlRendererFactory,
|
||||
private readonly PdfRendererFactory $pdfRendererFactory,
|
||||
private readonly CsvRendererFactory $csvRendererFactory,
|
||||
private readonly XlsxRendererFactory $xlsxRendererFactory,
|
||||
private readonly ExportTemplateRepository $exportTemplateRepository,
|
||||
private readonly LoggerInterface $logger,
|
||||
private EventDispatcherInterface $eventDispatcher,
|
||||
private HtmlRendererFactory $htmlRendererFactory,
|
||||
private PdfRendererFactory $pdfRendererFactory
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -81,26 +73,6 @@ final class ServiceExport
|
||||
{
|
||||
$renderer = [];
|
||||
|
||||
foreach ($this->exportTemplateRepository->findAll() as $template) {
|
||||
$tpl = new Template((string) $template->getId(), $template->getTitle()); // @phpstan-ignore argument.type
|
||||
$tpl->setColumns($template->getColumns());
|
||||
$tpl->setLocale($template->getLanguage());
|
||||
|
||||
switch ($template->getRenderer()) {
|
||||
case 'csv':
|
||||
$renderer[] = $this->csvRendererFactory->create($tpl);
|
||||
break;
|
||||
|
||||
case 'xlsx':
|
||||
$renderer[] = $this->xlsxRendererFactory->create($tpl);
|
||||
break;
|
||||
|
||||
default:
|
||||
$this->logger->error('Unknown export template type: ' . $template->getRenderer());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->documentDirs as $exportPath) {
|
||||
if (!is_dir($exportPath)) {
|
||||
continue;
|
||||
@@ -160,7 +132,7 @@ final class ServiceExport
|
||||
|
||||
public function getTimesheetExporterById(string $id): ?TimesheetExportInterface
|
||||
{
|
||||
foreach ($this->getTimesheetExporter() as $exporter) {
|
||||
foreach ($this->timesheetExporter as $exporter) {
|
||||
if ($exporter->getId() === $id) {
|
||||
return $exporter;
|
||||
}
|
||||
@@ -175,6 +147,7 @@ final class ServiceExport
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportQuery $query
|
||||
* @return ExportableItem[]
|
||||
* @throws TooManyItemsExportException
|
||||
*/
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export;
|
||||
|
||||
final class Template implements TemplateInterface
|
||||
{
|
||||
private ?string $locale = null;
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private array $columns = [];
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private array $options = [];
|
||||
|
||||
public function __construct(private readonly string $id, private readonly string $title)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $columns
|
||||
*/
|
||||
public function setColumns(array $columns): void
|
||||
{
|
||||
$this->columns = $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getOptions(): array
|
||||
{
|
||||
return $this->options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function setOptions(array $options): void
|
||||
{
|
||||
$this->options = $options;
|
||||
}
|
||||
|
||||
public function getLocale(): ?string
|
||||
{
|
||||
return $this->locale;
|
||||
}
|
||||
|
||||
public function setLocale(?string $locale): void
|
||||
{
|
||||
$this->locale = $locale;
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getColumns(): array
|
||||
{
|
||||
return $this->columns;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export;
|
||||
|
||||
interface TemplateInterface
|
||||
{
|
||||
public function getId(): string;
|
||||
|
||||
public function getTitle(): string;
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getColumns(): array;
|
||||
|
||||
public function getLocale(): ?string;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getOptions(): array;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\ExportTemplate;
|
||||
use App\Form\Type\ExportColumnsType;
|
||||
use App\Form\Type\ExportRendererType;
|
||||
use App\Form\Type\LanguageType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class ExportTemplateSpreadsheetForm extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('title', TextType::class);
|
||||
$builder->add('renderer', ExportRendererType::class, ['label' => 'type']);
|
||||
$builder->add('language', LanguageType::class, ['required' => false]);
|
||||
$builder->add('columns', ExportColumnsType::class, ['required' => true]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => ExportTemplate::class,
|
||||
'csrf_protection' => true,
|
||||
'csrf_field_name' => '_token',
|
||||
'csrf_token_id' => 'export_template_create',
|
||||
'attr' => [
|
||||
'data-form-event' => 'kimai.exportTemplate'
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -40,22 +40,18 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
|
||||
|
||||
$extendedOptions = ['class' => 'selectpicker'];
|
||||
|
||||
if (\array_key_exists('multiple', $options) && $options['multiple']) {
|
||||
if ($options['multiple']) {
|
||||
$extendedOptions['size'] = 1;
|
||||
}
|
||||
|
||||
if (\array_key_exists('width', $options) && false !== $options['width']) {
|
||||
if (false !== $options['width']) {
|
||||
$extendedOptions['data-width'] = $options['width'];
|
||||
}
|
||||
|
||||
if (\array_key_exists('search', $options) && false === $options['search']) {
|
||||
if (false === $options['search']) {
|
||||
$extendedOptions['data-disable-search'] = 1;
|
||||
}
|
||||
|
||||
if (\array_key_exists('order', $options) && true === $options['order']) {
|
||||
$extendedOptions['data-order'] = 1;
|
||||
}
|
||||
|
||||
// there is a very weird logic in vendor/symfony/twig-bridge/Resources/views/Form/form_div_layout.html.twig
|
||||
// in block "block choice_widget_collapsed" that resets "{% set required = false %}", so we fake it into the select
|
||||
if (true === $options['required'] && \is_array($options['attr']) && (!\array_key_exists('size', $options['attr']) || $options['attr']['size'] <= 1)) {
|
||||
@@ -79,9 +75,5 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
|
||||
$resolver->setDefined(['search']);
|
||||
$resolver->setAllowedTypes('search', 'boolean');
|
||||
$resolver->setDefault('search', true);
|
||||
|
||||
$resolver->setDefined(['order']);
|
||||
$resolver->setAllowedTypes('order', 'boolean');
|
||||
$resolver->setDefault('order', false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ use Symfony\Component\Form\CallbackTransformer;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\All;
|
||||
use Symfony\Component\Validator\Constraints\Valid;
|
||||
@@ -93,16 +92,13 @@ final class QuickEntryForm extends AbstractType
|
||||
'entry_options' => [
|
||||
'label' => false,
|
||||
'duration_minutes' => $this->configuration->getTimesheetIncrementDuration(),
|
||||
// this is NOT the start_date, because it would prevent projects from appearing in the
|
||||
// first days of the week if the project ends at the end of the week.
|
||||
// the validation still triggers if the user selects days outside the project range.
|
||||
// this is NOT the start_date, because it would prevent projects from appearing
|
||||
// in the first days of the week, if the projects ends at the end of the week
|
||||
// the validation still triggers if the user selects days outside the project range
|
||||
'start_date' => $options['end_date'],
|
||||
'end_date' => $options['end_date'],
|
||||
'empty_data' => function (FormInterface $form) use ($options) {
|
||||
if ($options['prototype_data'] instanceof QuickEntryModel) {
|
||||
return clone $options['prototype_data'];
|
||||
}
|
||||
throw new \Exception('Invalid Prototype given');
|
||||
return clone $options['prototype_data'];
|
||||
},
|
||||
'prototype_data' => clone $options['prototype_data'],
|
||||
],
|
||||
@@ -115,25 +111,6 @@ final class QuickEntryForm extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
public function finishView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
usort($view['rows']->children, function (FormView $a, FormView $b) {
|
||||
/** @var \App\Model\QuickEntryModel $objectA */
|
||||
$objectA = $a->vars['data'];
|
||||
/** @var \App\Model\QuickEntryModel $objectB */
|
||||
$objectB = $b->vars['data'];
|
||||
|
||||
$existingA = $objectA->hasExistingTimesheet();
|
||||
$existingB = $objectB->hasExistingTimesheet();
|
||||
|
||||
if ($existingA === $existingB) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ($existingA && !$existingB) ? -1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$start = new \DateTime();
|
||||
|
||||
@@ -59,7 +59,8 @@ class TimesheetEditForm extends AbstractType
|
||||
$timezone = $options['timezone'];
|
||||
$isNew = true;
|
||||
|
||||
if (isset($options['data']) && $options['data'] instanceof Timesheet) {
|
||||
if (isset($options['data'])) {
|
||||
/** @var Timesheet $entry */
|
||||
$entry = $options['data'];
|
||||
|
||||
$activity = $entry->getActivity();
|
||||
@@ -337,7 +338,7 @@ class TimesheetEditForm extends AbstractType
|
||||
function (FormEvent $event) {
|
||||
/** @var Timesheet|null $timesheet */
|
||||
$timesheet = $event->getData();
|
||||
if (null === $timesheet || ($timesheet instanceof Timesheet && $timesheet->isRunning())) {
|
||||
if (null === $timesheet || $timesheet->isRunning()) {
|
||||
$event->getForm()->get('duration')->setData(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Event\ActivityMetaDisplayEvent;
|
||||
use App\Event\CustomerMetaDisplayEvent;
|
||||
use App\Event\MetaDisplayEventInterface;
|
||||
use App\Event\ProjectMetaDisplayEvent;
|
||||
use App\Event\TimesheetMetaDisplayEvent;
|
||||
use App\Event\UserPreferenceDisplayEvent;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
final class ExportColumnsType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private array $ordered = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly TranslatorInterface $translator,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$columns = [
|
||||
'timesheet' => [
|
||||
'date' => 'date',
|
||||
'begin' => 'begin',
|
||||
'end' => 'end',
|
||||
$this->translator->trans('duration') . ' (1:30)' => 'duration',
|
||||
$this->translator->trans('duration') . ' (1:30:00)' => 'duration_seconds',
|
||||
$this->translator->trans('duration') . ' (1.5)' => 'duration_decimal',
|
||||
'currency' => 'currency',
|
||||
'rate' => 'rate',
|
||||
'internalRate' => 'internal_rate',
|
||||
'hourlyRate' => 'hourly_rate',
|
||||
'fixedRate' => 'fixed_rate',
|
||||
'description' => 'description',
|
||||
'exported' => 'exported',
|
||||
'billable' => 'billable',
|
||||
'tags' => 'tags',
|
||||
'type' => 'type',
|
||||
'category' => 'category',
|
||||
],
|
||||
'user' => [
|
||||
'alias' => 'user.alias',
|
||||
'username' => 'user.name',
|
||||
'account_number' => 'user.account_number',
|
||||
],
|
||||
'customer' => [
|
||||
'customer' => 'customer.name',
|
||||
'number' => 'customer.number',
|
||||
'vat_id' => 'customer.vat_id',
|
||||
],
|
||||
'project' => [
|
||||
'project' => 'project.name',
|
||||
'project_number' => 'project.number',
|
||||
'orderNumber' => 'project.order_number',
|
||||
],
|
||||
'activity' => [
|
||||
'activity' => 'activity.name',
|
||||
'activity_number' => 'activity.number',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent(new TimesheetQuery(), TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns['timesheet'][$metaField->getLabel()] = 'timesheet.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent(new CustomerQuery(), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns['customer'][$metaField->getLabel()] = 'customer.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent(new ProjectQuery(), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns['project'][$metaField->getLabel()] = 'project.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent(new ActivityQuery(), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns['activity'][$metaField->getLabel()] = 'activity.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
|
||||
$this->dispatcher->dispatch($event);
|
||||
foreach ($event->getPreferences() as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns['user'][$metaField->getLabel()] = 'user.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
$resolver->setDefaults([
|
||||
'choices' => $columns,
|
||||
'label' => 'modal.columns.label',
|
||||
'multiple' => true,
|
||||
// does not work in the frontend
|
||||
//'order' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->addEventListener(
|
||||
FormEvents::PRE_SUBMIT,
|
||||
function (FormEvent $event) {
|
||||
$data = $event->getData();
|
||||
if (\is_array($data)) {
|
||||
$this->ordered = $data; // @phpstan-ignore assign.propertyType
|
||||
}
|
||||
}
|
||||
);
|
||||
$builder->addEventListener(
|
||||
FormEvents::SUBMIT,
|
||||
function (FormEvent $event) {
|
||||
$event->setData($this->ordered);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<MetaTableTypeInterface>
|
||||
*/
|
||||
private function findMetaColumns(MetaDisplayEventInterface $event): array
|
||||
{
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
return $event->getFields();
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return ChoiceType::class;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
final class ExportRendererType extends AbstractType
|
||||
{
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'choices' => ['button.csv' => 'csv', 'button.xlsx' => 'xlsx'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return ChoiceType::class;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
final class QuickEntryTimesheetType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly Security $security)
|
||||
public function __construct(private Security $security)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -60,28 +60,8 @@ final class QuickEntryTimesheetType extends AbstractType
|
||||
$event->getForm()->get('duration')->setData(null);
|
||||
}
|
||||
|
||||
if ($data instanceof Timesheet && !$this->security->isGranted('edit', $data)) {
|
||||
$event->getForm()->remove('duration');
|
||||
if (null !== $data && !$this->security->isGranted('edit', $data)) {
|
||||
$event->getForm()->add('duration', DurationType::class, array_merge(['disabled' => true], $durationOptions));
|
||||
|
||||
$mainForm = $event->getForm()->getParent()?->getParent();
|
||||
if ($mainForm === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$isNew = $data->getId() === null;
|
||||
|
||||
foreach($mainForm->all() as $key => $child) {
|
||||
if ($key === 'timesheets') {
|
||||
continue;
|
||||
}
|
||||
if ($child->isDisabled() || $isNew) {
|
||||
continue;
|
||||
}
|
||||
$type = \get_class($child->getConfig()->getType()->getInnerType());
|
||||
$mainForm->remove($key);
|
||||
$mainForm->add($key, $type, array_merge($child->getConfig()->getOptions(), ['disabled' => true]));
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Model\QuickEntryModel;
|
||||
use App\Validator\Constraints\QuickEntryTimesheet;
|
||||
use DateTime;
|
||||
@@ -76,6 +77,21 @@ final class QuickEntryWeekType extends AbstractType
|
||||
};
|
||||
$builder->addEventListener(FormEvents::PRE_SET_DATA, $activityFunction);
|
||||
|
||||
$activityPreSubmitFunction = function (FormEvent $event) use ($activityOptions) {
|
||||
$data = $event->getData();
|
||||
|
||||
if (isset($data['project']) && !empty($data['project'])) {
|
||||
$activityOptions['projects'] = [$data['project']];
|
||||
}
|
||||
|
||||
if (isset($data['activity']) && !empty($data['activity'])) {
|
||||
$activityOptions['activities'] = [$data['activity']];
|
||||
}
|
||||
|
||||
$event->getForm()->add('activity', ActivityType::class, $activityOptions);
|
||||
};
|
||||
$builder->addEventListener(FormEvents::PRE_SUBMIT, $activityPreSubmitFunction);
|
||||
|
||||
$builder->add('metaFields', MetaFieldsCollectionType::class);
|
||||
|
||||
$builder->add('timesheets', CollectionType::class, [
|
||||
@@ -96,13 +112,13 @@ final class QuickEntryWeekType extends AbstractType
|
||||
]);
|
||||
|
||||
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options) {
|
||||
if ($event->getData() === null && $options['prototype_data'] instanceof QuickEntryModel) {
|
||||
if ($event->getData() === null) {
|
||||
$event->setData(clone $options['prototype_data']);
|
||||
}
|
||||
});
|
||||
|
||||
$builder->addModelTransformer(new CallbackTransformer(
|
||||
function ($transformValue) {
|
||||
function ($transformValue) use ($options) {
|
||||
/** @var QuickEntryModel|null $transformValue */
|
||||
if ($transformValue === null || $transformValue->isPrototype()) {
|
||||
return $transformValue;
|
||||
@@ -117,6 +133,9 @@ final class QuickEntryWeekType extends AbstractType
|
||||
}
|
||||
|
||||
$user = $transformValue->getUser();
|
||||
if ($user === null && $options['user'] instanceof User) {
|
||||
$user = $options['user'];
|
||||
}
|
||||
foreach ($transformValue->getTimesheets() as $timesheet) {
|
||||
$timesheet->setUser($user);
|
||||
$timesheet->setProject($project);
|
||||
@@ -143,7 +162,9 @@ final class QuickEntryWeekType extends AbstractType
|
||||
$activity = $data->getActivity();
|
||||
|
||||
foreach ($newRecords as $record) {
|
||||
$record->setUser($user);
|
||||
if ($user !== null) {
|
||||
$record->setUser($user);
|
||||
}
|
||||
if ($project !== null) {
|
||||
$record->setProject($project);
|
||||
}
|
||||
|
||||
@@ -218,7 +218,9 @@ final class InvoiceModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user currently creating the invoice.
|
||||
* Returns the user who is currently creating the invoice.
|
||||
*
|
||||
* @return User|null
|
||||
*/
|
||||
public function getUser(): ?User
|
||||
{
|
||||
|
||||
@@ -21,10 +21,7 @@ class LdapDriver
|
||||
{
|
||||
private ?Ldap $driver = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly LdapConfiguration $config,
|
||||
private readonly LoggerInterface $logger
|
||||
)
|
||||
public function __construct(private LdapConfiguration $config, private ?LoggerInterface $logger = null)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -44,8 +41,10 @@ class LdapDriver
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $attributes
|
||||
* @return array{'count': int, array<string, string|array<int, string>>}
|
||||
* @param string $baseDn
|
||||
* @param string $filter
|
||||
* @param array $attributes
|
||||
* @return array
|
||||
* @throws LdapDriverException
|
||||
*/
|
||||
public function search(string $baseDn, string $filter, array $attributes = []): array
|
||||
@@ -54,7 +53,7 @@ class LdapDriver
|
||||
|
||||
$attributes = array_unique(array_merge($attributes, ['+', '*']));
|
||||
|
||||
$this->logger->debug('{action}({base_dn}, {filter}, {attributes})', [
|
||||
$this->logDebug('{action}({base_dn}, {filter}, {attributes})', [
|
||||
'action' => 'ldap_search',
|
||||
'base_dn' => $baseDn,
|
||||
'filter' => $filter,
|
||||
@@ -68,7 +67,7 @@ class LdapDriver
|
||||
// searchEntries don't return 'count' key as specified by php native function ldap_get_entries()
|
||||
$entries['count'] = \count($entries);
|
||||
} catch (LdapException $exception) {
|
||||
$this->logger->error(\sprintf('Failed to search LDAP: %s', $exception->getMessage()), ['exception' => $exception]);
|
||||
$this->ldapExceptionHandler($exception);
|
||||
|
||||
throw new LdapDriverException('An error occurred with the search operation.');
|
||||
}
|
||||
@@ -81,7 +80,7 @@ class LdapDriver
|
||||
$driver = $this->getDriver();
|
||||
|
||||
try {
|
||||
$this->logger->debug('{action}({bindDn}, ****)', [
|
||||
$this->logDebug('{action}({bindDn}, ****)', [
|
||||
'action' => 'ldap_bind',
|
||||
'bindDn' => $bindDn,
|
||||
]);
|
||||
@@ -89,9 +88,36 @@ class LdapDriver
|
||||
|
||||
return $bind instanceof Ldap;
|
||||
} catch (LdapException $exception) {
|
||||
$this->logger->error(\sprintf('Failed binding to LDAP at %s: %s', $bindDn, $exception->getMessage()), ['exception' => new SanitizingException($exception, $password)]);
|
||||
$this->ldapExceptionHandler($exception, $password);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function ldapExceptionHandler(LdapException $exception, string $password = null): void
|
||||
{
|
||||
$sanitizedException = null !== $password ? new SanitizingException($exception, $password) : $exception;
|
||||
|
||||
switch ($exception->getCode()) {
|
||||
// Error level codes
|
||||
case LdapException::LDAP_SERVER_DOWN:
|
||||
if ($this->logger) {
|
||||
$this->logger->error('{exception}', ['exception' => $sanitizedException]);
|
||||
}
|
||||
break;
|
||||
|
||||
// Other level codes
|
||||
default:
|
||||
$this->logDebug('{exception}', ['exception' => $sanitizedException]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private function logDebug(string $message, array $context = []): void
|
||||
{
|
||||
if (null === $this->logger) {
|
||||
return;
|
||||
}
|
||||
$this->logger->debug($message, $context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ final class ProjectService
|
||||
$errors = $this->validator->validate($project, null, $groups);
|
||||
|
||||
if ($errors->count() > 0) {
|
||||
throw new ValidationFailedException($errors);
|
||||
throw new ValidationFailedException($errors, 'Validation Failed');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\ExportTemplate;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
|
||||
/**
|
||||
* @extends \Doctrine\ORM\EntityRepository<ExportTemplate>
|
||||
*/
|
||||
class ExportTemplateRepository extends EntityRepository
|
||||
{
|
||||
public function saveExportTemplate(ExportTemplate $template): void
|
||||
{
|
||||
$this->getEntityManager()->persist($template);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function removeExportTemplate(ExportTemplate $template): void
|
||||
{
|
||||
$this->getEntityManager()->remove($template);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ namespace App\Saml;
|
||||
use App\Configuration\SamlConfigurationInterface;
|
||||
use App\Saml\Security\SamlAuthenticationFailureHandler;
|
||||
use App\Saml\Security\SamlAuthenticationSuccessHandler;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
@@ -41,8 +40,7 @@ class SamlAuthenticator extends AbstractAuthenticator
|
||||
private readonly SamlAuthenticationFailureHandler $failureHandler,
|
||||
private readonly SamlAuthFactory $samlAuthFactory,
|
||||
private readonly SamlProvider $samlProvider,
|
||||
private readonly SamlConfigurationInterface $configuration,
|
||||
private readonly LoggerInterface $logger
|
||||
private readonly SamlConfigurationInterface $configuration
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -88,7 +86,6 @@ class SamlAuthenticator extends AbstractAuthenticator
|
||||
// file_put_contents(__DIR__ . '/../../var/log/saml.xml', $oneLoginAuth->getLastResponseXML());
|
||||
|
||||
if (\count($oneLoginAuth->getErrors()) > 0) {
|
||||
$this->logger->critical('SAML login failed: ' . $oneLoginAuth->getLastErrorReason());
|
||||
throw new AuthenticationException($oneLoginAuth->getLastErrorReason());
|
||||
}
|
||||
|
||||
@@ -105,9 +102,7 @@ class SamlAuthenticator extends AbstractAuthenticator
|
||||
|
||||
if (isset($this->options['username_attribute'])) {
|
||||
if (!\array_key_exists($this->options['username_attribute'], $attributes)) {
|
||||
$errorMessage = \sprintf("Attribute '%s' not found in SAML data", $this->options['username_attribute']);
|
||||
$this->logger->critical($errorMessage);
|
||||
throw new \Exception($errorMessage);
|
||||
throw new \Exception(\sprintf("Attribute '%s' not found in SAML data", $this->options['username_attribute']));
|
||||
}
|
||||
|
||||
$username = $attributes[$this->options['username_attribute']][0];
|
||||
|
||||
@@ -17,7 +17,7 @@ final class LockdownService
|
||||
{
|
||||
private ?bool $isActive = null;
|
||||
|
||||
public function __construct(private readonly SystemConfiguration $configuration)
|
||||
public function __construct(private SystemConfiguration $configuration)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ final class LockdownService
|
||||
return true;
|
||||
}
|
||||
|
||||
// further validate entries inside the most recent lockdown
|
||||
// further validate entries inside of the most recent lockdown
|
||||
if ($timesheetStart >= $lockdownStart) {
|
||||
// if grace period is still in effect, validation succeeds
|
||||
if ($now <= $lockdownGrace) {
|
||||
|
||||
@@ -45,12 +45,12 @@ final class TimesheetService
|
||||
private array $doNotValidateCodes = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly SystemConfiguration $configuration,
|
||||
private readonly TimesheetRepository $repository,
|
||||
private readonly TrackingModeService $trackingModeService,
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly AuthorizationCheckerInterface $auth,
|
||||
private readonly ValidatorInterface $validator
|
||||
private SystemConfiguration $configuration,
|
||||
private TimesheetRepository $repository,
|
||||
private TrackingModeService $trackingModeService,
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
private AuthorizationCheckerInterface $auth,
|
||||
private ValidatorInterface $validator
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ final class TimesheetService
|
||||
public function restartTimesheet(Timesheet $timesheet, Timesheet $copyFrom): Timesheet
|
||||
{
|
||||
$this->dispatcher->dispatch(new TimesheetRestartPreEvent($timesheet, $copyFrom));
|
||||
$this->saveNewTimesheet($timesheet); // @phpstan-ignore method.deprecated
|
||||
$this->saveNewTimesheet($timesheet);
|
||||
$this->dispatcher->dispatch(new TimesheetRestartPostEvent($timesheet, $copyFrom));
|
||||
|
||||
return $timesheet;
|
||||
@@ -111,7 +111,6 @@ final class TimesheetService
|
||||
* @throws ValidationFailedException for invalid timesheets or running timesheets that should be stopped
|
||||
* @throws InvalidArgumentException for already persisted timesheets
|
||||
* @throws AccessDeniedException if user is not allowed to start timesheet
|
||||
* @deprecated since 2.36.0 - use saveTimesheet() instead
|
||||
*/
|
||||
public function saveNewTimesheet(Timesheet $timesheet): Timesheet
|
||||
{
|
||||
@@ -151,19 +150,12 @@ final class TimesheetService
|
||||
return $timesheet;
|
||||
}
|
||||
|
||||
public function saveTimesheet(Timesheet $timesheet): Timesheet
|
||||
{
|
||||
if ($timesheet->getId() === null) {
|
||||
return $this->saveNewTimesheet($timesheet); // @phpstan-ignore method.deprecated
|
||||
} else {
|
||||
return $this->updateTimesheet($timesheet); // @phpstan-ignore method.deprecated
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does NOT validate the given timesheet.
|
||||
* Does NOT validate the given timesheet!
|
||||
*
|
||||
* @deprecated since 2.36.0 - use saveTimesheet() instead
|
||||
* @param Timesheet $timesheet
|
||||
* @return Timesheet
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function updateTimesheet(Timesheet $timesheet): Timesheet
|
||||
{
|
||||
@@ -253,7 +245,7 @@ final class TimesheetService
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new ValidationFailedException($errors);
|
||||
throw new ValidationFailedException($errors, 'Validation Failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ class UserService
|
||||
$errors = $this->validator->validate($user, null, $groups);
|
||||
|
||||
if ($errors->count() > 0) {
|
||||
throw new ValidationFailedException($errors);
|
||||
throw new ValidationFailedException($errors, 'Validation Failed');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
#[\Attribute(\Attribute::TARGET_PROPERTY)]
|
||||
final class ExportRenderer extends Constraint
|
||||
{
|
||||
public const UNKNOWN_TYPE = 'kimai-export-type-00';
|
||||
|
||||
protected const ERROR_NAMES = [
|
||||
self::UNKNOWN_TYPE => 'Unknown exporter type.',
|
||||
];
|
||||
|
||||
public string $message = 'Unknown exporter type.';
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
final class ExportRendererValidator extends ConstraintValidator
|
||||
{
|
||||
/**
|
||||
* @param string|mixed $value
|
||||
*/
|
||||
public function validate(mixed $value, Constraint $constraint): void
|
||||
{
|
||||
if (!($constraint instanceof ExportRenderer)) {
|
||||
throw new UnexpectedTypeException($constraint, ExportRenderer::class);
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ids = ['csv', 'xlsx'];
|
||||
|
||||
if (!\is_string($value) || !\in_array($value, $ids, true)) {
|
||||
$this->context->buildViolation(ExportRenderer::getErrorName(ExportRenderer::UNKNOWN_TYPE))
|
||||
->setParameter('{{ value }}', $this->formatValue($value))
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(ExportRenderer::UNKNOWN_TYPE)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
final class TimesheetNegativeDuration extends TimesheetConstraint
|
||||
{
|
||||
public const NEGATIVE_DURATION_ERROR = 'kimai-timesheet-negative-duration-01';
|
||||
|
||||
protected const ERROR_NAMES = [
|
||||
self::NEGATIVE_DURATION_ERROR => 'Duration cannot be negative.',
|
||||
];
|
||||
|
||||
public string $message = 'Duration cannot be negative.';
|
||||
|
||||
public function getTargets(): string
|
||||
{
|
||||
return self::CLASS_CONSTRAINT;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use App\Entity\Timesheet as TimesheetEntity;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
final class TimesheetNegativeDurationValidator extends ConstraintValidator
|
||||
{
|
||||
public function validate(mixed $value, Constraint $constraint): void
|
||||
{
|
||||
if (!($constraint instanceof TimesheetNegativeDuration)) {
|
||||
throw new UnexpectedTypeException($constraint, TimesheetNegativeDuration::class);
|
||||
}
|
||||
|
||||
if (!\is_object($value) || !($value instanceof TimesheetEntity)) {
|
||||
throw new UnexpectedTypeException($value, TimesheetEntity::class);
|
||||
}
|
||||
|
||||
if ($value->isRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$duration = $value->getCalculatedDuration();
|
||||
|
||||
if ($duration !== null && $duration < 0) {
|
||||
$this->context->buildViolation($constraint->message)
|
||||
->atPath('duration')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetNegativeDuration::NEGATIVE_DURATION_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ final class TimesheetZeroDurationValidator extends ConstraintValidator
|
||||
$duration = $value->getCalculatedDuration();
|
||||
}
|
||||
|
||||
if ($duration === 0) {
|
||||
if ($duration <= 0) {
|
||||
$this->context->buildViolation($constraint->message)
|
||||
->atPath('duration')
|
||||
->setTranslationDomain('validators')
|
||||
|
||||
@@ -14,7 +14,7 @@ final class ValidationException extends \RuntimeException
|
||||
public function __construct(string $message = null)
|
||||
{
|
||||
if ($message === null) {
|
||||
$message = 'Validation Failed';
|
||||
$message = 'Validation failed';
|
||||
}
|
||||
parent::__construct($message, 400);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ use Symfony\Component\Validator\ConstraintViolationListInterface;
|
||||
|
||||
final class ValidationFailedException extends \RuntimeException
|
||||
{
|
||||
public function __construct(private readonly ConstraintViolationListInterface $violations, ?string $message = null)
|
||||
public function __construct(private ConstraintViolationListInterface $violations, ?string $message = null)
|
||||
{
|
||||
if ($message === null) {
|
||||
$message = 'Validation Failed';
|
||||
$message = 'Validation failed';
|
||||
}
|
||||
parent::__construct($message, 400);
|
||||
}
|
||||
|
||||
@@ -24,18 +24,12 @@
|
||||
'activity': {'class': 'd-none', 'orderBy': false},
|
||||
'description': {'class': 'd-none d-xl-table-cell timesheet-description', 'orderBy': false},
|
||||
'tags': {'class': 'd-none d-xl-table-cell', 'orderBy': false},
|
||||
'unit_price': {'class': 'd-none text-nowrap text-end', 'orderBy': false},
|
||||
'duration': {'class': 'text-end text-nowrap', 'orderBy': false},
|
||||
} %}
|
||||
{% if show_rates %}
|
||||
{% set columns = columns|merge({
|
||||
'unit_price': {'class': 'd-none text-nowrap text-end', 'orderBy': false},
|
||||
'internalRate': {'class': 'text-end d-none text-nowrap', 'orderBy': false},
|
||||
'total_rate': {'class': 'text-end text-nowrap', 'orderBy': false},
|
||||
}) %}
|
||||
{% endif %}
|
||||
{% set columns = columns|merge({
|
||||
'internalRate': {'class': 'text-end d-none text-nowrap', 'orderBy': false},
|
||||
'total_rate': {'class': 'text-end text-nowrap', 'orderBy': false},
|
||||
'actions': {'class': 'actions alwaysVisible', 'orderBy': false},
|
||||
}) %}
|
||||
} %}
|
||||
|
||||
{% set tableName = 'export' %}
|
||||
{% set editExported = is_granted('edit_exported_timesheet') %}
|
||||
@@ -51,11 +45,9 @@
|
||||
{% if totalDuration > 0 %}
|
||||
{{ status_duration(totalDuration|duration) }}
|
||||
{% endif %}
|
||||
{% if show_rates %}
|
||||
{% for totalCurrency, totalRate in totalRates %}
|
||||
{{ status_money(totalRate|money(totalCurrency)) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% for totalCurrency, totalRate in totalRates %}
|
||||
{{ status_money(totalRate|money(totalCurrency)) }}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
{% block main_before %}
|
||||
@@ -110,6 +102,16 @@
|
||||
{% block box_attributes %}id="preview_export"{% endblock %}
|
||||
{% block box_body_class %}p-0{% endblock %}
|
||||
{% block box_footer %}
|
||||
{% set buttons = {} %}
|
||||
{% for button in renderer %}
|
||||
{% set title = button.title %}
|
||||
{% set group = [] %}
|
||||
{% if buttons[(title)] is defined %}
|
||||
{% set group = buttons[(title)] %}
|
||||
{% endif %}
|
||||
{% set group = group|merge([button]) %}
|
||||
{% set buttons = buttons|merge({(title): group}) %}
|
||||
{% endfor %}
|
||||
{% if showMarkAsExportedButton %}
|
||||
<div class="d-flex">
|
||||
<div class="form-check form-switch">
|
||||
@@ -120,51 +122,33 @@
|
||||
{% endif %}
|
||||
<div class="d-flex">
|
||||
<div class="btn-group me-auto" id="export-buttons" role="group">
|
||||
{% set allowCreate = is_granted('create_export_template') %}
|
||||
{% for type, group in buttons %}
|
||||
{% set btnTitle = ('button.' ~ type)|trans %}
|
||||
{% if btnTitle == ('button.' ~ type) %}
|
||||
{% set btnTitle = type|trans %}
|
||||
{% for group in buttons %}
|
||||
{% set button = group.0 %}
|
||||
{% set btnTitle = ('button.' ~ button.title)|trans %}
|
||||
{% if btnTitle == ('button.' ~ button.title) %}
|
||||
{% set btnTitle = button.title %}
|
||||
{% endif %}
|
||||
{% if group|length == 1 %}
|
||||
{% for id, title in group %}
|
||||
{% set btnTitle2 = ('button.' ~ title)|trans %}
|
||||
{% if btnTitle2 != ('button.' ~ title) %}
|
||||
{% set btnTitle = btnTitle2 %}
|
||||
{% endif %}
|
||||
<button type="button" id="export-{{ id }}-button" class="btn btn-success startExportBtn" data-type="{{ id }}">
|
||||
{{ btnTitle }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<button type="button" id="export-{{ button.id }}-button" class="btn btn-success startExportBtn" data-type="{{ button.id }}">
|
||||
{{ btnTitle }}
|
||||
</button>
|
||||
{% elseif group|length > 1 %}
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-success dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
{{ btnTitle }}
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
{% for id, title in group %}
|
||||
{% set btnTitle = id|trans({}, 'export') %}
|
||||
{% if btnTitle == id %}
|
||||
{% set btnTitle = title|trans %}
|
||||
{% endif %}
|
||||
{% if btnTitle == id%}
|
||||
{% set btnTitle = id|split('.')|first|replace({'-': ' ', '_': ' '})|split(' ')|map(t => t|capitalize)|join(' ') %}
|
||||
{% endif %}
|
||||
<a href="#" class="dropdown-item startExportBtn" data-type="{{ id }}">
|
||||
{{ btnTitle }}
|
||||
{% if allowCreate and loop.index0 != 0 and (type == 'csv' or type == 'xlsx') %}
|
||||
<span class="dropdown-action modal-ajax-form open-edit ps-2 ms-auto" data-href="{{ path('export_template_edit', {exportTemplate: id}) }}">{{ icon('edit') }}</span>
|
||||
<span class="dropdown-action api-link text-red dd-ts-trash text-danger ms-2" href="{{ path('delete_export_template', {id: id}) }}" data-event="kimai.exportTemplate" data-method="DELETE" data-question="confirm.delete" data-msg-error="action.delete.error" data-msg-success="action.delete.success">{{ icon('delete') }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% for button in group %}
|
||||
{% set btnTitle = (button.id)|trans({}, 'export') %}
|
||||
{% if btnTitle == button.id %}
|
||||
{% set btnTitle = button.id|split('.')|first|replace({'-': ' ', '_': ' '})|split(' ')|map(t => t|capitalize)|join(' ') %}
|
||||
{% endif %}
|
||||
<a href="#" class="dropdown-item startExportBtn" data-type="{{ button.id }}">{{ btnTitle }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if allowCreate %}
|
||||
{{ widgets.action_button('create', {'url': path('export_template_create'), 'class': 'btn modal-ajax-form open-edit', 'title': 'create'|trans}) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if showToggleButton %}
|
||||
<button id="toggle-button" class="btn ms-auto d-none d-sm-inline-flex">
|
||||
@@ -185,9 +169,7 @@
|
||||
<tr>
|
||||
<th>{{ 'customer'|trans }}</th>
|
||||
<th class="w-min text-end d-none d-sm-table-cell">{{ 'duration'|trans }}</th>
|
||||
{% if show_rates %}
|
||||
<th class="w-min text-end">{{ 'total_rate'|trans }}</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -198,13 +180,11 @@
|
||||
{{ widgets.label_customer(row.customer) }}
|
||||
</td>
|
||||
<td class="w-min text-end d-none d-sm-table-cell">
|
||||
{{ row.duration|duration }}
|
||||
{{ row.duration|duration(decimal) }}
|
||||
</td>
|
||||
{% if show_rates %}
|
||||
<td class="w-min text-end">
|
||||
{{ row.rate|money(currency) }}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -255,20 +235,18 @@
|
||||
{{ widgets.tag_list(entry.tags) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}" data-duration="{{ entry.duration }}">
|
||||
{{ entry.duration|duration }}
|
||||
</td>
|
||||
{% if show_rates %}
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }}">
|
||||
{{ rate|money(currency) }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}" data-duration="{{ entry.duration }}">
|
||||
{{ entry.duration|duration(decimal) }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'internalRate') }}">
|
||||
{{ entry.internalRate|money(currency) }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'total_rate') }}">
|
||||
{{ entry.rate|money(currency) }}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'actions') }}">
|
||||
{% if is_granted('edit_export', entry) %}
|
||||
{% if entry.exported %}
|
||||
@@ -310,8 +288,6 @@
|
||||
{% block javascripts %}
|
||||
{{ parent() }}
|
||||
<script type="text/javascript">
|
||||
KimaiReloadPageWidget.create('kimai.exportTemplate', true);
|
||||
|
||||
function updateTimesheetExportState(node, id)
|
||||
{
|
||||
/** @type {KimaiAlert} */
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{% extends kimai_context.modalRequest ? 'form.html.twig' : 'base.html.twig' %}
|
||||
|
||||
{% block main %}
|
||||
{% set formEditTemplate = kimai_context.modalRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig' %}
|
||||
{% set formOptions = {
|
||||
'title': 'create'|trans,
|
||||
'form': form,
|
||||
'back': path('export_template_create')
|
||||
} %}
|
||||
{% embed formEditTemplate with formOptions %}
|
||||
{% block form_body %}
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
{{ form_row(form.title) }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{{ form_row(form.renderer) }}
|
||||
</div>
|
||||
</div>
|
||||
{{ form_row(form.language) }}
|
||||
{{ form_rest(form) }}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
{% endblock %}
|
||||
@@ -13,15 +13,11 @@
|
||||
{{ form_errors(form) }}
|
||||
{% endblock %}
|
||||
{% block box_after %}
|
||||
{% if locked %}
|
||||
{{ widgets.alert('warning', 'The chosen date is already locked.'|trans({}, 'validators')) }}
|
||||
{% else %}
|
||||
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
|
||||
<button type="button" class="btn btn-success add-item-link" data-collection-prototype="{{ form.rows.vars.id }}" data-collection-holder="ts-collection">
|
||||
{{ icon('create', true) }}
|
||||
{{ 'action.add'|trans }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
{# "table-responsive" does not work, because that would render dropdowns at the bottom behind the container #}
|
||||
|
||||
@@ -303,6 +303,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
|
||||
'user' => ['result' => 'object', 'type' => '@User'],
|
||||
'dueDays' => 'int',
|
||||
'invoiceNumber' => 'string',
|
||||
'invoiceFilename' => 'string',
|
||||
'metaFields' => 'array',
|
||||
'paymentDate' => '@datetime',
|
||||
'status' => 'string',
|
||||
|
||||
@@ -48,7 +48,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
|
||||
}
|
||||
}
|
||||
|
||||
$expectedKeys = ['Actions', 'Activity', 'Default', 'Customer', 'Project', 'Tag', 'Team', 'Timesheet', 'User', 'Invoice', 'Export'];
|
||||
$expectedKeys = ['Actions', 'Activity', 'Default', 'Customer', 'Project', 'Tag', 'Team', 'Timesheet', 'User', 'Invoice'];
|
||||
$actual = array_keys($tags);
|
||||
|
||||
sort($actual);
|
||||
@@ -73,9 +73,9 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
|
||||
'/api/customers/{id}/meta',
|
||||
'/api/customers/{id}/rates',
|
||||
'/api/customers/{id}/rates/{rateId}',
|
||||
'/api/export/{id}',
|
||||
'/api/invoices',
|
||||
'/api/invoices/{id}',
|
||||
'/api/invoices/{id}/download',
|
||||
'/api/projects',
|
||||
'/api/projects/{id}',
|
||||
'/api/projects/{id}/meta',
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\API;
|
||||
|
||||
use App\Entity\ExportTemplate;
|
||||
use App\Entity\User;
|
||||
use App\Repository\ExportTemplateRepository;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* @group integration
|
||||
*/
|
||||
class ExportControllerTest extends APIControllerBaseTestCase
|
||||
{
|
||||
private function importExportTemplate(): ExportTemplate
|
||||
{
|
||||
/** @var ExportTemplateRepository $repository */
|
||||
$repository = $this->getEntityManager()->getRepository(ExportTemplate::class);
|
||||
|
||||
$template = new ExportTemplate();
|
||||
$template->setRenderer('csv');
|
||||
$template->setTitle('csv');
|
||||
$template->setColumns(['activity.name', 'project.number', 'project.name', 'customer.name', 'user.account_number', 'duration', 'date', 'rate', 'currency']);
|
||||
$template->setLanguage('en');
|
||||
$template->setLanguage('en');
|
||||
|
||||
$repository->saveExportTemplate($template);
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
public function testDeleteIsSecure(): void
|
||||
{
|
||||
$this->assertUrlIsSecured('/api/export/1', Request::METHOD_DELETE);
|
||||
}
|
||||
|
||||
public function testDeleteActionWithUnknownTemplate(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->assertNotFoundForDelete($client, '/api/export/' . PHP_INT_MAX);
|
||||
}
|
||||
|
||||
public function testDeleteEntityIsSecure(): void
|
||||
{
|
||||
$client = $this->createClient();
|
||||
$template = $this->importExportTemplate();
|
||||
|
||||
$this->assertRequestIsSecured($client, '/api/export/' . $template->getId(), Request::METHOD_DELETE);
|
||||
}
|
||||
|
||||
public function testDeleteActionWithoutAuthorization(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$template = $this->importExportTemplate();
|
||||
|
||||
$this->request($client, '/api/export/' . $template->getId(), Request::METHOD_DELETE);
|
||||
|
||||
$response = $client->getResponse();
|
||||
$this->assertApiResponseAccessDenied($response);
|
||||
}
|
||||
|
||||
public function testDeleteAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$template = $this->importExportTemplate();
|
||||
|
||||
$this->request($client, '/api/export/' . $template->getId(), Request::METHOD_DELETE);
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
self::assertEquals(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode());
|
||||
self::assertEmpty($client->getResponse()->getContent());
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,11 @@ class InvoiceControllerTest extends APIControllerBaseTestCase
|
||||
self::assertApiResponseTypeStructure('Invoice', $result);
|
||||
}
|
||||
|
||||
public function testDownloadInvoice(): void
|
||||
{
|
||||
$this->fail('Missing test');
|
||||
}
|
||||
|
||||
public function testNotFound(): void
|
||||
{
|
||||
$this->assertEntityNotFound(User::ROLE_USER, '/api/invoices/' . PHP_INT_MAX);
|
||||
|
||||
@@ -9,14 +9,11 @@
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Entity\ExportTemplate;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Tests\DataFixtures\ExportTemplateFixtures;
|
||||
use App\Tests\DataFixtures\TimesheetFixtures;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Symfony\Component\DomCrawler\Field\FormField;
|
||||
|
||||
/**
|
||||
* @group integration
|
||||
@@ -28,7 +25,7 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
|
||||
$this->assertUrlIsSecured('/export/');
|
||||
}
|
||||
|
||||
public function testIsSecureForRole(): void
|
||||
public function testIsSecureForrole(): void
|
||||
{
|
||||
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/export/');
|
||||
}
|
||||
@@ -93,16 +90,6 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
|
||||
// +1 row for summary
|
||||
$this->assertDataTableRowCount($client, 'datatable_export', 22);
|
||||
|
||||
$header = $client->getCrawler()->filter('section.content div.datatable_export table.dataTable thead th');
|
||||
$titles = [];
|
||||
/** @var \DOMElement $th */
|
||||
foreach ($header as $th) {
|
||||
$titles[] = trim($th->textContent);
|
||||
}
|
||||
self::assertEquals([
|
||||
'', 'Date', 'User', 'Project', 'Activity', 'Description', 'Tags', 'Duration', 'Unit price', 'Internal price', 'Total price', '',
|
||||
], $titles);
|
||||
|
||||
// assert export type buttons are available
|
||||
$expected = [
|
||||
'csv' => 'csv',
|
||||
@@ -259,71 +246,4 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
|
||||
self::assertTrue($timesheet->isExported());
|
||||
}
|
||||
}
|
||||
|
||||
public function testCreateTemplateIsSecure(): void
|
||||
{
|
||||
$this->assertUrlIsSecured('/export/template-create');
|
||||
}
|
||||
|
||||
public function testCreateTemplateIsSecureForRole(): void
|
||||
{
|
||||
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/export/template-create');
|
||||
}
|
||||
|
||||
public function testCreateTemplateAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->assertAccessIsGranted($client, '/export/template-create');
|
||||
$form = $client->getCrawler()->filter('form[name=export_template_spreadsheet_form]')->form();
|
||||
$client->submit($form, [
|
||||
'export_template_spreadsheet_form' => [
|
||||
'title' => 'My temaplte name',
|
||||
'renderer' => 'xlsx',
|
||||
'language' => 'de',
|
||||
'columns' => 'date',
|
||||
]
|
||||
]);
|
||||
|
||||
$this->assertIsRedirect($client, $this->createUrl('/export/'));
|
||||
|
||||
$templates = $this->getEntityManager()->getRepository(ExportTemplate::class)->findAll();
|
||||
self::assertCount(1, $templates);
|
||||
$template = array_pop($templates);
|
||||
$id = $template->getId();
|
||||
|
||||
$this->request($client, $this->createUrl('/export/template-edit/' . $id));
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
$editForm = $client->getCrawler()->filter('form[name=export_template_spreadsheet_form]')->form();
|
||||
$field = $editForm->get('export_template_spreadsheet_form[title]');
|
||||
self::assertInstanceOf(FormField::class, $field);
|
||||
self::assertEquals('My temaplte name', $field->getValue());
|
||||
}
|
||||
|
||||
public function testEditTemplateAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
/** @var ExportTemplate[] $templates */
|
||||
$templates = $this->importFixture(new ExportTemplateFixtures());
|
||||
|
||||
$id = $templates[0]->getId();
|
||||
|
||||
$this->request($client, $this->createUrl('/export/template-edit/' . $id));
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
$form = $client->getCrawler()->filter('form[name=export_template_spreadsheet_form]')->form();
|
||||
$field = $form->get('export_template_spreadsheet_form[title]');
|
||||
self::assertInstanceOf(FormField::class, $field);
|
||||
self::assertEquals('CSV Test', $field->getValue());
|
||||
|
||||
$client->submit($form, [
|
||||
'export_template_spreadsheet_form' => [
|
||||
'title' => 'My temaplte name',
|
||||
]
|
||||
]);
|
||||
|
||||
$this->assertIsRedirect($client, $this->createUrl('/export/'));
|
||||
|
||||
/** @var ExportTemplate $template */
|
||||
$template = $this->getEntityManager()->getRepository(ExportTemplate::class)->find($id);
|
||||
self::assertEquals('My temaplte name', $template->getTitle());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ class PermissionControllerTest extends AbstractControllerBaseTestCase
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||
$this->assertAccessIsGranted($client, '/admin/permissions');
|
||||
$this->assertHasDataTable($client);
|
||||
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 136);
|
||||
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 135);
|
||||
$this->assertPageActions($client, [
|
||||
'create modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),
|
||||
]);
|
||||
|
||||
@@ -35,9 +35,12 @@ class CustomerServiceTest extends TestCase
|
||||
private function getSut(
|
||||
?EventDispatcherInterface $dispatcher = null,
|
||||
?ValidatorInterface $validator = null,
|
||||
?CustomerRepository $repository = null,
|
||||
?SystemConfiguration $configuration = null
|
||||
): CustomerService {
|
||||
$repository = $this->createMock(CustomerRepository::class);
|
||||
if ($repository === null) {
|
||||
$repository = $this->createMock(CustomerRepository::class);
|
||||
}
|
||||
|
||||
if ($dispatcher === null) {
|
||||
$dispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
@@ -160,7 +163,7 @@ class CustomerServiceTest extends TestCase
|
||||
]
|
||||
]);
|
||||
|
||||
$sut = $this->getSut(null, null, $configuration);
|
||||
$sut = $this->getSut(null, null, null, $configuration);
|
||||
$customer = $sut->createNewCustomer('Test');
|
||||
|
||||
self::assertEquals((string) $expected, $customer->getNumber());
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\DataFixtures;
|
||||
|
||||
use App\Entity\ExportTemplate;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
final class ExportTemplateFixtures implements TestFixture
|
||||
{
|
||||
/**
|
||||
* @return ExportTemplate[]
|
||||
*/
|
||||
public function load(ObjectManager $manager): array
|
||||
{
|
||||
$template1 = new ExportTemplate();
|
||||
$template1->setRenderer('csv');
|
||||
$template1->setLanguage('de');
|
||||
$template1->setTitle('CSV Test');
|
||||
$template1->setColumns(['date', 'user.name', 'duration', 'customer.name']);
|
||||
$manager->persist($template1);
|
||||
|
||||
$template2 = new ExportTemplate();
|
||||
$template2->setRenderer('xlsx');
|
||||
$template2->setLanguage('en');
|
||||
$template2->setTitle('Excel Test');
|
||||
$template2->setColumns(['date', 'user.name', 'duration_seconds', 'project.name']);
|
||||
$manager->persist($template2);
|
||||
|
||||
$manager->flush();
|
||||
|
||||
return [$template1, $template2];
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\ExportTemplate;
|
||||
|
||||
/**
|
||||
* @covers \App\Entity\ExportTemplate
|
||||
*/
|
||||
class ExportTemplateTest extends AbstractEntityTestCase
|
||||
{
|
||||
public function testDefaultValues(): void
|
||||
{
|
||||
$sut = new ExportTemplate();
|
||||
self::assertNull($sut->getId());
|
||||
self::assertNull($sut->getTitle());
|
||||
self::assertEquals('csv', $sut->getRenderer());
|
||||
self::assertNull($sut->getLanguage());
|
||||
self::assertEquals([], $sut->getColumns());
|
||||
self::assertEquals([], $sut->getOptions());
|
||||
}
|
||||
|
||||
public function testSetter(): void
|
||||
{
|
||||
$sut = new ExportTemplate();
|
||||
self::assertEquals('New', (string) $sut);
|
||||
|
||||
$sut->setTitle('foo');
|
||||
self::assertEquals('foo', $sut->getTitle());
|
||||
self::assertEquals('foo', (string) $sut);
|
||||
$sut->setTitle(null);
|
||||
self::assertNull($sut->getTitle());
|
||||
self::assertEquals('New', (string) $sut);
|
||||
|
||||
$sut->setRenderer('xlsx');
|
||||
self::assertEquals('xlsx', $sut->getRenderer());
|
||||
|
||||
$sut->setLanguage('de');
|
||||
self::assertEquals('de', $sut->getLanguage());
|
||||
$sut->setLanguage(null);
|
||||
self::assertNull($sut->getLanguage());
|
||||
|
||||
$sut->setColumns(['foo', 'bar', 'WORLD']);
|
||||
self::assertEquals(['foo', 'bar', 'WORLD'], $sut->getColumns());
|
||||
$sut->setColumns(null);
|
||||
self::assertEquals([], $sut->getColumns());
|
||||
|
||||
$sut->setOptions(['foo' => 1, 'bar' => true, 'WORLD' => 'HELLO']);
|
||||
self::assertEquals(['foo' => 1, 'bar' => true, 'WORLD' => 'HELLO'], $sut->getOptions());
|
||||
$sut->setOptions(null);
|
||||
self::assertEquals([], $sut->getOptions());
|
||||
}
|
||||
|
||||
public function testClone(): void
|
||||
{
|
||||
$sut = new ExportTemplate();
|
||||
$r = new \ReflectionObject($sut);
|
||||
$p = $r->getProperty('id');
|
||||
$p->setAccessible(true);
|
||||
$p->setValue($sut, 13);
|
||||
self::assertEquals(13, $sut->getId());
|
||||
|
||||
$sut2 = clone $sut;
|
||||
self::assertNull($sut2->getId());
|
||||
}
|
||||
}
|
||||
@@ -251,7 +251,7 @@ class TimesheetValidationTest extends KernelTestCase
|
||||
$entity->setBegin($begin);
|
||||
$entity->setEnd($end);
|
||||
|
||||
$this->assertHasViolationForField($entity, ['end_date', 'duration']);
|
||||
$this->assertHasViolationForField($entity, 'end_date');
|
||||
|
||||
// allow same begin and end
|
||||
$entity = $this->getEntity();
|
||||
|
||||
@@ -53,7 +53,6 @@ class UserTest extends TestCase
|
||||
|
||||
$user->setUserIdentifier('foo');
|
||||
self::assertEquals('foo', $user->getUserIdentifier());
|
||||
self::assertEquals('foo', $user->getIdentifier());
|
||||
self::assertEquals('foo', $user->getDisplayName());
|
||||
$user->setAlias('BAR');
|
||||
self::assertEquals('BAR', $user->getDisplayName());
|
||||
@@ -90,7 +89,6 @@ class UserTest extends TestCase
|
||||
public function testWorkContract(): void
|
||||
{
|
||||
$user = new User();
|
||||
self::assertFalse($user->hasContractSettings());
|
||||
|
||||
self::assertEquals(0, $user->getWorkHoursMonday());
|
||||
self::assertEquals(0, $user->getWorkHoursTuesday());
|
||||
@@ -129,7 +127,6 @@ class UserTest extends TestCase
|
||||
$user->setWorkHoursSunday(7800);
|
||||
$user->setHolidaysPerYear(10.7);
|
||||
self::assertTrue($user->hasWorkHourConfiguration());
|
||||
self::assertTrue($user->hasContractSettings());
|
||||
|
||||
self::assertEquals(7200, $user->getWorkHoursMonday());
|
||||
self::assertEquals(7300, $user->getWorkHoursTuesday());
|
||||
@@ -161,16 +158,6 @@ class UserTest extends TestCase
|
||||
|
||||
$user->setPublicHolidayGroup('DE-NRW');
|
||||
self::assertEquals('DE-NRW', $user->getPublicHolidayGroup());
|
||||
|
||||
self::assertNull($user->getWorkStartingDay());
|
||||
$workStart = new \DateTimeImmutable('2018-07-23');
|
||||
$user->setWorkStartingDay($workStart);
|
||||
self::assertEquals($workStart, $user->getWorkStartingDay());
|
||||
|
||||
self::assertNull($user->getLastWorkingDay());
|
||||
$workStart = new \DateTimeImmutable('2021-02-13');
|
||||
$user->setLastWorkingDay($workStart);
|
||||
self::assertEquals($workStart, $user->getLastWorkingDay());
|
||||
}
|
||||
|
||||
public function testColor(): void
|
||||
@@ -261,21 +248,6 @@ class UserTest extends TestCase
|
||||
|
||||
$user->setPreferenceValue('export_decimal', true);
|
||||
self::assertTrue($user->isExportDecimal());
|
||||
|
||||
$prefs = $user->getPreferences();
|
||||
self::assertCount(3, $prefs);
|
||||
|
||||
self::assertInstanceOf(UserPreference::class, $prefs[0]);
|
||||
self::assertEquals('test', $prefs[0]->getName());
|
||||
|
||||
self::assertInstanceOf(UserPreference::class, $prefs[1]);
|
||||
self::assertEquals('test2', $prefs[1]->getName());
|
||||
|
||||
self::assertInstanceOf(UserPreference::class, $prefs[2]);
|
||||
self::assertEquals('export_decimal', $prefs[2]->getName());
|
||||
|
||||
$user->setPreferences(new ArrayCollection([]));
|
||||
self::assertCount(0, $user->getPreferences());
|
||||
}
|
||||
|
||||
public function testDisplayName(): void
|
||||
@@ -360,12 +332,6 @@ class UserTest extends TestCase
|
||||
self::assertTrue($sut->isTeamleadOf($team2));
|
||||
self::assertTrue($sut->isInTeam($team2));
|
||||
|
||||
self::assertTrue($sut->isTeamleadOf($team2));
|
||||
$user2 = new User();
|
||||
self::assertFalse($sut->isTeamleadOfUser($user2));
|
||||
$team2->addUser($user2);
|
||||
self::assertTrue($sut->isTeamleadOfUser($user2));
|
||||
|
||||
self::assertCount(2, $sut->getTeams());
|
||||
$sut->removeMembership(new TeamMember());
|
||||
self::assertCount(2, $sut->getTeams());
|
||||
@@ -658,21 +624,4 @@ class UserTest extends TestCase
|
||||
self::assertNotNull($user->getSupervisor());
|
||||
self::assertSame($supervisor, $user->getSupervisor());
|
||||
}
|
||||
|
||||
public function testLastLogin(): void
|
||||
{
|
||||
$dateTime = new \DateTime('now', new \DateTimeZone('UTC'));
|
||||
|
||||
$user = new User();
|
||||
$user->setTimezone('Europe/Berlin');
|
||||
|
||||
$lastLogin = $user->getLastLogin();
|
||||
self::assertNull($lastLogin);
|
||||
$user->setLastLogin($dateTime);
|
||||
|
||||
$lastLogin = $user->getLastLogin();
|
||||
self::assertNotNull($lastLogin);
|
||||
self::assertInstanceOf(\DateTime::class, $lastLogin);
|
||||
self::assertEquals('Europe/Berlin', $lastLogin->getTimezone()->getName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@ use App\Entity\User;
|
||||
use App\Export\Base\CsvRenderer;
|
||||
use App\Export\Base\SpreadsheetRenderer;
|
||||
use App\Tests\Export\Renderer\AbstractRendererTestCase;
|
||||
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use App\Tests\Export\Renderer\MetaFieldColumnSubscriber;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
@@ -38,13 +37,12 @@ class CsvRendererTest extends AbstractRendererTestCase
|
||||
$security->expects($this->any())->method('getUser')->willReturn($user);
|
||||
$security->expects($this->any())->method('isGranted')->willReturn(true);
|
||||
|
||||
$translator = $this->getContainer()->get(TranslatorInterface::class);
|
||||
self::assertInstanceOf(TranslatorInterface::class, $translator);
|
||||
$translator = $this->createMock(TranslatorInterface::class);
|
||||
|
||||
$dispatcher = new EventDispatcher();
|
||||
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
|
||||
$dispatcher->addSubscriber(new MetaFieldColumnSubscriber());
|
||||
|
||||
return new CsvRenderer(new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class)), $translator);
|
||||
return new CsvRenderer(new SpreadsheetRenderer($dispatcher, $security), $translator);
|
||||
}
|
||||
|
||||
public function testConfiguration(): void
|
||||
@@ -52,43 +50,23 @@ class CsvRendererTest extends AbstractRendererTestCase
|
||||
$sut = $this->getAbstractRenderer();
|
||||
|
||||
self::assertEquals('csv', $sut->getId());
|
||||
self::assertEquals('default', $sut->getTitle());
|
||||
|
||||
$sut->setTitle('foo-bar');
|
||||
self::assertEquals('foo-bar', $sut->getTitle());
|
||||
|
||||
$sut->setId('bar-id');
|
||||
self::assertEquals('bar-id', $sut->getId());
|
||||
self::assertEquals('csv', $sut->getTitle());
|
||||
}
|
||||
|
||||
public static function getTestModel(): array
|
||||
{
|
||||
$en = [
|
||||
'Date', 'From', 'To', 'Duration', 'Currency', 'Price', 'Internal price', 'Hourly price', 'Fixed price', 'Name',
|
||||
'User', 'Staff number', 'Customer', 'Project', 'Activity', 'Description', 'Billable', 'Tags',
|
||||
'Type', 'category', 'Account', 'Project number', 'VAT-ID', 'Order number',
|
||||
'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'mypref',
|
||||
];
|
||||
$de = [
|
||||
'Datum', 'Von', 'Bis', 'Dauer', 'Währung', 'Preis', 'Interner Preis', 'Preis pro Stunde', 'Festpreis', 'Name',
|
||||
'Benutzer', 'Personalnummer', 'Kunde', 'Projekt', 'Tätigkeit', 'Beschreibung', 'Abrechenbar', 'Schlagworte',
|
||||
'Typ', 'category', 'Kundennummer', 'Projektnummer', 'Umsatzsteuer-ID', 'Bestellnummer',
|
||||
'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'mypref',
|
||||
];
|
||||
|
||||
return [
|
||||
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, false, null, $en],
|
||||
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, true, 'de', $de]
|
||||
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, false],
|
||||
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, true]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getTestModel
|
||||
*/
|
||||
public function testRender(string $totalDuration, string $totalRate, string $expectedRate, int $expectedRows, int $expectedDescriptions, int $expectedUser1, int $expectedUser2, int $expectedUser3, bool $exportDecimal, ?string $locale, array $header): void
|
||||
public function testRender(string $totalDuration, string $totalRate, string $expectedRate, int $expectedRows, int $expectedDescriptions, int $expectedUser1, int $expectedUser2, int $expectedUser3, bool $exportDecimal): void
|
||||
{
|
||||
$sut = $this->getAbstractRenderer($exportDecimal);
|
||||
$sut->setLocale($locale);
|
||||
|
||||
/** @var BinaryFileResponse $response */
|
||||
$response = $this->render($sut);
|
||||
@@ -124,13 +102,11 @@ class CsvRendererTest extends AbstractRendererTestCase
|
||||
$all[] = str_getcsv($row);
|
||||
}
|
||||
|
||||
self::assertEquals($header, $all[0]);
|
||||
|
||||
$expected = [
|
||||
'2019-06-16',
|
||||
'12:00',
|
||||
'12:06',
|
||||
($exportDecimal ? '0.11' : '0:06'),
|
||||
($exportDecimal ? '0.11' : '0:06:40'),
|
||||
//'0.11',
|
||||
'EUR',
|
||||
'0',
|
||||
@@ -158,14 +134,13 @@ class CsvRendererTest extends AbstractRendererTestCase
|
||||
'',
|
||||
'project-foo2',
|
||||
'activity-bar',
|
||||
'',
|
||||
];
|
||||
|
||||
$expected2 = [
|
||||
'2019-06-16',
|
||||
'12:00',
|
||||
'12:06',
|
||||
($exportDecimal ? '0.11' : '0:06'),
|
||||
($exportDecimal ? '0.11' : '0:06:40'),
|
||||
//'0.11',
|
||||
'EUR',
|
||||
'0',
|
||||
@@ -193,7 +168,6 @@ class CsvRendererTest extends AbstractRendererTestCase
|
||||
'',
|
||||
'project-foo2',
|
||||
'activity-bar',
|
||||
'',
|
||||
];
|
||||
|
||||
self::assertEquals(7, \count($all));
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Export\Base;
|
||||
|
||||
use App\Export\Base\PDFRenderer;
|
||||
use App\Pdf\HtmlToPdfConverter;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use App\Tests\Export\Renderer\AbstractRendererTestCase;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Base\PDFRenderer
|
||||
* @covers \App\Export\Base\RendererTrait
|
||||
* @covers \App\Pdf\PdfRendererTrait
|
||||
* @group integration
|
||||
*/
|
||||
class PdfRendererTest extends AbstractRendererTestCase
|
||||
{
|
||||
protected function getAbstractRenderer(bool $exportDecimal = false): PDFRenderer
|
||||
{
|
||||
$twig = $this->createMock(Environment::class);
|
||||
$converter = $this->createMock(HtmlToPdfConverter::class);
|
||||
$projectStatisticService = $this->createMock(ProjectStatisticService::class);
|
||||
|
||||
return new PDFRenderer($twig, $converter, $projectStatisticService);
|
||||
}
|
||||
|
||||
public function testConfiguration(): void
|
||||
{
|
||||
$sut = $this->getAbstractRenderer();
|
||||
|
||||
self::assertEquals('pdf', $sut->getId());
|
||||
self::assertEquals('pdf', $sut->getTitle());
|
||||
|
||||
$sut->setTitle('foo-bar');
|
||||
self::assertEquals('foo-bar', $sut->getTitle());
|
||||
|
||||
$sut->setId('bar-id');
|
||||
self::assertEquals('bar-id', $sut->getId());
|
||||
|
||||
self::assertEquals([], $sut->getPdfOptions());
|
||||
$sut->setPdfOption('foo', 'bar');
|
||||
self::assertEquals(['foo' => 'bar'], $sut->getPdfOptions());
|
||||
$sut->setPdfOption('foo', 'bar2');
|
||||
self::assertEquals(['foo' => 'bar2'], $sut->getPdfOptions());
|
||||
$sut->setPdfOption('hello', 'world');
|
||||
self::assertEquals(['foo' => 'bar2', 'hello' => 'world'], $sut->getPdfOptions());
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,10 @@ namespace App\Tests\Export\Base;
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\Base\SpreadsheetRenderer;
|
||||
use App\Export\Package\SpreadsheetPackage;
|
||||
use App\Export\Template;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Tests\Export\Package\MemoryPackage;
|
||||
use App\Tests\Export\Renderer\AbstractRendererTestCase;
|
||||
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
@@ -36,7 +31,7 @@ class SpreadsheetRendererTest extends AbstractRendererTestCase
|
||||
$spreadsheetPackage = $this->createMock(SpreadsheetPackage::class);
|
||||
$spreadsheetPackage->expects(self::once())->method('setColumns');
|
||||
|
||||
$renderer = new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class));
|
||||
$renderer = new SpreadsheetRenderer($dispatcher, $security);
|
||||
$renderer->writeSpreadsheet($spreadsheetPackage, [], new TimesheetQuery());
|
||||
}
|
||||
|
||||
@@ -65,7 +60,7 @@ class SpreadsheetRendererTest extends AbstractRendererTestCase
|
||||
$exportItem->method('getType')->willReturn('type');
|
||||
$exportItem->method('getCategory')->willReturn('category');
|
||||
|
||||
$renderer = new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class));
|
||||
$renderer = new SpreadsheetRenderer($dispatcher, $security);
|
||||
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem], new TimesheetQuery());
|
||||
}
|
||||
|
||||
@@ -98,96 +93,4 @@ class SpreadsheetRendererTest extends AbstractRendererTestCase
|
||||
$renderer = new SpreadsheetRenderer($dispatcher, $security);
|
||||
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem, $exportItem], new TimesheetQuery());
|
||||
}
|
||||
|
||||
public static function getTestData(): iterable
|
||||
{
|
||||
yield [null, [
|
||||
'date' => 'date',
|
||||
'begin' => 'begin',
|
||||
'end' => 'end',
|
||||
'duration' => 'duration',
|
||||
'currency' => 'currency',
|
||||
'rate' => 'rate',
|
||||
'internalRate' => 'internalRate',
|
||||
'hourlyRate' => 'hourlyRate',
|
||||
'fixedRate' => 'fixedRate',
|
||||
'alias' => 'alias',
|
||||
'username' => 'username',
|
||||
'account_number' => 'account_number',
|
||||
'customer' => 'customer',
|
||||
'project' => 'project',
|
||||
'activity' => 'activity',
|
||||
'description' => 'description',
|
||||
'billable' => 'billable',
|
||||
'tags' => 'tags',
|
||||
'type' => 'type',
|
||||
'category' => 'category',
|
||||
'number' => 'number',
|
||||
'project_number' => 'project_number',
|
||||
'vat_id' => 'vat_id',
|
||||
'orderNumber' => 'orderNumber',
|
||||
'timesheet.meta.foo' => 'Working place',
|
||||
'timesheet.meta.foo2' => 'Working place',
|
||||
'customer.meta.customer-foo' => 'Working place',
|
||||
'project.meta.project-foo' => 'Working place',
|
||||
'project.meta.project-foo2' => 'Working place',
|
||||
'activity.meta.activity-foo' => 'Working place',
|
||||
'user.meta.mypref' => 'mypref',
|
||||
]];
|
||||
|
||||
$template = new Template('test', 'Testing');
|
||||
$template->setLocale('de');
|
||||
$template->setColumns(['date', 'user.name', 'duration_decimal', 'customer.name', 'exported', 'user.meta.mypref']);
|
||||
|
||||
yield [$template, [
|
||||
'date' => 'date',
|
||||
'duration' => 'duration',
|
||||
'username' => 'username',
|
||||
'customer' => 'customer',
|
||||
'exported' => 'exported',
|
||||
'user.meta.mypref' => 'mypref',
|
||||
]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getTestData
|
||||
*/
|
||||
public function testWriteSpreadsheetCsv(?Template $template, array $expectedColumns): void
|
||||
{
|
||||
$dispatcher = new EventDispatcher();
|
||||
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$spreadsheetPackage = new MemoryPackage();
|
||||
|
||||
$exportItem = $this->createMock(ExportableItem::class);
|
||||
$exportItem->method('getBegin')->willReturn(new \DateTime());
|
||||
$exportItem->method('getEnd')->willReturn(new \DateTime());
|
||||
$exportItem->method('getDuration')->willReturn(3600);
|
||||
$exportItem->method('getRate')->willReturn(100.0);
|
||||
$exportItem->method('getInternalRate')->willReturn(80.0);
|
||||
$exportItem->method('getHourlyRate')->willReturn(50.0);
|
||||
$exportItem->method('getFixedRate')->willReturn(200.0);
|
||||
$exportItem->method('getUser')->willReturn(null);
|
||||
$exportItem->method('getProject')->willReturn(null);
|
||||
$exportItem->method('getActivity')->willReturn(null);
|
||||
$exportItem->method('getDescription')->willReturn('Test description');
|
||||
$exportItem->method('isBillable')->willReturn(true);
|
||||
$exportItem->method('getTagsAsArray')->willReturn(['tag1', 'tag2']);
|
||||
$exportItem->method('getType')->willReturn('type');
|
||||
$exportItem->method('getCategory')->willReturn('category');
|
||||
|
||||
$renderer = new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class));
|
||||
$renderer->setTemplate($template);
|
||||
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem], new TimesheetQuery());
|
||||
|
||||
$columnNames = [];
|
||||
foreach ($spreadsheetPackage->getColumns() as $column) {
|
||||
$columnNames[$column->getName()] = $column->getHeader();
|
||||
}
|
||||
|
||||
self::assertEquals(null, $spreadsheetPackage->getFilename());
|
||||
self::assertEquals($expectedColumns, $columnNames);
|
||||
self::assertCount(2, $spreadsheetPackage->getRows());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\Entity\User;
|
||||
use App\Export\Base\SpreadsheetRenderer;
|
||||
use App\Export\Base\XlsxRenderer;
|
||||
use App\Tests\Export\Renderer\AbstractRendererTestCase;
|
||||
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
|
||||
use App\Tests\Export\Renderer\MetaFieldColumnSubscriber;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
@@ -38,7 +38,7 @@ class XlsxRendererTest extends AbstractRendererTestCase
|
||||
$translator->method('trans')->willReturnArgument(0);
|
||||
|
||||
$dispatcher = new EventDispatcher();
|
||||
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
|
||||
$dispatcher->addSubscriber(new MetaFieldColumnSubscriber());
|
||||
|
||||
return new XlsxRenderer(new SpreadsheetRenderer($dispatcher, $security), $translator);
|
||||
}
|
||||
@@ -46,16 +46,9 @@ class XlsxRendererTest extends AbstractRendererTestCase
|
||||
public function testConfiguration(): void
|
||||
{
|
||||
$sut = $this->getAbstractRenderer();
|
||||
$sut->setLocale('de');
|
||||
|
||||
self::assertEquals('xlsx', $sut->getId());
|
||||
self::assertEquals('default', $sut->getTitle());
|
||||
|
||||
$sut->setTitle('foo-bar');
|
||||
self::assertEquals('foo-bar', $sut->getTitle());
|
||||
|
||||
$sut->setId('bar-id');
|
||||
self::assertEquals('bar-id', $sut->getId());
|
||||
self::assertEquals('xlsx', $sut->getTitle());
|
||||
}
|
||||
|
||||
public function testRender(): void
|
||||
|
||||
@@ -19,13 +19,13 @@ class DurationFormatterTest extends TestCase
|
||||
{
|
||||
public function testGetFormat(): void
|
||||
{
|
||||
$formatter = new DurationFormatter('[hh]:mm:ss');
|
||||
$formatter = new DurationFormatter();
|
||||
self::assertEquals('[hh]:mm:ss', $formatter->getFormat());
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsFormattedDurationQuiteLong(): void
|
||||
{
|
||||
$formatter = new DurationFormatter('[hh]:mm:ss');
|
||||
$formatter = new DurationFormatter();
|
||||
$result = $formatter->formatValue(701213);
|
||||
self::assertInstanceOf(\DateInterval::class, $result);
|
||||
self::assertEquals('194:46:53', $result->format('%r%H:%I:%S'));
|
||||
@@ -33,7 +33,7 @@ class DurationFormatterTest extends TestCase
|
||||
|
||||
public function testFormatValueReturnsFormattedDurationForNumericValue(): void
|
||||
{
|
||||
$formatter = new DurationFormatter('[hh]:mm:ss');
|
||||
$formatter = new DurationFormatter();
|
||||
$result = $formatter->formatValue(7213);
|
||||
self::assertInstanceOf(\DateInterval::class, $result);
|
||||
self::assertEquals('02:00:13', $result->format('%r%H:%I:%S'));
|
||||
@@ -41,7 +41,7 @@ class DurationFormatterTest extends TestCase
|
||||
|
||||
public function testFormatValueReturnsZeroForNonNumericValue(): void
|
||||
{
|
||||
$formatter = new DurationFormatter('[hh]:mm:ss');
|
||||
$formatter = new DurationFormatter();
|
||||
$result = $formatter->formatValue('not a number');
|
||||
self::assertInstanceOf(\DateInterval::class, $result);
|
||||
self::assertEquals('00:00:00', $result->format('%r%H:%I:%S'));
|
||||
@@ -49,7 +49,7 @@ class DurationFormatterTest extends TestCase
|
||||
|
||||
public function testFormatValueReturnsFormattedDurationForFloatValue(): void
|
||||
{
|
||||
$formatter = new DurationFormatter('[hh]:mm:ss');
|
||||
$formatter = new DurationFormatter();
|
||||
$result = $formatter->formatValue(4521.5);
|
||||
self::assertInstanceOf(\DateInterval::class, $result);
|
||||
self::assertEquals('01:15:21', $result->format('%r%H:%I:%S'));
|
||||
@@ -57,7 +57,7 @@ class DurationFormatterTest extends TestCase
|
||||
|
||||
public function testFormatValueReturnsZeroForNullValue(): void
|
||||
{
|
||||
$formatter = new DurationFormatter('[hh]:mm:ss');
|
||||
$formatter = new DurationFormatter();
|
||||
$result = $formatter->formatValue(null);
|
||||
self::assertInstanceOf(\DateInterval::class, $result);
|
||||
self::assertEquals('00:00:00', $result->format('%r%H:%I:%S'));
|
||||
@@ -65,7 +65,7 @@ class DurationFormatterTest extends TestCase
|
||||
|
||||
public function testFormatValueReturnsFormattedDurationForNegativeValue(): void
|
||||
{
|
||||
$formatter = new DurationFormatter('[hh]:mm:ss');
|
||||
$formatter = new DurationFormatter();
|
||||
$result = $formatter->formatValue(-3600);
|
||||
self::assertInstanceOf(\DateInterval::class, $result);
|
||||
self::assertEquals('-01:00:00', $result->format('%r%H:%I:%S'));
|
||||
|
||||
@@ -19,50 +19,42 @@ class DurationPlainFormatterTest extends TestCase
|
||||
{
|
||||
public function testFormatValueReturnsFormattedDurationQuiteLong(): void
|
||||
{
|
||||
$formatter = new DurationPlainFormatter(true);
|
||||
$formatter = new DurationPlainFormatter();
|
||||
$result = $formatter->formatValue(701213);
|
||||
self::assertEquals('194:46:53', $result);
|
||||
|
||||
$formatter = new DurationPlainFormatter(false);
|
||||
$result = $formatter->formatValue(701213);
|
||||
self::assertEquals('194:46', $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsFormattedDurationForNumericValue(): void
|
||||
{
|
||||
$formatter = new DurationPlainFormatter(true);
|
||||
$formatter = new DurationPlainFormatter();
|
||||
$result = $formatter->formatValue(8246);
|
||||
self::assertEquals('2:17:26', $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsZeroForNonNumericValue(): void
|
||||
{
|
||||
$formatter = new DurationPlainFormatter(true);
|
||||
$formatter = new DurationPlainFormatter();
|
||||
$result = $formatter->formatValue('not a number');
|
||||
self::assertEquals('0:00:00', $result);
|
||||
|
||||
$formatter = new DurationPlainFormatter(false);
|
||||
$result = $formatter->formatValue('not a number');
|
||||
self::assertEquals('0:00', $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsFormattedDurationForFloatValue(): void
|
||||
{
|
||||
$formatter = new DurationPlainFormatter(true);
|
||||
$formatter = new DurationPlainFormatter();
|
||||
$result = $formatter->formatValue(44513.5);
|
||||
self::assertEquals('12:21:53', $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsZeroForNullValue(): void
|
||||
{
|
||||
$formatter = new DurationPlainFormatter(true);
|
||||
$formatter = new DurationPlainFormatter();
|
||||
$result = $formatter->formatValue(null);
|
||||
self::assertEquals('0:00:00', $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsFormattedDurationForNegativeValue(): void
|
||||
{
|
||||
$formatter = new DurationPlainFormatter(true);
|
||||
$formatter = new DurationPlainFormatter();
|
||||
$result = $formatter->formatValue(-3600);
|
||||
self::assertEquals('-1:00:00', $result);
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Export\Package;
|
||||
|
||||
use App\Export\Package\Column;
|
||||
use App\Export\Package\SpreadsheetPackage;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Package\SpoutSpreadsheet
|
||||
*/
|
||||
class MemoryPackage implements SpreadsheetPackage
|
||||
{
|
||||
private ?string $filename = null;
|
||||
/** @var array<Column> */
|
||||
private array $columns = [];
|
||||
private array $rows = [];
|
||||
private bool $saved = false;
|
||||
|
||||
public function open(string $filename): void
|
||||
{
|
||||
$this->filename = $filename;
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->saved = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<Column> $columns
|
||||
*/
|
||||
public function setColumns(array $columns): void
|
||||
{
|
||||
$this->columns = $columns;
|
||||
}
|
||||
|
||||
public function addRow(array $columns, array $options = []): void
|
||||
{
|
||||
$this->rows[] = ['columns' => $columns, 'options' => $options];
|
||||
}
|
||||
|
||||
public function getFilename(): ?string
|
||||
{
|
||||
return $this->filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Column[]
|
||||
*/
|
||||
public function getColumns(): array
|
||||
{
|
||||
return $this->columns;
|
||||
}
|
||||
|
||||
public function getRows(): array
|
||||
{
|
||||
return $this->rows;
|
||||
}
|
||||
|
||||
public function isSaved(): bool
|
||||
{
|
||||
return $this->saved;
|
||||
}
|
||||
}
|
||||
@@ -13,15 +13,22 @@ use App\Entity\Activity;
|
||||
use App\Entity\ActivityMeta;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\CustomerMeta;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\ProjectMeta;
|
||||
use App\Entity\Tag;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\TimesheetMeta;
|
||||
use App\Entity\User;
|
||||
use App\Event\ActivityMetaDisplayEvent;
|
||||
use App\Event\CustomerMetaDisplayEvent;
|
||||
use App\Event\ProjectMetaDisplayEvent;
|
||||
use App\Event\TimesheetMetaDisplayEvent;
|
||||
use App\Export\ExportRendererInterface;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
abstract class AbstractRendererTestCase extends KernelTestCase
|
||||
@@ -138,3 +145,47 @@ abstract class AbstractRendererTestCase extends KernelTestCase
|
||||
return $renderer->render($entries, $query);
|
||||
}
|
||||
}
|
||||
|
||||
class MetaFieldColumnSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
TimesheetMetaDisplayEvent::class => ['loadTimesheetField', 200],
|
||||
CustomerMetaDisplayEvent::class => ['loadCustomerField', 200],
|
||||
ProjectMetaDisplayEvent::class => ['loadProjectField', 200],
|
||||
ActivityMetaDisplayEvent::class => ['loadActivityField', 200],
|
||||
];
|
||||
}
|
||||
|
||||
public function loadTimesheetField(TimesheetMetaDisplayEvent $event): void
|
||||
{
|
||||
$event->addField($this->prepareEntity(new TimesheetMeta(), 'foo'));
|
||||
$event->addField($this->prepareEntity(new TimesheetMeta(), 'foo2'));
|
||||
}
|
||||
|
||||
public function loadCustomerField(CustomerMetaDisplayEvent $event): void
|
||||
{
|
||||
$event->addField($this->prepareEntity(new CustomerMeta(), 'customer-foo'));
|
||||
}
|
||||
|
||||
public function loadProjectField(ProjectMetaDisplayEvent $event): void
|
||||
{
|
||||
$event->addField($this->prepareEntity(new ProjectMeta(), 'project-foo'));
|
||||
$event->addField($this->prepareEntity(new ProjectMeta(), 'project-foo2')->setIsVisible(false));
|
||||
}
|
||||
|
||||
public function loadActivityField(ActivityMetaDisplayEvent $event): void
|
||||
{
|
||||
$event->addField($this->prepareEntity(new ActivityMeta(), 'activity-foo'));
|
||||
}
|
||||
|
||||
private function prepareEntity(MetaTableTypeInterface $meta, string $name): MetaTableTypeInterface
|
||||
{
|
||||
return $meta
|
||||
->setLabel('Working place')
|
||||
->setName($name)
|
||||
->setType(TextType::class)
|
||||
->setIsVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Export\Renderer;
|
||||
|
||||
use App\Export\Base\CsvRenderer;
|
||||
use App\Export\Renderer\CsvRendererFactory;
|
||||
use App\Export\Template;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Renderer\CsvRendererFactory
|
||||
*/
|
||||
class CsvRendererFactoryTest extends TestCase
|
||||
{
|
||||
public function testCreate(): void
|
||||
{
|
||||
$sut = new CsvRendererFactory(
|
||||
$this->createMock(EventDispatcherInterface::class),
|
||||
$this->createMock(Security::class),
|
||||
$this->createMock(TranslatorInterface::class),
|
||||
$this->createMock(LoggerInterface::class)
|
||||
);
|
||||
|
||||
$template = new Template('foo-id', 'bar-title');
|
||||
$template->setLocale('it_IT');
|
||||
|
||||
$renderer = $sut->create($template);
|
||||
|
||||
self::assertInstanceOf(CsvRenderer::class, $renderer);
|
||||
self::assertEquals('foo-id', $renderer->getId());
|
||||
self::assertEquals('bar-title', $renderer->getTitle());
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Export\Renderer;
|
||||
|
||||
use App\Export\Base\XlsxRenderer;
|
||||
use App\Export\Renderer\XlsxRendererFactory;
|
||||
use App\Export\Template;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Renderer\XlsxRendererFactory
|
||||
*/
|
||||
class XlsxRendererFactoryTest extends TestCase
|
||||
{
|
||||
public function testCreate(): void
|
||||
{
|
||||
$sut = new XlsxRendererFactory(
|
||||
$this->createMock(EventDispatcherInterface::class),
|
||||
$this->createMock(Security::class),
|
||||
$this->createMock(TranslatorInterface::class),
|
||||
$this->createMock(LoggerInterface::class)
|
||||
);
|
||||
|
||||
$template = new Template('foo-id', 'bar-title');
|
||||
$template->setLocale('it_IT');
|
||||
|
||||
$renderer = $sut->create($template);
|
||||
|
||||
self::assertInstanceOf(XlsxRenderer::class, $renderer);
|
||||
self::assertEquals('foo-id', $renderer->getId());
|
||||
self::assertEquals('bar-title', $renderer->getTitle());
|
||||
}
|
||||
}
|
||||
@@ -10,22 +10,15 @@
|
||||
namespace App\Tests\Export;
|
||||
|
||||
use App\Activity\ActivityStatisticService;
|
||||
use App\Entity\ExportTemplate;
|
||||
use App\Export\Base\CsvRenderer;
|
||||
use App\Export\Base\HtmlRenderer;
|
||||
use App\Export\Base\XlsxRenderer;
|
||||
use App\Export\ExportRepositoryInterface;
|
||||
use App\Export\ServiceExport;
|
||||
use App\Export\Timesheet\HtmlRenderer as HtmlExporter;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use App\Repository\ExportTemplateRepository;
|
||||
use App\Repository\Query\ExportQuery;
|
||||
use App\Tests\Mocks\Export\CsvRendererFactoryMock;
|
||||
use App\Tests\Mocks\Export\HtmlRendererFactoryMock;
|
||||
use App\Tests\Mocks\Export\PdfRendererFactoryMock;
|
||||
use App\Tests\Mocks\Export\XlsxRendererFactoryMock;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
use Twig\Environment;
|
||||
@@ -35,48 +28,12 @@ use Twig\Environment;
|
||||
*/
|
||||
class ServiceExportTest extends TestCase
|
||||
{
|
||||
private function createSut(bool $withTemplates = false, int $failureCount = 1): ServiceExport
|
||||
private function createSut(): ServiceExport
|
||||
{
|
||||
$repository = $this->createMock(ExportTemplateRepository::class);
|
||||
$templates = [];
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
if ($withTemplates) {
|
||||
$template1 = $this->createMock(ExportTemplate::class);
|
||||
$template1->method('getId')->willReturn(1);
|
||||
$template1->method('getTitle')->willReturn('CSV Test');
|
||||
$template1->method('getLanguage')->willReturn('de');
|
||||
$template1->method('getRenderer')->willReturn('csv');
|
||||
$template1->method('getColumns')->willReturn(['date', 'customer.name', 'duration', 'rate']);
|
||||
|
||||
$template2 = $this->createMock(ExportTemplate::class);
|
||||
$template2->method('getId')->willReturn(2);
|
||||
$template2->method('getTitle')->willReturn('XLSX Test');
|
||||
$template2->method('getLanguage')->willReturn('it');
|
||||
$template2->method('getRenderer')->willReturn('xlsx');
|
||||
$template2->method('getColumns')->willReturn(['date', 'begin', 'duration', 'rate', 'user.name']);
|
||||
|
||||
$template3 = $this->createMock(ExportTemplate::class);
|
||||
$template3->method('getTitle')->willReturn('XLSX Test');
|
||||
$template3->method('getLanguage')->willReturn('it');
|
||||
$template3->method('getRenderer')->willReturn('foo'); // invalid renderer will be ignored
|
||||
$template3->method('getColumns')->willReturn(['date', 'begin', 'duration', 'rate', 'user.name']);
|
||||
|
||||
$logger->expects($this->exactly($failureCount))->method('error')->with('Unknown export template type: ' . $template3->getRenderer());
|
||||
|
||||
$templates = [$template1, $template2, $template3];
|
||||
}
|
||||
|
||||
$repository->method('findAll')->willReturn($templates);
|
||||
|
||||
return new ServiceExport(
|
||||
$this->createMock(EventDispatcherInterface::class),
|
||||
(new HtmlRendererFactoryMock($this))->create(),
|
||||
(new PdfRendererFactoryMock($this))->create(),
|
||||
(new CsvRendererFactoryMock($this))->create(),
|
||||
(new XlsxRendererFactoryMock($this))->create(),
|
||||
$repository,
|
||||
$logger,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,18 +88,4 @@ class ServiceExportTest extends TestCase
|
||||
|
||||
self::assertEquals([], $items);
|
||||
}
|
||||
|
||||
public function testWithTemplates(): void
|
||||
{
|
||||
$sut = $this->createSut(true, 5);
|
||||
|
||||
$renderer = $sut->getRenderer();
|
||||
self::assertCount(2, $renderer);
|
||||
self::assertInstanceOf(CsvRenderer::class, $renderer[0]);
|
||||
self::assertInstanceOf(XlsxRenderer::class, $renderer[1]);
|
||||
self::assertInstanceOf(CsvRenderer::class, $sut->getRendererById('1'));
|
||||
self::assertNull($sut->getRendererById('default'));
|
||||
self::assertNull($sut->getRendererById('csv'));
|
||||
self::assertNull($sut->getRendererById('xlsx'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Export;
|
||||
|
||||
use App\Export\Template;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Template
|
||||
*/
|
||||
class TemplateTest extends TestCase
|
||||
{
|
||||
public function testDefaultValues(): void
|
||||
{
|
||||
$template = new Template('id', 'title');
|
||||
self::assertEquals('id', $template->getId());
|
||||
self::assertEquals('title', $template->getTitle());
|
||||
self::assertNull($template->getLocale());
|
||||
self::assertEquals([], $template->getColumns());
|
||||
self::assertEquals([], $template->getOptions());
|
||||
}
|
||||
|
||||
public function testSetsAndGetsColumnsCorrectly(): void
|
||||
{
|
||||
$template = new Template('id', 'title');
|
||||
$columns = ['Column1', 'Column2'];
|
||||
$template->setColumns($columns);
|
||||
self::assertEquals($columns, $template->getColumns());
|
||||
$template->setColumns([]);
|
||||
self::assertEquals([], $template->getColumns());
|
||||
}
|
||||
|
||||
public function testSetsAndGetsOptionsCorrectly(): void
|
||||
{
|
||||
$template = new Template('id', 'title');
|
||||
$options = ['key1' => 'value1', 'key2' => 'value2'];
|
||||
$template->setOptions($options);
|
||||
self::assertEquals($options, $template->getOptions());
|
||||
$template->setOptions([]);
|
||||
self::assertEquals([], $template->getOptions());
|
||||
}
|
||||
|
||||
public function testSetsAndGetsLocaleCorrectly(): void
|
||||
{
|
||||
$template = new Template('id', 'title');
|
||||
$template->setLocale('en_US');
|
||||
self::assertEquals('en_US', $template->getLocale());
|
||||
}
|
||||
|
||||
public function testHandlesNullLocaleGracefully(): void
|
||||
{
|
||||
$template = new Template('id', 'title');
|
||||
$template->setLocale(null);
|
||||
self::assertNull($template->getLocale());
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Form;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use App\Entity\ExportTemplate;
|
||||
use App\Form\ExportTemplateSpreadsheetForm;
|
||||
use App\Form\Type\ExportColumnsType;
|
||||
use App\Form\Type\LanguageType;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Form\FormTypeInterface;
|
||||
use Symfony\Component\Form\Test\TypeTestCase;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* @covers \App\Form\ExportTemplateSpreadsheetForm
|
||||
*/
|
||||
class ExportTemplateSpreadsheetFormTest extends TypeTestCase
|
||||
{
|
||||
/**
|
||||
* @return FormTypeInterface[]
|
||||
*/
|
||||
protected function getTypes(): array // @phpstan-ignore missingType.generics
|
||||
{
|
||||
$dispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
$translator = $this->createMock(TranslatorInterface::class);
|
||||
|
||||
return [
|
||||
new ExportColumnsType($dispatcher, $translator),
|
||||
new LanguageType(new LocaleService([]))
|
||||
];
|
||||
}
|
||||
|
||||
public function testWithGlobalNewActivity(): void
|
||||
{
|
||||
$model = new ExportTemplate();
|
||||
$form = $this->factory->createBuilder(ExportTemplateSpreadsheetForm::class, $model);
|
||||
|
||||
$attr = $form->getFormConfig()->getOption('attr');
|
||||
self::assertIsArray($attr);
|
||||
self::assertArrayHasKey('data-form-event', $attr);
|
||||
self::assertEquals('kimai.exportTemplate', $attr['data-form-event']);
|
||||
|
||||
self::assertTrue($form->has('title'));
|
||||
self::assertTrue($form->has('renderer'));
|
||||
self::assertTrue($form->has('language'));
|
||||
self::assertTrue($form->has('columns'));
|
||||
|
||||
self::assertTrue($form->get('title')->getRequired());
|
||||
self::assertTrue($form->get('renderer')->getRequired());
|
||||
self::assertFalse($form->get('language')->getRequired());
|
||||
self::assertTrue($form->get('columns')->getRequired());
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,6 @@ namespace App\Tests\Form\Extension;
|
||||
use App\Form\Extension\DocumentationLinkExtension;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
@@ -45,18 +43,4 @@ class DocumentationLinkExtensionTest extends TestCase
|
||||
$this->expectException(InvalidOptionsException::class);
|
||||
$resolver->resolve(['docu_chapter' => true]);
|
||||
}
|
||||
|
||||
public function testBuildView(): void
|
||||
{
|
||||
$sut = new DocumentationLinkExtension();
|
||||
$form = $this->createMock(FormInterface::class);
|
||||
|
||||
$view = new FormView();
|
||||
$sut->buildView($view, $form, ['docu_chapter' => null]);
|
||||
self::assertEquals(['attr' => [], 'value' => null, 'docu_chapter' => null], $view->vars);
|
||||
|
||||
$view = new FormView();
|
||||
$sut->buildView($view, $form, ['docu_chapter' => 'customers']);
|
||||
self::assertEquals(['attr' => [], 'value' => null, 'docu_chapter' => 'customers'], $view->vars);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@ use App\Form\Extension\EnhancedChoiceTypeExtension;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
@@ -32,52 +30,15 @@ class EnhancedChoiceTypeExtensionTest extends TestCase
|
||||
$resolver = new OptionsResolver();
|
||||
$sut = new EnhancedChoiceTypeExtension();
|
||||
$sut->configureOptions($resolver);
|
||||
self::assertEquals(['selectpicker', 'width', 'search', 'order'], $resolver->getDefinedOptions());
|
||||
self::assertEquals(['selectpicker', 'width', 'search'], $resolver->getDefinedOptions());
|
||||
self::assertTrue($resolver->hasDefault('selectpicker'));
|
||||
self::assertTrue($resolver->hasDefault('width'));
|
||||
self::assertTrue($resolver->hasDefault('search'));
|
||||
self::assertTrue($resolver->hasDefault('order'));
|
||||
self::assertFalse($resolver->isRequired('selectpicker'));
|
||||
self::assertFalse($resolver->isRequired('width'));
|
||||
self::assertFalse($resolver->isRequired('search'));
|
||||
|
||||
$result = $resolver->resolve([]);
|
||||
self::assertEquals(['selectpicker' => true, 'width' => '100%', 'search' => true, 'order' => false], $result);
|
||||
}
|
||||
|
||||
public static function getTestData(): iterable
|
||||
{
|
||||
yield [
|
||||
['expanded' => true],
|
||||
['value' => null, 'attr' => []]
|
||||
];
|
||||
|
||||
yield [
|
||||
['multiple' => false, 'width' => false, 'search' => true, 'selectpicker' => true, 'order', 'required' => false],
|
||||
['value' => null, 'attr' => ['class' => 'selectpicker']]
|
||||
];
|
||||
|
||||
yield [
|
||||
['multiple' => false, 'width' => '100%', 'search' => true, 'required' => false, 'order' => true],
|
||||
['value' => null, 'attr' => ['class' => 'selectpicker', 'data-width' => '100%', 'data-order' => 1]]
|
||||
];
|
||||
|
||||
yield [
|
||||
['multiple' => true, 'width' => '50%', 'search' => false, 'required' => true, 'attr' => []],
|
||||
['value' => null, 'attr' => ['size' => 1, 'class' => 'selectpicker', 'data-width' => '50%', 'data-disable-search' => 1, 'required' => 'required', 'placeholder' => '']]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getTestData
|
||||
*/
|
||||
public function testBuildView(array $options, array $expected): void
|
||||
{
|
||||
$sut = new EnhancedChoiceTypeExtension();
|
||||
$view = new FormView();
|
||||
$form = $this->createMock(FormInterface::class);
|
||||
|
||||
$sut->buildView($view, $form, $options);
|
||||
self::assertEquals($expected, $view->vars);
|
||||
self::assertEquals(['selectpicker' => true, 'width' => '100%', 'search' => true], $result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Form\Type;
|
||||
|
||||
use App\Form\Type\ExportColumnsType;
|
||||
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\Test\TypeTestCase;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* @covers \App\Form\Type\ExportColumnsType
|
||||
*/
|
||||
class ExportColumnsTypeTest extends TypeTestCase
|
||||
{
|
||||
public static function getTestData(): iterable
|
||||
{
|
||||
yield [['foo', 'bar'], []];
|
||||
|
||||
yield [
|
||||
['user.name', 'customer.meta.customer-foo', 'duration', 'hello', 'user.meta.mypref'],
|
||||
['user.name', 'customer.meta.customer-foo', 'duration', 'user.meta.mypref']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ExportColumnsType[]
|
||||
*/
|
||||
protected function getTypes(): array
|
||||
{
|
||||
$dispatcher = new EventDispatcher();
|
||||
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
|
||||
|
||||
$translator = $this->createMock(TranslatorInterface::class);
|
||||
|
||||
return [
|
||||
new ExportColumnsType($dispatcher, $translator)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $value
|
||||
* @param array<mixed> $expected
|
||||
* @dataProvider getTestData
|
||||
*/
|
||||
public function testSubmitValidData(array $value, array $expected): void
|
||||
{
|
||||
$data = ['columns' => $value];
|
||||
$model = new TypeTestModel(['columns' => []]);
|
||||
|
||||
$form = $this->factory->createBuilder(FormType::class, $model);
|
||||
$form->add('columns', ExportColumnsType::class);
|
||||
$form = $form->getForm();
|
||||
|
||||
$expected = new TypeTestModel([
|
||||
'columns' => $expected
|
||||
]);
|
||||
|
||||
$form->submit($data);
|
||||
|
||||
self::assertTrue($form->isSynchronized());
|
||||
self::assertEquals($expected, $model);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Form\Type;
|
||||
|
||||
use App\Form\Type\ExportRendererType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\Test\TypeTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Form\Type\ExportRendererType
|
||||
*/
|
||||
class ExportRendererTypeTest extends TypeTestCase
|
||||
{
|
||||
public static function getTestData(): iterable
|
||||
{
|
||||
yield ['foo', null];
|
||||
yield ['csv', 'csv'];
|
||||
yield ['csV', null];
|
||||
yield ['xlsx', 'xlsx'];
|
||||
yield ['XLSX', null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getTestData
|
||||
*/
|
||||
public function testSubmitValidData(string $value, string|null $expected): void
|
||||
{
|
||||
$data = ['renderer' => $value];
|
||||
$model = new TypeTestModel(['renderer' => null]);
|
||||
|
||||
$form = $this->factory->createBuilder(FormType::class, $model);
|
||||
$form->add('renderer', ExportRendererType::class);
|
||||
$form = $form->getForm();
|
||||
|
||||
$expected = new TypeTestModel([
|
||||
'renderer' => $expected
|
||||
]);
|
||||
|
||||
$form->submit($data);
|
||||
|
||||
self::assertTrue($form->isSynchronized());
|
||||
self::assertEquals($expected, $model);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ namespace App\Tests\Form\Type;
|
||||
*/
|
||||
class TypeTestModel extends \ArrayObject
|
||||
{
|
||||
public function __set(string $name, string|int|null|array $value)
|
||||
public function __set(string $name, string|int|null $value)
|
||||
{
|
||||
$this->offsetSet($name, $value);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user