From 136104d4b0c75102f3843725ce2a9bdb902dccd9 Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Sun, 22 Dec 2024 18:36:47 +0100 Subject: [PATCH] faster spreadsheet exporter based on opensout and other export improvements (#5238) --- composer.json | 1 + composer.lock | 95 +- config/services.yaml | 1 + phpstan.neon | 55 -- phpstan.sh | 2 +- .../Reporting/ProjectViewController.php | 5 - .../Reporting/UserMonthController.php | 5 +- .../Reporting/UserWeekController.php | 5 +- .../Reporting/UserYearController.php | 5 +- .../Base/AbstractSpreadsheetRenderer.php | 809 ------------------ src/Export/Base/CsvRenderer.php | 83 +- src/Export/Base/ExportTrait.php | 31 + src/Export/Base/HtmlRenderer.php | 40 +- src/Export/Base/PDFRenderer.php | 15 +- src/Export/Base/SpreadsheetRenderer.php | 215 +++++ src/Export/Base/XlsxRenderer.php | 119 +-- .../Package/CellFormatter/ArrayFormatter.php | 22 + .../CellFormatter/BooleanFormatter.php | 26 + .../CellFormatter/CellFormatterInterface.php | 15 + .../Package/CellFormatter/DateFormatter.php | 26 + .../CellFormatter/DefaultFormatter.php | 26 + .../CellFormatter/DurationFormatter.php | 22 + .../Package/CellFormatter/RateFormatter.php | 26 + .../Package/CellFormatter/TextFormatter.php | 28 + .../Package/CellFormatter/TimeFormatter.php | 26 + src/Export/Package/Column.php | 61 ++ src/Export/Package/PhpOfficeSpreadsheet.php | 131 +++ src/Export/Package/SpoutSpreadsheet.php | 94 ++ src/Export/Package/SpreadsheetPackage.php | 31 + src/Export/Renderer/CsvRenderer.php | 21 - src/Export/Renderer/HtmlRenderer.php | 21 - src/Export/Renderer/HtmlRendererFactory.php | 9 +- src/Export/Renderer/PDFRenderer.php | 21 - src/Export/Renderer/PdfRendererFactory.php | 7 +- src/Export/Renderer/XlsxRenderer.php | 21 - .../Writer/BinaryFileResponseWriter.php | 4 +- src/Export/Timesheet/CsvRenderer.php | 21 - src/Export/Timesheet/HtmlRenderer.php | 5 - src/Export/Timesheet/PDFRenderer.php | 21 - src/Export/Timesheet/XlsxRenderer.php | 21 - src/Export/TimesheetExportInterface.php | 4 +- templates/export/index.html.twig | 6 + .../customer/monthly_projects.html.twig | 2 +- .../customer/monthly_projects_data.html.twig | 6 +- .../monthly_projects_export.html.twig | 12 +- .../reporting/project_list_export.html.twig | 37 +- .../report_user_list_export.html.twig | 21 +- .../report_user_list_monthly_export.html.twig | 21 +- .../AbstractUserPeriodControllerTestCase.php | 28 + .../Reporting/UserMonthControllerTest.php | 5 + .../Reporting/UserWeekControllerTest.php | 5 + .../Reporting/UserYearControllerTest.php | 5 + .../ExportServiceCompilerPassTest.php | 8 +- .../{Renderer => Base}/CsvRendererTest.php | 100 ++- tests/Export/Base/SpreadsheetRendererTest.php | 102 +++ .../{Renderer => Base}/XlsxRendererTest.php | 33 +- .../CellFormatter/ArrayFormatterTest.php | 42 + .../CellFormatter/BooleanFormatterTest.php | 77 ++ .../CellFormatter/DateFormatterTest.php | 43 + .../CellFormatter/DefaultFormatterTest.php | 56 ++ .../CellFormatter/DurationFormatterTest.php | 54 ++ .../CellFormatter/RateFormatterTest.php | 78 ++ .../CellFormatter/TextFormatterTest.php | 59 ++ .../CellFormatter/TimeFormatterTest.php | 43 + tests/Export/Package/ColumnTest.php | 88 ++ .../Package/PhpOfficeSpreadsheetTest.php | 121 +++ tests/Export/Package/SpoutSpreadsheetTest.php | 47 + .../Renderer/AbstractRendererTestCase.php | 28 - .../Renderer/HtmlRendererFactoryTest.php | 3 +- tests/Export/Renderer/HtmlRendererTest.php | 3 +- .../Renderer/PdfRendererFactoryTest.php | 2 +- tests/Export/Renderer/PdfRendererTest.php | 3 +- tests/Export/ServiceExportTest.php | 2 +- .../Timesheet/AbstractRendererTestCase.php | 30 - tests/Export/Timesheet/CsvRendererTest.php | 114 --- tests/Export/Timesheet/PdfRendererTest.php | 3 +- tests/Export/Timesheet/XlsxRendererTest.php | 53 -- tests/Twig/ExtensionsTest.php | 4 +- tests/Twig/RuntimeExtensionsTest.php | 10 +- tests/phpstan.neon | 172 +--- 80 files changed, 2023 insertions(+), 1699 deletions(-) delete mode 100644 src/Export/Base/AbstractSpreadsheetRenderer.php create mode 100644 src/Export/Base/ExportTrait.php create mode 100644 src/Export/Base/SpreadsheetRenderer.php create mode 100644 src/Export/Package/CellFormatter/ArrayFormatter.php create mode 100644 src/Export/Package/CellFormatter/BooleanFormatter.php create mode 100644 src/Export/Package/CellFormatter/CellFormatterInterface.php create mode 100644 src/Export/Package/CellFormatter/DateFormatter.php create mode 100644 src/Export/Package/CellFormatter/DefaultFormatter.php create mode 100644 src/Export/Package/CellFormatter/DurationFormatter.php create mode 100644 src/Export/Package/CellFormatter/RateFormatter.php create mode 100644 src/Export/Package/CellFormatter/TextFormatter.php create mode 100644 src/Export/Package/CellFormatter/TimeFormatter.php create mode 100644 src/Export/Package/Column.php create mode 100644 src/Export/Package/PhpOfficeSpreadsheet.php create mode 100644 src/Export/Package/SpoutSpreadsheet.php create mode 100644 src/Export/Package/SpreadsheetPackage.php delete mode 100644 src/Export/Renderer/CsvRenderer.php delete mode 100644 src/Export/Renderer/HtmlRenderer.php delete mode 100644 src/Export/Renderer/PDFRenderer.php delete mode 100644 src/Export/Renderer/XlsxRenderer.php delete mode 100644 src/Export/Timesheet/CsvRenderer.php delete mode 100644 src/Export/Timesheet/PDFRenderer.php delete mode 100644 src/Export/Timesheet/XlsxRenderer.php rename tests/Export/{Renderer => Base}/CsvRendererTest.php (61%) create mode 100644 tests/Export/Base/SpreadsheetRendererTest.php rename tests/Export/{Renderer => Base}/XlsxRendererTest.php (53%) create mode 100644 tests/Export/Package/CellFormatter/ArrayFormatterTest.php create mode 100644 tests/Export/Package/CellFormatter/BooleanFormatterTest.php create mode 100644 tests/Export/Package/CellFormatter/DateFormatterTest.php create mode 100644 tests/Export/Package/CellFormatter/DefaultFormatterTest.php create mode 100644 tests/Export/Package/CellFormatter/DurationFormatterTest.php create mode 100644 tests/Export/Package/CellFormatter/RateFormatterTest.php create mode 100644 tests/Export/Package/CellFormatter/TextFormatterTest.php create mode 100644 tests/Export/Package/CellFormatter/TimeFormatterTest.php create mode 100644 tests/Export/Package/ColumnTest.php create mode 100644 tests/Export/Package/PhpOfficeSpreadsheetTest.php create mode 100644 tests/Export/Package/SpoutSpreadsheetTest.php delete mode 100644 tests/Export/Timesheet/CsvRendererTest.php delete mode 100644 tests/Export/Timesheet/XlsxRendererTest.php diff --git a/composer.json b/composer.json index d38f7cac..353c7bb5 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 900bb6fc..0321a7d0 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/services.yaml b/config/services.yaml index faf2922b..e985506b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -25,6 +25,7 @@ services: - '../src/Doctrine/Extensions/' - '../src/Entity/' - '../src/Event/' + - '../src/Export/Package/' - '../src/Form/Model/' - '../src/Model/' - '../src/Repository/Loader/' diff --git a/phpstan.neon b/phpstan.neon index c494538a..779be9d2 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -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 diff --git a/phpstan.sh b/phpstan.sh index b200590a..3763bb28 100755 --- a/phpstan.sh +++ b/phpstan.sh @@ -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 diff --git a/src/Controller/Reporting/ProjectViewController.php b/src/Controller/Reporting/ProjectViewController.php index e1a05844..4f71bb92 100644 --- a/src/Controller/Reporting/ProjectViewController.php +++ b/src/Controller/Reporting/ProjectViewController.php @@ -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(); diff --git a/src/Controller/Reporting/UserMonthController.php b/src/Controller/Reporting/UserMonthController.php index 24945ee8..f90e5d9b 100644 --- a/src/Controller/Reporting/UserMonthController.php +++ b/src/Controller/Reporting/UserMonthController.php @@ -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()); diff --git a/src/Controller/Reporting/UserWeekController.php b/src/Controller/Reporting/UserWeekController.php index 45713cbd..d93acb76 100644 --- a/src/Controller/Reporting/UserWeekController.php +++ b/src/Controller/Reporting/UserWeekController.php @@ -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()); diff --git a/src/Controller/Reporting/UserYearController.php b/src/Controller/Reporting/UserYearController.php index b1b541fd..6e2bbdf9 100644 --- a/src/Controller/Reporting/UserYearController.php +++ b/src/Controller/Reporting/UserYearController.php @@ -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(); diff --git a/src/Export/Base/AbstractSpreadsheetRenderer.php b/src/Export/Base/AbstractSpreadsheetRenderer.php deleted file mode 100644 index d927c83b..00000000 --- a/src/Export/Base/AbstractSpreadsheetRenderer.php +++ /dev/null @@ -1,809 +0,0 @@ - - */ - 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> $columns - * @return array> - */ - 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; -} diff --git a/src/Export/Base/CsvRenderer.php b/src/Export/Base/CsvRenderer.php index ec5a3828..566bbd09 100644 --- a/src/Export/Base/CsvRenderer.php +++ b/src/Export/Base/CsvRenderer.php @@ -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' + ); + } + + /** + * @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'); } - $this->setRateStyle($sheet, $column, $row, $currency); + + $spreadsheet = new SpoutSpreadsheet(new Writer()); + $spreadsheet->open($filename); + + $this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query); + + return new \SplFileInfo($filename); } } diff --git a/src/Export/Base/ExportTrait.php b/src/Export/Base/ExportTrait.php new file mode 100644 index 00000000..a62f4437 --- /dev/null +++ b/src/Export/Base/ExportTrait.php @@ -0,0 +1,31 @@ +headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename); + + $response->headers->set('Content-Type', $contentType); + $response->headers->set('Content-Disposition', $disposition); + $response->deleteFileAfterSend(true); + + return $response; + } +} diff --git a/src/Export/Base/HtmlRenderer.php b/src/Export/Base/HtmlRenderer.php index cd680579..d51ad3b0 100644 --- a/src/Export/Base/HtmlRenderer.php +++ b/src/Export/Base/HtmlRenderer.php @@ -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'; + } } diff --git a/src/Export/Base/PDFRenderer.php b/src/Export/Base/PDFRenderer.php index 2f4def2e..3e2fed1f 100644 --- a/src/Export/Base/PDFRenderer.php +++ b/src/Export/Base/PDFRenderer.php @@ -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; diff --git a/src/Export/Base/SpreadsheetRenderer.php b/src/Export/Base/SpreadsheetRenderer.php new file mode 100644 index 00000000..3088b3ed --- /dev/null +++ b/src/Export/Base/SpreadsheetRenderer.php @@ -0,0 +1,215 @@ +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 + */ + 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; + } +} diff --git a/src/Export/Base/XlsxRenderer.php b/src/Export/Base/XlsxRenderer.php index 41c25f75..f4692d2a 100644 --- a/src/Export/Base/XlsxRenderer.php +++ b/src/Export/Base/XlsxRenderer.php @@ -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); + } } diff --git a/src/Export/Package/CellFormatter/ArrayFormatter.php b/src/Export/Package/CellFormatter/ArrayFormatter.php new file mode 100644 index 00000000..31632dd2 --- /dev/null +++ b/src/Export/Package/CellFormatter/ArrayFormatter.php @@ -0,0 +1,22 @@ +format('Y-m-d'); + } + + if ($value === null) { + return null; + } + + throw new \InvalidArgumentException('Only DateTimeInterface can be formatted'); + } +} diff --git a/src/Export/Package/CellFormatter/DefaultFormatter.php b/src/Export/Package/CellFormatter/DefaultFormatter.php new file mode 100644 index 00000000..10b84730 --- /dev/null +++ b/src/Export/Package/CellFormatter/DefaultFormatter.php @@ -0,0 +1,26 @@ +sanitizeDde && \is_string($value)) { + $value = StringHelper::sanitizeDDE($value); + } + + return $value; + } +} diff --git a/src/Export/Package/CellFormatter/TimeFormatter.php b/src/Export/Package/CellFormatter/TimeFormatter.php new file mode 100644 index 00000000..b9574ef4 --- /dev/null +++ b/src/Export/Package/CellFormatter/TimeFormatter.php @@ -0,0 +1,26 @@ +format('H:i'); + } + + if ($value === null) { + return null; + } + + throw new \InvalidArgumentException('Only DateTimeInterface can be formatted'); + } +} diff --git a/src/Export/Package/Column.php b/src/Export/Package/Column.php new file mode 100644 index 00000000..d3bb74b9 --- /dev/null +++ b/src/Export/Package/Column.php @@ -0,0 +1,61 @@ +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; + } +} diff --git a/src/Export/Package/PhpOfficeSpreadsheet.php b/src/Export/Package/PhpOfficeSpreadsheet.php new file mode 100644 index 00000000..e4bcf758 --- /dev/null +++ b/src/Export/Package/PhpOfficeSpreadsheet.php @@ -0,0 +1,131 @@ +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 $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 $columns + * @param array $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++; + } +} diff --git a/src/Export/Package/SpoutSpreadsheet.php b/src/Export/Package/SpoutSpreadsheet.php new file mode 100644 index 00000000..ecc0eff8 --- /dev/null +++ b/src/Export/Package/SpoutSpreadsheet.php @@ -0,0 +1,94 @@ +writer->setCreator(Constants::SOFTWARE); + } + + /** + * @param array $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 $columns + * @param array $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(); + } +} diff --git a/src/Export/Package/SpreadsheetPackage.php b/src/Export/Package/SpreadsheetPackage.php new file mode 100644 index 00000000..4736e4c2 --- /dev/null +++ b/src/Export/Package/SpreadsheetPackage.php @@ -0,0 +1,31 @@ + $columns + */ + public function setHeader(array $columns): void; + + /** + * @param array $columns + * @param array $options + */ + public function addRow(array $columns, array $options = []): void; +} diff --git a/src/Export/Renderer/CsvRenderer.php b/src/Export/Renderer/CsvRenderer.php deleted file mode 100644 index eded5274..00000000 --- a/src/Export/Renderer/CsvRenderer.php +++ /dev/null @@ -1,21 +0,0 @@ -prefix = urlencode($prefix); } diff --git a/src/Export/Timesheet/CsvRenderer.php b/src/Export/Timesheet/CsvRenderer.php deleted file mode 100644 index c6f9655d..00000000 --- a/src/Export/Timesheet/CsvRenderer.php +++ /dev/null @@ -1,21 +0,0 @@ - {{ entry.description|desc2html }} + + {% if entry.type == 'timesheet' %} + {{ widgets.tag_list(entry.tags) }} + {% endif %} + {{ rate|money(currency) }} diff --git a/templates/reporting/customer/monthly_projects.html.twig b/templates/reporting/customer/monthly_projects.html.twig index fbeb7d3b..e62add98 100644 --- a/templates/reporting/customer/monthly_projects.html.twig +++ b/templates/reporting/customer/monthly_projects.html.twig @@ -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 %} diff --git a/templates/reporting/customer/monthly_projects_data.html.twig b/templates/reporting/customer/monthly_projects_data.html.twig index 5a182324..a6f5ae1a 100644 --- a/templates/reporting/customer/monthly_projects_data.html.twig +++ b/templates/reporting/customer/monthly_projects_data.html.twig @@ -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) %} {{ user.0.name }} - + {% set value = user.0[dataType] %} {% block user_activity %} {% if dataType == 'rate' or dataType == 'internalRate' %} @@ -62,7 +62,7 @@ {% endblock %} {% if loop.parent.loop.first %} - 1 %} rowspan="{{ rowspan }}"{% endif %} class="text-center text-nowrap"{% if dataTypeFormat is not null %} data-format="{{ dataTypeFormat }}"{% endif %}> + 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 %} - 1 %} rowspan="{{ rowspan }}" style="{{ rowspanStyle }}"{% endif %} class="text-center text-nowrap"{% if dataTypeFormat is not null %} data-format="{{ dataTypeFormat }}"{% endif %}> + 1 %} rowspan="{{ rowspan }}" style="{{ rowspanStyle }}"{% endif %} class="text-center text-nowrap"> {% set value = project[dataType] %} {% block project_total %} {% if dataType == 'rate' or dataType == 'internalRate' %} diff --git a/templates/reporting/customer/monthly_projects_export.html.twig b/templates/reporting/customer/monthly_projects_export.html.twig index 52186efe..f176e7cf 100644 --- a/templates/reporting/customer/monthly_projects_export.html.twig +++ b/templates/reporting/customer/monthly_projects_export.html.twig @@ -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 %} diff --git a/templates/reporting/project_list_export.html.twig b/templates/reporting/project_list_export.html.twig index 26e3fcee..454464c2 100644 --- a/templates/reporting/project_list_export.html.twig +++ b/templates/reporting/project_list_export.html.twig @@ -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 @@ – {% 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 %} - – + {{ 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 %} - – + {{ 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 %} diff --git a/templates/reporting/report_user_list_export.html.twig b/templates/reporting/report_user_list_export.html.twig index 7e6f2842..e814c8e1 100644 --- a/templates/reporting/report_user_list_export.html.twig +++ b/templates/reporting/report_user_list_export.html.twig @@ -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 }} {% 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 %} diff --git a/templates/reporting/report_user_list_monthly_export.html.twig b/templates/reporting/report_user_list_monthly_export.html.twig index 0b6e43c7..59940856 100644 --- a/templates/reporting/report_user_list_monthly_export.html.twig +++ b/templates/reporting/report_user_list_monthly_export.html.twig @@ -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') }} {% 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 %} diff --git a/tests/Controller/Reporting/AbstractUserPeriodControllerTestCase.php b/tests/Controller/Reporting/AbstractUserPeriodControllerTestCase.php index 07c56b9f..541cb622 100644 --- a/tests/Controller/Reporting/AbstractUserPeriodControllerTestCase.php +++ b/tests/Controller/Reporting/AbstractUserPeriodControllerTestCase.php @@ -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); diff --git a/tests/Controller/Reporting/UserMonthControllerTest.php b/tests/Controller/Reporting/UserMonthControllerTest.php index 8b3b5b03..15bcd7a9 100644 --- a/tests/Controller/Reporting/UserMonthControllerTest.php +++ b/tests/Controller/Reporting/UserMonthControllerTest.php @@ -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'; diff --git a/tests/Controller/Reporting/UserWeekControllerTest.php b/tests/Controller/Reporting/UserWeekControllerTest.php index 5760a5fc..507ceca1 100644 --- a/tests/Controller/Reporting/UserWeekControllerTest.php +++ b/tests/Controller/Reporting/UserWeekControllerTest.php @@ -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'; diff --git a/tests/Controller/Reporting/UserYearControllerTest.php b/tests/Controller/Reporting/UserYearControllerTest.php index 59fb1107..03305adb 100644 --- a/tests/Controller/Reporting/UserYearControllerTest.php +++ b/tests/Controller/Reporting/UserYearControllerTest.php @@ -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'; diff --git a/tests/DependencyInjection/Compiler/ExportServiceCompilerPassTest.php b/tests/DependencyInjection/Compiler/ExportServiceCompilerPassTest.php index d6a5ce6c..f2ed1e24 100644 --- a/tests/DependencyInjection/Compiler/ExportServiceCompilerPassTest.php +++ b/tests/DependencyInjection/Compiler/ExportServiceCompilerPassTest.php @@ -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; diff --git a/tests/Export/Renderer/CsvRendererTest.php b/tests/Export/Base/CsvRendererTest.php similarity index 61% rename from tests/Export/Renderer/CsvRendererTest.php rename to tests/Export/Base/CsvRendererTest.php index 71d8b0f5..1e78387d 100644 --- a/tests/Export/Renderer/CsvRendererTest.php +++ b/tests/Export/Base/CsvRendererTest.php @@ -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', '', - '', - 'foo,bar', - '', - ' 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]); } } diff --git a/tests/Export/Base/SpreadsheetRendererTest.php b/tests/Export/Base/SpreadsheetRendererTest.php new file mode 100644 index 00000000..2eca5ea2 --- /dev/null +++ b/tests/Export/Base/SpreadsheetRendererTest.php @@ -0,0 +1,102 @@ +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()); + } +} diff --git a/tests/Export/Renderer/XlsxRendererTest.php b/tests/Export/Base/XlsxRendererTest.php similarity index 53% rename from tests/Export/Renderer/XlsxRendererTest.php rename to tests/Export/Base/XlsxRendererTest.php index 61e0077c..bcde3ae5 100644 --- a/tests/Export/Renderer/XlsxRendererTest.php +++ b/tests/Export/Base/XlsxRendererTest.php @@ -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); diff --git a/tests/Export/Package/CellFormatter/ArrayFormatterTest.php b/tests/Export/Package/CellFormatter/ArrayFormatterTest.php new file mode 100644 index 00000000..f172dfdc --- /dev/null +++ b/tests/Export/Package/CellFormatter/ArrayFormatterTest.php @@ -0,0 +1,42 @@ +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); + } +} diff --git a/tests/Export/Package/CellFormatter/BooleanFormatterTest.php b/tests/Export/Package/CellFormatter/BooleanFormatterTest.php new file mode 100644 index 00000000..b5590f38 --- /dev/null +++ b/tests/Export/Package/CellFormatter/BooleanFormatterTest.php @@ -0,0 +1,77 @@ +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([]); + } +} diff --git a/tests/Export/Package/CellFormatter/DateFormatterTest.php b/tests/Export/Package/CellFormatter/DateFormatterTest.php new file mode 100644 index 00000000..a9ae5c79 --- /dev/null +++ b/tests/Export/Package/CellFormatter/DateFormatterTest.php @@ -0,0 +1,43 @@ +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'); + } +} diff --git a/tests/Export/Package/CellFormatter/DefaultFormatterTest.php b/tests/Export/Package/CellFormatter/DefaultFormatterTest.php new file mode 100644 index 00000000..13990327 --- /dev/null +++ b/tests/Export/Package/CellFormatter/DefaultFormatterTest.php @@ -0,0 +1,56 @@ +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()); + } +} diff --git a/tests/Export/Package/CellFormatter/DurationFormatterTest.php b/tests/Export/Package/CellFormatter/DurationFormatterTest.php new file mode 100644 index 00000000..27c0d3af --- /dev/null +++ b/tests/Export/Package/CellFormatter/DurationFormatterTest.php @@ -0,0 +1,54 @@ +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); + } +} diff --git a/tests/Export/Package/CellFormatter/RateFormatterTest.php b/tests/Export/Package/CellFormatter/RateFormatterTest.php new file mode 100644 index 00000000..6e02d12f --- /dev/null +++ b/tests/Export/Package/CellFormatter/RateFormatterTest.php @@ -0,0 +1,78 @@ +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([]); + } +} diff --git a/tests/Export/Package/CellFormatter/TextFormatterTest.php b/tests/Export/Package/CellFormatter/TextFormatterTest.php new file mode 100644 index 00000000..a400b2cc --- /dev/null +++ b/tests/Export/Package/CellFormatter/TextFormatterTest.php @@ -0,0 +1,59 @@ +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); + } +} diff --git a/tests/Export/Package/CellFormatter/TimeFormatterTest.php b/tests/Export/Package/CellFormatter/TimeFormatterTest.php new file mode 100644 index 00000000..9a507280 --- /dev/null +++ b/tests/Export/Package/CellFormatter/TimeFormatterTest.php @@ -0,0 +1,43 @@ +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'); + } +} diff --git a/tests/Export/Package/ColumnTest.php b/tests/Export/Package/ColumnTest.php new file mode 100644 index 00000000..5f0aae0b --- /dev/null +++ b/tests/Export/Package/ColumnTest.php @@ -0,0 +1,88 @@ +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()); + } +} diff --git a/tests/Export/Package/PhpOfficeSpreadsheetTest.php b/tests/Export/Package/PhpOfficeSpreadsheetTest.php new file mode 100644 index 00000000..c5e1d21a --- /dev/null +++ b/tests/Export/Package/PhpOfficeSpreadsheetTest.php @@ -0,0 +1,121 @@ +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']); + } +} diff --git a/tests/Export/Package/SpoutSpreadsheetTest.php b/tests/Export/Package/SpoutSpreadsheetTest.php new file mode 100644 index 00000000..89184011 --- /dev/null +++ b/tests/Export/Package/SpoutSpreadsheetTest.php @@ -0,0 +1,47 @@ +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)); + } +} diff --git a/tests/Export/Renderer/AbstractRendererTestCase.php b/tests/Export/Renderer/AbstractRendererTestCase.php index a9b4a3a6..ad74791c 100644 --- a/tests/Export/Renderer/AbstractRendererTestCase.php +++ b/tests/Export/Renderer/AbstractRendererTestCase.php @@ -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'); diff --git a/tests/Export/Renderer/HtmlRendererFactoryTest.php b/tests/Export/Renderer/HtmlRendererFactoryTest.php index 5a142d79..41624191 100644 --- a/tests/Export/Renderer/HtmlRendererFactoryTest.php +++ b/tests/Export/Renderer/HtmlRendererFactoryTest.php @@ -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()); } } diff --git a/tests/Export/Renderer/HtmlRendererTest.php b/tests/Export/Renderer/HtmlRendererTest.php index 18abd244..d8c4eb2a 100644 --- a/tests/Export/Renderer/HtmlRendererTest.php +++ b/tests/Export/Renderer/HtmlRendererTest.php @@ -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 diff --git a/tests/Export/Renderer/PdfRendererFactoryTest.php b/tests/Export/Renderer/PdfRendererFactoryTest.php index 3ee2faac..09fd1011 100644 --- a/tests/Export/Renderer/PdfRendererFactoryTest.php +++ b/tests/Export/Renderer/PdfRendererFactoryTest.php @@ -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; diff --git a/tests/Export/Renderer/PdfRendererTest.php b/tests/Export/Renderer/PdfRendererTest.php index a12dc14a..f655b4ea 100644 --- a/tests/Export/Renderer/PdfRendererTest.php +++ b/tests/Export/Renderer/PdfRendererTest.php @@ -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 diff --git a/tests/Export/ServiceExportTest.php b/tests/Export/ServiceExportTest.php index 9fb29f3e..a68b7266 100644 --- a/tests/Export/ServiceExportTest.php +++ b/tests/Export/ServiceExportTest.php @@ -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; diff --git a/tests/Export/Timesheet/AbstractRendererTestCase.php b/tests/Export/Timesheet/AbstractRendererTestCase.php index 5fc47fd3..c0a79b13 100644 --- a/tests/Export/Timesheet/AbstractRendererTestCase.php +++ b/tests/Export/Timesheet/AbstractRendererTestCase.php @@ -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'); diff --git a/tests/Export/Timesheet/CsvRendererTest.php b/tests/Export/Timesheet/CsvRendererTest.php deleted file mode 100644 index 2a875c97..00000000 --- a/tests/Export/Timesheet/CsvRendererTest.php +++ /dev/null @@ -1,114 +0,0 @@ -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]); - } -} diff --git a/tests/Export/Timesheet/PdfRendererTest.php b/tests/Export/Timesheet/PdfRendererTest.php index 40f08b43..d42d80f6 100644 --- a/tests/Export/Timesheet/PdfRendererTest.php +++ b/tests/Export/Timesheet/PdfRendererTest.php @@ -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 diff --git a/tests/Export/Timesheet/XlsxRendererTest.php b/tests/Export/Timesheet/XlsxRendererTest.php deleted file mode 100644 index 876d871b..00000000 --- a/tests/Export/Timesheet/XlsxRendererTest.php +++ /dev/null @@ -1,53 +0,0 @@ -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())); - } -} diff --git a/tests/Twig/ExtensionsTest.php b/tests/Twig/ExtensionsTest.php index 8d94998f..c6e65f10 100644 --- a/tests/Twig/ExtensionsTest.php +++ b/tests/Twig/ExtensionsTest.php @@ -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 diff --git a/tests/Twig/RuntimeExtensionsTest.php b/tests/Twig/RuntimeExtensionsTest.php index 8d7e02b5..c5c5fba8 100644 --- a/tests/Twig/RuntimeExtensionsTest.php +++ b/tests/Twig/RuntimeExtensionsTest.php @@ -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; } diff --git a/tests/phpstan.neon b/tests/phpstan.neon index 2bb4a72f..f2dd3e1e 100644 --- a/tests/phpstan.neon +++ b/tests/phpstan.neon @@ -619,57 +619,57 @@ parameters: - message: "#^Method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:assertAccessIsGranted\\(\\) has parameter \\$parameters with no value type specified in iterable type array\\.$#" count: 1 - path: Controller/AbstractControllerBaseTestCase.php + path: Controller/AbstractControllerBaseTestCase.php - message: "#^Method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:assertFormHasValidationError\\(\\) has parameter \\$fieldNames with no value type specified in iterable type array\\.$#" count: 1 - path: Controller/AbstractControllerBaseTestCase.php + path: Controller/AbstractControllerBaseTestCase.php - message: "#^Method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:assertFormHasValidationError\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#" count: 1 - path: Controller/AbstractControllerBaseTestCase.php + path: Controller/AbstractControllerBaseTestCase.php - message: "#^Method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:assertHasValidationError\\(\\) has parameter \\$fieldNames with no value type specified in iterable type array\\.$#" count: 1 - path: Controller/AbstractControllerBaseTestCase.php + path: Controller/AbstractControllerBaseTestCase.php - message: "#^Method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:assertHasValidationError\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#" count: 1 - path: Controller/AbstractControllerBaseTestCase.php + path: Controller/AbstractControllerBaseTestCase.php - message: "#^Method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:assertPageActions\\(\\) has parameter \\$buttons with no value type specified in iterable type array\\.$#" count: 1 - path: Controller/AbstractControllerBaseTestCase.php + path: Controller/AbstractControllerBaseTestCase.php - message: "#^Method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:importFixture\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 - path: Controller/AbstractControllerBaseTestCase.php + path: Controller/AbstractControllerBaseTestCase.php - message: "#^Method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:request\\(\\) has parameter \\$parameters with no value type specified in iterable type array\\.$#" count: 1 - path: Controller/AbstractControllerBaseTestCase.php + path: Controller/AbstractControllerBaseTestCase.php - message: "#^Method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:requestPure\\(\\) has parameter \\$parameters with no value type specified in iterable type array\\.$#" count: 1 - path: Controller/AbstractControllerBaseTestCase.php + path: Controller/AbstractControllerBaseTestCase.php - message: "#^Method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:setSystemConfiguration\\(\\) has parameter \\$value with no type specified\\.$#" count: 1 - path: Controller/AbstractControllerBaseTestCase.php + path: Controller/AbstractControllerBaseTestCase.php - message: "#^Parameter \\#2 \\$haystack of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|false given\\.$#" count: 9 - path: Controller/AbstractControllerBaseTestCase.php + path: Controller/AbstractControllerBaseTestCase.php - message: "#^Cannot call method getCustomer\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#" @@ -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