diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 556d8862..86f62571 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -60,6 +60,10 @@ jobs: ${{ steps.composer-cache.outputs.dir }} key: ${{ github.run_id }}-${{ runner.os }}-${{ hashFiles('composer.json') }}-symfony-${{ matrix.symfony }} + - name: 'Remove portphp/csv if needed' + if: matrix.symfony == '^8.0' + run: composer remove portphp/csv --no-update + - name: "Install dependencies" run: composer update --no-interaction --no-scripts diff --git a/app/Entity/Book.php b/app/Entity/Book.php index 324824ed..30b6781f 100644 --- a/app/Entity/Book.php +++ b/app/Entity/Book.php @@ -15,6 +15,7 @@ use App\Grid\BookGrid; use App\Repository\BookRepository; +use App\Responder\ExportGridToCsvResponder; use Doctrine\ORM\Mapping as ORM; use Sylius\Component\Resource\Annotation\SyliusCrudRoutes; use Sylius\Component\Resource\Model\ResourceInterface; @@ -40,6 +41,11 @@ template: '@SyliusAdminUi/crud/index.html.twig', shortName: 'withoutGrid', ), + new Index( + shortName: 'export', + responder: ExportGridToCsvResponder::class, + grid: BookGrid::class, + ), new Delete(), new BulkDelete(), new Show(), diff --git a/app/Grid/BookGrid.php b/app/Grid/BookGrid.php index 21172331..1efada35 100644 --- a/app/Grid/BookGrid.php +++ b/app/Grid/BookGrid.php @@ -14,6 +14,7 @@ namespace App\Grid; use App\Entity\Book; +use Sylius\Bundle\GridBundle\Builder\Action\Action; use Sylius\Bundle\GridBundle\Builder\Action\CreateAction; use Sylius\Bundle\GridBundle\Builder\Action\DeleteAction; use Sylius\Bundle\GridBundle\Builder\Action\ShowAction; @@ -55,6 +56,12 @@ public function buildGrid(GridBuilderInterface $gridBuilder): void ->addActionGroup( MainActionGroup::create( CreateAction::create(), + Action::create(name: 'export', type: 'export') + ->setOptions([ + 'link' => [ + 'route' => 'app_admin_book_export', + ], + ]), ), ) ->addActionGroup( diff --git a/app/Responder/ExportGridToCsvResponder.php b/app/Responder/ExportGridToCsvResponder.php new file mode 100644 index 00000000..1fcd1499 --- /dev/null +++ b/app/Responder/ExportGridToCsvResponder.php @@ -0,0 +1,129 @@ +setStream($output); + + $fields = $this->sortFields($data->getDefinition()->getFields()); + $this->writeHeaders($writer, $fields); + $this->writeRows($writer, $fields, $data); + + $writer->finish(); + }); + + $response->headers->set('Content-Type', 'text/csv; charset=UTF-8'); + $response->headers->set('Content-Disposition', 'attachment; filename="export.csv"'); + + return $response; + } + + /** + * @param Field[] $fields + */ + private function writeHeaders(CsvWriter $writer, array $fields): void + { + $labels = array_map(fn (Field $field) => $this->translator->trans($field->getLabel()), $fields); + + $writer->writeItem($labels); + } + + /** + * @param Field[] $fields + */ + private function writeRows(CsvWriter $writer, array $fields, GridViewInterface $gridView): void + { + /** @var PagerfantaInterface $paginator */ + $paginator = $gridView->getData(); + Assert::isInstanceOf($paginator, PagerfantaInterface::class); + + for ($currentPage = 1; $currentPage <= $paginator->getNbPages(); ++$currentPage) { + $paginator->setCurrentPage($currentPage); + $this->writePageResults($writer, $fields, $gridView, $paginator->getCurrentPageResults()); + } + } + + /** + * @param Field[] $fields + * @param iterable $pageResults + */ + private function writePageResults(CsvWriter $writer, array $fields, GridViewInterface $gridView, iterable $pageResults): void + { + foreach ($pageResults as $resource) { + $rows = []; + foreach ($fields as $field) { + $rows[] = $this->getFieldValue($gridView, $field, $resource); + } + $writer->writeItem($rows); + } + } + + private function getFieldValue(GridViewInterface $gridView, Field $field, object $data): string + { + $renderedData = $this->gridRenderer->renderField($gridView, $field, $data); + $renderedData = str_replace(\PHP_EOL, '', $renderedData); + + return trim(strip_tags($renderedData)); + } + + /** + * @param Field[] $fields + * + * @return Field[] + */ + private function sortFields(array $fields): array + { + $sortedFields = $fields; + + uasort($sortedFields, fn (Field $fieldA, Field $fieldB) => $fieldA->getPosition() <=> $fieldB->getPosition()); + + return $sortedFields; + } +} diff --git a/composer.json b/composer.json index 2d9da6f0..ff71cd65 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "laminas/laminas-stdlib": "^3.18", "pagerfanta/doctrine-orm-adapter": "^4.6", "pagerfanta/twig": "^4.6", + "portphp/csv": "^2.0", "sylius/grid-bundle": "^1.13 || ^1.15@alpha", "sylius/resource-bundle": "^1.13 || ^1.14@alpha", "symfony/asset": "^6.4 || ^7.4 || ^8.0", @@ -109,7 +110,7 @@ }, "extra": { "symfony": { - "require": "8.0.*" + "require": "7.4.*" } }, "scripts": { diff --git a/config/packages/sylius_grid.yaml b/config/packages/sylius_grid.yaml index 52431ffc..c59aa3a8 100644 --- a/config/packages/sylius_grid.yaml +++ b/config/packages/sylius_grid.yaml @@ -1,4 +1,6 @@ sylius_grid: templates: + action: + export: 'shared/grid/action/export.html.twig' filter: 'App\Grid\Filter\SpeakerFilter': '@SyliusBootstrapAdminUi/shared/grid/filter/entity.html.twig' diff --git a/docs/.gitbook/assets/grid_export.png b/docs/.gitbook/assets/grid_export.png new file mode 100644 index 00000000..52d876a2 Binary files /dev/null and b/docs/.gitbook/assets/grid_export.png differ diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 90cf3471..c1304397 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -14,6 +14,7 @@ * [Customizing the page titles](cookbook/admin_panel/page_titles.md) * [Customizing the metatags](cookbook/admin_panel/metatags.md) * [Using autocompletes](cookbook/admin_panel/using-autocompletes.md) + * [Exporting grid data](cookbook/admin_panel/grid_export.md) * [How to use in a DDD architecture](cookbook/ddd_architecture.md) * [Architecture overview](cookbook/ddd_architecture/overview.md) * [Resource configuration](cookbook/ddd_architecture/resource_configuration.md) diff --git a/docs/cookbook/admin_panel.md b/docs/cookbook/admin_panel.md index 4f69ab2c..bb146fff 100644 --- a/docs/cookbook/admin_panel.md +++ b/docs/cookbook/admin_panel.md @@ -8,4 +8,4 @@ * [Customizing the page titles](admin_panel/page_titles.md) * [Customizing the metatags](admin_panel/metatags.md) * [Using autocompletes](admin_panel/using-autocompletes.md) - +* [Exporting grid data](admin_panel/grid_export.md) diff --git a/docs/cookbook/admin_panel/grid_export.md b/docs/cookbook/admin_panel/grid_export.md new file mode 100644 index 00000000..9bf7b1cf --- /dev/null +++ b/docs/cookbook/admin_panel/grid_export.md @@ -0,0 +1,258 @@ +# Exporting grid data + +In this cookbook, we assume that you have already created a `Book` resource and configured a grid to show a book list. + +In this example, we'll create a CSV export. + +
Exporting grid data
+ +## The responder + +First, create the responder using the [https://github.com/portphp/csv](portphp/csv) package. + +{% code title="src/Responder/ExportGridToCsvResponder.php" lineNumbers="true" %} +```php +setStream($output); + + $fields = $this->sortFields($data->getDefinition()->getFields()); + $this->writeHeaders($writer, $fields); + $this->writeRows($writer, $fields, $data); + + $writer->finish(); + }); + + $response->headers->set('Content-Type', 'text/csv; charset=UTF-8'); + $response->headers->set('Content-Disposition', 'attachment; filename="export.csv"'); + + return $response; + } + + /** + * @param Field[] $fields + */ + private function writeHeaders(CsvWriter $writer, array $fields): void + { + $labels = array_map(fn (Field $field) => $this->translator->trans($field->getLabel()), $fields); + + $writer->writeItem($labels); + } + + /** + * @param Field[] $fields + */ + private function writeRows(CsvWriter $writer, array $fields, GridViewInterface $gridView): void + { + /** @var PagerfantaInterface $paginator */ + $paginator = $gridView->getData(); + Assert::isInstanceOf($paginator, PagerfantaInterface::class); + + for ($currentPage = 1; $currentPage <= $paginator->getNbPages(); ++$currentPage) { + $paginator->setCurrentPage($currentPage); + $this->writePageResults($writer, $fields, $gridView, $paginator->getCurrentPageResults()); + } + } + + /** + * @param Field[] $fields + * @param iterable $pageResults + */ + private function writePageResults(CsvWriter $writer, array $fields, GridViewInterface $gridView, iterable $pageResults): void + { + foreach ($pageResults as $resource) { + $rows = []; + foreach ($fields as $field) { + $rows[] = $this->getFieldValue($gridView, $field, $resource); + } + $writer->writeItem($rows); + } + } + + private function getFieldValue(GridViewInterface $gridView, Field $field, object $data): string + { + $renderedData = $this->gridRenderer->renderField($gridView, $field, $data); + $renderedData = str_replace(PHP_EOL, '', $renderedData); + + return trim(strip_tags($renderedData)); + } + + /** + * @param Field[] $fields + * + * @return Field[] + */ + private function sortFields(array $fields): array + { + $sortedFields = $fields; + + uasort($sortedFields, fn (Field $fieldA, Field $fieldB) => $fieldA->getPosition() <=> $fieldB->getPosition()); + + return $sortedFields; + } +} +``` +{% endcode %} + +## Configure a new operation + +{% code title="src/Entity/Book.php" lineNumbers="true" %} +```php +addActionGroup( + MainActionGroup::create( + // ... + Action::create('export', 'export') + // Optional, you can configure it globally instead. + ->setTemplate('shared/grid/action/export.html.twig') + , + ) + ) + ; + } +} +``` +{% endcode %} + +## Create the export action Twig template + +You can configure the template for the export action + +{% code title="templates/shared/grid/action/export.html.twig" lineNumbers="true" %} +```twig +{% set path = options.link.url|default(path(options.link.route|default(grid.requestConfiguration.getRouteName('export')), options.link.parameters|default([]))) %} + +{% set message = action.label %} +{% if message is empty %} + {% set message = 'app.ui.export' %} +{% endif %} + + + {{ ux_icon(action.icon|default('iwwa:csv'), {class: 'icon dropdown-item-icon'}) }} + {{ message|trans }} + +``` +{% endcode %} + +## Configure the translation key + +In the export action Twig template, we have introduced the `app.ui.export` translation key. +So we need to configure its translation. + +{% code title="translations/messages.en.yaml" lineNumbers="true" %} +```yaml +app: + ui: + # ... + export: Export +``` +{% endcode %} + +## Global config template for export actions + +To avoid repeating the `setTemplate` option across grid configurations, define it globally in the Grid Bundle config. + +{% code title="config/packages/sylius_grid.yaml" lineNumbers="true" %} +```yaml +sylius_grid: + templates: + action: + export: 'shared/grid/action/export.html.twig' +``` +{% endcode %} diff --git a/templates/shared/grid/action/export.html.twig b/templates/shared/grid/action/export.html.twig new file mode 100644 index 00000000..996af004 --- /dev/null +++ b/templates/shared/grid/action/export.html.twig @@ -0,0 +1,11 @@ +{% set path = options.link.url|default(path(options.link.route|default(grid.requestConfiguration.getRouteName('export')), options.link.parameters|default([]))) %} + +{% set message = action.label %} +{% if message is empty %} + {% set message = 'app.ui.export' %} +{% endif %} + + + {{ ux_icon(action.icon|default('iwwa:csv'), {class: 'icon dropdown-item-icon'}) }} + {{ message|trans }} + diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 193a9c2e..4ec6efa9 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -14,6 +14,7 @@ app: configuration: Configuration edit_conference: Edit conference ends_at: Ends at + export: Export first_name: First name last_name: Last name library: Library diff --git a/translations/messages.fr.yaml b/translations/messages.fr.yaml index f74658d7..7075c7c7 100644 --- a/translations/messages.fr.yaml +++ b/translations/messages.fr.yaml @@ -14,6 +14,7 @@ app: configuration: Configuration edit_conference: Modifier la conférence ends_at: Termine à + export: Export first_name: Prénom last_name: Nom de famille library: Bibliothèque