faster spreadsheet exporter based on opensout and other export improvements (#5238)

This commit is contained in:
Kevin Papst
2024-12-22 18:36:47 +01:00
committed by GitHub
parent c7f0508707
commit 136104d4b0
80 changed files with 2023 additions and 1699 deletions

View File

@@ -41,6 +41,7 @@
"nelmio/api-doc-bundle": "^4.0",
"nelmio/cors-bundle": "^2.0",
"onelogin/php-saml": "^4.0",
"openspout/openspout": "^4.0",
"pagerfanta/pagerfanta": "^3.0",
"phpoffice/phpspreadsheet": "^2.0",
"phpoffice/phpword": "^1.0",

95
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "455e6cae5b8c0ce7458599a0f317c2d3",
"content-hash": "9f659b51f4e0278a3fc52665e4be66a5",
"packages": [
{
"name": "azuyalabs/yasumi",
@@ -3301,6 +3301,99 @@
],
"time": "2024-05-30T15:10:40+00:00"
},
{
"name": "openspout/openspout",
"version": "v4.25.0",
"source": {
"type": "git",
"url": "https://github.com/openspout/openspout.git",
"reference": "519affe730d92e1598720a6467227fc28550f0e6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/openspout/openspout/zipball/519affe730d92e1598720a6467227fc28550f0e6",
"reference": "519affe730d92e1598720a6467227fc28550f0e6",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-filter": "*",
"ext-libxml": "*",
"ext-xmlreader": "*",
"ext-zip": "*",
"php": "~8.1.0 || ~8.2.0 || ~8.3.0"
},
"require-dev": {
"ext-zlib": "*",
"friendsofphp/php-cs-fixer": "^3.64.0",
"infection/infection": "^0.29.6",
"phpbench/phpbench": "^1.3.1",
"phpstan/phpstan": "^1.12.4",
"phpstan/phpstan-phpunit": "^1.4.0",
"phpstan/phpstan-strict-rules": "^1.6.1",
"phpunit/phpunit": "^10.5.20 || ^11.3.6"
},
"suggest": {
"ext-iconv": "To handle non UTF-8 CSV files (if \"php-mbstring\" is not already installed or is too limited)",
"ext-mbstring": "To handle non UTF-8 CSV files (if \"iconv\" is not already installed)"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.3.x-dev"
}
},
"autoload": {
"psr-4": {
"OpenSpout\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Adrien Loison",
"email": "adrien@box.com"
}
],
"description": "PHP Library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way",
"homepage": "https://github.com/openspout/openspout",
"keywords": [
"OOXML",
"csv",
"excel",
"memory",
"odf",
"ods",
"office",
"open",
"php",
"read",
"scale",
"spreadsheet",
"stream",
"write",
"xlsx"
],
"support": {
"issues": "https://github.com/openspout/openspout/issues",
"source": "https://github.com/openspout/openspout/tree/v4.25.0"
},
"funding": [
{
"url": "https://paypal.me/filippotessarotto",
"type": "custom"
},
{
"url": "https://github.com/Slamdunk",
"type": "github"
}
],
"time": "2024-09-24T09:03:42+00:00"
},
{
"name": "pagerfanta/pagerfanta",
"version": "v3.8.0",

View File

@@ -25,6 +25,7 @@ services:
- '../src/Doctrine/Extensions/'
- '../src/Entity/'
- '../src/Event/'
- '../src/Export/Package/'
- '../src/Form/Model/'
- '../src/Model/'
- '../src/Repository/Loader/'

View File

@@ -1557,61 +1557,6 @@ parameters:
count: 1
path: src/Export/Annotation/Order.php
-
message: "#^Cannot call method getCurrency\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 4
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Cannot call method getMetaField\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Cannot call method getName\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Cannot call method getNumber\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Cannot call method getVatId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Parameter \\#1 \\$id of method Symfony\\\\Contracts\\\\Translation\\\\TranslatorInterface\\:\\:trans\\(\\) expects string, string\\|null given\\.$#"
count: 5
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Parameter \\#1 \\$name of method App\\\\Entity\\\\Activity\\:\\:getMetaField\\(\\) expects string, string\\|null given\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Parameter \\#1 \\$name of method App\\\\Entity\\\\ExportableItem\\:\\:getMetaField\\(\\) expects string, string\\|null given\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Parameter \\#1 \\$name of method App\\\\Entity\\\\Project\\:\\:getMetaField\\(\\) expects string, string\\|null given\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Parameter \\#1 \\$name of method App\\\\Entity\\\\User\\:\\:getPreference\\(\\) expects string, string\\|null given\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Property App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:\\$columns type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Export/Base/AbstractSpreadsheetRenderer.php
-
message: "#^Cannot call method getBudget\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
count: 1

View File

@@ -27,7 +27,7 @@ if [[ -n $1 ]]; then
elif [ "$1" == 'core' ]; then
vendor/bin/phpstan analyse -c phpstan.neon ${ARGS}
exit
elif [ "$1" == 'test' ]; then
elif [ "$1" == 'test' ] || [ "$1" == 'tests' ]; then
vendor/bin/phpstan analyse -c tests/phpstan.neon ${ARGS}
exit
else

View File

@@ -78,11 +78,6 @@ final class ProjectViewController extends AbstractController
{
$data = $this->getData($request, $service);
// Projektübersicht inkl. dem was Projektdetails anzeigen
// Budget / Zeitbudget
// Abrechenbar
// Interner Preis
$content = $this->renderView('reporting/project_list_export.html.twig', $data);
$reader = new Html();

View File

@@ -41,7 +41,7 @@ final class UserMonthController extends AbstractUserReportController
#[Route(path: '/month_export', name: 'report_user_month_export', methods: ['GET', 'POST'])]
public function export(Request $request): Response
{
$data = $this->getData($request);
$data = $this->getData($request, true);
$content = $this->renderView('reporting/report_by_user_data.html.twig', $data);
@@ -53,13 +53,14 @@ final class UserMonthController extends AbstractUserReportController
return $writer->getFileResponse($spreadsheet);
}
private function getData(Request $request): array
private function getData(Request $request, bool $export = false): array
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
$canChangeUser = $this->canSelectUser();
$values = new MonthByUser();
$values->setDecimal($export);
$values->setUser($currentUser);
$values->setDate($dateTimeFactory->getStartOfMonth());

View File

@@ -40,7 +40,7 @@ final class UserWeekController extends AbstractUserReportController
#[Route(path: '/week_export', name: 'report_user_week_export', methods: ['GET', 'POST'])]
public function export(Request $request): Response
{
$data = $this->getData($request);
$data = $this->getData($request, true);
$content = $this->renderView('reporting/report_by_user_data.html.twig', $data);
@@ -52,13 +52,14 @@ final class UserWeekController extends AbstractUserReportController
return $writer->getFileResponse($spreadsheet);
}
private function getData(Request $request): array
private function getData(Request $request, bool $export = false): array
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
$canChangeUser = $this->canSelectUser();
$values = new WeekByUser();
$values->setDecimal($export);
$values->setUser($currentUser);
$values->setDate($dateTimeFactory->getStartOfWeek());

View File

@@ -39,7 +39,7 @@ final class UserYearController extends AbstractUserReportController
#[Route(path: '/year_export', name: 'report_user_year_export', methods: ['GET', 'POST'])]
public function export(Request $request, SystemConfiguration $systemConfiguration): Response
{
$data = $this->getData($request, $systemConfiguration);
$data = $this->getData($request, $systemConfiguration, true);
$content = $this->renderView('reporting/report_by_user_year_export.html.twig', $data);
@@ -51,13 +51,14 @@ final class UserYearController extends AbstractUserReportController
return $writer->getFileResponse($spreadsheet);
}
private function getData(Request $request, SystemConfiguration $systemConfiguration): array
private function getData(Request $request, SystemConfiguration $systemConfiguration, bool $export = false): array
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
$canChangeUser = $this->canSelectUser();
$values = new YearByUser();
$values->setDecimal($export);
$values->setUser($currentUser);
$defaultDate = $dateTimeFactory->createStartOfYear();

View File

@@ -1,809 +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\Base;
use App\Entity\ExportableItem;
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\Export\ExportFilename;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TimesheetQuery;
use App\Twig\LocaleFormatExtensions;
use App\Utils\StringHelper;
use DateTime;
use PhpOffice\PhpSpreadsheet\Cell\CellAddress;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @internal means no BC promise whatsoever!
*/
abstract class AbstractSpreadsheetRenderer
{
public const DATETIME_FORMAT = 'yyyy-mm-dd hh:mm';
public const TIME_FORMAT = 'hh:mm';
public const DURATION_FORMAT = '[hh]:mm';
public const DURATION_DECIMAL = '#0.00';
// https://support.microsoft.com/de-de/office/zahlenformatcodes-5026bbd6-04bc-48cd-bf33-80f18b4eae68
// Part 1 = positive; Part 2 = negative; Part 3 = zero; Part 4 = Text
public const RATE_FORMAT_DEFAULT = '#.##0,00 [$%1$s];-#.##0,00 [$%1$s]';
public const RATE_FORMAT_LEFT = '_("%1$s"* #,##0.00_);_("%1$s"* -#,##0.00;_("%1$s"* "-"??_);_(@_)';
public const RATE_FORMAT_RIGHT = '_(* "%1$s" #,##0.00_);_(* "%1$s" -#,##0.00;_(* "%1$s" "-"??_);_(@_)';
/**
* @internal used in html to excel exporter
*/
public const RATE_FORMAT_NO_CURRENCY = '#,##0.00;-#,##0.00';
/**
* @see self:RATE_FORMAT_*
*/
protected string $rateFormat = self::RATE_FORMAT_LEFT;
protected string $durationFormat = self::DURATION_FORMAT;
protected int $durationBase = 86400;
/**
* @var array<string, array>
*/
protected array $columns = [
'date' => [],
'begin' => [],
'end' => [],
'duration' => [],
'rate' => [],
'rate_internal' => [
'label' => 'internalRate', // different translation key
],
'user' => [
'label' => 'name'
],
'username' => [],
'accountNumber' => [
'label' => 'account_number'
],
'customer' => [],
'project' => [],
'activity' => [],
'description' => [
'maxWidth' => 50,
'wrapText' => false,
'sanitizeDDE' => true,
],
'exported' => [],
'billable' => [],
'tags' => [],
'hourlyRate' => [],
'fixedRate' => [],
'timesheet-meta' => [],
'customer-meta' => [],
'project-meta' => [],
'activity-meta' => [],
'user-meta' => [],
'type' => [],
'category' => [],
'customer_number' => [],
'customer_vat' => [],
'order_number' => [],
];
public function __construct(
protected TranslatorInterface $translator,
protected LocaleFormatExtensions $dateExtension,
protected EventDispatcherInterface $dispatcher,
protected Security $voter
) {
}
protected function isRenderRate(TimesheetQuery $query): bool
{
if ($this->voter->getUser() === null) {
// for command line export
return true;
}
if (null !== $query->getUser()) {
return $this->voter->isGranted('view_rate_own_timesheet');
}
return $this->voter->isGranted('view_rate_other_timesheet');
}
protected function setFormattedDateTime(Worksheet $sheet, int $column, int $row, ?DateTime $date): void
{
if (null === $date) {
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), '');
return;
}
$excelDate = Date::PHPToExcel($date);
if ($excelDate === false) {
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $date);
return;
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $excelDate);
// TODO why is that format hardcoded and does not depend on the users locale?
$sheet->getStyle(CellAddress::fromColumnAndRow($column, $row))->getNumberFormat()->setFormatCode(self::DATETIME_FORMAT);
}
protected function setFormattedTime(Worksheet $sheet, int $column, int $row, ?DateTime $date): void
{
if (null === $date) {
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), '');
return;
}
$excelDate = Date::PHPToExcel($date);
if ($excelDate === false) {
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $date);
return;
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $excelDate);
$sheet->getStyle(CellAddress::fromColumnAndRow($column, $row))->getNumberFormat()->setFormatCode(self::TIME_FORMAT);
}
protected function setFormattedDate(Worksheet $sheet, int $column, int $row, ?DateTime $date): void
{
if (null === $date) {
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), '');
return;
}
$excelDate = Date::PHPToExcel($date);
if ($excelDate === false) {
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $date);
return;
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $excelDate);
// TODO why is that format hardcoded and does not depend on the users locale?
$sheet->getStyle(CellAddress::fromColumnAndRow($column, $row))->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_DATE_YYYYMMDD);
}
protected function setDurationTotal(Worksheet $sheet, int $column, int $row, string $startCoordinate, string $endCoordinate): void
{
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), \sprintf('=SUBTOTAL(9,%s:%s)', $startCoordinate, $endCoordinate));
$style = $sheet->getStyle(CellAddress::fromColumnAndRow($column, $row));
$style->getNumberFormat()->setFormatCode($this->durationFormat);
}
protected function setDuration(Worksheet $sheet, int $column, int $row, ?int $duration): void
{
if (null === $duration) {
$duration = 0;
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), \sprintf('=%s/%s', $duration, $this->durationBase));
$sheet->getStyle(CellAddress::fromColumnAndRow($column, $row))->getNumberFormat()->setFormatCode($this->durationFormat);
}
protected function setRateTotal(Worksheet $sheet, int $column, int $row, string $startCoordinate, string $endCoordinate): void
{
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), \sprintf('=SUBTOTAL(9,%s:%s)', $startCoordinate, $endCoordinate));
}
protected function setRateStyle(Worksheet $sheet, int $column, int $row, ?string $currency): void
{
$sheet->getStyle(CellAddress::fromColumnAndRow($column, $row))->getNumberFormat()->setFormatCode(
\sprintf($this->rateFormat, $currency ?? '')
);
}
protected function setRate(Worksheet $sheet, int $column, int $row, ?float $rate, ?string $currency): void
{
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $rate ?? 0.0);
$this->setRateStyle($sheet, $column, $row, $currency);
}
/**
* @param MetaDisplayEventInterface $event
* @return MetaTableTypeInterface[]
*/
protected function findMetaColumns(MetaDisplayEventInterface $event): array
{
$this->dispatcher->dispatch($event);
return $event->getFields();
}
/**
* @param ExportableItem[] $exportItems
* @param TimesheetQuery $query
* @param array<string, array<string, callable|int|float|false|null>> $columns
* @return array<string, array<string, callable|int|float|false|null>>
*/
protected function getColumns(array $exportItems, TimesheetQuery $query, array $columns): array
{
if (null !== $query->getCurrentUser() && $query->getCurrentUser()->isExportDecimal()) {
$this->durationFormat = self::DURATION_DECIMAL;
$this->durationBase = 3600;
}
$showRates = $this->isRenderRate($query);
if (isset($columns['date']) && !isset($columns['date']['render'])) {
$columns['date']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$this->setFormattedDate($sheet, $column, $row, $entity->getBegin());
};
}
if (isset($columns['begin']) && !isset($columns['begin']['render'])) {
$columns['begin']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$this->setFormattedTime($sheet, $column, $row, $entity->getBegin());
};
}
if (isset($columns['end']) && !isset($columns['end']['render'])) {
$columns['end']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$this->setFormattedTime($sheet, $column, $row, $entity->getEnd());
};
}
if (isset($columns['duration']) && !isset($columns['duration']['render'])) {
$columns['duration']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$this->setDuration($sheet, $column, $row, $entity->getDuration());
};
}
if ($showRates && isset($columns['rate']) && !isset($columns['rate']['render'])) {
$columns['rate']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$currency = '';
if (null !== $entity->getProject()) {
$currency = $entity->getProject()->getCustomer()->getCurrency();
}
$this->setRate($sheet, $column, $row, $entity->getRate(), $currency);
};
}
if ($showRates && isset($columns['rate_internal']) && !isset($columns['rate_internal']['render'])) {
$columns['rate_internal']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$currency = '';
if (null !== $entity->getProject()) {
$currency = $entity->getProject()->getCustomer()->getCurrency();
}
$this->setRate($sheet, $column, $row, $entity->getInternalRate(), $currency);
};
}
if (isset($columns['user']) && !isset($columns['user']['render'])) {
$columns['user']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$user = '';
if (null !== $entity->getUser()) {
$user = $entity->getUser()->getDisplayName();
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $user);
};
}
if (isset($columns['username'])) {
if (!isset($columns['username']['render'])) {
$columns['username']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$username = '';
if (null !== $entity->getUser()) {
$username = $entity->getUser()->getUserIdentifier();
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $username);
};
}
}
if (isset($columns['accountNumber'])) {
if (!isset($columns['accountNumber']['render'])) {
$columns['accountNumber']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$accountNumber = '';
if (null !== $entity->getUser()) {
$accountNumber = $entity->getUser()->getAccountNumber();
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $accountNumber);
};
}
}
if (isset($columns['customer']) && !isset($columns['customer']['render'])) {
$columns['customer']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$customer = '';
if (null !== $entity->getProject()) {
$customer = $entity->getProject()->getCustomer()->getName();
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $customer);
};
}
if (isset($columns['project']) && !isset($columns['project']['render'])) {
$columns['project']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$project = '';
if (null !== $entity->getProject()) {
$project = $entity->getProject()->getName();
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $project);
};
}
if (isset($columns['activity']) && !isset($columns['activity']['render'])) {
$columns['activity']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$activity = '';
if (null !== $entity->getActivity()) {
$activity = $entity->getActivity()->getName();
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $activity);
};
}
if (isset($columns['description']) && !isset($columns['description']['render'])) {
$maxWidth = \array_key_exists('maxWidth', $columns['description']) && is_numeric($columns['description']['maxWidth']) ? (int) $columns['description']['maxWidth'] : null;
$wrapText = \array_key_exists('wrapText', $columns['description']) ? (bool) $columns['description']['wrapText'] : false;
$sanitizeText = \array_key_exists('sanitizeDDE', $columns['description']) ? (bool) $columns['description']['sanitizeDDE'] : true;
// This column has a column-only formatter to set the maximum width of a column.
// It needs to be executed once, so we use this as a flag on when to skip it.
$isColumnFormatted = false;
$columns['description']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) use (&$isColumnFormatted, $maxWidth, $wrapText, $sanitizeText) {
$cell = $sheet->getCell(CellAddress::fromColumnAndRow($column, $row));
$desc = $entity->getDescription();
if ($sanitizeText && null !== $desc) {
$desc = StringHelper::sanitizeDDE($desc);
}
$cell->setValueExplicit($desc, DataType::TYPE_STRING);
// Apply wrap text if configured
if ($wrapText) {
$cell->getStyle()->getAlignment()->setWrapText(true);
}
// Apply max width, only needs to be once per column
if (!$isColumnFormatted) {
if (null !== $maxWidth) {
$sheet->getColumnDimensionByColumn($column)->setWidth($maxWidth);
}
$isColumnFormatted = true;
}
};
}
if (isset($columns['exported']) && !isset($columns['exported']['render'])) {
$columns['exported']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$exported = $entity->isExported() ? 'yes' : 'no';
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans($exported));
};
}
if (isset($columns['billable']) && !isset($columns['billable']['render'])) {
$columns['billable']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$exported = $entity->isBillable() ? 'yes' : 'no';
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans($exported));
};
}
if (isset($columns['tags']) && !isset($columns['tags']['render'])) {
$columns['tags']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), implode(',', $entity->getTagsAsArray()));
};
}
if ($showRates && isset($columns['hourlyRate']) && !isset($columns['hourlyRate']['render'])) {
$columns['hourlyRate']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$currency = '';
if (null !== $entity->getProject()) {
$currency = $entity->getProject()->getCustomer()->getCurrency();
}
$this->setRate($sheet, $column, $row, $entity->getHourlyRate(), $currency);
};
}
if ($showRates && isset($columns['fixedRate']) && !isset($columns['fixedRate']['render'])) {
$columns['fixedRate']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$currency = '';
if (null !== $entity->getProject()) {
$currency = $entity->getProject()->getCustomer()->getCurrency();
}
$this->setRate($sheet, $column, $row, $entity->getFixedRate(), $currency);
};
}
if (isset($columns['timesheet-meta'])) {
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));
$columns['timesheet-meta'] = [
'header' => function (Worksheet $sheet, int $row, int $column) use ($timesheetMetaFields): int {
foreach ($timesheetMetaFields as $metaField) {
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
}
return \count($timesheetMetaFields);
},
'render' => function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) use ($timesheetMetaFields): int {
foreach ($timesheetMetaFields as $metaField) {
$metaFieldValue = '';
$metaField = $entity->getMetaField($metaField->getName());
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $metaFieldValue);
}
return \count($timesheetMetaFields);
}
];
}
if (isset($columns['customer-meta'])) {
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT));
$columns['customer-meta'] = [
'header' => function (Worksheet $sheet, int $row, int $column) use ($customerMetaFields): int {
foreach ($customerMetaFields as $metaField) {
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
}
return \count($customerMetaFields);
},
'render' => function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) use ($customerMetaFields): int {
foreach ($customerMetaFields as $metaField) {
$metaFieldValue = '';
if (null !== $entity->getProject()) {
$metaField = $entity->getProject()->getCustomer()->getMetaField($metaField->getName());
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $metaFieldValue);
}
return \count($customerMetaFields);
}
];
}
if (isset($columns['project-meta'])) {
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT));
$columns['project-meta'] = [
'header' => function (Worksheet $sheet, int $row, int $column) use ($projectMetaFields): int {
foreach ($projectMetaFields as $metaField) {
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
}
return \count($projectMetaFields);
},
'render' => function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) use ($projectMetaFields): int {
foreach ($projectMetaFields as $metaField) {
$metaFieldValue = '';
if (null !== $entity->getProject()) {
$metaField = $entity->getProject()->getMetaField($metaField->getName());
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $metaFieldValue);
}
return \count($projectMetaFields);
}
];
}
if (isset($columns['activity-meta'])) {
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query->copyTo(new ActivityQuery()), ActivityMetaDisplayEvent::EXPORT));
$columns['activity-meta'] = [
'header' => function (Worksheet $sheet, int $row, int $column) use ($activityMetaFields): int {
foreach ($activityMetaFields as $metaField) {
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
}
return \count($activityMetaFields);
},
'render' => function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) use ($activityMetaFields): int {
foreach ($activityMetaFields as $metaField) {
$metaFieldValue = '';
if (null !== $entity->getActivity()) {
$metaField = $entity->getActivity()->getMetaField($metaField->getName());
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $metaFieldValue);
}
return \count($activityMetaFields);
}
];
}
if (isset($columns['user-meta'])) {
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
$this->dispatcher->dispatch($event);
$userPreferences = $event->getPreferences();
$columns['user-meta'] = [
'header' => function (Worksheet $sheet, int $row, int $column) use ($userPreferences): int {
foreach ($userPreferences as $metaField) {
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
}
return \count($userPreferences);
},
'render' => function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) use ($userPreferences): int {
foreach ($userPreferences as $preference) {
$metaFieldValue = '';
if (null !== $entity->getUser()) {
$metaField = $entity->getUser()->getPreference($preference->getName());
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $metaFieldValue);
}
return \count($userPreferences);
}
];
}
if (isset($columns['type']) && !isset($columns['type']['render'])) {
$columns['type']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $entity->getType());
};
}
if (isset($columns['category']) && !isset($columns['category']['render'])) {
$columns['category']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $entity->getCategory());
};
}
if (isset($columns['customer_number'])) {
if (!isset($columns['customer_number']['header'])) {
$columns['customer_number']['header'] = function (Worksheet $sheet, int $row, int $column): int {
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans('number'));
return 1;
};
}
if (!isset($columns['customer_number']['render'])) {
$columns['customer_number']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$customerId = '';
if (null !== $entity->getProject()) {
$customerId = $entity->getProject()->getCustomer()->getNumber();
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $customerId);
};
}
}
if (isset($columns['customer_vat']) && !isset($columns['customer_vat']['render'])) {
if (!isset($columns['customer_vat']['header'])) {
$columns['customer_vat']['header'] = function (Worksheet $sheet, int $row, int $column): int {
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans('vat_id'));
return 1;
};
}
if (!isset($columns['customer_vat']['render'])) {
$columns['customer_vat']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$customerVat = '';
if (null !== $entity->getProject()) {
$customerVat = $entity->getProject()->getCustomer()->getVatId();
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $customerVat);
};
}
}
if (isset($columns['order_number']) && !isset($columns['order_number']['render'])) {
if (!isset($columns['order_number']['header'])) {
$columns['order_number']['header'] = function (Worksheet $sheet, int $row, int $column): int {
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans('orderNumber'));
return 1;
};
}
if (!isset($columns['order_number']['render'])) {
$columns['order_number']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
$orderNumber = '';
if (null !== $entity->getProject()) {
$orderNumber = $entity->getProject()->getOrderNumber();
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $orderNumber);
};
}
}
if (!$showRates) {
$removes = ['rate', 'fixedRate', 'hourlyRate', 'rate_internal'];
foreach ($removes as $removeMe) {
if (\array_key_exists($removeMe, $columns)) {
unset($columns[$removeMe]);
}
}
}
return $columns;
}
/**
* @param ExportableItem[] $exportItems
* @param TimesheetQuery $query
* @return Spreadsheet
* @throws \PhpOffice\PhpSpreadsheet\Exception
*/
protected function fromArrayToSpreadsheet(array $exportItems, TimesheetQuery $query): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
// Set default row height to automatic, so we can specify wrap text columns later on
// without bloating the output file as we would need to store stylesheet info for every cell.
// LibreOffice is still not considering this flag, @see https://github.com/PHPOffice/PHPExcel/issues/588
// with no solution implemented so nothing we can do about it there.
$sheet->getDefaultRowDimension()->setRowHeight(-1);
$recordsHeaderColumn = 1;
$recordsHeaderRow = 1;
$columns = $this->getColumns($exportItems, $query, $this->columns);
foreach ($columns as $label => $settings) {
if (isset($settings['header'])) {
if (!\is_callable($settings['header'])) {
throw new \RuntimeException('Invalid header renderer given for: ' . $label);
}
$amount = $settings['header']($sheet, $recordsHeaderRow, $recordsHeaderColumn);
$recordsHeaderColumn += $amount;
} else {
$sheet->setCellValue(CellAddress::fromColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow), $this->translator->trans((\array_key_exists('label', $settings) && \is_string($settings['label'])) ? $settings['label'] : $label));
}
}
$entryHeaderRow = $recordsHeaderRow + 1;
$durationColumn = null;
$rateColumn = null;
$internalRateColumn = null;
foreach ($exportItems as $exportItem) {
$entryHeaderColumn = 1;
foreach ($columns as $label => $settings) {
if ($label === 'duration') {
$durationColumn = $entryHeaderColumn;
} elseif ($label === 'rate') {
$rateColumn = $entryHeaderColumn;
} elseif ($label === 'rate_internal') {
$internalRateColumn = $entryHeaderColumn;
}
if (!\array_key_exists('render', $settings) || !\is_callable($settings['render'])) {
throw new \RuntimeException(\sprintf('Missing or invalid renderer for export column %s', $label));
}
$amount = $settings['render']($sheet, $entryHeaderRow, $entryHeaderColumn, $exportItem);
$entryHeaderColumn += (null === $amount) ? 1 : (int) $amount;
}
$entryHeaderRow++;
}
if ($this->isTotalRowSupported()) {
if (null !== $durationColumn) {
$startCoordinate = $sheet->getCell(CellAddress::fromColumnAndRow($durationColumn, 2))->getCoordinate();
$endCoordinate = $sheet->getCell(CellAddress::fromColumnAndRow($durationColumn, $entryHeaderRow - 1))->getCoordinate();
$this->setDurationTotal($sheet, $durationColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyle(CellAddress::fromColumnAndRow($durationColumn, $entryHeaderRow));
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
}
if (null !== $rateColumn) {
$startCoordinate = $sheet->getCell(CellAddress::fromColumnAndRow($rateColumn, 2))->getCoordinate();
$endCoordinate = $sheet->getCell(CellAddress::fromColumnAndRow($rateColumn, $entryHeaderRow - 1))->getCoordinate();
$this->setRateTotal($sheet, $rateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyle(CellAddress::fromColumnAndRow($rateColumn, $entryHeaderRow));
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
}
if (null !== $internalRateColumn) {
$startCoordinate = $sheet->getCell(CellAddress::fromColumnAndRow($internalRateColumn, 2))->getCoordinate();
$endCoordinate = $sheet->getCell(CellAddress::fromColumnAndRow($internalRateColumn, $entryHeaderRow - 1))->getCoordinate();
$this->setRateTotal($sheet, $internalRateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyle(CellAddress::fromColumnAndRow($internalRateColumn, $entryHeaderRow));
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
}
}
return $spreadsheet;
}
protected function isTotalRowSupported(): bool
{
return false;
}
/**
* @param ExportableItem[] $exportItems
* @param TimesheetQuery $query
* @return Response
* @throws \PhpOffice\PhpSpreadsheet\Exception
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
public function render(array $exportItems, TimesheetQuery $query): Response
{
$spreadsheet = $this->fromArrayToSpreadsheet($exportItems, $query);
$file = $this->saveSpreadsheet($spreadsheet);
$filename = new ExportFilename($query);
return $this->getFileResponse($file, $filename->getFilename() . $this->getFileExtension());
}
/**
* @return string
*/
abstract public function getFileExtension(): string;
/**
* @param string $file
* @param string $filename
* @return BinaryFileResponse
*/
protected function getFileResponse(string $file, string $filename): BinaryFileResponse
{
$response = new BinaryFileResponse($file);
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
$response->headers->set('Content-Type', $this->getContentType());
$response->headers->set('Content-Disposition', $disposition);
$response->deleteFileAfterSend(true);
return $response;
}
/**
* @return string
*/
abstract protected function getContentType(): string;
/**
* @param Spreadsheet $spreadsheet
* @return string
* @throws \Exception
*/
abstract protected function saveSpreadsheet(Spreadsheet $spreadsheet): string;
}

View File

@@ -9,45 +9,21 @@
namespace App\Export\Base;
use PhpOffice\PhpSpreadsheet\Cell\CellAddress;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use App\Entity\ExportableItem;
use App\Export\ExportFilename;
use App\Export\Package\SpoutSpreadsheet;
use App\Export\RendererInterface;
use App\Export\TimesheetExportInterface;
use App\Repository\Query\TimesheetQuery;
use OpenSpout\Writer\CSV\Writer;
use Symfony\Component\HttpFoundation\Response;
class CsvRenderer extends AbstractSpreadsheetRenderer
final class CsvRenderer implements RendererInterface, TimesheetExportInterface
{
/**
* @return string
*/
public function getFileExtension(): string
use ExportTrait;
public function __construct(private readonly SpreadsheetRenderer $spreadsheetRenderer)
{
return '.csv';
}
/**
* @return string
*/
protected function getContentType(): string
{
return 'text/csv';
}
/**
* @param Spreadsheet $spreadsheet
* @return string
* @throws \Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
{
$filename = @tempnam(sys_get_temp_dir(), 'kimai-export-csv');
if (false === $filename) {
throw new \Exception('Could not open temporary file');
}
$writer = IOFactory::createWriter($spreadsheet, 'Csv');
$writer->save($filename);
return $filename;
}
public function getId(): string
@@ -55,17 +31,38 @@ class CsvRenderer extends AbstractSpreadsheetRenderer
return 'csv';
}
protected function setDuration(Worksheet $sheet, int $column, int $row, ?int $duration): void
public function getTitle(): string
{
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), \sprintf('=%s', $duration ?? 0));
return 'csv';
}
protected function setRate(Worksheet $sheet, int $column, int $row, ?float $rate, ?string $currency): void
/**
* @param ExportableItem[] $exportItems
*/
public function render(array $exportItems, TimesheetQuery $query): Response
{
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $rate);
if ($rate === 0.00) {
return;
return $this->getFileResponse(
$this->renderFile($exportItems, $query),
(new ExportFilename($query))->getFilename() . '.csv',
'text/csv'
);
}
$this->setRateStyle($sheet, $column, $row, $currency);
/**
* @param ExportableItem[] $exportItems
*/
public function renderFile(array $exportItems, TimesheetQuery $query): \SplFileInfo
{
$filename = @tempnam(sys_get_temp_dir(), 'kimai-export-csv');
if (false === $filename) {
throw new \Exception('Could not open temporary file');
}
$spreadsheet = new SpoutSpreadsheet(new Writer());
$spreadsheet->open($filename);
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);
return new \SplFileInfo($filename);
}
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Base;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
/**
* @internal
*/
trait ExportTrait
{
protected function getFileResponse(string $file, string $filename, string $contentType): BinaryFileResponse
{
$response = new BinaryFileResponse($file);
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
$response->headers->set('Content-Type', $contentType);
$response->headers->set('Content-Disposition', $disposition);
$response->deleteFileAfterSend(true);
return $response;
}
}

View File

@@ -18,6 +18,7 @@ use App\Event\MetaDisplayEventInterface;
use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Event\UserPreferenceDisplayEvent;
use App\Export\ExportRendererInterface;
use App\Project\ProjectStatisticService;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\CustomerQuery;
@@ -29,29 +30,22 @@ use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
use Twig\Extension\SandboxExtension;
class HtmlRenderer
class HtmlRenderer implements ExportRendererInterface
{
use RendererTrait;
/**
* @var string
*/
private $id = 'html';
/**
* @var string
*/
private $template = 'default.html.twig';
private string $id = 'html';
private string $template = 'default.html.twig';
public function __construct(
protected Environment $twig,
protected EventDispatcherInterface $dispatcher,
private ProjectStatisticService $projectStatisticService,
private ActivityStatisticService $activityStatisticService
protected readonly Environment $twig,
protected readonly EventDispatcherInterface $dispatcher,
private readonly ProjectStatisticService $projectStatisticService,
private readonly ActivityStatisticService $activityStatisticService
) {
}
/**
* @param MetaDisplayEventInterface $event
* @return MetaTableTypeInterface[]
*/
protected function findMetaColumns(MetaDisplayEventInterface $event): array
@@ -75,11 +69,6 @@ class HtmlRenderer
/**
* @param ExportableItem[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
@@ -123,22 +112,23 @@ class HtmlRenderer
return '@export/' . $this->template;
}
public function setTemplate(string $filename): HtmlRenderer
public function setTemplate(string $filename): void
{
$this->template = $filename;
return $this;
}
public function setId(string $id): HtmlRenderer
public function setId(string $id): void
{
$this->id = $id;
return $this;
}
public function getId(): string
{
return $this->id;
}
public function getTitle(): string
{
return 'print';
}
}

View File

@@ -11,6 +11,8 @@ namespace App\Export\Base;
use App\Entity\ExportableItem;
use App\Export\ExportFilename;
use App\Export\ExportRendererInterface;
use App\Export\TimesheetExportInterface;
use App\Pdf\HtmlToPdfConverter;
use App\Pdf\PdfContext;
use App\Pdf\PdfRendererTrait;
@@ -21,7 +23,7 @@ use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
use Twig\Extension\SandboxExtension;
class PDFRenderer implements DispositionInlineInterface
class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface, TimesheetExportInterface
{
use RendererTrait;
use PDFRendererTrait;
@@ -30,10 +32,19 @@ class PDFRenderer implements DispositionInlineInterface
private string $template = 'default.pdf.twig';
private array $pdfOptions = [];
public function __construct(private Environment $twig, private HtmlToPdfConverter $converter, private ProjectStatisticService $projectStatisticService)
public function __construct(
private readonly Environment $twig,
private readonly HtmlToPdfConverter $converter,
private readonly ProjectStatisticService $projectStatisticService
)
{
}
public function getTitle(): string
{
return 'pdf';
}
protected function getTemplate(): string
{
return '@export/' . $this->template;

View File

@@ -0,0 +1,215 @@
<?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\Base;
use App\Entity\ExportableItem;
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\Export\Package\CellFormatter\ArrayFormatter;
use App\Export\Package\CellFormatter\BooleanFormatter;
use App\Export\Package\CellFormatter\DateFormatter;
use App\Export\Package\CellFormatter\DefaultFormatter;
use App\Export\Package\CellFormatter\DurationFormatter;
use App\Export\Package\CellFormatter\RateFormatter;
use App\Export\Package\CellFormatter\TextFormatter;
use App\Export\Package\CellFormatter\TimeFormatter;
use App\Export\Package\Column;
use App\Export\Package\SpreadsheetPackage;
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\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @internal means no BC promise whatsoever!
*/
final class SpreadsheetRenderer
{
public function __construct(
protected TranslatorInterface $translator,
protected EventDispatcherInterface $dispatcher,
protected Security $voter
) {
}
private function isRenderRate(TimesheetQuery $query): bool
{
if ($this->voter->getUser() === null) {
// for command line export
return true;
}
if (null !== $query->getUser()) {
return $this->voter->isGranted('view_rate_own_timesheet');
}
return $this->voter->isGranted('view_rate_other_timesheet');
}
/**
* @return MetaTableTypeInterface[]
*/
private function findMetaColumns(MetaDisplayEventInterface $event): array
{
$this->dispatcher->dispatch($event);
return $event->getFields();
}
/**
* @param ExportableItem[] $exportItems
*/
public function writeSpreadsheet(SpreadsheetPackage $spreadsheetPackage, array $exportItems, TimesheetQuery $query): void
{
$columns = $this->getColumns($query);
$headerRow = [];
foreach ($columns as $column) {
$headerRow[] = $this->translator->trans($column->getHeader());
}
$spreadsheetPackage->setHeader($headerRow);
$currentRow = 1;
foreach ($exportItems as $exportItem) {
$cells = [];
foreach ($columns as $column) {
$cells[] = $column->getValue($exportItem);
}
$spreadsheetPackage->addRow($cells);
$currentRow++;
}
if ($currentRow > 1) {
$totalColumns = ['duration', 'rate', 'internalRate'];
$columnNames = range('A', 'Z');
$totalRow = [];
$totalColumn = 1;
foreach ($columns as $column) {
$formula = null;
if (\in_array($column->getName(), $totalColumns)) {
$columnName = $columnNames[$totalColumn - 1];
$formula = \sprintf('=SUM(%s2:%s%s)', $columnName, $columnName, $currentRow);
}
$totalRow[] = $formula;
$totalColumn++;
}
$spreadsheetPackage->addRow($totalRow, ['totals' => true]);
}
$spreadsheetPackage->save();
}
/**
* @return array<Column>
*/
private function getColumns(TimesheetQuery $query): array
{
$showRates = $this->isRenderRate($query);
$columns = [];
$columns[] = (new Column('date', new DateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin());
$columns[] = (new Column('begin', new TimeFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin());
$columns[] = (new Column('end', new TimeFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getEnd());
$columns[] = (new Column('duration', new DurationFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration());
if ($showRates) {
$columns[] = (new Column('currency', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency());
$columns[] = (new Column('rate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getRate());
$columns[] = (new Column('internalRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getInternalRate());
$columns[] = (new Column('hourlyRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getHourlyRate());
$columns[] = (new Column('fixedRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getFixedRate());
}
$columns[] = (new Column('username', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName());
$columns[] = (new Column('account_number', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber());
$columns[] = (new Column('customer', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName());
$columns[] = (new Column('project', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName());
$columns[] = (new Column('activity', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getName());
$columns[] = (new Column('description', new TextFormatter(true)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDescription());
//$columns[] = (new Column('exported', new BooleanFormatter()))->withExtractor(fn(ExportableItem $exportableItem) => $exportableItem->isExported());
$columns[] = (new Column('billable', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isBillable());
$columns[] = (new Column('tags', new ArrayFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getTagsAsArray());
$columns[] = (new Column('type', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getType());
$columns[] = (new Column('category', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getCategory());
$columns[] = (new Column('number', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getNumber());
$columns[] = (new Column('project_number', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getNumber());
$columns[] = (new Column('vat_id', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getVatId());
$columns[] = (new Column('orderNumber', new DefaultFormatter()))->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(), new DefaultFormatter()))
->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getMetaField($metaField->getName())?->getValue();
});
}
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() === null) {
continue;
}
$columns[] = (new Column('customer.meta.' . $metaField->getName(), new DefaultFormatter()))
->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getProject()?->getCustomer()?->getMetaField($metaField->getName())?->getValue();
});
}
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() === null) {
continue;
}
$columns[] = (new Column('project.meta.' . $metaField->getName(), new DefaultFormatter()))
->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(), new DefaultFormatter()))
->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getActivity()?->getMetaField($metaField->getName())?->getValue();
});
}
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
$this->dispatcher->dispatch($event);
foreach ($event->getPreferences() as $metaField) {
if ($metaField->getName() === null) {
continue;
}
$columns[] = (new Column('user.meta.' . $metaField->getName(), new DefaultFormatter()))
->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getUser()?->getPreference($metaField->getName())?->getValue();
});
}
return $columns;
}
}

View File

@@ -9,85 +9,60 @@
namespace App\Export\Base;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use App\Entity\ExportableItem;
use App\Export\ExportFilename;
use App\Export\Package\SpoutSpreadsheet;
use App\Export\RendererInterface;
use App\Export\TimesheetExportInterface;
use App\Repository\Query\TimesheetQuery;
use OpenSpout\Writer\XLSX\Writer;
use Symfony\Component\HttpFoundation\Response;
class XlsxRenderer extends AbstractSpreadsheetRenderer
final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
{
protected function isTotalRowSupported(): bool
use ExportTrait;
public function __construct(private readonly SpreadsheetRenderer $spreadsheetRenderer)
{
return true;
}
public function getFileExtension(): string
{
return '.xlsx';
}
/**
* @return string
*/
protected function getContentType(): string
{
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
}
/**
* @param Spreadsheet $spreadsheet
* @return string
* @throws \Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
{
$filename = @tempnam(sys_get_temp_dir(), 'kimai-export-xlsx');
if (false === $filename) {
throw new \Exception('Could not open temporary file');
}
$this->applyStyles($spreadsheet);
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$writer->save($filename);
return $filename;
}
protected function applyStyles(Spreadsheet $spreadsheet): void
{
// Store expensive calculations for later
$sheet = $spreadsheet->getActiveSheet();
$highestRow = $sheet->getHighestRow();
$highestColumn = $sheet->getHighestColumn();
// Enable auto filter for header row
$sheet->setAutoFilter('A1:' . $highestColumn . '1');
// Freeze first row and date & time columns for easier navigation
$sheet->freezePane('D2');
foreach ($sheet->getColumnIterator() as $columnName => $column) {
// We default to a reasonable auto-width decided by the client,
// sadly ->getDefaultColumnDimension() is not supported so it needs
// to be specific about what column should be auto sized.
$col = $sheet->getColumnDimension($columnName);
// If no other width is specified (which defaults to -1)
if ((int) $col->getWidth() === -1) {
$col->setAutoSize(true);
}
}
// Text inside cells should be top left
$sheet
->getStyle('A2:' . $highestColumn . $highestRow)
->getAlignment()
->setVertical(Alignment::VERTICAL_TOP)
->setHorizontal(Alignment::HORIZONTAL_LEFT);
}
public function getId(): string
{
return 'xlsx';
}
public function getTitle(): string
{
return 'xlsx';
}
/**
* @param ExportableItem[] $exportItems
*/
public function render(array $exportItems, TimesheetQuery $query): Response
{
return $this->getFileResponse(
$this->renderFile($exportItems, $query),
(new ExportFilename($query))->getFilename() . '.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
);
}
/**
* @param ExportableItem[] $exportItems
*/
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());
$spreadsheet->open($filename);
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);
return new \SplFileInfo($filename);
}
}

View File

@@ -0,0 +1,22 @@
<?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\Package\CellFormatter;
final class ArrayFormatter implements CellFormatterInterface
{
public function formatValue(mixed $value): mixed
{
if (!\is_array($value)) {
throw new \InvalidArgumentException('Only arrays are supported');
}
return implode(', ', $value);
}
}

View File

@@ -0,0 +1,26 @@
<?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\Package\CellFormatter;
final class BooleanFormatter implements CellFormatterInterface
{
public function formatValue(mixed $value): mixed
{
if ($value === null) {
return false;
}
if (!\is_scalar($value)) {
throw new \InvalidArgumentException('Only scalar values are supported');
}
return (bool) $value;
}
}

View File

@@ -0,0 +1,15 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Package\CellFormatter;
interface CellFormatterInterface
{
public function formatValue(mixed $value): mixed;
}

View File

@@ -0,0 +1,26 @@
<?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\Package\CellFormatter;
final class DateFormatter implements CellFormatterInterface
{
public function formatValue(mixed $value): mixed
{
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d');
}
if ($value === null) {
return null;
}
throw new \InvalidArgumentException('Only DateTimeInterface can be formatted');
}
}

View File

@@ -0,0 +1,26 @@
<?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\Package\CellFormatter;
final class DefaultFormatter implements CellFormatterInterface
{
public function formatValue(mixed $value): mixed
{
if ($value === null) {
return null;
}
if (!\is_scalar($value)) {
throw new \InvalidArgumentException('Only scalar values are supported');
}
return $value;
}
}

View File

@@ -0,0 +1,22 @@
<?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\Package\CellFormatter;
final class DurationFormatter implements CellFormatterInterface
{
public function formatValue(mixed $value): mixed
{
if (is_numeric($value)) {
return (float) number_format($value / 3600, 2, '.', '');
}
return 0.0;
}
}

View File

@@ -0,0 +1,26 @@
<?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\Package\CellFormatter;
final class RateFormatter implements CellFormatterInterface
{
public function formatValue(mixed $value): mixed
{
if (is_numeric($value)) {
return (float) number_format((float) $value, 2, '.', '');
}
if ($value === null) {
return 0.0;
}
throw new \InvalidArgumentException('Only numeric values be formatted');
}
}

View File

@@ -0,0 +1,28 @@
<?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\Package\CellFormatter;
use App\Utils\StringHelper;
final class TextFormatter implements CellFormatterInterface
{
public function __construct(private readonly bool $sanitizeDde)
{
}
public function formatValue(mixed $value): mixed
{
if ($this->sanitizeDde && \is_string($value)) {
$value = StringHelper::sanitizeDDE($value);
}
return $value;
}
}

View File

@@ -0,0 +1,26 @@
<?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\Package\CellFormatter;
final class TimeFormatter implements CellFormatterInterface
{
public function formatValue(mixed $value): mixed
{
if ($value instanceof \DateTimeInterface) {
return $value->format('H:i');
}
if ($value === null) {
return null;
}
throw new \InvalidArgumentException('Only DateTimeInterface can be formatted');
}
}

View File

@@ -0,0 +1,61 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Package;
use App\Entity\ExportableItem;
use App\Export\Package\CellFormatter\CellFormatterInterface;
class Column
{
private ?string $header = null;
private \Closure|null $extractor = null;
public function __construct(private readonly string $name, private readonly CellFormatterInterface $formatter)
{
}
public function getName(): string
{
return $this->name;
}
public function withHeader(?string $header): Column
{
$this->header = $header;
return $this;
}
public function withExtractor(\Closure $extractor): Column
{
$this->extractor = $extractor;
return $this;
}
public function extract(ExportableItem $exportableItem): mixed
{
if ($this->extractor === null) {
throw new \InvalidArgumentException('Missing extractor on column: ' . $this->name);
}
return ($this->extractor)($exportableItem);
}
public function getValue(ExportableItem $exportableItem): mixed
{
return $this->formatter->formatValue($this->extract($exportableItem));
}
public function getHeader(): string
{
return $this->header ?? $this->name;
}
}

View File

@@ -0,0 +1,131 @@
<?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\Package;
use PhpOffice\PhpSpreadsheet\Cell\CellAddress;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
final class PhpOfficeSpreadsheet implements SpreadsheetPackage
{
private ?Spreadsheet $spreadsheet;
private ?Worksheet $worksheet;
private int $currentRow = 1;
private ?string $filename = null;
public function __construct()
{
$this->spreadsheet = new Spreadsheet();
$this->worksheet = $this->spreadsheet->getActiveSheet();
}
public function open(string $filename): void
{
$this->filename = $filename;
}
public function save(): void
{
if ($this->filename === null) {
throw new \Exception('Need to call open() first before save()');
}
if ($this->spreadsheet === null || $this->worksheet === null) {
throw new \Exception('Cannot re-use spreadsheet after calling save()');
}
$sheet = $this->worksheet;
// Store expensive calculations for later
$highestRow = $sheet->getHighestRow();
$highestColumn = $sheet->getHighestColumn();
// Enable auto filter for header row
$sheet->setAutoFilter('A1:' . $highestColumn . '1');
// Freeze first row and date & time columns for easier navigation
$sheet->freezePane('D2');
foreach ($sheet->getColumnIterator() as $columnName => $column) {
// We default to a reasonable auto-width decided by the client,
// sadly ->getDefaultColumnDimension() is not supported so it needs
// to be specific about what column should be auto sized.
$col = $sheet->getColumnDimension($columnName);
// If no other width is specified (which defaults to -1)
if ((int) $col->getWidth() === -1) {
$col->setAutoSize(true);
}
}
// Text inside cells should be top left
$sheet
->getStyle('A2:' . $highestColumn . $highestRow)
->getAlignment()
->setVertical(Alignment::VERTICAL_TOP)
->setHorizontal(Alignment::HORIZONTAL_LEFT);
$writer = IOFactory::createWriter($this->spreadsheet, 'Xlsx');
$writer->save($this->filename);
$this->spreadsheet = null;
$this->worksheet = null;
}
/**
* @param array<string> $columns
*/
public function setHeader(array $columns): void
{
if ($this->worksheet === null) {
throw new \Exception('Cannot re-use spreadsheet after calling save()');
}
$counter = 1;
foreach ($columns as $column) {
$pos = CellAddress::fromColumnAndRow($counter, 1);
$this->worksheet->setCellValue($pos, $column);
$style = $this->worksheet->getStyle($pos);
$style->getBorders()->getBottom()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
$counter++;
}
$this->currentRow++;
}
/**
* @param array<int, mixed> $columns
* @param array<string, mixed> $options
*/
public function addRow(array $columns, array $options = []): void
{
if ($this->worksheet === null) {
throw new \Exception('Cannot re-use spreadsheet after calling save()');
}
$counter = 1;
foreach ($columns as $column) {
$this->worksheet->setCellValue(CellAddress::fromColumnAndRow($counter, $this->currentRow), $column);
if (\array_key_exists('totals', $options) && $options['totals'] === true) {
$style = $this->worksheet->getStyle(CellAddress::fromColumnAndRow($counter, $this->currentRow));
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
}
$counter++;
}
$this->currentRow++;
}
}

View File

@@ -0,0 +1,94 @@
<?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\Package;
use App\Constants;
use OpenSpout\Common\Entity\Cell;
use OpenSpout\Common\Entity\Row;
use OpenSpout\Common\Entity\Style\Border;
use OpenSpout\Common\Entity\Style\BorderPart;
use OpenSpout\Common\Entity\Style\Color;
use OpenSpout\Common\Entity\Style\Style;
use OpenSpout\Writer\AbstractWriterMultiSheets;
use OpenSpout\Writer\CSV\Writer;
use OpenSpout\Writer\WriterInterface;
use OpenSpout\Writer\XLSX\Entity\SheetView;
class SpoutSpreadsheet implements SpreadsheetPackage
{
public function __construct(private readonly WriterInterface $writer)
{
$this->writer->setCreator(Constants::SOFTWARE);
}
/**
* @param array<string> $columns
*/
public function setHeader(array $columns): void
{
$tmp = [];
foreach ($columns as $column) {
$tmp[] = Cell::fromValue($column);
}
$style = new Style();
$style->setShouldWrapText(false);
$style->setShouldShrinkToFit(true);
$style->setBackgroundColor('EEEEEE');
$style->setBorder(new Border(new BorderPart(Border::BOTTOM, Color::BLACK, Border::WIDTH_THIN, Border::STYLE_SOLID)));
$style->setFontBold();
$this->writer->addRow(new Row($tmp, $style));
}
/**
* @param array<int, mixed> $columns
* @param array<string, mixed> $options
*/
public function addRow(array $columns, array $options = []): void
{
$style = new Style();
$style->setShouldWrapText(false);
$style->setShouldShrinkToFit(true);
if (\array_key_exists('totals', $options) && $options['totals'] === true) {
if ($this->writer instanceof Writer) {
return;
}
$style->setBorder(new Border(new BorderPart(Border::TOP, Color::BLACK, Border::WIDTH_THIN, Border::STYLE_SOLID)));
$style->setFontBold();
}
$tmp = [];
foreach ($columns as $column) {
$tmp[] = Cell::fromValue($column); // @phpstan-ignore argument.type
}
$this->writer->addRow(new Row($tmp, $style));
}
public function open(string $filename): void
{
$this->writer->openToFile($filename);
if ($this->writer instanceof AbstractWriterMultiSheets) {
$sheetView = new SheetView();
$sheetView->setFreezeColumn('D');
$sheetView->setFreezeRow(2);
$this->writer->getCurrentSheet()->setSheetView($sheetView);
}
}
public function save(): void
{
$this->writer->close();
}
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Package;
interface SpreadsheetPackage
{
/**
* Pass the temporary filename where data will be written to.
*/
public function open(string $filename): void;
public function save(): void;
/**
* @param array<string> $columns
*/
public function setHeader(array $columns): void;
/**
* @param array<int, mixed> $columns
* @param array<string, mixed> $options
*/
public function addRow(array $columns, array $options = []): void;
}

View File

@@ -1,21 +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 as BaseCsvRenderer;
use App\Export\RendererInterface;
final class CsvRenderer extends BaseCsvRenderer implements RendererInterface
{
public function getTitle(): string
{
return 'csv';
}
}

View File

@@ -1,21 +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\HtmlRenderer as BaseHtmlRenderer;
use App\Export\ExportRendererInterface;
final class HtmlRenderer extends BaseHtmlRenderer implements ExportRendererInterface
{
public function getTitle(): string
{
return 'print';
}
}

View File

@@ -10,6 +10,7 @@
namespace App\Export\Renderer;
use App\Activity\ActivityStatisticService;
use App\Export\Base\HtmlRenderer;
use App\Project\ProjectStatisticService;
use Psr\EventDispatcher\EventDispatcherInterface;
use Twig\Environment;
@@ -17,10 +18,10 @@ use Twig\Environment;
final class HtmlRendererFactory
{
public function __construct(
private Environment $twig,
private EventDispatcherInterface $dispatcher,
private ProjectStatisticService $projectStatisticService,
private ActivityStatisticService $activityStatisticService
private readonly Environment $twig,
private readonly EventDispatcherInterface $dispatcher,
private readonly ProjectStatisticService $projectStatisticService,
private readonly ActivityStatisticService $activityStatisticService
) {
}

View File

@@ -1,21 +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\PDFRenderer as BasePDFRenderer;
use App\Export\ExportRendererInterface;
final class PDFRenderer extends BasePDFRenderer implements ExportRendererInterface
{
public function getTitle(): string
{
return 'pdf';
}
}

View File

@@ -9,6 +9,7 @@
namespace App\Export\Renderer;
use App\Export\Base\PDFRenderer;
use App\Pdf\HtmlToPdfConverter;
use App\Project\ProjectStatisticService;
use Twig\Environment;
@@ -16,9 +17,9 @@ use Twig\Environment;
final class PdfRendererFactory
{
public function __construct(
private Environment $twig,
private HtmlToPdfConverter $converter,
private ProjectStatisticService $projectStatisticService
private readonly Environment $twig,
private readonly HtmlToPdfConverter $converter,
private readonly ProjectStatisticService $projectStatisticService
) {
}

View File

@@ -1,21 +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\XlsxRenderer as BaseXlsxRenderer;
use App\Export\RendererInterface;
final class XlsxRenderer extends BaseXlsxRenderer implements RendererInterface
{
public function getTitle(): string
{
return 'xlsx';
}
}

View File

@@ -11,7 +11,6 @@ namespace App\Export\Spreadsheet\Writer;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
final class BinaryFileResponseWriter implements WriterInterface
@@ -19,10 +18,9 @@ final class BinaryFileResponseWriter implements WriterInterface
private string $prefix;
/**
* @param WriterInterface $writer
* @param string $prefix is only urlencoded but not validated and can break the response if you pass in invalid character
*/
public function __construct(private WriterInterface $writer, string $prefix)
public function __construct(private readonly WriterInterface $writer, string $prefix)
{
$this->prefix = urlencode($prefix);
}

View File

@@ -1,21 +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\Timesheet;
use App\Export\Base\CsvRenderer as BaseCsvRenderer;
use App\Export\TimesheetExportInterface;
final class CsvRenderer extends BaseCsvRenderer implements TimesheetExportInterface
{
public function getTitle(): string
{
return 'csv';
}
}

View File

@@ -23,9 +23,4 @@ final class HtmlRenderer extends BaseHtmlRenderer implements TimesheetExportInte
{
return 'print';
}
public function getTitle(): string
{
return 'print';
}
}

View File

@@ -1,21 +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\Timesheet;
use App\Export\Base\PDFRenderer as BasePDFRenderer;
use App\Export\TimesheetExportInterface;
final class PDFRenderer extends BasePDFRenderer implements TimesheetExportInterface
{
public function getTitle(): string
{
return 'pdf';
}
}

View File

@@ -1,21 +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\Timesheet;
use App\Export\Base\XlsxRenderer as BaseXlsxRenderer;
use App\Export\TimesheetExportInterface;
final class XlsxRenderer extends BaseXlsxRenderer implements TimesheetExportInterface
{
public function getTitle(): string
{
return 'xlsx';
}
}

View File

@@ -18,9 +18,9 @@ use Symfony\Component\HttpFoundation\Response;
interface TimesheetExportInterface
{
/**
* @param Timesheet[] $timesheets
* @param Timesheet[] $exportItems
*/
public function render(array $timesheets, TimesheetQuery $query): Response;
public function render(array $exportItems, TimesheetQuery $query): Response;
public function getId(): string;

View File

@@ -23,6 +23,7 @@
'project': {'class': 'd-none d-sm-table-cell', 'orderBy': false},
'activity': {'class': 'd-none', 'orderBy': false},
'description': {'class': 'd-none d-xl-table-cell timesheet-description', 'orderBy': false},
'tags': {'class': 'd-none d-xl-table-cell', 'orderBy': false},
'unit_price': {'class': 'd-none text-nowrap text-end', 'orderBy': false},
'duration': {'class': 'text-end text-nowrap', 'orderBy': false},
'internalRate': {'class': 'text-end d-none text-nowrap', 'orderBy': false},
@@ -229,6 +230,11 @@
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }}">
{{ entry.description|desc2html }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'tags') }}">
{% if entry.type == 'timesheet' %}
{{ widgets.tag_list(entry.tags) }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }}">
{{ rate|money(currency) }}
</td>

View File

@@ -16,7 +16,7 @@
{% if stats is empty %}
{{ nothing_found() }}
{% else %}
{% embed 'reporting/customer/monthly_projects_data.html.twig' with {'dataTypeFormat': null, 'stats': stats, 'dataType': dataType, 'decimal': decimal} only %}
{% embed 'reporting/customer/monthly_projects_data.html.twig' with {'stats': stats, 'dataType': dataType, 'decimal': decimal} only %}
{% set rowspanStyle = 'vertical-align: middle' %}
{% endembed %}
{% endif %}

View File

@@ -51,7 +51,7 @@
{% if project.activities[activity.id] is defined and i <= project.activities[activity.id]['users']|length %}
{% set user = project.activities[activity.id]['users']|slice(-i) %}
<td>{{ user.0.name }}</td>
<td class="text-center"{% if dataTypeFormat is not null %} data-format="{{ dataTypeFormat }}"{% endif %}>
<td class="text-center">
{% set value = user.0[dataType] %}
{% block user_activity %}
{% if dataType == 'rate' or dataType == 'internalRate' %}
@@ -62,7 +62,7 @@
{% endblock %}
</td>
{% if loop.parent.loop.first %}
<td{% if rowspan > 1 %} rowspan="{{ rowspan }}"{% endif %} class="text-center text-nowrap"{% if dataTypeFormat is not null %} data-format="{{ dataTypeFormat }}"{% endif %}>
<td{% if rowspan > 1 %} rowspan="{{ rowspan }}"{% endif %} class="text-center text-nowrap">
{% set value = project.activities[activity.id][dataType] %}
{% block project_activity %}
{% if dataType == 'rate' or dataType == 'internalRate' %}
@@ -82,7 +82,7 @@
{% endif %}
{% endfor %}
{% if loop.first %}
<th{% if rowspan > 1 %} rowspan="{{ rowspan }}" style="{{ rowspanStyle }}"{% endif %} class="text-center text-nowrap"{% if dataTypeFormat is not null %} data-format="{{ dataTypeFormat }}"{% endif %}>
<th{% if rowspan > 1 %} rowspan="{{ rowspan }}" style="{{ rowspanStyle }}"{% endif %} class="text-center text-nowrap">
{% set value = project[dataType] %}
{% block project_total %}
{% if dataType == 'rate' or dataType == 'internalRate' %}

View File

@@ -1,30 +1,24 @@
{% embed 'reporting/customer/monthly_projects_data.html.twig' with {'decimal': true, 'stats': stats, 'dataType': dataType} only %}
{% set dataTypeFormat = null %}
{% if dataType == 'rate' or dataType == 'internalRate' %}
{% set dataTypeFormat = constant('App\\Export\\Base\\AbstractSpreadsheetRenderer::RATE_FORMAT_NO_CURRENCY') %}
{% elseif dataType == 'duration' %}
{% set dataTypeFormat = constant('App\\Export\\Base\\AbstractSpreadsheetRenderer::DURATION_DECIMAL') %}
{% endif %}
{% set rowspanStyle = 'vertical-align:center' %}
{% block user_activity %}
{% if dataType == 'rate' or dataType == 'internalRate' %}
{{ value|money(currency) }}
{% else %}
{{ value / 3600 }}
{{ value|duration(true) }}
{% endif %}
{% endblock %}
{% block project_activity %}
{% if dataType == 'rate' or dataType == 'internalRate' %}
{{ value|money(currency) }}
{% else %}
{{ value / 3600 }}
{{ value|duration(true) }}
{% endif %}
{% endblock %}
{% block project_total %}
{% if dataType == 'rate' or dataType == 'internalRate' %}
{{ value|money(currency) }}
{% else %}
{{ value / 3600 }}
{{ value|duration(true) }}
{% endif %}
{% endblock %}
{% endembed %}

View File

@@ -1,6 +1,7 @@
{% set columns = {
'customer': {'title': 'customer'|trans},
'name': {'title': 'name'|trans},
'currency': {'title': 'currency'|trans},
'budgetType': {'title': 'budgetType'|trans},
} %}
{% if is_granted('budget_time', 'project') %}
@@ -54,6 +55,8 @@
{% endif %}
{% elseif name == 'customer' %}
{{ project.customer.name }}
{% elseif name == 'currency' %}
{{ currency }}
{% elseif name == 'lastRecord' %}
{% if entry.lastRecord is not null %}
{{ entry.lastRecord|date_short }}
@@ -61,60 +64,56 @@
&ndash;
{% endif %}
{% elseif name == 'today' %}
{{ entry.durationDay|duration }}
{{ entry.durationDay|duration(true) }}
{% elseif name == 'week' %}
{{ entry.durationWeek|duration }}
{{ entry.durationWeek|duration(true) }}
{% elseif name == 'month' %}
{{ entry.durationMonth|duration }}
{{ entry.durationMonth|duration(true) }}
{% elseif name == 'timeBudget' %}
{% if showTimeBudget %}
{{ budgetStatisticModel.getTimeBudget()|duration }}
{% else %}
&ndash;
{{ budgetStatisticModel.getTimeBudget()|duration(true) }}
{% endif %}
{% elseif name == 'durationTotal' %}
{{ entry.durationTotal|duration }}
{{ entry.durationTotal|duration(true) }}
{% elseif name == 'billableTime' %}
{% if canSeeTimeBudget %}
{{ budgetStatisticModel.getTimeBudgetSpent()|duration }}
{{ budgetStatisticModel.getTimeBudgetSpent()|duration(true) }}
{% endif %}
{% elseif name == 'exported' %}
{% if canSeeTimeBudget %}
{{ entry.notExportedDuration|duration }}
{{ entry.notExportedDuration|duration(true) }}
{% endif %}
{% elseif name == 'budget' %}
{% if showMoneyBudget %}
{{ budgetStatisticModel.getBudget()|money(currency) }}
{% else %}
&ndash;
{{ budgetStatisticModel.getBudget()|money }}
{% endif %}
{% elseif name == 'amountTotal' %}
{% if canSeeMoneyBudget %}
{{ budgetStatisticModel.getStatisticTotal().getRate()|money(currency) }}
{{ budgetStatisticModel.getStatisticTotal().getRate()|money }}
{% endif %}
{% elseif name == 'billableMoney' %}
{% if canSeeMoneyBudget %}
{{ budgetStatisticModel.getBudgetSpent()|money(currency) }}
{{ budgetStatisticModel.getBudgetSpent()|money }}
{% endif %}
{% elseif name == 'internalRate' %}
{% if canSeeMoneyBudget %}
{{ budgetStatisticModel.getInternalRate()|money(currency) }}
{{ budgetStatisticModel.getInternalRate()|money }}
{% endif %}
{% elseif name == 'revenue' %}
{% if canSeeMoneyBudget %}
{{ revenue|money(currency) }}
{{ revenue|money }}
{% endif %}
{% elseif name == 'hourlyBillable' %}
{% if canSeeMoneyBudget and durationTotal > 0 %}
{{ (budgetStatisticModel.getBudgetSpent() / durationTotal)|money(currency) }}
{{ (budgetStatisticModel.getBudgetSpent() / durationTotal)|money }}
{% endif %}
{% elseif name == 'hourlyTotal' %}
{% if canSeeMoneyBudget and durationTotal > 0 %}
{{ (revenue / durationTotal)|money(currency) }}
{{ (revenue / durationTotal)|money }}
{% endif %}
{% elseif name == 'invoiced' %}
{% if canSeeMoneyBudget %}
{{ entry.notExportedRate|money(currency) }}
{{ entry.notExportedRate|money }}
{% endif %}
{% elseif name == 'projectStart' %}
{% if project.start is not null %}{{ project.start|date_short }}{% endif %}

View File

@@ -1,24 +1,18 @@
{% embed 'reporting/user_list_period_data.html.twig' with {'avatar': false, 'showAccountNumber': true, 'stats': stats, 'dataType': dataType, 'period_attribute': period_attribute} only %}
{% set dataTypeFormat = '' %}
{% if dataType == 'rate' or dataType == 'internalRate' %}
{% set dataTypeFormat = ' data-format="' ~ constant('App\\Export\\Base\\AbstractSpreadsheetRenderer::RATE_FORMAT_NO_CURRENCY') ~ '"' %}
{% elseif dataType == 'duration' %}
{% set dataTypeFormat = ' data-format="' ~ constant('App\\Export\\Base\\AbstractSpreadsheetRenderer::DURATION_DECIMAL') ~ '"' %}
{% endif %}
{% block user_column %}
{{ userPeriod.user.displayName|sanitize_dde }}
{% endblock %}
{% block duration -%}
{{ period.totalDuration / 3600 }}
{{ period.totalDuration|duration(true) }}
{%- endblock %}
{% block total_duration -%}
{{ absoluteDuration / 3600 }}
{{ absoluteDuration|duration(true) }}
{%- endblock %}
{% block total_duration_user -%}
{{ usersTotalDuration / 3600 }}
{{ usersTotalDuration|duration(true) }}
{%- endblock %}
{% block total_duration_period -%}
{{ total / 3600 }}
{{ total|duration(true) }}
{%- endblock %}
{% block rate %}
{{ period.totalRate }}
@@ -49,11 +43,4 @@
{{ column|date_short }}
</th>
{% endblock %}
{% block user_column_cell_attribute %} data-format="@"{% endblock %}
{% block period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
{% block user_total_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
{% block total_period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
{% block total_rate_period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
{% block total_internal_rate_period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
{% block total_duration_period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
{% endembed %}

View File

@@ -1,24 +1,18 @@
{% embed 'reporting/user_list_period_data.html.twig' with {'avatar': false, 'showAccountNumber': true, 'stats': stats, 'dataType': dataType, 'period_attribute': period_attribute} only %}
{% set dataTypeFormat = '' %}
{% if dataType == 'rate' or dataType == 'internalRate' %}
{% set dataTypeFormat = ' data-format="' ~ constant('App\\Export\\Base\\AbstractSpreadsheetRenderer::RATE_FORMAT_NO_CURRENCY') ~ '"' %}
{% elseif dataType == 'duration' %}
{% set dataTypeFormat = ' data-format="' ~ constant('App\\Export\\Base\\AbstractSpreadsheetRenderer::DURATION_DECIMAL') ~ '"' %}
{% endif %}
{% block user_column %}
{{ userPeriod.user.displayName|sanitize_dde }}
{% endblock %}
{% block duration -%}
{{ period.totalDuration / 3600 }}
{{ period.totalDuration|duration(true) }}
{%- endblock %}
{% block total_duration -%}
{{ absoluteDuration / 3600 }}
{{ absoluteDuration|duration(true) }}
{%- endblock %}
{% block total_duration_user -%}
{{ usersTotalDuration / 3600 }}
{{ usersTotalDuration|duration(true) }}
{%- endblock %}
{% block total_duration_period -%}
{{ total / 3600 }}
{{ total|duration(true) }}
{%- endblock %}
{% block rate %}
{{ period.totalRate }}
@@ -49,11 +43,4 @@
{{ column|month_name }} {{ column|date_format('Y') }}
</th>
{% endblock %}
{% block user_column_cell_attribute %} data-format="@"{% endblock %}
{% block period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
{% block user_total_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
{% block total_period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
{% block total_rate_period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
{% block total_internal_rate_period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
{% block total_duration_period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
{% endembed %}

View File

@@ -12,6 +12,7 @@ namespace App\Tests\Controller\Reporting;
use App\Entity\User;
use App\Tests\Controller\AbstractControllerBaseTestCase;
use App\Tests\DataFixtures\TimesheetFixtures;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* @group integration
@@ -30,6 +31,8 @@ abstract class AbstractUserPeriodControllerTestCase extends AbstractControllerBa
abstract protected function getReportUrl(): string;
abstract protected function getExportUrl(): string;
abstract protected function getBoxId(): string;
public function testIsSecure(): void
@@ -61,6 +64,31 @@ abstract class AbstractUserPeriodControllerTestCase extends AbstractControllerBa
self::assertEquals($title, $cell->text());
}
/**
* @dataProvider getTestData
*/
public function testUserPeriodReportExport(int $user, string $dataType, string $title): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->importReportingFixture(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, \sprintf('%s?user=%s&date=2023-12-23&sumType=%s', $this->getExportUrl(), $user, $dataType));
$response = $client->getResponse();
self::assertTrue($response->isSuccessful());
self::assertInstanceOf(BinaryFileResponse::class, $response);
// temporary file will be deleted!
$file = $response->getFile();
self::assertFileDoesNotExist($response->getFile());
$disposition = $response->headers->get('Content-Disposition');
self::assertIsString($disposition);
self::assertNotEmpty($disposition);
self::assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
self::assertStringContainsString('attachment; filename=kimai-export-user-', $disposition);
}
public function testUserPeriodReportAsTeamlead(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);

View File

@@ -19,6 +19,11 @@ class UserMonthControllerTest extends AbstractUserPeriodControllerTestCase
return '/reporting/user/month';
}
protected function getExportUrl(): string
{
return '/reporting/user/month_export';
}
protected function getBoxId(): string
{
return 'user-month-reporting-box';

View File

@@ -19,6 +19,11 @@ class UserWeekControllerTest extends AbstractUserPeriodControllerTestCase
return '/reporting/user/week';
}
protected function getExportUrl(): string
{
return '/reporting/user/week_export';
}
protected function getBoxId(): string
{
return 'user-week-reporting-box';

View File

@@ -19,6 +19,11 @@ class UserYearControllerTest extends AbstractUserPeriodControllerTestCase
return '/reporting/user/year';
}
protected function getExportUrl(): string
{
return '/reporting/user/year_export';
}
protected function getBoxId(): string
{
return 'user-year-reporting-box';

View File

@@ -10,13 +10,13 @@
namespace App\Tests\DependencyInjection\Compiler;
use App\DependencyInjection\Compiler\ExportServiceCompilerPass;
use App\Export\Base\CsvRenderer;
use App\Export\Base\HtmlRenderer;
use App\Export\Base\PDFRenderer;
use App\Export\Base\XlsxRenderer;
use App\Export\ExportRepositoryInterface;
use App\Export\Renderer\CsvRenderer;
use App\Export\Renderer\HtmlRenderer;
use App\Export\RendererInterface;
use App\Export\ServiceExport;
use App\Export\Timesheet\PDFRenderer;
use App\Export\Timesheet\XlsxRenderer;
use App\Export\TimesheetExportInterface;
use App\Export\TimesheetExportRepository;
use PHPUnit\Framework\TestCase;

View File

@@ -7,41 +7,62 @@
* file that was distributed with this source code.
*/
namespace App\Tests\Export\Renderer;
namespace App\Tests\Export\Base;
use App\Export\Renderer\CsvRenderer;
use App\Entity\User;
use App\Export\Base\CsvRenderer;
use App\Export\Base\SpreadsheetRenderer;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use App\Tests\Export\Renderer\MetaFieldColumnSubscriber;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @covers \App\Export\Base\CsvRenderer
* @covers \App\Export\Base\AbstractSpreadsheetRenderer
* @covers \App\Export\Base\SpreadsheetRenderer
* @covers \App\Export\Base\RendererTrait
* @covers \App\Export\Renderer\CsvRenderer
* @covers \App\Export\Package\SpoutSpreadsheet
* @group integration
*/
class CsvRendererTest extends AbstractRendererTestCase
{
protected function getAbstractRenderer(): CsvRenderer
{
$security = $this->createMock(Security::class);
$security->expects($this->any())->method('getUser')->willReturn(new User());
$security->expects($this->any())->method('isGranted')->willReturn(true);
$translator = $this->createMock(TranslatorInterface::class);
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriber());
return new CsvRenderer(new SpreadsheetRenderer($translator, $dispatcher, $security));
}
public function testConfiguration(): void
{
$sut = $this->getAbstractRenderer(CsvRenderer::class);
$sut = $this->getAbstractRenderer();
self::assertEquals('csv', $sut->getId());
self::assertEquals('csv', $sut->getTitle());
}
public static function getTestModel()
public static function getTestModel(): array
{
return [
['400', '2437.12', ' EUR 1,947.99 ', 7, 6, 1, 2, 2]
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2]
];
}
/**
* @dataProvider getTestModel
*/
public function testRender($totalDuration, $totalRate, $expectedRate, $expectedRows, $expectedDescriptions, $expectedUser1, $expectedUser2, $expectedUser3): void
public function testRender(string $totalDuration, string $totalRate, string $expectedRate, int $expectedRows, int $expectedDescriptions, int $expectedUser1, int $expectedUser2, int $expectedUser3): void
{
$sut = $this->getAbstractRenderer(CsvRenderer::class);
$sut = $this->getAbstractRenderer();
/** @var BinaryFileResponse $response */
$response = $this->render($sut);
@@ -53,17 +74,19 @@ class CsvRendererTest extends AbstractRendererTestCase
self::assertTrue(file_exists($file->getRealPath()));
$content = file_get_contents($file->getRealPath());
self::assertIsString($content);
self::assertStringContainsString('"' . $expectedRate . '"', $content);
self::assertStringContainsString($expectedRate, $content);
self::assertEquals($expectedRows, substr_count($content, PHP_EOL));
self::assertEquals($expectedDescriptions, substr_count($content, 'activity description'));
self::assertEquals($expectedUser1, substr_count($content, ',"kevin",'));
self::assertEquals($expectedUser3, substr_count($content, ',"hello-world",'));
self::assertEquals($expectedUser2, substr_count($content, ',"foo-bar",'));
self::assertEquals($expectedDescriptions, substr_count($content, '"activity description"'));
self::assertEquals($expectedUser1, substr_count($content, ',kevin,'));
self::assertEquals($expectedUser3, substr_count($content, ',hello-world,'));
self::assertEquals($expectedUser2, substr_count($content, ',foo-bar,'));
ob_start();
$response->sendContent();
$content2 = ob_get_clean();
self::assertIsString($content2);
self::assertEquals($content, $content2);
self::assertFalse(file_exists($file->getRealPath()));
@@ -71,6 +94,7 @@ class CsvRendererTest extends AbstractRendererTestCase
$all = [];
$rows = str_getcsv($content2, PHP_EOL);
foreach ($rows as $row) {
self::assertIsString($row);
$all[] = str_getcsv($row);
}
@@ -78,70 +102,70 @@ class CsvRendererTest extends AbstractRendererTestCase
'2019-06-16',
'12:00',
'12:06',
'400',
'0.11',
'EUR',
'0',
'',
'kevin',
'0',
'0',
'84',
'kevin',
'',
'Customer Name',
'project name',
'activity description',
'',
'',
'',
'1',
'foo, bar',
'timesheet',
'work',
'A-0123456789',
'',
' EUR 84.00 ',
'DE-9876543210',
'ORDER-123',
'meta-bar',
'meta-bar2',
'customer-bar',
'',
'project-foo2',
'activity-bar',
'timesheet',
'work',
'A-0123456789',
'DE-9876543210',
'ORDER-123',
];
$expected2 = [
'2019-06-16',
'12:00',
'12:06',
'400',
'0.11',
'EUR',
'0',
'',
'nivek',
'0',
'0',
'-100.92',
'nivek',
'',
'Customer Name',
'project name',
'activity description',
'',
'1',
'',
'timesheet',
'work',
'A-0123456789',
'',
'',
'',
' EUR -100.92',
'DE-9876543210',
'ORDER-123',
'',
'',
'customer-bar',
'',
'project-foo2',
'activity-bar',
'timesheet',
'work',
'A-0123456789',
'DE-9876543210',
'ORDER-123',
];
self::assertEquals(7, \count($all));
self::assertEquals($expected, $all[5]);
self::assertEquals($expected2, $all[6]);
self::assertEquals(\count($expected), \count($all[0]));
self::assertEquals('foo', $all[4][15]);
self::assertEquals('foo', $all[4][16]);
}
}

View File

@@ -0,0 +1,102 @@
<?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\Entity\ExportableItem;
use App\Export\Base\SpreadsheetRenderer;
use App\Export\Package\SpreadsheetPackage;
use App\Repository\Query\TimesheetQuery;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @covers \App\Export\Base\SpreadsheetRenderer
* @group integration
*/
class SpreadsheetRendererTest extends AbstractRendererTestCase
{
public function testWriteSpreadsheetCreatesSpreadsheetWithCorrectHeaders(): void
{
$translator = $this->createMock(TranslatorInterface::class);
$translator->method('trans')->willReturnArgument(0);
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$security = $this->createMock(Security::class);
$spreadsheetPackage = $this->createMock(SpreadsheetPackage::class);
$spreadsheetPackage->expects(self::once())->method('setHeader')->with([
'date', 'begin', 'end', 'duration', 'currency', 'rate', 'internalRate', 'hourlyRate', 'fixedRate', 'username', 'account_number', 'customer', 'project', 'activity', 'description', 'billable', 'tags', 'type', 'category', 'number', 'project_number', 'vat_id', 'orderNumber'
]);
$renderer = new SpreadsheetRenderer($translator, $dispatcher, $security);
$renderer->writeSpreadsheet($spreadsheetPackage, [], new TimesheetQuery());
}
public function testWriteSpreadsheetAddsRowsForExportItems(): void
{
$translator = $this->createMock(TranslatorInterface::class);
$translator->method('trans')->willReturnArgument(0);
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$security = $this->createMock(Security::class);
$spreadsheetPackage = $this->createMock(SpreadsheetPackage::class);
$spreadsheetPackage->expects(self::exactly(2))->method('addRow');
$spreadsheetPackage->expects(self::once())->method('save');
$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($translator, $dispatcher, $security);
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem], new TimesheetQuery());
}
public function testWriteSpreadsheetAddsTotalRowWhenMoreThanOneRow(): void
{
$translator = $this->createMock(TranslatorInterface::class);
$translator->method('trans')->willReturnArgument(0);
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$security = $this->createMock(Security::class);
$spreadsheetPackage = $this->createMock(SpreadsheetPackage::class);
$spreadsheetPackage->expects(self::exactly(3))->method('addRow');
$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($translator, $dispatcher, $security);
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem, $exportItem], new TimesheetQuery());
}
}

View File

@@ -7,23 +7,44 @@
* file that was distributed with this source code.
*/
namespace App\Tests\Export\Renderer;
namespace App\Tests\Export\Base;
use App\Export\Renderer\XlsxRenderer;
use App\Entity\User;
use App\Export\Base\SpreadsheetRenderer;
use App\Export\Base\XlsxRenderer;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use App\Tests\Export\Renderer\MetaFieldColumnSubscriber;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @covers \App\Export\Base\XlsxRenderer
* @covers \App\Export\Base\AbstractSpreadsheetRenderer
* @covers \App\Export\Base\SpreadsheetRenderer
* @covers \App\Export\Base\RendererTrait
* @covers \App\Export\Renderer\XlsxRenderer
* @covers \App\Export\Package\SpoutSpreadsheet
* @group integration
*/
class XlsxRendererTest extends AbstractRendererTestCase
{
protected function getAbstractRenderer(): XlsxRenderer
{
$security = $this->createMock(Security::class);
$security->expects($this->any())->method('getUser')->willReturn(new User());
$security->expects($this->any())->method('isGranted')->willReturn(true);
$translator = $this->createMock(TranslatorInterface::class);
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriber());
return new XlsxRenderer(new SpreadsheetRenderer($translator, $dispatcher, $security));
}
public function testConfiguration(): void
{
$sut = $this->getAbstractRenderer(XlsxRenderer::class);
$sut = $this->getAbstractRenderer();
self::assertEquals('xlsx', $sut->getId());
self::assertEquals('xlsx', $sut->getTitle());
@@ -31,7 +52,7 @@ class XlsxRendererTest extends AbstractRendererTestCase
public function testRender(): void
{
$sut = $this->getAbstractRenderer(XlsxRenderer::class);
$sut = $this->getAbstractRenderer();
/** @var BinaryFileResponse $response */
$response = $this->render($sut);

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Export\Package\CellFormatter;
use App\Export\Package\CellFormatter\ArrayFormatter;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Export\Package\CellFormatter\ArrayFormatter
*/
class ArrayFormatterTest extends TestCase
{
public function testFormatValueReturnsCommaSeparatedStringForArray(): void
{
$formatter = new ArrayFormatter();
$result = $formatter->formatValue(['one', 'two', 'three']);
self::assertEquals('one, two, three', $result);
}
public function testFormatValueThrowsExceptionForNonArray(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Only arrays are supported');
$formatter = new ArrayFormatter();
$formatter->formatValue('not an array');
}
public function testFormatValueReturnsEmptyStringForEmptyArray(): void
{
$formatter = new ArrayFormatter();
$result = $formatter->formatValue([]);
self::assertEquals('', $result);
}
}

View File

@@ -0,0 +1,77 @@
<?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\CellFormatter;
use App\Export\Package\CellFormatter\BooleanFormatter;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Export\Package\CellFormatter\BooleanFormatter
*/
class BooleanFormatterTest extends TestCase
{
public function testFormatValueReturnsFalseForNull(): void
{
$formatter = new BooleanFormatter();
$result = $formatter->formatValue(null);
self::assertFalse($result);
}
public function testFormatValueReturnsTrueForTrue(): void
{
$formatter = new BooleanFormatter();
$result = $formatter->formatValue(true);
self::assertTrue($result);
}
public function testFormatValueReturnsFalseForFalse(): void
{
$formatter = new BooleanFormatter();
$result = $formatter->formatValue(false);
self::assertFalse($result);
}
public function testFormatValueReturnsTrueForNonZeroNumber(): void
{
$formatter = new BooleanFormatter();
$result = $formatter->formatValue(1);
self::assertTrue($result);
}
public function testFormatValueReturnsFalseForZero(): void
{
$formatter = new BooleanFormatter();
$result = $formatter->formatValue(0);
self::assertFalse($result);
}
public function testFormatValueReturnsTrueForNonEmptyString(): void
{
$formatter = new BooleanFormatter();
$result = $formatter->formatValue('non-empty');
self::assertTrue($result);
}
public function testFormatValueReturnsFalseForEmptyString(): void
{
$formatter = new BooleanFormatter();
$result = $formatter->formatValue('');
self::assertFalse($result);
}
public function testFormatValueThrowsExceptionForNonScalar(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Only scalar values are supported');
$formatter = new BooleanFormatter();
$formatter->formatValue([]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Export\Package\CellFormatter;
use App\Export\Package\CellFormatter\DateFormatter;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Export\Package\CellFormatter\DateFormatter
*/
class DateFormatterTest extends TestCase
{
public function testFormatValueReturnsFormattedDateForDateTime(): void
{
$formatter = new DateFormatter();
$date = new \DateTime('2023-10-01');
$result = $formatter->formatValue($date);
self::assertEquals('2023-10-01', $result);
}
public function testFormatValueReturnsNullForNullValue(): void
{
$formatter = new DateFormatter();
$result = $formatter->formatValue(null);
self::assertNull($result);
}
public function testFormatValueThrowsExceptionForNonDateTime(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Only DateTimeInterface can be formatted');
$formatter = new DateFormatter();
$formatter->formatValue('not a date');
}
}

View File

@@ -0,0 +1,56 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Export\Package\CellFormatter;
use App\Export\Package\CellFormatter\DefaultFormatter;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Export\Package\CellFormatter\DefaultFormatter
*/
class DefaultFormatterTest extends TestCase
{
public function testFormatValueReturnsSameValueForScalar(): void
{
$formatter = new DefaultFormatter();
$result = $formatter->formatValue('string');
self::assertEquals('string', $result);
$result = $formatter->formatValue(123);
self::assertEquals(123, $result);
$result = $formatter->formatValue(45.67);
self::assertEquals(45.67, $result);
$result = $formatter->formatValue(true);
self::assertTrue($result);
$result = $formatter->formatValue(null);
self::assertNull($result);
}
public function testFormatValueThrowsExceptionForNonScalar(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Only scalar values are supported');
$formatter = new DefaultFormatter();
$formatter->formatValue([]);
}
public function testFormatValueThrowsExceptionForObject(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Only scalar values are supported');
$formatter = new DefaultFormatter();
$formatter->formatValue(new \stdClass());
}
}

View File

@@ -0,0 +1,54 @@
<?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\CellFormatter;
use App\Export\Package\CellFormatter\DurationFormatter;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Export\Package\CellFormatter\DurationFormatter
*/
class DurationFormatterTest extends TestCase
{
public function testFormatValueReturnsFormattedDurationForNumericValue(): void
{
$formatter = new DurationFormatter();
$result = $formatter->formatValue(7200);
self::assertEquals(2.00, $result);
}
public function testFormatValueReturnsZeroForNonNumericValue(): void
{
$formatter = new DurationFormatter();
$result = $formatter->formatValue('not a number');
self::assertEquals(0.0, $result);
}
public function testFormatValueReturnsFormattedDurationForFloatValue(): void
{
$formatter = new DurationFormatter();
$result = $formatter->formatValue(4500.5);
self::assertEquals(1.25, $result);
}
public function testFormatValueReturnsZeroForNullValue(): void
{
$formatter = new DurationFormatter();
$result = $formatter->formatValue(null);
self::assertEquals(0.0, $result);
}
public function testFormatValueReturnsFormattedDurationForNegativeValue(): void
{
$formatter = new DurationFormatter();
$result = $formatter->formatValue(-3600);
self::assertEquals(-1.00, $result);
}
}

View File

@@ -0,0 +1,78 @@
<?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\CellFormatter;
use App\Export\Package\CellFormatter\RateFormatter;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Export\Package\CellFormatter\RateFormatter
*/
class RateFormatterTest extends TestCase
{
public function testFormatValueReturnsFormattedFloatForNumericValue(): void
{
$formatter = new RateFormatter();
$result = $formatter->formatValue(1234.5678);
self::assertEquals(1234.57, $result);
$result = $formatter->formatValue('1234.5678');
self::assertEquals(1234.57, $result);
}
public function testFormatValueThrowsForNonNumericValue(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Only numeric values be formatted');
$formatter = new RateFormatter();
$result = $formatter->formatValue('not a number');
self::assertEquals(0.0, $result);
}
public function testFormatValueReturnsZeroForNullValue(): void
{
$formatter = new RateFormatter();
$result = $formatter->formatValue(null);
self::assertEquals(0.0, $result);
}
public function testFormatValueReturnsFormattedFloatForNegativeNumericValue(): void
{
$formatter = new RateFormatter();
$result = $formatter->formatValue(-1234.5678);
self::assertEquals(-1234.57, $result);
}
public function testFormatValueReturnsFormattedFloatForFloatWithTwoDecimalPlaces(): void
{
$formatter = new RateFormatter();
$result = $formatter->formatValue(1234.56);
self::assertEquals(1234.56, $result);
}
public function testFormatValueThrowsExceptionForNonNumericNonNullValue(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Only numeric values be formatted');
$formatter = new RateFormatter();
$formatter->formatValue(new \stdClass());
}
public function testFormatValueThrowsExceptionFoArrayValue(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Only numeric values be formatted');
$formatter = new RateFormatter();
$formatter->formatValue([]);
}
}

View File

@@ -0,0 +1,59 @@
<?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\CellFormatter;
use App\Export\Package\CellFormatter\TextFormatter;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Export\Package\CellFormatter\TextFormatter
*/
class TextFormatterTest extends TestCase
{
public function testFormatValueReturnsSanitizedStringWhenSanitizeDdeIsTrue(): void
{
$formatter = new TextFormatter(true);
$result = $formatter->formatValue('=cmd|\' /C calc\'!A0');
self::assertEquals('\' =cmd|\' /C calc\'!A0', $result);
}
public function testFormatValueReturnsOriginalStringWhenSanitizeDdeIsFalse(): void
{
$formatter = new TextFormatter(false);
$result = $formatter->formatValue('=cmd|\' /C calc\'!A0');
self::assertEquals('=cmd|\' /C calc\'!A0', $result);
}
public function testFormatValueReturnsOriginalValueForNonStringWhenSanitizeDdeIsTrue(): void
{
$formatter = new TextFormatter(true);
$result = $formatter->formatValue(123);
self::assertEquals(123, $result);
$result = $formatter->formatValue(45.67);
self::assertEquals(45.67, $result);
$result = $formatter->formatValue(true);
self::assertTrue($result);
}
public function testFormatValueReturnsOriginalValueForNonStringWhenSanitizeDdeIsFalse(): void
{
$formatter = new TextFormatter(false);
$result = $formatter->formatValue(123);
self::assertEquals(123, $result);
$result = $formatter->formatValue(45.67);
self::assertEquals(45.67, $result);
$result = $formatter->formatValue(true);
self::assertTrue($result);
}
}

View File

@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Export\Package\CellFormatter;
use App\Export\Package\CellFormatter\TimeFormatter;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Export\Package\CellFormatter\TimeFormatter
*/
class TimeFormatterTest extends TestCase
{
public function testFormatValueReturnsFormattedTimeForDateTimeInterface(): void
{
$formatter = new TimeFormatter();
$dateTime = new \DateTime('2023-10-10 14:30:00');
$result = $formatter->formatValue($dateTime);
self::assertEquals('14:30', $result);
}
public function testFormatValueReturnsNullForNullValue(): void
{
$formatter = new TimeFormatter();
$result = $formatter->formatValue(null);
self::assertNull($result);
}
public function testFormatValueThrowsExceptionForNonDateTimeInterface(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Only DateTimeInterface can be formatted');
$formatter = new TimeFormatter();
$formatter->formatValue('not a DateTime');
}
}

View File

@@ -0,0 +1,88 @@
<?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\Entity\ExportableItem;
use App\Export\Package\CellFormatter\CellFormatterInterface;
use App\Export\Package\Column;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Export\Package\Column
*/
class ColumnTest extends TestCase
{
public function testGetNameReturnsColumnName(): void
{
$formatter = $this->createMock(CellFormatterInterface::class);
$column = new Column('testName', $formatter);
self::assertEquals('testName', $column->getName());
}
public function withHeaderSetsHeader(): void
{
$formatter = $this->createMock(CellFormatterInterface::class);
$column = new Column('testName', $formatter);
$column->withHeader('testHeader');
self::assertEquals('testHeader', $column->getHeader());
}
public function withExtractorSetsExtractor(): void
{
$formatter = $this->createMock(CellFormatterInterface::class);
$column = new Column('testName', $formatter);
$extractor = function (ExportableItem $item) {
return $item->getId();
};
$column->withExtractor($extractor);
$exportableItem = $this->createMock(ExportableItem::class);
$exportableItem->method('getId')->willReturn(123);
self::assertEquals(123, $column->extract($exportableItem));
}
public function testExtractThrowsExceptionWhenExtractorIsNull(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Missing extractor on column: testName');
$formatter = $this->createMock(CellFormatterInterface::class);
$column = new Column('testName', $formatter);
$exportableItem = $this->createMock(ExportableItem::class);
$column->extract($exportableItem);
}
public function testGetValueReturnsFormattedValue(): void
{
$formatter = $this->createMock(CellFormatterInterface::class);
$formatter->method('formatValue')->willReturn('formattedValue');
$column = new Column('testName', $formatter);
$extractor = function (ExportableItem $item) {
return 'rawValue';
};
$column->withExtractor($extractor);
$exportableItem = $this->createMock(ExportableItem::class);
self::assertEquals('formattedValue', $column->getValue($exportableItem));
}
public function testGetHeaderReturnsHeaderWhenSet(): void
{
$formatter = $this->createMock(CellFormatterInterface::class);
$column = new Column('testName', $formatter);
$column->withHeader('testHeader');
self::assertEquals('testHeader', $column->getHeader());
}
public function testGetHeaderReturnsNameWhenHeaderIsNull(): void
{
$formatter = $this->createMock(CellFormatterInterface::class);
$column = new Column('testName', $formatter);
self::assertEquals('testName', $column->getHeader());
}
}

View File

@@ -0,0 +1,121 @@
<?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\PhpOfficeSpreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Export\Package\PhpOfficeSpreadsheet
*/
class PhpOfficeSpreadsheetTest extends TestCase
{
private string $filename;
private int $counter = 1;
protected function setUp(): void
{
$this->filename = realpath(__DIR__ . '/../../_data/') . '/test' . $this->counter++ . '.xlsx';
}
protected function tearDown(): void
{
if (file_exists($this->filename)) {
unlink($this->filename);
}
}
public function testopenSetsFilename(): void
{
$spreadsheetPackage = new PhpOfficeSpreadsheet();
$spreadsheetPackage->open($this->filename);
$reflection = new \ReflectionClass($spreadsheetPackage);
$property = $reflection->getProperty('filename');
$property->setAccessible(true);
self::assertEquals($this->filename, $property->getValue($spreadsheetPackage));
}
public function testsaveThrowsExceptionWhenFilenameIsNull(): void
{
$this->expectException(\Exception::class);
$this->expectExceptionMessage('Need to call open() first before save()');
$spreadsheetPackage = new PhpOfficeSpreadsheet();
$spreadsheetPackage->save();
}
public function testsaveWritesFile(): void
{
$spreadsheetPackage = new PhpOfficeSpreadsheet();
$spreadsheetPackage->open($this->filename);
$spreadsheetPackage->setHeader(['Foo', 'Bar']);
$spreadsheetPackage->addRow(['Data1', 'Data2']);
$spreadsheetPackage->addRow(['Data3', 'Data4']);
$spreadsheetPackage->save();
self::assertGreaterThan(0, filesize($this->filename));
}
public function testsaveThrowsExceptionWhenSpreadsheetIsNull(): void
{
$this->expectException(\Exception::class);
$this->expectExceptionMessage('Cannot re-use spreadsheet after calling save()');
$spreadsheetPackage = new PhpOfficeSpreadsheet();
$spreadsheetPackage->open($this->filename);
$spreadsheetPackage->save();
$spreadsheetPackage->save();
}
public function testsetHeaderSetsHeaderRow(): void
{
$spreadsheetPackage = new PhpOfficeSpreadsheet();
$spreadsheetPackage->open($this->filename);
$spreadsheetPackage->setHeader(['Column1', 'Column2']);
$reflection = new \ReflectionClass($spreadsheetPackage);
$property = $reflection->getProperty('worksheet');
$property->setAccessible(true);
/** @var Worksheet $worksheet */
$worksheet = $property->getValue($spreadsheetPackage);
self::assertNotNull($worksheet);
self::assertEquals('Column1', $worksheet->getCell('A1')->getValue());
self::assertEquals('Column2', $worksheet->getCell('B1')->getValue());
}
public function testaddRowAddsDataRow(): void
{
$spreadsheetPackage = new PhpOfficeSpreadsheet();
$spreadsheetPackage->open($this->filename);
$spreadsheetPackage->addRow(['Data1', 'Data2']);
$reflection = new \ReflectionClass($spreadsheetPackage);
$property = $reflection->getProperty('worksheet');
$property->setAccessible(true);
/** @var Worksheet $worksheet */
$worksheet = $property->getValue($spreadsheetPackage);
self::assertEquals('Data1', $worksheet->getCell('A1')->getValue());
self::assertEquals('Data2', $worksheet->getCell('B1')->getValue());
}
public function testaddRowThrowsExceptionWhenSpreadsheetIsNull(): void
{
$this->expectException(\Exception::class);
$this->expectExceptionMessage('Cannot re-use spreadsheet after calling save()');
$spreadsheetPackage = new PhpOfficeSpreadsheet();
$spreadsheetPackage->open($this->filename);
$spreadsheetPackage->save();
$spreadsheetPackage->addRow(['Data1', 'Data2']);
}
}

View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Export\Package;
use App\Export\Package\SpoutSpreadsheet;
use OpenSpout\Writer\CSV\Writer;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Export\Package\SpoutSpreadsheet
*/
class SpoutSpreadsheetTest extends TestCase
{
private string $filename;
private int $counter = 1;
protected function setUp(): void
{
$this->filename = realpath(__DIR__ . '/../../_data/') . '/test' . $this->counter++ . '.xlsx';
}
protected function tearDown(): void
{
if (file_exists($this->filename)) {
unlink($this->filename);
}
}
public function testsaveWritesFile(): void
{
$spreadsheetPackage = new SpoutSpreadsheet(new Writer());
$spreadsheetPackage->open($this->filename);
$spreadsheetPackage->setHeader(['Foo', 'Bar']);
$spreadsheetPackage->addRow(['Data1', 'Data2']);
$spreadsheetPackage->addRow(['Data3', 'Data4']);
$spreadsheetPackage->save();
self::assertGreaterThan(0, filesize($this->filename));
}
}

View File

@@ -9,7 +9,6 @@
namespace App\Tests\Export\Renderer;
use App\Configuration\LocaleService;
use App\Entity\Activity;
use App\Entity\ActivityMeta;
use App\Entity\Customer;
@@ -26,41 +25,14 @@ use App\Event\CustomerMetaDisplayEvent;
use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Export\ExportRendererInterface;
use App\Export\TimesheetExportInterface;
use App\Repository\Query\TimesheetQuery;
use App\Twig\LocaleFormatExtensions;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatorInterface;
abstract class AbstractRendererTestCase extends KernelTestCase
{
/**
* @param class-string $classname
*/
protected function getAbstractRenderer(string $classname): ExportRendererInterface|TimesheetExportInterface
{
$languages = [
'en' => LocaleService::DEFAULT_SETTINGS
];
$security = $this->createMock(Security::class);
$security->expects($this->any())->method('getUser')->willReturn(new User());
$security->expects($this->any())->method('isGranted')->willReturn(true);
$translator = $this->createMock(TranslatorInterface::class);
$dateExtension = new LocaleFormatExtensions(new LocaleService($languages));
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriber());
return new $classname($translator, $dateExtension, $dispatcher, $security);
}
protected function render(ExportRendererInterface $renderer): Response
{
$customer = new Customer('Customer Name');

View File

@@ -10,7 +10,7 @@
namespace App\Tests\Export\Renderer;
use App\Activity\ActivityStatisticService;
use App\Export\Renderer\HtmlRenderer;
use App\Export\Base\HtmlRenderer;
use App\Export\Renderer\HtmlRendererFactory;
use App\Project\ProjectStatisticService;
use PHPUnit\Framework\TestCase;
@@ -35,5 +35,6 @@ class HtmlRendererFactoryTest extends TestCase
self::assertInstanceOf(HtmlRenderer::class, $renderer);
self::assertEquals('foo', $renderer->getId());
self::assertEquals('print', $renderer->getTitle());
}
}

View File

@@ -11,7 +11,7 @@ namespace App\Tests\Export\Renderer;
use App\Activity\ActivityStatisticService;
use App\Entity\User;
use App\Export\Renderer\HtmlRenderer;
use App\Export\Base\HtmlRenderer;
use App\Project\ProjectStatisticService;
use Symfony\Bridge\Twig\AppVariable;
use Symfony\Component\EventDispatcher\EventDispatcher;
@@ -24,7 +24,6 @@ use Twig\Environment;
/**
* @covers \App\Export\Base\HtmlRenderer
* @covers \App\Export\Base\RendererTrait
* @covers \App\Export\Renderer\HtmlRenderer
* @group integration
*/
class HtmlRendererTest extends AbstractRendererTestCase

View File

@@ -9,7 +9,7 @@
namespace App\Tests\Export\Renderer;
use App\Export\Renderer\PDFRenderer;
use App\Export\Base\PDFRenderer;
use App\Export\Renderer\PdfRendererFactory;
use App\Pdf\HtmlToPdfConverter;
use App\Project\ProjectStatisticService;

View File

@@ -9,7 +9,7 @@
namespace App\Tests\Export\Renderer;
use App\Export\Renderer\PDFRenderer;
use App\Export\Base\PDFRenderer;
use App\Pdf\HtmlToPdfConverter;
use App\Pdf\MPdfConverter;
use App\Project\ProjectStatisticService;
@@ -21,7 +21,6 @@ use Twig\Environment;
/**
* @covers \App\Export\Base\PDFRenderer
* @covers \App\Export\Base\RendererTrait
* @covers \App\Export\Renderer\PDFRenderer
* @group integration
*/
class PdfRendererTest extends AbstractRendererTestCase

View File

@@ -10,8 +10,8 @@
namespace App\Tests\Export;
use App\Activity\ActivityStatisticService;
use App\Export\Base\HtmlRenderer;
use App\Export\ExportRepositoryInterface;
use App\Export\Renderer\HtmlRenderer;
use App\Export\ServiceExport;
use App\Export\Timesheet\HtmlRenderer as HtmlExporter;
use App\Project\ProjectStatisticService;

View File

@@ -9,7 +9,6 @@
namespace App\Tests\Export\Timesheet;
use App\Configuration\LocaleService;
use App\Entity\Activity;
use App\Entity\ActivityMeta;
use App\Entity\Customer;
@@ -27,42 +26,13 @@ use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Export\TimesheetExportInterface;
use App\Repository\Query\TimesheetQuery;
use App\Twig\LocaleFormatExtensions;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatorInterface;
abstract class AbstractRendererTestCase extends KernelTestCase
{
/**
* @param class-string $classname
*/
protected function getAbstractRenderer(string $classname): TimesheetExportInterface
{
$languages = [
'en' => LocaleService::DEFAULT_SETTINGS
];
$user = new User();
$user->setUserIdentifier('ssdf');
$security = $this->createMock(Security::class);
$security->expects($this->any())->method('getUser')->willReturn($user);
$security->expects($this->any())->method('isGranted')->willReturn(true);
$translator = $this->getMockBuilder(TranslatorInterface::class)->getMock();
$dateExtension = new LocaleFormatExtensions(new LocaleService($languages));
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriber());
return new $classname($translator, $dateExtension, $dispatcher, $security);
}
protected function render(TimesheetExportInterface $renderer): Response
{
$customer = new Customer('Customer Name');

View File

@@ -1,114 +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\Timesheet;
use App\Export\Timesheet\CsvRenderer;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* @covers \App\Export\Base\CsvRenderer
* @covers \App\Export\Base\AbstractSpreadsheetRenderer
* @covers \App\Export\Base\RendererTrait
* @covers \App\Export\Timesheet\CsvRenderer
* @group integration
*/
class CsvRendererTest extends AbstractRendererTestCase
{
public function testConfiguration(): void
{
$sut = $this->getAbstractRenderer(CsvRenderer::class);
self::assertEquals('csv', $sut->getId());
self::assertEquals('csv', $sut->getTitle());
}
public static function getTestModel()
{
return [
['400', '2437.12', ' EUR 1,947.99 ', 6, 5, 1, 2, 2]
];
}
/**
* @dataProvider getTestModel
*/
public function testRender($totalDuration, $totalRate, $expectedRate, $expectedRows, $expectedDescriptions, $expectedUser1, $expectedUser2, $expectedUser3): void
{
$sut = $this->getAbstractRenderer(CsvRenderer::class);
/** @var BinaryFileResponse $response */
$response = $this->render($sut);
$file = $response->getFile();
$prefix = date('Ymd');
self::assertEquals('text/csv', $response->headers->get('Content-Type'));
self::assertEquals('attachment; filename=' . $prefix . '-Customer_Name-project_name.csv', $response->headers->get('Content-Disposition'));
self::assertTrue(file_exists($file->getRealPath()));
$content = file_get_contents($file->getRealPath());
self::assertStringContainsString('"' . $expectedRate . '"', $content);
self::assertEquals($expectedRows, substr_count($content, PHP_EOL));
self::assertEquals($expectedDescriptions, substr_count($content, 'activity description'));
self::assertEquals($expectedUser1, substr_count($content, ',"kevin",'));
self::assertEquals($expectedUser3, substr_count($content, ',"hello-world",'));
self::assertEquals($expectedUser2, substr_count($content, ',"foo-bar",'));
ob_start();
$response->sendContent();
$content2 = ob_get_clean();
self::assertEquals($content, $content2);
self::assertFalse(file_exists($file->getRealPath()));
$all = [];
$rows = str_getcsv($content2, PHP_EOL);
foreach ($rows as $row) {
$all[] = str_getcsv($row);
}
$expected = [
'2019-06-16',
'12:00',
'12:06',
'400',
'0',
'',
'kevin',
'kevin',
'',
'Customer Name',
'project name',
'activity description',
'',
'',
'',
'foo,bar',
'',
' EUR 84.00 ',
'meta-bar',
'meta-bar2',
'customer-bar',
'',
'project-foo2',
'activity-bar',
'timesheet',
'work',
'A-0123456789',
'DE-9876543210',
'ORDER-123',
];
self::assertEquals(6, \count($all));
self::assertEquals($expected, $all[5]);
self::assertEquals(\count($expected), \count($all[0]));
self::assertEquals('foo', $all[4][15]);
}
}

View File

@@ -9,7 +9,7 @@
namespace App\Tests\Export\Timesheet;
use App\Export\Timesheet\PDFRenderer;
use App\Export\Base\PDFRenderer;
use App\Pdf\HtmlToPdfConverter;
use App\Pdf\MPdfConverter;
use App\Project\ProjectStatisticService;
@@ -21,7 +21,6 @@ use Twig\Environment;
/**
* @covers \App\Export\Base\PDFRenderer
* @covers \App\Export\Base\RendererTrait
* @covers \App\Export\Timesheet\PDFRenderer
* @group integration
*/
class PdfRendererTest extends AbstractRendererTestCase

View File

@@ -1,53 +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\Timesheet;
use App\Export\Timesheet\XlsxRenderer;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* @covers \App\Export\Base\XlsxRenderer
* @covers \App\Export\Base\AbstractSpreadsheetRenderer
* @covers \App\Export\Base\RendererTrait
* @covers \App\Export\Timesheet\XlsxRenderer
* @group integration
*/
class XlsxRendererTest extends AbstractRendererTestCase
{
public function testConfiguration(): void
{
$sut = $this->getAbstractRenderer(XlsxRenderer::class);
self::assertEquals('xlsx', $sut->getId());
self::assertEquals('xlsx', $sut->getTitle());
}
public function testRender(): void
{
$sut = $this->getAbstractRenderer(XlsxRenderer::class);
/** @var BinaryFileResponse $response */
$response = $this->render($sut);
$file = $response->getFile();
$prefix = date('Ymd');
self::assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
self::assertEquals('attachment; filename=' . $prefix . '-Customer_Name-project_name.xlsx', $response->headers->get('Content-Disposition'));
self::assertTrue(file_exists($file->getRealPath()));
ob_start();
$response->sendContent();
$content2 = ob_get_clean();
self::assertNotEmpty($content2);
self::assertFalse(file_exists($file->getRealPath()));
}
}

View File

@@ -14,7 +14,7 @@ use App\Entity\Activity;
use App\Entity\User;
use App\Twig\Extensions;
use PHPUnit\Framework\TestCase;
use Twig\Node\Node;
use Twig\Node\TextNode;
use Twig\TwigFilter;
use Twig\TwigFunction;
use Twig\TwigTest;
@@ -59,7 +59,7 @@ class ExtensionsTest extends TestCase
// make sure that the nl2str filters does proper escaping
self::assertEquals('nl2str', $twigFilters[$id]->getName());
self::assertEquals('html', $twigFilters[$id]->getPreEscape());
self::assertEquals(['html'], $twigFilters[$id]->getSafe(new Node()));
self::assertEquals(['html'], $twigFilters[$id]->getSafe(new TextNode('', 10)));
}
public function testGetFunctions(): void

View File

@@ -11,7 +11,7 @@ namespace App\Tests\Twig;
use App\Twig\RuntimeExtensions;
use PHPUnit\Framework\TestCase;
use Twig\Node\Node;
use Twig\Node\TextNode;
use Twig\TwigFilter;
use Twig\TwigFunction;
@@ -80,20 +80,20 @@ class RuntimeExtensionsTest extends TestCase
switch ($filter->getName()) {
case 'md2html':
self::assertEquals('html', $filters[0]->getPreEscape());
self::assertEquals(['html'], $filters[0]->getSafe(new Node()));
self::assertEquals(['html'], $filters[0]->getSafe(new TextNode('', 10)));
$found_md2html = true;
break;
case 'desc2html':
self::assertEquals(['html'], $filters[1]->getSafe(new Node()));
self::assertEquals(['html'], $filters[1]->getSafe(new TextNode('', 10)));
$found_desc2html = true;
break;
case 'comment2html':
self::assertEquals(['html'], $filters[2]->getSafe(new Node()));
self::assertEquals(['html'], $filters[2]->getSafe(new TextNode('', 10)));
$found_comment2html = true;
break;
case 'comment1line':
self::assertEquals('html', $filters[3]->getPreEscape());
self::assertEquals(['html'], $filters[3]->getSafe(new Node()));
self::assertEquals(['html'], $filters[3]->getSafe(new TextNode('', 10)));
$found_comment1line = true;
break;
}

View File

@@ -1416,81 +1416,6 @@ parameters:
count: 1
path: EventSubscriber/WizardSubscriberTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\AbstractRendererTestCase\\:\\:getAbstractRenderer\\(\\) should return App\\\\Export\\\\ExportRendererInterface\\|App\\\\Export\\\\TimesheetExportInterface but returns object\\.$#"
count: 1
path: Export/Renderer/AbstractRendererTestCase.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\CsvRendererTest\\:\\:getTestModel\\(\\) has no return type specified\\.$#"
count: 1
path: Export/Renderer/CsvRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedDescriptions with no type specified\\.$#"
count: 1
path: Export/Renderer/CsvRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedRate with no type specified\\.$#"
count: 1
path: Export/Renderer/CsvRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedRows with no type specified\\.$#"
count: 1
path: Export/Renderer/CsvRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedUser1 with no type specified\\.$#"
count: 1
path: Export/Renderer/CsvRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedUser2 with no type specified\\.$#"
count: 1
path: Export/Renderer/CsvRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedUser3 with no type specified\\.$#"
count: 1
path: Export/Renderer/CsvRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$totalDuration with no type specified\\.$#"
count: 1
path: Export/Renderer/CsvRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$totalRate with no type specified\\.$#"
count: 1
path: Export/Renderer/CsvRendererTest.php
-
message: "#^Parameter \\#1 \\$haystack of function substr_count expects string, string\\|false given\\.$#"
count: 5
path: Export/Renderer/CsvRendererTest.php
-
message: "#^Parameter \\#1 \\$renderer of method App\\\\Tests\\\\Export\\\\Renderer\\\\AbstractRendererTestCase\\:\\:render\\(\\) expects App\\\\Export\\\\ExportRendererInterface, App\\\\Export\\\\ExportRendererInterface\\|App\\\\Export\\\\TimesheetExportInterface given\\.$#"
count: 1
path: Export/Renderer/CsvRendererTest.php
-
message: "#^Parameter \\#1 \\$string of function str_getcsv expects string, string\\|false given\\.$#"
count: 1
path: Export/Renderer/CsvRendererTest.php
-
message: "#^Parameter \\#1 \\$string of function str_getcsv expects string, string\\|null given\\.$#"
count: 1
path: Export/Renderer/CsvRendererTest.php
-
message: "#^Parameter \\#2 \\$haystack of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|false given\\.$#"
count: 1
path: Export/Renderer/CsvRendererTest.php
-
message: "#^Parameter \\#1 \\$haystack of function substr_count expects string, string\\|false given\\.$#"
count: 6
@@ -1501,11 +1426,6 @@ parameters:
count: 7
path: Export/Renderer/HtmlRendererTest.php
-
message: "#^Parameter \\#1 \\$renderer of method App\\\\Tests\\\\Export\\\\Renderer\\\\AbstractRendererTestCase\\:\\:render\\(\\) expects App\\\\Export\\\\ExportRendererInterface, App\\\\Export\\\\ExportRendererInterface\\|App\\\\Export\\\\TimesheetExportInterface given\\.$#"
count: 1
path: Export/Renderer/XlsxRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Spreadsheet\\\\CellFormatter\\\\AbstractFormatterTestCase\\:\\:getActualValue\\(\\) has no return type specified\\.$#"
count: 1
@@ -1606,76 +1526,6 @@ parameters:
count: 1
path: Export/Spreadsheet/Writer/BinaryFileResponseWriterTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\AbstractRendererTestCase\\:\\:getAbstractRenderer\\(\\) should return App\\\\Export\\\\TimesheetExportInterface but returns object\\.$#"
count: 1
path: Export/Timesheet/AbstractRendererTestCase.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\CsvRendererTest\\:\\:getTestModel\\(\\) has no return type specified\\.$#"
count: 1
path: Export/Timesheet/CsvRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedDescriptions with no type specified\\.$#"
count: 1
path: Export/Timesheet/CsvRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedRate with no type specified\\.$#"
count: 1
path: Export/Timesheet/CsvRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedRows with no type specified\\.$#"
count: 1
path: Export/Timesheet/CsvRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedUser1 with no type specified\\.$#"
count: 1
path: Export/Timesheet/CsvRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedUser2 with no type specified\\.$#"
count: 1
path: Export/Timesheet/CsvRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedUser3 with no type specified\\.$#"
count: 1
path: Export/Timesheet/CsvRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$totalDuration with no type specified\\.$#"
count: 1
path: Export/Timesheet/CsvRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$totalRate with no type specified\\.$#"
count: 1
path: Export/Timesheet/CsvRendererTest.php
-
message: "#^Parameter \\#1 \\$haystack of function substr_count expects string, string\\|false given\\.$#"
count: 5
path: Export/Timesheet/CsvRendererTest.php
-
message: "#^Parameter \\#1 \\$string of function str_getcsv expects string, string\\|false given\\.$#"
count: 1
path: Export/Timesheet/CsvRendererTest.php
-
message: "#^Parameter \\#1 \\$string of function str_getcsv expects string, string\\|null given\\.$#"
count: 1
path: Export/Timesheet/CsvRendererTest.php
-
message: "#^Parameter \\#2 \\$haystack of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|false given\\.$#"
count: 1
path: Export/Timesheet/CsvRendererTest.php
-
message: "#^Parameter \\#2 \\$haystack of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|false given\\.$#"
count: 1