From f3a35b3878278ba7ede9381c9378f34438164688 Mon Sep 17 00:00:00 2001 From: Maxime Pinot Date: Tue, 28 Apr 2020 13:19:56 +0200 Subject: [PATCH] Add server side export (#83) * Add server side export * Add missing dev dependency * Replace pseudo-type "iterable" by "\Traversable" To support PHP 7.0 * Replace TranslatorInterface by DataCollectorTranslator To maintain BC, it is not possible to type hint with the TranslatorInterface as older versions of Symfony inject "Symfony\Component\Translation\TranslatorInterface" while newer versions of Symfony inject "Symfony\Contracts\Translation\TranslatorInterface". * Fix ArgumentCountError (Symfony ^3.4) * Fix Content Disposition header format * Do not display the export form * Excel: render HTML in cells The 'render' column option is usually use to inject HTML. * Fix Translator injection Tests were passing using "Symfony\Component\Translation\DataCollectorTranslator" as type-hint but Symfony may inject "Symfony\Bundle\FrameworkBundle\Translation\Translator". * Use a Generator instead of a new Iterator * Using a Generator makes the code more readable. * Avoid creating an array (iterator_to_array) may be sligtly faster? * Add functional test "testEmptyDataTable" * Fix tests * Preserve DataTableState Export the entire table but keep current sort and filtering * Automatically tag exporters * Add CSV exporter * Add missing "_dt" * Fix "DT_RowId" check * Make dependency on contracts explicit Will not fix #122 but should prevent similar issues. * Symfony 3 is not supported, according to the readme.md (#123) * Symfony 3 is not supported, according to the readme.md Symfony 3 is not supported, according to the readme.md. This updates the documentation to reflect that only Symfony 4.1+ is supported by this bundle * Update index.html.md * Option for custom datetime format for creating object (#127) * Fix failing test by reverting custom WebClient handling Alternative to #125 * Fix deprecations helper crashing old Symfony versions * Drop bad testing method on old Symfony frameworks This is actually obsolete since Symfony Flex, as you can now run Symfony 4.1 with more recent components. * Apply code style * Use weak deprecation testing * Prepare 0.4.1 * Fix deprecations (#129) * removed unused symfony/templating * set lowest version of persistence to 1.3.4 * added missing SymfonyTestListener * raise minimum version of doctrine/orm to 2.6.3 * set phpunit schema location to installed package * Update translations with script (#130) * script for updating translation messages from datatables language files * added czech and slovak languages * en rebuilt by script * add missing closing bracket for example (#131) * Integrate translation update script * Update README.md * Restructure tests to improve readability * Bump nokogiri from 1.10.7 to 1.10.8 in /docs (#133) Bumps [nokogiri](https://github.com/sparklemotion/nokogiri) from 1.10.7 to 1.10.8. - [Release notes](https://github.com/sparklemotion/nokogiri/releases) - [Changelog](https://github.com/sparklemotion/nokogiri/blob/master/CHANGELOG.md) - [Commits](https://github.com/sparklemotion/nokogiri/compare/v1.10.7...v1.10.8) Signed-off-by: dependabot[bot] * Test local translations (#139) * Upgrade to PHPUnit 8/9 * Update Scrutinizer config * Force use outdated version of ocramius/package-versions Breaks both SymfonyInsight and Scrutinizer otherwise. * Fix SymfonyInsight badge * Fix SymfonyInsight badge for real * Javascript improvements (#145) * JS improvements * Remove scrollX * Allow config.options and config.url as functions * Remove empty line * Fix export issue when using GET method * Add server side export * Add missing dev dependency * Replace pseudo-type "iterable" by "\Traversable" To support PHP 7.0 * Replace TranslatorInterface by DataCollectorTranslator To maintain BC, it is not possible to type hint with the TranslatorInterface as older versions of Symfony inject "Symfony\Component\Translation\TranslatorInterface" while newer versions of Symfony inject "Symfony\Contracts\Translation\TranslatorInterface". * Fix ArgumentCountError (Symfony ^3.4) * Fix Content Disposition header format * Do not display the export form * Excel: render HTML in cells The 'render' column option is usually use to inject HTML. * Fix Translator injection Tests were passing using "Symfony\Component\Translation\DataCollectorTranslator" as type-hint but Symfony may inject "Symfony\Bundle\FrameworkBundle\Translation\Translator". * Use a Generator instead of a new Iterator * Using a Generator makes the code more readable. * Avoid creating an array (iterator_to_array) may be sligtly faster? * Add functional test "testEmptyDataTable" * Fix tests * Preserve DataTableState Export the entire table but keep current sort and filtering * Automatically tag exporters * Add CSV exporter * Add missing "_dt" * Fix "DT_RowId" check * Fix export issue when using GET method * Fix tests Co-authored-by: Niels Keurentjes Co-authored-by: Spyros Sakellaropoulos Co-authored-by: freezy Co-authored-by: rwkt <37826694+rwkt@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Daniel Gorgan --- .gitignore | 1 + composer.json | 4 +- src/DataTable.php | 23 ++- src/DataTableFactory.php | 9 +- src/DataTableState.php | 12 ++ .../Compiler/LocatorRegistrationPass.php | 2 + .../DataTablesExtension.php | 3 + .../UnknownDataTableExporterException.php | 52 +++++++ src/Exporter/Csv/CsvExporter.php | 43 ++++++ src/Exporter/DataTableExporterCollection.php | 57 +++++++ src/Exporter/DataTableExporterEvents.php | 32 ++++ src/Exporter/DataTableExporterInterface.php | 38 +++++ src/Exporter/DataTableExporterManager.php | 142 ++++++++++++++++++ .../Event/DataTableExporterResponseEvent.php | 45 ++++++ src/Exporter/Excel/ExcelExporter.php | 83 ++++++++++ src/Resources/config/services.xml | 18 +++ src/Resources/public/js/datatables.js | 74 +++++++++ .../Controller/ExporterController.php | 103 +++++++++++++ .../DataTable/Exporter/TxtExporter.php | 48 ++++++ .../Resources/views/exporter.html.twig | 33 ++++ tests/Fixtures/routing.yml | 8 + tests/Fixtures/services.yml | 6 + .../Exporter/Csv/CsvExporterTest.php | 44 ++++++ .../DataTableExporterResponseEventTest.php | 53 +++++++ .../Exporter/Excel/ExcelExporterTest.php | 114 ++++++++++++++ tests/Unit/Adapter/DoctrineTest.php | 5 +- tests/Unit/Adapter/ElasticaTest.php | 3 +- tests/Unit/ColumnTest.php | 20 ++- tests/Unit/DataTableTest.php | 11 +- .../DataTableExporterCollectionTest.php | 37 +++++ .../Exporter/DataTableExporterManagerTest.php | 43 ++++++ tests/Unit/Exporter/ExcelExporterTest.php | 45 ++++++ 32 files changed, 1192 insertions(+), 19 deletions(-) create mode 100644 src/Exception/UnknownDataTableExporterException.php create mode 100644 src/Exporter/Csv/CsvExporter.php create mode 100644 src/Exporter/DataTableExporterCollection.php create mode 100644 src/Exporter/DataTableExporterEvents.php create mode 100644 src/Exporter/DataTableExporterInterface.php create mode 100644 src/Exporter/DataTableExporterManager.php create mode 100644 src/Exporter/Event/DataTableExporterResponseEvent.php create mode 100644 src/Exporter/Excel/ExcelExporter.php create mode 100644 tests/Fixtures/AppBundle/Controller/ExporterController.php create mode 100644 tests/Fixtures/AppBundle/DataTable/Exporter/TxtExporter.php create mode 100644 tests/Fixtures/AppBundle/Resources/views/exporter.html.twig create mode 100644 tests/Functional/Exporter/Csv/CsvExporterTest.php create mode 100644 tests/Functional/Exporter/Event/DataTableExporterResponseEventTest.php create mode 100644 tests/Functional/Exporter/Excel/ExcelExporterTest.php create mode 100644 tests/Unit/Exporter/DataTableExporterCollectionTest.php create mode 100644 tests/Unit/Exporter/DataTableExporterManagerTest.php create mode 100644 tests/Unit/Exporter/ExcelExporterTest.php diff --git a/.gitignore b/.gitignore index 05ab2478..ce4311d8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /composer.lock /tmp /vendor +/tests/Fixtures/var/ diff --git a/composer.json b/composer.json index 28605da8..560190a3 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "doctrine/persistence": "^1.3.4", "friendsofphp/php-cs-fixer": "^2.7", "mongodb/mongodb": "^1.2", + "phpoffice/phpspreadsheet": "^1.6", "ocramius/package-versions": "1.4.*", "phpunit/phpunit": "^8.5|^9.0", "ruflin/elastica": "^6.0|^7.0", @@ -54,7 +55,8 @@ "doctrine/orm": "For full automated integration with Doctrine entities", "mongodb/mongodb": "For integration with MongoDB collections", "ruflin/elastica": "For integration with Elasticsearch indexes", - "symfony/twig-bundle": "To use default Twig based rendering and TwigColumn" + "symfony/twig-bundle": "To use default Twig based rendering and TwigColumn", + "phpoffice/phpspreadsheet": "To export the data from DataTables to Excel" }, "autoload": { "psr-4": { "Omines\\DataTablesBundle\\": "src/"} diff --git a/src/DataTable.php b/src/DataTable.php index ff561389..cf6cd30d 100644 --- a/src/DataTable.php +++ b/src/DataTable.php @@ -19,9 +19,11 @@ use Omines\DataTablesBundle\Exception\InvalidArgumentException; use Omines\DataTablesBundle\Exception\InvalidConfigurationException; use Omines\DataTablesBundle\Exception\InvalidStateException; +use Omines\DataTablesBundle\Exporter\DataTableExporterManager; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\OptionsResolver\OptionsResolver; /** @@ -69,6 +71,9 @@ class DataTable /** @var EventDispatcherInterface */ protected $eventDispatcher; + /** @var DataTableExporterManager */ + protected $exporterManager; + /** @var string */ protected $method = Request::METHOD_POST; @@ -107,10 +112,16 @@ class DataTable /** * DataTable constructor. + * + * @param EventDispatcherInterface $eventDispatcher + * @param DataTableExporterManager $exporterManager + * @param array $options + * @param Instantiator|null $instantiator */ - public function __construct(EventDispatcherInterface $eventDispatcher, array $options = [], Instantiator $instantiator = null) + public function __construct(EventDispatcherInterface $eventDispatcher, DataTableExporterManager $exporterManager, array $options = [], Instantiator $instantiator = null) { $this->eventDispatcher = $eventDispatcher; + $this->exporterManager = $exporterManager; $this->instantiator = $instantiator ?? new Instantiator(); @@ -277,12 +288,20 @@ public function handleRequest(Request $request): self return $this; } - public function getResponse(): JsonResponse + public function getResponse(): Response { if (null === $this->state) { throw new InvalidStateException('The DataTable does not know its state yet, did you call handleRequest?'); } + // Server side export + if (null !== $this->state->getExporterName()) { + return $this->exporterManager + ->setDataTable($this) + ->setExporterName($this->state->getExporterName()) + ->getResponse(); + } + $resultSet = $this->getResultSet(); $response = [ 'draw' => $this->state->getDraw(), diff --git a/src/DataTableFactory.php b/src/DataTableFactory.php index 07e90a49..7443ae76 100644 --- a/src/DataTableFactory.php +++ b/src/DataTableFactory.php @@ -13,6 +13,7 @@ namespace Omines\DataTablesBundle; use Omines\DataTablesBundle\DependencyInjection\Instantiator; +use Omines\DataTablesBundle\Exporter\DataTableExporterManager; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; @@ -33,15 +34,19 @@ class DataTableFactory /** @var EventDispatcherInterface */ protected $eventDispatcher; + /** @var DataTableExporterManager */ + protected $exporterManager; + /** * DataTableFactory constructor. */ - public function __construct(array $config, DataTableRendererInterface $renderer, Instantiator $instantiator, EventDispatcherInterface $eventDispatcher) + public function __construct(array $config, DataTableRendererInterface $renderer, Instantiator $instantiator, EventDispatcherInterface $eventDispatcher, DataTableExporterManager $exporterManager) { $this->config = $config; $this->renderer = $renderer; $this->instantiator = $instantiator; $this->eventDispatcher = $eventDispatcher; + $this->exporterManager = $exporterManager; } /** @@ -51,7 +56,7 @@ public function create(array $options = []) { $config = $this->config; - return (new DataTable($this->eventDispatcher, array_merge($config['options'] ?? [], $options), $this->instantiator)) + return (new DataTable($this->eventDispatcher, $this->exporterManager, array_merge($config['options'] ?? [], $options), $this->instantiator)) ->setRenderer($this->renderer) ->setMethod($config['method'] ?? Request::METHOD_POST) ->setPersistState($config['persist_state'] ?? 'fragment') diff --git a/src/DataTableState.php b/src/DataTableState.php index 4b1e5b88..92679ed9 100644 --- a/src/DataTableState.php +++ b/src/DataTableState.php @@ -49,6 +49,9 @@ class DataTableState /** @var bool */ private $isCallback = false; + /** @var string */ + private $exporterName = null; + /** * DataTableState constructor. */ @@ -83,6 +86,7 @@ public function applyParameters(ParameterBag $parameters) $this->draw = $parameters->getInt('draw'); $this->isCallback = true; $this->isInitial = $parameters->getBoolean('_init', false); + $this->exporterName = $parameters->get('_exporter'); $this->start = (int) $parameters->get('start', $this->start); $this->length = (int) $parameters->get('length', $this->length); @@ -224,4 +228,12 @@ public function setColumnSearch(AbstractColumn $column, string $search, bool $is return $this; } + + /** + * @return string + */ + public function getExporterName() + { + return $this->exporterName; + } } diff --git a/src/DependencyInjection/Compiler/LocatorRegistrationPass.php b/src/DependencyInjection/Compiler/LocatorRegistrationPass.php index 40826911..5b10ad39 100644 --- a/src/DependencyInjection/Compiler/LocatorRegistrationPass.php +++ b/src/DependencyInjection/Compiler/LocatorRegistrationPass.php @@ -16,6 +16,7 @@ use Omines\DataTablesBundle\Column\AbstractColumn; use Omines\DataTablesBundle\DataTableTypeInterface; use Omines\DataTablesBundle\DependencyInjection\Instantiator; +use Omines\DataTablesBundle\Exporter\DataTableExporterInterface; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; @@ -39,6 +40,7 @@ public function process(ContainerBuilder $container) AdapterInterface::class => $this->registerLocator($container, 'adapter'), AbstractColumn::class => $this->registerLocator($container, 'column'), DataTableTypeInterface::class => $this->registerLocator($container, 'type'), + DataTableExporterInterface::class => $this->registerLocator($container, 'exporter'), ]]); } diff --git a/src/DependencyInjection/DataTablesExtension.php b/src/DependencyInjection/DataTablesExtension.php index cddfd84e..84b0d8e6 100644 --- a/src/DependencyInjection/DataTablesExtension.php +++ b/src/DependencyInjection/DataTablesExtension.php @@ -15,6 +15,7 @@ use Omines\DataTablesBundle\Adapter\AdapterInterface; use Omines\DataTablesBundle\Column\AbstractColumn; use Omines\DataTablesBundle\DataTableTypeInterface; +use Omines\DataTablesBundle\Exporter\DataTableExporterInterface; use Omines\DataTablesBundle\Filter\AbstractFilter; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -55,6 +56,8 @@ public function load(array $configs, ContainerBuilder $container) ->setShared(false); $container->registerForAutoconfiguration(DataTableTypeInterface::class) ->addTag('datatables.type'); + $container->registerForAutoconfiguration(DataTableExporterInterface::class) + ->addTag('datatables.exporter'); } /** diff --git a/src/Exception/UnknownDataTableExporterException.php b/src/Exception/UnknownDataTableExporterException.php new file mode 100644 index 00000000..cbb27fcd --- /dev/null +++ b/src/Exception/UnknownDataTableExporterException.php @@ -0,0 +1,52 @@ + + */ +class UnknownDataTableExporterException extends \Exception +{ + /** + * UnknownDataTableExporterException constructor. + * + * @param string $name The name of the DataTable exporter that cannot be found + * @param \Traversable $referencedExporters Available DataTable exporters + */ + public function __construct(string $name, \Traversable $referencedExporters) + { + $format = <<formatReferencedExporters($referencedExporters))); + } + + private function formatReferencedExporters(\Traversable $referencedExporters): string + { + $names = []; + + /** @var DataTableExporterInterface $exporter */ + foreach ($referencedExporters as $exporter) { + $names[] = sprintf('"%s"', $exporter->getName()); + } + + return implode(', ', $names); + } +} diff --git a/src/Exporter/Csv/CsvExporter.php b/src/Exporter/Csv/CsvExporter.php new file mode 100644 index 00000000..56757378 --- /dev/null +++ b/src/Exporter/Csv/CsvExporter.php @@ -0,0 +1,43 @@ + + */ +class CsvExporter implements DataTableExporterInterface +{ + /** + * {@inheritdoc} + */ + public function export(array $columnNames, \Iterator $data): \SplFileInfo + { + $filePath = sys_get_temp_dir() . '/' . uniqid('dt') . '.csv'; + + $file = fopen($filePath, 'w'); + + fputcsv($file, $columnNames); + + foreach ($data as $row) { + fputcsv($file, array_map('strip_tags', $row)); + } + + fclose($file); + + return new \SplFileInfo($filePath); + } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'csv'; + } +} diff --git a/src/Exporter/DataTableExporterCollection.php b/src/Exporter/DataTableExporterCollection.php new file mode 100644 index 00000000..f7455cce --- /dev/null +++ b/src/Exporter/DataTableExporterCollection.php @@ -0,0 +1,57 @@ + + */ +class DataTableExporterCollection +{ + /** @var \Traversable The available exporters */ + private $exporters; + + /** + * DataTableExporterCollection constructor. + * + * @param \Traversable $exporters + */ + public function __construct(\Traversable $exporters) + { + $this->exporters = $exporters; + } + + /** + * Finds a DataTable exporter that matches the given name. + * + * @param string $name + * + * @return DataTableExporterInterface + * + * @throws UnknownDataTableExporterException + */ + public function getByName(string $name): DataTableExporterInterface + { + foreach ($this->exporters as $exporter) { + if ($exporter->getName() === $name) { + return $exporter; + } + } + + throw new UnknownDataTableExporterException($name, $this->exporters); + } +} diff --git a/src/Exporter/DataTableExporterEvents.php b/src/Exporter/DataTableExporterEvents.php new file mode 100644 index 00000000..5757d506 --- /dev/null +++ b/src/Exporter/DataTableExporterEvents.php @@ -0,0 +1,32 @@ + + */ +final class DataTableExporterEvents +{ + /** + * The PRE_RESPONSE event is dispatched before sending + * the BinaryFileResponse to the user. + * + * Note that the file is accessible through the Response object. + * Both the file and the Response can be modified before being sent. + * + * @Event("Omines\DataTablesBundle\Exporter\Event\DataTableExporterResponseEvent") + */ + const PRE_RESPONSE = 'omines_datatables.exporter.pre_response'; +} diff --git a/src/Exporter/DataTableExporterInterface.php b/src/Exporter/DataTableExporterInterface.php new file mode 100644 index 00000000..cf1339c9 --- /dev/null +++ b/src/Exporter/DataTableExporterInterface.php @@ -0,0 +1,38 @@ + + */ +interface DataTableExporterInterface +{ + /** + * Exports the data from the DataTable to a file. + * + * @param array $columnNames + * @param \Iterator $data + * + * @return \SplFileInfo + */ + public function export(array $columnNames, \Iterator $data): \SplFileInfo; + + /** + * A unique name to identify the exporter. + * + * @return string + */ + public function getName(): string; +} diff --git a/src/Exporter/DataTableExporterManager.php b/src/Exporter/DataTableExporterManager.php new file mode 100644 index 00000000..283e3bb5 --- /dev/null +++ b/src/Exporter/DataTableExporterManager.php @@ -0,0 +1,142 @@ + + */ +class DataTableExporterManager +{ + /** @var DataTable */ + private $dataTable; + + /** @var DataTableExporterCollection */ + private $exporterCollection; + + /** @var string */ + private $exporterName; + + /** @var TranslatorInterface|LegacyTranslatorInterface */ + private $translator; + + /** + * DataTableExporterManager constructor. + * + * @param DataTableExporterCollection $exporterCollection + * @param TranslatorInterface|LegacyTranslatorInterface $translator + */ + public function __construct(DataTableExporterCollection $exporterCollection, $translator) + { + if (!$translator instanceof TranslatorInterface && !$translator instanceof LegacyTranslatorInterface) { + throw new InvalidArgumentException(sprintf('Expected an instance of "Symfony\Contracts\Translation\TranslatorInterface" or "Symfony\Component\Translation\TranslatorInterface". Got "%s" instead.', is_object($translator) ? get_class($translator) : gettype($translator))); + } + + $this->exporterCollection = $exporterCollection; + $this->translator = $translator; + } + + /** + * @param string $exporterName + * + * @return DataTableExporterManager + */ + public function setExporterName(string $exporterName): self + { + $this->exporterName = $exporterName; + + return $this; + } + + /** + * @param DataTable $dataTable + * + * @return DataTableExporterManager + */ + public function setDataTable(DataTable $dataTable): self + { + $this->dataTable = $dataTable; + + return $this; + } + + /** + * @return Response + * + * @throws \Omines\DataTablesBundle\Exception\UnknownDataTableExporterException + */ + public function getResponse(): Response + { + $exporter = $this->exporterCollection->getByName($this->exporterName); + $file = $exporter->export($this->getColumnNames(), $this->getAllData()); + + $response = new BinaryFileResponse($file); + $response->deleteFileAfterSend(true); + $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $response->getFile()->getFilename()); + + $this->dataTable->getEventDispatcher()->dispatch(new DataTableExporterResponseEvent($response), DataTableExporterEvents::PRE_RESPONSE); + + return $response; + } + + /** + * The translated column names. + * + * @return string[] + */ + private function getColumnNames(): array + { + $columns = []; + + foreach ($this->dataTable->getColumns() as $column) { + $columns[] = $this->translator->trans($column->getLabel(), [], $this->dataTable->getTranslationDomain()); + } + + return $columns; + } + + /** + * Browse the entire DataTable (all pages). + * + * A Generator is created in order to remove the 'DT_RowId' key + * which is created by some adapters (e.g. ORMAdapter). + * + * @return \Iterator + */ + private function getAllData(): \Iterator + { + $data = $this->dataTable + ->getAdapter() + ->getData($this->dataTable->getState()->setStart(0)->setLength(-1)) + ->getData(); + + foreach ($data as $row) { + if (array_key_exists('DT_RowId', $row)) { + unset($row['DT_RowId']); + } + + yield $row; + } + } +} diff --git a/src/Exporter/Event/DataTableExporterResponseEvent.php b/src/Exporter/Event/DataTableExporterResponseEvent.php new file mode 100644 index 00000000..133a97f5 --- /dev/null +++ b/src/Exporter/Event/DataTableExporterResponseEvent.php @@ -0,0 +1,45 @@ + + */ +class DataTableExporterResponseEvent extends Event +{ + /** @var BinaryFileResponse */ + private $response; + + /** + * DataTableExporterResponseEvent constructor. + * + * @param BinaryFileResponse $response + */ + public function __construct(BinaryFileResponse $response) + { + $this->response = $response; + } + + /** + * @return BinaryFileResponse + */ + public function getResponse(): BinaryFileResponse + { + return $this->response; + } +} diff --git a/src/Exporter/Excel/ExcelExporter.php b/src/Exporter/Excel/ExcelExporter.php new file mode 100644 index 00000000..3b0a71db --- /dev/null +++ b/src/Exporter/Excel/ExcelExporter.php @@ -0,0 +1,83 @@ + + */ +class ExcelExporter implements DataTableExporterInterface +{ + /** + * {@inheritdoc} + * + * @throws \PhpOffice\PhpSpreadsheet\Exception + */ + public function export(array $columnNames, \Iterator $data): \SplFileInfo + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getSheet(0); + + $sheet->fromArray($columnNames, null, 'A1'); + $sheet->getStyle('A1:' . $sheet->getHighestColumn() . '1')->getFont()->setBold(true); + + $rowIndex = 2; + $htmlHelper = new Helper\Html(); + foreach ($data as $row) { + $colIndex = 1; + foreach ($row as $value) { + $sheet->setCellValueByColumnAndRow($colIndex++, $rowIndex, $htmlHelper->toRichTextObject($value)); + } + ++$rowIndex; + } + + $this->autoSizeColumnWidth($sheet); + + $filePath = sys_get_temp_dir() . '/' . uniqid('dt') . '.xlsx'; + + $writer = new Xlsx($spreadsheet); + $writer->save($filePath); + + return new \SplFileInfo($filePath); + } + + /** + * Sets the columns width to automatically fit the contents. + * + * @param Worksheet $sheet + * + * @throws \PhpOffice\PhpSpreadsheet\Exception + */ + private function autoSizeColumnWidth(Worksheet $sheet) + { + foreach (range(1, Coordinate::columnIndexFromString($sheet->getHighestColumn(1))) as $column) { + $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($column))->setAutoSize(true); + } + } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'excel'; + } +} diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 5c1e877b..00746fc9 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -18,6 +18,24 @@ + + + + + + + + + + + + + + + + + + %datatables.config% diff --git a/src/Resources/public/js/datatables.js b/src/Resources/public/js/datatables.js index 38590865..2bc27332 100644 --- a/src/Resources/public/js/datatables.js +++ b/src/Resources/public/js/datatables.js @@ -127,6 +127,80 @@ url: window.location.origin + window.location.pathname }; + /** + * Server-side export. + */ + $.fn.initDataTables.exportBtnAction = function(exporterName, settings) { + settings = $.extend({}, $.fn.initDataTables.defaults, settings); + + return function(e, dt) { + const params = $.param($.extend({}, dt.ajax.params(), {'_dt': settings.name, '_exporter': exporterName})); + + // Credit: https://stackoverflow.com/a/23797348 + const xhr = new XMLHttpRequest(); + xhr.open(settings.method, settings.method === 'GET' ? (settings.url + '?' + params) : settings.url, true); + xhr.responseType = 'arraybuffer'; + xhr.onload = function () { + if (this.status === 200) { + let filename = ""; + const disposition = xhr.getResponseHeader('Content-Disposition'); + if (disposition && disposition.indexOf('attachment') !== -1) { + const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; + const matches = filenameRegex.exec(disposition); + if (matches != null && matches[1]) { + filename = matches[1].replace(/['"]/g, ''); + } + } + + const type = xhr.getResponseHeader('Content-Type'); + + let blob; + if (typeof File === 'function') { + try { + blob = new File([this.response], filename, { type: type }); + } catch (e) { /* Edge */ } + } + + if (typeof blob === 'undefined') { + blob = new Blob([this.response], { type: type }); + } + + if (typeof window.navigator.msSaveBlob !== 'undefined') { + // IE workaround for "HTML7007: One or more blob URLs were revoked by closing the blob for which they were created. These URLs will no longer resolve as the data backing the URL has been freed." + window.navigator.msSaveBlob(blob, filename); + } + else { + const URL = window.URL || window.webkitURL; + const downloadUrl = URL.createObjectURL(blob); + + if (filename) { + // use HTML5 a[download] attribute to specify filename + const a = document.createElement("a"); + // safari doesn't support this yet + if (typeof a.download === 'undefined') { + window.location = downloadUrl; + } + else { + a.href = downloadUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + } + } + else { + window.location = downloadUrl; + } + + setTimeout(function() { URL.revokeObjectURL(downloadUrl); }, 100); // cleanup + } + } + }; + + xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); + xhr.send(settings.method === 'POST' ? params : null); + } + }; + /** * Convert a querystring to a proper array - reverses $.param */ diff --git a/tests/Fixtures/AppBundle/Controller/ExporterController.php b/tests/Fixtures/AppBundle/Controller/ExporterController.php new file mode 100644 index 00000000..ec7cec61 --- /dev/null +++ b/tests/Fixtures/AppBundle/Controller/ExporterController.php @@ -0,0 +1,103 @@ + + */ +class ExporterController extends AbstractController +{ + public function exportAction(Request $request, DataTableFactory $dataTableFactory): Response + { + $table = $dataTableFactory + ->create() + ->add('firstName', TextColumn::class, [ + 'render' => function (string $value, Person $context) { + return '' . $value . ''; + }, + ]) + ->add('lastName', TextColumn::class) + ->createAdapter(ORMAdapter::class, [ + 'entity' => Person::class, + 'query' => function (QueryBuilder $builder) { + $builder + ->select('p') + ->from(Person::class, 'p') + ->setMaxResults(5) + ->orderBy('p.id', 'ASC'); + }, + ]) + ->addEventListener(DataTableExporterEvents::PRE_RESPONSE, function (DataTableExporterResponseEvent $e) { + $response = $e->getResponse(); + $response->deleteFileAfterSend(false); + $ext = $response->getFile()->getExtension(); + $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'custom_filename.' . $ext); + }) + ->handleRequest($request); + + if ($table->isCallback()) { + return $table->getResponse(); + } + + return $this->render('@App/exporter.html.twig', [ + 'datatable' => $table, + ]); + } + + public function exportEmptyDataTableAction(Request $request, DataTableFactory $dataTableFactory): Response + { + $table = $dataTableFactory + ->create() + ->add('firstName', TextColumn::class, [ + 'render' => function (string $value, Person $context) { + return '' . $value . ''; + }, + ]) + ->add('lastName', TextColumn::class) + ->createAdapter(ORMAdapter::class, [ + 'entity' => Person::class, + 'query' => function (QueryBuilder $builder) { + $builder + ->select('p') + ->from(Person::class, 'p') + ->where('p.firstName = :firstName') + ->setParameter('firstName', 'This user does not exist.') + ; + }, + ]) + ->addEventListener(DataTableExporterEvents::PRE_RESPONSE, function (DataTableExporterResponseEvent $e) { + $e->getResponse()->deleteFileAfterSend(false); + }) + ->handleRequest($request); + + if ($table->isCallback()) { + return $table->getResponse(); + } + + return $this->render('@App/exporter.html.twig', [ + 'datatable' => $table, + ]); + } +} diff --git a/tests/Fixtures/AppBundle/DataTable/Exporter/TxtExporter.php b/tests/Fixtures/AppBundle/DataTable/Exporter/TxtExporter.php new file mode 100644 index 00000000..71f85e6c --- /dev/null +++ b/tests/Fixtures/AppBundle/DataTable/Exporter/TxtExporter.php @@ -0,0 +1,48 @@ + + */ +class TxtExporter implements DataTableExporterInterface +{ + /** + * {@inheritdoc} + */ + public function export(array $columnNames, \Iterator $data): \SplFileInfo + { + $filename = sys_get_temp_dir() . '/dt.txt'; + $handle = fopen($filename, 'w'); + + fwrite($handle, implode(' ', $columnNames) . "\n"); + + foreach ($data as $datum) { + fwrite($handle, implode(' ', $datum) . "\n"); + } + + fclose($handle); + + return new \SplFileInfo($filename); + } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'txt'; + } +} diff --git a/tests/Fixtures/AppBundle/Resources/views/exporter.html.twig b/tests/Fixtures/AppBundle/Resources/views/exporter.html.twig new file mode 100644 index 00000000..3f2abfbe --- /dev/null +++ b/tests/Fixtures/AppBundle/Resources/views/exporter.html.twig @@ -0,0 +1,33 @@ + + + + Test server-side export + + +
+ + + + + diff --git a/tests/Fixtures/routing.yml b/tests/Fixtures/routing.yml index 19aad452..e4b1dcdc 100644 --- a/tests/Fixtures/routing.yml +++ b/tests/Fixtures/routing.yml @@ -36,3 +36,11 @@ translation: orm_adapter_events.pre_query: path: /orm-adapter-events/pre-query controller: Tests\Fixtures\AppBundle\Controller\ORMAdapterEventsController::preQueryAction + +exporter: + path: /exporter + controller: Tests\Fixtures\AppBundle\Controller\ExporterController::exportAction + +exporter_empty_datatable: + path: /exporter-empty-datatable + controller: Tests\Fixtures\AppBundle\Controller\ExporterController::exportEmptyDataTableAction diff --git a/tests/Fixtures/services.yml b/tests/Fixtures/services.yml index 7ec9af57..2604f47c 100644 --- a/tests/Fixtures/services.yml +++ b/tests/Fixtures/services.yml @@ -20,3 +20,9 @@ services: class: Symfony\Component\Cache\DoctrineProvider arguments: - '@doctrine.system_cache_pool' + + test.Omines\DataTablesBundle\Exporter\DataTableExporterCollection: '@Omines\DataTablesBundle\Exporter\DataTableExporterCollection' + + Tests\Fixtures\AppBundle\DataTable\Exporter\TxtExporter: + tags: + - datatables.exporter diff --git a/tests/Functional/Exporter/Csv/CsvExporterTest.php b/tests/Functional/Exporter/Csv/CsvExporterTest.php new file mode 100644 index 00000000..f915cfe7 --- /dev/null +++ b/tests/Functional/Exporter/Csv/CsvExporterTest.php @@ -0,0 +1,44 @@ + + */ +class CsvExporterTest extends WebTestCase +{ + /** @var KernelBrowser */ + private $client; + + protected function setUp(): void + { + self::ensureKernelShutdown(); + $this->client = self::createClient(); + } + + public function testExport(): void + { + $this->client->request('POST', '/exporter', ['_dt' => 'dt', '_exporter' => 'csv']); + + /** @var BinaryFileResponse $response */ + $response = $this->client->getResponse(); + + $csvFile = fopen($response->getFile()->getPathname(), 'r'); + + self::assertEquals(['dt.columns.firstName', 'dt.columns.lastName'], fgetcsv($csvFile)); + + $i = 0; + while (false !== ($row = fgetcsv($csvFile))) { + self::assertEquals(['FirstName'.$i, 'LastName'.$i], $row); + ++$i; + } + } +} diff --git a/tests/Functional/Exporter/Event/DataTableExporterResponseEventTest.php b/tests/Functional/Exporter/Event/DataTableExporterResponseEventTest.php new file mode 100644 index 00000000..a02373c0 --- /dev/null +++ b/tests/Functional/Exporter/Event/DataTableExporterResponseEventTest.php @@ -0,0 +1,53 @@ + + */ +class DataTableExporterResponseEventTest extends WebTestCase +{ + /** @var KernelBrowser */ + private $client; + + protected function setUp(): void + { + self::ensureKernelShutdown(); + $this->client = self::createClient(); + } + + /** + * @dataProvider exporterNameProvider + */ + public function testPreResponseEvent(string $exporterName, string $ext): void + { + $this->client->request('POST', '/exporter', ['_dt' => 'dt', '_exporter' => $exporterName]); + + /** @var BinaryFileResponse $response */ + $response = $this->client->getResponse(); + + static::assertStringContainsString($response->headers->get('content-disposition'), sprintf('attachment; filename=custom_filename.%s', $ext)); + } + + public function exporterNameProvider(): array + { + return [ + ['excel', 'xlsx'], + ['txt', 'txt'], + ]; + } +} diff --git a/tests/Functional/Exporter/Excel/ExcelExporterTest.php b/tests/Functional/Exporter/Excel/ExcelExporterTest.php new file mode 100644 index 00000000..7a909f8a --- /dev/null +++ b/tests/Functional/Exporter/Excel/ExcelExporterTest.php @@ -0,0 +1,114 @@ + + */ +class ExcelExporterTest extends WebTestCase +{ + /** @var KernelBrowser */ + private $client; + + protected function setUp(): void + { + self::ensureKernelShutdown(); + $this->client = self::createClient(); + } + + /** + * @throws \PhpOffice\PhpSpreadsheet\Exception + * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception + */ + public function testExport(): void + { + $this->client->request('POST', '/exporter', ['_dt' => 'dt', '_exporter' => 'excel']); + + /** @var BinaryFileResponse $response */ + $response = $this->client->getResponse(); + + $sheet = IOFactory::load($response->getFile()->getPathname())->getActiveSheet(); + + static::assertSame('dt.columns.firstName', $sheet->getCell('A1')->getFormattedValue()); + static::assertSame('dt.columns.lastName', $sheet->getCell('B1')->getFormattedValue()); + + static::assertSame('FirstName0', $sheet->getCell('A2')->getFormattedValue()); + static::assertSame('LastName0', $sheet->getCell('B2')->getFormattedValue()); + + static::assertSame('FirstName1', $sheet->getCell('A3')->getFormattedValue()); + static::assertSame('LastName1', $sheet->getCell('B3')->getFormattedValue()); + + static::assertSame('FirstName2', $sheet->getCell('A4')->getFormattedValue()); + static::assertSame('LastName2', $sheet->getCell('B4')->getFormattedValue()); + + static::assertSame('FirstName3', $sheet->getCell('A5')->getFormattedValue()); + static::assertSame('LastName3', $sheet->getCell('B5')->getFormattedValue()); + + static::assertSame('FirstName4', $sheet->getCell('A6')->getFormattedValue()); + static::assertSame('LastName4', $sheet->getCell('B6')->getFormattedValue()); + } + + /** + * @throws \PhpOffice\PhpSpreadsheet\Exception + * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception + */ + public function testEmptyDataTable(): void + { + $this->client->request('POST', '/exporter-empty-datatable', ['_dt' => 'dt', '_exporter' => 'excel']); + + /** @var BinaryFileResponse $response */ + $response = $this->client->getResponse(); + + static::assertTrue($response->isSuccessful()); + + $sheet = IOFactory::load($response->getFile()->getPathname())->getActiveSheet(); + + static::assertSame('dt.columns.firstName', $sheet->getCell('A1')->getFormattedValue()); + static::assertSame('dt.columns.lastName', $sheet->getCell('B1')->getFormattedValue()); + + static::assertEmpty($sheet->getCell('A2')->getFormattedValue()); + static::assertEmpty($sheet->getCell('B2')->getFormattedValue()); + } + + /** + * @throws \PhpOffice\PhpSpreadsheet\Exception + * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception + */ + public function testWithSearch(): void + { + $this->client->request('POST', '/exporter', [ + '_dt' => 'dt', + '_exporter' => 'excel', + 'search' => ['value' => 'FirstName124'], + ]); + + /** @var BinaryFileResponse $response */ + $response = $this->client->getResponse(); + + $sheet = IOFactory::load($response->getFile()->getPathname())->getActiveSheet(); + + static::assertSame('dt.columns.firstName', $sheet->getCell('A1')->getFormattedValue()); + static::assertSame('dt.columns.lastName', $sheet->getCell('B1')->getFormattedValue()); + + static::assertSame('FirstName124', $sheet->getCell('A2')->getFormattedValue()); + static::assertSame('LastName124', $sheet->getCell('B2')->getFormattedValue()); + + static::assertEmpty($sheet->getCell('A3')->getFormattedValue()); + static::assertEmpty($sheet->getCell('B3')->getFormattedValue()); + } +} diff --git a/tests/Unit/Adapter/DoctrineTest.php b/tests/Unit/Adapter/DoctrineTest.php index feb7085a..ad59af67 100644 --- a/tests/Unit/Adapter/DoctrineTest.php +++ b/tests/Unit/Adapter/DoctrineTest.php @@ -20,6 +20,7 @@ use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter; use Omines\DataTablesBundle\Column\TextColumn; use Omines\DataTablesBundle\DataTable; +use Omines\DataTablesBundle\Exporter\DataTableExporterManager; use Omines\DataTablesBundle\Exception\InvalidConfigurationException; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -34,7 +35,7 @@ class DoctrineTest extends TestCase { public function testSearchCriteriaProvider() { - $table = new DataTable($this->createMock(EventDispatcher::class)); + $table = new DataTable($this->createMock(EventDispatcher::class), $this->createMock(DataTableExporterManager::class)); $table ->add('firstName', TextColumn::class) ->add('lastName', TextColumn::class) @@ -63,6 +64,7 @@ public function testORMAdapterRequiresDependency() { $this->expectException(\LogicException::class); $this->expectExceptionMessage('doctrine/doctrine-bundle'); + (new ORMAdapter()); } @@ -70,6 +72,7 @@ public function testInvalidQueryProcessorThrows() { $this->expectException(InvalidConfigurationException::class); $this->expectExceptionMessage('Provider must be a callable or implement QueryBuilderProcessorInterface'); + (new ORMAdapter($this->createMock(ManagerRegistry::class))) ->configure([ 'entity' => 'bar', diff --git a/tests/Unit/Adapter/ElasticaTest.php b/tests/Unit/Adapter/ElasticaTest.php index 8759f3cf..fbec7ed2 100644 --- a/tests/Unit/Adapter/ElasticaTest.php +++ b/tests/Unit/Adapter/ElasticaTest.php @@ -18,6 +18,7 @@ use Omines\DataTablesBundle\Column\TextColumn; use Omines\DataTablesBundle\DataTable; use Omines\DataTablesBundle\DataTableState; +use Omines\DataTablesBundle\Exporter\DataTableExporterManager; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\HttpFoundation\Request; @@ -62,7 +63,7 @@ public function testElasticaAdapter() ; // Set up a dummy table - $table = (new DataTable($this->createMock(EventDispatcher::class))) + $table = (new DataTable($this->createMock(EventDispatcher::class), $this->createMock(DataTableExporterManager::class))) ->setName('foo') ->setMethod(Request::METHOD_GET) ->add('foo', TextColumn::class, ['field' => 'foo', 'globalSearchable' => true]) diff --git a/tests/Unit/ColumnTest.php b/tests/Unit/ColumnTest.php index 1d0dbe42..51dcceda 100644 --- a/tests/Unit/ColumnTest.php +++ b/tests/Unit/ColumnTest.php @@ -20,6 +20,7 @@ use Omines\DataTablesBundle\Column\TwigColumn; use Omines\DataTablesBundle\DataTable; use Omines\DataTablesBundle\Exception\MissingDependencyException; +use Omines\DataTablesBundle\Exporter\DataTableExporterManager; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -36,7 +37,7 @@ public function testDateTimeColumn() $column->initialize('test', 1, [ 'nullValue' => 'foo', 'format' => 'd-m-Y', - ], (new DataTable($this->createMock(EventDispatcher::class)))->setName('foo')); + ], $this->createDataTable()->setName('foo')); $this->assertSame('03-04-2015', $column->transform('2015-04-03')); $this->assertSame('foo', $column->transform(null)); @@ -48,7 +49,7 @@ public function testDateTimeColumnWithCreateFromFormat() $column->initialize('test', 1, [ 'format' => 'd.m.Y H:i:s', 'createFromFormat' => 'Y-m-d\TH:i:sP', - ], (new DataTable($this->createMock(EventDispatcher::class)))->setName('foo')); + ], $this->createDataTable()->setName('foo')); $this->assertSame('19.02.2020 22:30:34', $column->transform('2020-02-19T22:30:34+00:00')); } @@ -59,7 +60,7 @@ public function testTextColumn() $column->initialize('test', 1, [ 'data' => 'bar', 'render' => 'foo%s', - ], (new DataTable($this->createMock(EventDispatcher::class)))->setName('foo')); + ], $this->createDataTable()->setName('foo')); $this->assertFalse($column->isRaw()); $this->assertSame('foobar', $column->transform(null)); @@ -72,7 +73,7 @@ public function testBoolColumn() $column->initialize('test', 1, [ 'trueValue' => 'yes', 'nullValue' => 'null', - ], new DataTable($this->createMock(EventDispatcher::class))); + ], $this->createDataTable()); $this->assertSame('yes', $column->transform(5)); $this->assertSame('yes', $column->transform(true)); @@ -95,7 +96,7 @@ public function testMapColumn() 1 => 'bar', 2 => 'baz', ], - ], new DataTable($this->createMock(EventDispatcher::class))); + ], $this->createDataTable()); $this->assertSame('foo', $column->transform(0)); $this->assertSame('bar', $column->transform(1)); @@ -106,7 +107,7 @@ public function testMapColumn() public function testNumberColumn() { $column = new NumberColumn(); - $column->initialize('test', 1, [], new DataTable($this->createMock(EventDispatcher::class))); + $column->initialize('test', 1, [], $this->createDataTable()); $this->assertSame('5', $column->transform(5)); $this->assertSame('1', $column->transform(true)); @@ -127,7 +128,7 @@ public function testColumnWithClosures() 'render' => function ($value) { return mb_strtoupper($value); }, - ], new DataTable($this->createMock(EventDispatcher::class))); + ], $this->createDataTable()); $this->assertFalse($column->isRaw()); $this->assertSame('BAR', $column->transform(null)); @@ -140,4 +141,9 @@ public function testTwigDependencyDetection() new TwigColumn(); } + + private function createDataTable(): DataTable + { + return new DataTable($this->createMock(EventDispatcher::class), $this->createMock(DataTableExporterManager::class)); + } } diff --git a/tests/Unit/DataTableTest.php b/tests/Unit/DataTableTest.php index 7fcaaff2..925381a2 100644 --- a/tests/Unit/DataTableTest.php +++ b/tests/Unit/DataTableTest.php @@ -20,6 +20,7 @@ use Omines\DataTablesBundle\DataTablesBundle; use Omines\DataTablesBundle\DependencyInjection\DataTablesExtension; use Omines\DataTablesBundle\DependencyInjection\Instantiator; +use Omines\DataTablesBundle\Exporter\DataTableExporterManager; use Omines\DataTablesBundle\Exception\InvalidArgumentException; use Omines\DataTablesBundle\Exception\InvalidConfigurationException; use Omines\DataTablesBundle\Exception\InvalidStateException; @@ -47,7 +48,7 @@ public function testBundle() public function testFactory() { - $factory = new DataTableFactory(['language_from_cdn' => false], $this->createMock(TwigRenderer::class), new Instantiator(), $this->createMock(EventDispatcher::class)); + $factory = new DataTableFactory(['language_from_cdn' => false], $this->createMock(TwigRenderer::class), new Instantiator(), $this->createMock(EventDispatcher::class), $this->createMock(DataTableExporterManager::class)); $table = $factory->create(['pageLength' => 684, 'dom' => 'bar']); $this->assertSame(684, $table->getOption('pageLength')); @@ -61,7 +62,7 @@ public function testFactory() public function testFactoryRemembersInstances() { - $factory = new DataTableFactory([], $this->createMock(TwigRenderer::class), new Instantiator(), $this->createMock(EventDispatcher::class)); + $factory = new DataTableFactory([], $this->createMock(TwigRenderer::class), new Instantiator(), $this->createMock(EventDispatcher::class), $this->createMock(DataTableExporterManager::class)); $reflection = new \ReflectionClass(DataTableFactory::class); $property = $reflection->getProperty('resolvedTypes'); @@ -116,7 +117,7 @@ public function testFactoryFailsOnInvalidType() $container = new ContainerBuilder(); (new DataTablesExtension())->load([], $container); - $factory = new DataTableFactory($container->getParameter('datatables.config'), $this->createMock(TwigRenderer::class), new Instantiator(), $this->createMock(EventDispatcher::class)); + $factory = new DataTableFactory($container->getParameter('datatables.config'), $this->createMock(TwigRenderer::class), new Instantiator(), $this->createMock(EventDispatcher::class), $this->createMock(DataTableExporterManager::class)); $factory->createFromType('foobar'); } @@ -211,12 +212,12 @@ public function testInvalidDataTableTypeThrows() $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Could not resolve type "foo" to a service or class'); - (new DataTableFactory([], $this->createMock(DataTableRendererInterface::class), new Instantiator(), $this->createMock(EventDispatcher::class))) + (new DataTableFactory([], $this->createMock(DataTableRendererInterface::class), new Instantiator(), $this->createMock(EventDispatcher::class), $this->createMock(DataTableExporterManager::class))) ->createFromType('foo'); } private function createMockDataTable(array $options = []) { - return new DataTable($this->createMock(EventDispatcher::class), $options); + return new DataTable($this->createMock(EventDispatcher::class), $this->createMock(DataTableExporterManager::class), $options); } } diff --git a/tests/Unit/Exporter/DataTableExporterCollectionTest.php b/tests/Unit/Exporter/DataTableExporterCollectionTest.php new file mode 100644 index 00000000..efc7e012 --- /dev/null +++ b/tests/Unit/Exporter/DataTableExporterCollectionTest.php @@ -0,0 +1,37 @@ + + */ +class DataTableExporterCollectionTest extends KernelTestCase +{ + protected function setUp(): void + { + static::bootKernel(); + } + + public function testUnknownExporter() + { + static::expectException(UnknownDataTableExporterException::class); + static::$container + ->get('Omines\DataTablesBundle\Exporter\DataTableExporterCollection') + ->getByName('unknown-exporter'); + } +} diff --git a/tests/Unit/Exporter/DataTableExporterManagerTest.php b/tests/Unit/Exporter/DataTableExporterManagerTest.php new file mode 100644 index 00000000..fce79ac2 --- /dev/null +++ b/tests/Unit/Exporter/DataTableExporterManagerTest.php @@ -0,0 +1,43 @@ + + */ +class DataTableExporterManagerTest extends TestCase +{ + public function testTranslatorInjection() + { + $exporterCollectionMock = $this->createMock(DataTableExporterCollection::class); + + static::expectException(InvalidArgumentException::class); + (new DataTableExporterManager($exporterCollectionMock, null)); + + static::expectException(InvalidArgumentException::class); + (new DataTableExporterManager($exporterCollectionMock, $this->createMock(DataCollectorTranslator::class))); + + static::assertInstanceOf(DataTableExporterManager::class, (new DataTableExporterManager($exporterCollectionMock, $this->createMock(TranslatorInterface::class)))); + static::assertInstanceOf(DataTableExporterManager::class, (new DataTableExporterManager($exporterCollectionMock, $this->createMock(LegacyTranslatorInterface::class)))); + } +} diff --git a/tests/Unit/Exporter/ExcelExporterTest.php b/tests/Unit/Exporter/ExcelExporterTest.php new file mode 100644 index 00000000..94938578 --- /dev/null +++ b/tests/Unit/Exporter/ExcelExporterTest.php @@ -0,0 +1,45 @@ + + */ +class ExcelExporterTest extends KernelTestCase +{ + /** @var DataTableExporterCollection */ + private $exporterCollection; + + protected function setUp(): void + { + static::bootKernel(); + + $this->exporterCollection = static::$container->get('Omines\DataTablesBundle\Exporter\DataTableExporterCollection'); + } + + public function testTag() + { + static::assertInstanceOf(ExcelExporter::class, $this->exporterCollection->getByName('excel')); + } + + public function testName() + { + static::assertSame('excel', $this->exporterCollection->getByName('excel')->getName()); + } +}