diff --git a/.gitignore b/.gitignore index a529c29..38f3611 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,7 @@ vendor node_modules *.map -.env \ No newline at end of file +.env + +*.log +*.png \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 4c37f30..bcf5966 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,20 @@ +sudo: required language: php +dist: trusty php: - 7.1 - 7.2 +addons: + chrome: stable + before_script: + - set -e - cp .env.travis .env - - composer self-update - - composer install --no-interaction + - travis_retry composer install --no-interaction + - google-chrome-stable --headless --disable-gpu --remote-debugging-port=9222 http://localhost & script: - - vendor/bin/phpunit tests/Unit/PackageTestCase.php + - vendor/bin/phpunit tests/Unit/PackageTestCases.php +# - vendor/bin/phpunit tests/Browser/BrowserTestCases.php diff --git a/README.MD b/README.MD index 02b9a8b..3ac0329 100644 --- a/README.MD +++ b/README.MD @@ -17,6 +17,7 @@ This package allows rendering of data via a tabular format (grid). The grid uses + Jquery pjax (quickly view data without having to reload the page). Note that you'll need to [create the middleware on your own](https://gist.github.com/JeffreyWay/8526696b6f29201c4e33) + [Date picker](https://github.com/dangrossman/bootstrap-daterangepicker.git) (optional - for the single date & date range filters) +> **Note that from version 2.0.2 onwards, you'll need the package [barryvdh/laravel-dompdf](https://github.com/barryvdh/laravel-dompdf) to export data from the grid as PDF** # Getting started diff --git a/composer.json b/composer.json index 18f8aa5..8d52ee0 100644 --- a/composer.json +++ b/composer.json @@ -32,12 +32,13 @@ }, "suggest": { "maatwebsite/excel": "For exporting grid data as excel (~3.0)", - "spatie/laravel-pjax": "For pjax support on the server side" + "barryvdh/laravel-dompdf": "For exporting grid data as PDF (^0.8.2)" }, "require-dev": { "phpunit/phpunit": "~7.0", "orchestra/testbench": "~3.0", - "orchestra/testbench-dusk": "^3.5" + "orchestra/testbench-dusk": "^3.5", + "phpunit/php-code-coverage": "^6.0" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 21657d2..aa1dfeb 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": "5b37882b0fecddb552d1901d5c935b70", + "content-hash": "71246461cd0ef3f16488416afb86ff23", "packages": [ { "name": "doctrine/inflector", diff --git a/phpunit.xml b/phpunit.xml index db4b908..aa4446f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -9,12 +9,12 @@ processIsolation="false" stopOnFailure="false"> - - ./tests/Feature + + ./tests/Browser - - ./tests/Unit + + ./tests/Unit diff --git a/src/Export/ExcelExport.php b/src/Export/ExcelExport.php index 55d2501..baba43e 100644 --- a/src/Export/ExcelExport.php +++ b/src/Export/ExcelExport.php @@ -6,87 +6,68 @@ namespace Leantony\Grid\Export; -use Excel; -use Illuminate\Database\Eloquent\Builder; +use Illuminate\Contracts\View\View; use Maatwebsite\Excel\Concerns\Exportable; -use Maatwebsite\Excel\Concerns\FromQuery; +use Illuminate\Support\Collection; +use Maatwebsite\Excel\Concerns\FromCollection; use Maatwebsite\Excel\Concerns\WithHeadings; -use Maatwebsite\Excel\Concerns\WithMapping; use Maatwebsite\Excel\Concerns\WithTitle; -class ExcelExport implements FromQuery, WithTitle, WithHeadings, WithMapping +class ExcelExport implements FromCollection, WithTitle, WithHeadings { use Exportable; /** - * @var Builder + * The data + * + * @var Collection */ - private $query; + protected $data; /** - * @var array + * The title of the report + * + * @var string */ - private $pinch; + private $title; /** + * The columns to export + * * @var array */ private $columns; - /** - * @var string - */ - private $title; - /** - * @var callable - */ - private $mapperFunction; /** + * The headings to export + * * @var array */ private $headings; /** - * ExcelExport constructor. - * @param Builder $builder - * @param array $pinch - * @param array $columns - * @param array $headings - * @param string $title - * @param callable $mapperFunction + * DefaultExport constructor. + * @param array $params */ - public function __construct($builder, array $pinch, array $columns, array $headings, string $title, callable $mapperFunction) - { - $this->query = $builder; - $this->pinch = $pinch; - $this->columns = $columns; - $this->headings = $headings; - $this->title = $title; - $this->mapperFunction = $mapperFunction; - } - - public function query() + public function __construct(array $params) { - return $this->query->select($this->pinch); + $this->title = $params['title']; + $this->columns = $params['columns']; + $this->data = $params['data']; + $this->headings = $params['headings']; } - public function map($data): array + public function collection() { - return call_user_func($this->mapperFunction, $data, $this->columns, false); + return $this->data; } - /** - * @return string - */ public function title(): string { return $this->title; } - /** - * @return array - */ public function headings(): array { return $this->headings; diff --git a/src/Export/PdfExport.php b/src/Export/PdfExport.php new file mode 100644 index 0000000..72252d6 --- /dev/null +++ b/src/Export/PdfExport.php @@ -0,0 +1,34 @@ +loadHTML(view($exportView, ['columns' => $exportableColumns, 'data' => $data])->render()); + return $pdf->download($fileName . '.pdf'); + } + throw new \InvalidArgumentException("PDF library not found. Please install 'barryvdh/laravel-dompdf' to handle PDF exports"); + } +} \ No newline at end of file diff --git a/src/HasGridConfigurations.php b/src/HasGridConfigurations.php index 9ca7942..30be29b 100644 --- a/src/HasGridConfigurations.php +++ b/src/HasGridConfigurations.php @@ -94,7 +94,7 @@ trait HasGridConfigurations * * @var int */ - private $maxExportRows; + private $gridExportQueryChunkSize; /** * @var string @@ -321,12 +321,12 @@ public function getGridExportView(): string return $this->exportView; } - public function getGridMaxExportRows(): int + public function getGridExportQueryChunkSize(): int { - if ($this->maxExportRows === null) { - $this->maxExportRows = config('grid.export.max_rows', 5000); + if ($this->gridExportQueryChunkSize === null) { + $this->gridExportQueryChunkSize = config('grid.export.chunk_size', 300); } - return $this->maxExportRows; + return $this->gridExportQueryChunkSize; } public function getGridColumnsToSkipOnGeneration(): array diff --git a/src/Listeners/DataExportHandler.php b/src/Listeners/DataExportHandler.php index aa037ee..4d789c6 100644 --- a/src/Listeners/DataExportHandler.php +++ b/src/Listeners/DataExportHandler.php @@ -11,9 +11,12 @@ use Illuminate\Http\Response; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use Leantony\Grid\Export\DefaultExporter; use Leantony\Grid\Export\ExcelExport; +use Leantony\Grid\Export\ExcelExporter; use Leantony\Grid\Export\HtmlExport; use Leantony\Grid\Export\JsonExport; +use Leantony\Grid\Export\PdfExport; use Leantony\Grid\GridInterface; use Leantony\Grid\GridResources; @@ -97,19 +100,28 @@ public function exportAs($type = 'xlsx') { switch ($type) { case 'pdf': + { + return (new PdfExport())->export($this->getExportData(), [ + 'exportableColumns' => $this->getExportableColumns()[1], + 'fileName' => $this->getFileNameForExport(), + 'exportView' => $this->getGridExportView(), + ]); + } case 'csv': case 'xlsx': { - list($pinch, $columns) = $this->getExportableColumns(); + $columns = $this->getExportableColumns()[1]; // headings $headings = $columns->map(function ($col) { return $col->name; })->toArray(); - $exporter = new ExcelExport($this->getQuery(), $pinch, $columns->toArray(), $headings, 'report', function ($data, $columns) { - return call_user_func([$this, 'dataFormatter'], $data, $columns, false); - }); - return $exporter->download($this->getFileNameForExport() . '.' . $type); + return (new ExcelExport([ + 'title' => $this->getGrid()->getName(), + 'columns' => $columns, + 'data' => $this->getExportData(), + 'headings' => $headings, + ]))->download($this->getFileNameForExport() . '.' . $type); } case 'html': { @@ -188,21 +200,23 @@ public function getFileNameForExport(): string */ public function getExportData(array $params = []): Collection { - $useUnformattedKeys = $params['doNotFormatKeys'] ?? false; + $doNotFormatKeys = $params['doNotFormatKeys'] ?? false; list($pinch, $columns) = $this->getExportableColumns(); - // works on the underlying query instance - $values = $this->getQuery()->take($this->getGrid()->getGridMaxExportRows())->get(); + $records = new Collection(); - // customize the results - $columns = $columns->toArray(); + $this->getQuery()->select($pinch)->chunk($this->getGridExportQueryChunkSize(), function ($items) use ($columns, $params, $doNotFormatKeys, $records) { + // customize the results + $columns = $columns->toArray(); - $data = $values->map(function ($value) use ($columns, $params, $useUnformattedKeys) { - return call_user_func([$this, 'dataFormatter'], $value, $columns, $useUnformattedKeys); + $data = $items->map(function ($value) use ($columns, $params, $doNotFormatKeys) { + return call_user_func([$this, 'dataFormatter'], $value, $columns, $doNotFormatKeys); + }); + $records->push($data); }); - return $data; + return $records[0]; } /** @@ -210,10 +224,10 @@ public function getExportData(array $params = []): Collection * * @param mixed $item * @param array $columns - * @param boolean $useUnformattedKeys + * @param boolean $doNotFormatKeys * @return array */ - protected function dataFormatter($item, array $columns, bool $useUnformattedKeys): array + protected function dataFormatter($item, array $columns, bool $doNotFormatKeys): array { $data = []; foreach ($columns as $column) { @@ -221,11 +235,11 @@ protected function dataFormatter($item, array $columns, bool $useUnformattedKeys // `processColumns()` would have already taken care of processing the callbacks // so here, we only pass the required arguments if (is_callable($column->data)) { - $key = $useUnformattedKeys ? $column->key : $column->name; + $key = $doNotFormatKeys ? $column->key : $column->name; $value = call_user_func($column->data, $item, $column->key); array_push($data, [$key => $value]); } else { - $key = $useUnformattedKeys ? $column->key : $column->name; + $key = $doNotFormatKeys ? $column->key : $column->name; $value = $item->{$column->key}; array_push($data, [$key => $value]); } diff --git a/src/resources/config/grid.php b/src/resources/config/grid.php index 9ca1250..86a05db 100644 --- a/src/resources/config/grid.php +++ b/src/resources/config/grid.php @@ -56,9 +56,9 @@ ], /** - * Max allowed export rows + * Export chunk size */ - 'max_rows' => 5000 + 'chunk_size' => 200 ], /** diff --git a/src/resources/views/reports/report.blade.php b/src/resources/views/reports/report.blade.php index b1795a5..e5a12b2 100644 --- a/src/resources/views/reports/report.blade.php +++ b/src/resources/views/reports/report.blade.php @@ -1,22 +1,29 @@ - - - - @foreach($columns as $column) - - @endforeach - - - - @foreach($data as $k => $v) + +
+
- {{ $column->name }} -
- @foreach($v as $c) - + @foreach($columns as $column) + @endforeach - @endforeach - -
- {!! $c !!} - + {{ $column->name }} +
\ No newline at end of file + + @foreach($data as $k => $v) + + @foreach($v as $c) + + {!! $c !!} + + @endforeach + + @endforeach + + + + \ No newline at end of file diff --git a/tests/Browser/BrowserTestCase.php b/tests/Browser/BrowserTestCase.php deleted file mode 100644 index 9fdf102..0000000 --- a/tests/Browser/BrowserTestCase.php +++ /dev/null @@ -1,11 +0,0 @@ -setBinary('/usr/bin/google-chrome'); + $chromeOptions->addArguments(['no-first-run', 'no-sandbox']); + $capabilities = DesiredCapabilities::chrome(); + $capabilities->setCapability(ChromeOptions::CAPABILITY, $chromeOptions); + + return RemoteWebDriver::create( + 'http://localhost:9515', + $capabilities + ); + } + + /** + * Test seeing the grid + * + * @return void + * @throws \Throwable + * @test + */ + public function can_see_grid() + { + $this->browse(function ($browser) { + /** @var $browser Browser */ + $browser->visit('/users') + ->assertSee('Users') + ->assertSee('tester_1') + ->assertSee('tester_6') + ->assertSee('testrole_1'); + }); + } + + /** + * Test refreshing the grid + * + * @return void + * @throws \Throwable + * @test + */ + public function can_refresh_grid() + { + $this->browse(function ($browser) { + /** @var $browser Browser */ + $browser->visit('/users') + ->clickLink('Refresh') + ->assertPathIs('/users'); + }); + } + + /** + * @throws \Throwable + * @test + */ + public function can_do_an_export_as_excel() + { + $this->browse(function ($browser) { + /** @var $browser Browser */ + $browser->visit('/users') + ->clickLink('excel') + ->assertQueryStringHas('export', 'xlsx'); + }); + } + + /** + * @throws \Throwable + * @test + */ + public function can_do_an_export_as_html() + { + $this->browse(function ($browser) { + /** @var $browser Browser */ + $browser->visit('/users') + ->clickLink('html') + ->assertQueryStringHas('export', 'html'); + }); + } + + /** + * @throws \Throwable + * @test + */ + public function can_do_an_export_as_csv() + { + $this->browse(function ($browser) { + /** @var $browser Browser */ + $browser->visit('/users') + ->clickLink('csv') + ->assertQueryStringHas('export', 'csv'); + }); + } + + /** + * @throws \Throwable + * @test + */ + public function can_do_an_export_as_pdf() + { + $this->browse(function ($browser) { + /** @var $browser Browser */ + $browser->visit('/users') + ->clickLink('pdf') + ->assertQueryStringHas('export', 'pdf'); + }); + } +} \ No newline at end of file diff --git a/tests/Browser/console/.gitkeep b/tests/Browser/console/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Browser/screenshots/.gitkeep b/tests/Browser/screenshots/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Controller/UsersTestController.php b/tests/Setup/Controller/UsersTestController.php similarity index 53% rename from tests/Controller/UsersTestController.php rename to tests/Setup/Controller/UsersTestController.php index a30c69b..a1c51f1 100644 --- a/tests/Controller/UsersTestController.php +++ b/tests/Setup/Controller/UsersTestController.php @@ -1,9 +1,11 @@ create(['query' => $query, 'request' => $request]) ->render(); } - public function show($id) { + /** + * @param UsersGridCustomized $usersGridCustomized + * @param Request $request + * @return string + * @throws \Exception + */ + public function index_two(UsersGridCustomized $usersGridCustomized, Request $request) + { + $query = User::with('role'); + + return $usersGridCustomized + ->create(['query' => $query, 'request' => $request]) + ->render(); + } + + public function show($id) + { // } diff --git a/tests/Controller/UsersGrid.php b/tests/Setup/Grids/UsersGrid.php similarity index 98% rename from tests/Controller/UsersGrid.php rename to tests/Setup/Grids/UsersGrid.php index 61e3f35..2368284 100644 --- a/tests/Controller/UsersGrid.php +++ b/tests/Setup/Grids/UsersGrid.php @@ -1,10 +1,10 @@ columns = [ + "id" => [ + "label" => "ID", + "filter" => [ + "enabled" => true, + "operator" => "=" + ], + "styles" => [ + "column" => "grid-w-10" + ] + ], + "name" => [ + "search" => [ + "enabled" => false + ], + "filter" => [ + "enabled" => false, + "operator" => "=" + ], + "styles" => [ + "column" => "grid-w-40" + ] + ], + "role_id" => [ + 'label' => 'Role', + 'export' => false, + 'search' => ['enabled' => false], + 'presenter' => function ($columnData, $columnName) { + return $columnData->role->name; + }, + 'filter' => [ + 'enabled' => true, + 'type' => 'select', + 'data' => Role::query()->pluck('name', 'id') + ] + ], + "email" => [ + "search" => [ + "enabled" => true + ], + "filter" => [ + "enabled" => true, + "operator" => "=" + ] + ], + "created_at" => [ + "sort" => false, + "date" => "true", + "filter" => [ + "enabled" => true, + "type" => "date", + "operator" => "<=" + ] + ] + ]; + } + + /** + * Set the links/routes. This are referenced using named routes, for the sake of simplicity + * + * @return void + */ + public function setRoutes() + { + // searching, sorting and filtering + $this->setIndexRouteName('users.index'); + + // crud support + $this->setCreateRouteName('users.create'); + $this->setViewRouteName('users.show'); + $this->setDeleteRouteName('users.destroy'); + + // default route parameter + $this->setDefaultRouteParameter('id'); + } + + /** + * Return a closure that is executed per row, to render a link that will be clicked on to execute an action + * + * @return Closure + */ + public function getLinkableCallback(): Closure + { + return function ($gridName, $item) { + return route($this->getViewRouteName(), [$gridName => $item->id]); + }; + } + + /** + * Configure rendered buttons, or add your own + * + * @return void + */ + public function configureButtons() + { + // call `addRowButton` to add a row button + // call `addToolbarButton` to add a toolbar button + // call `makeCustomButton` to do either of the above, but passing in the button properties as an array + + // call `editToolbarButton` to edit a toolbar button + // call `editRowButton` to edit a row button + // call `editButtonProperties` to do either of the above. All the edit functions accept the properties as an array + } + + /** + * Returns a closure that will be executed to apply a class for each row on the grid + * The closure takes two arguments - `name` of grid, and `item` being iterated upon + * + * @return Closure + */ + public function getRowCssStyle(): Closure + { + return function ($gridName, $item) { + // e.g, to add a success class to specific table rows; + return $item->id % 2 === 0 ? 'table-success' : ''; + }; + } +} \ No newline at end of file diff --git a/tests/Controller/UsersGridInterface.php b/tests/Setup/Grids/UsersGridInterface.php similarity index 78% rename from tests/Controller/UsersGridInterface.php rename to tests/Setup/Grids/UsersGridInterface.php index 06d7266..91df8f2 100644 --- a/tests/Controller/UsersGridInterface.php +++ b/tests/Setup/Grids/UsersGridInterface.php @@ -1,6 +1,6 @@ set('grid.warn_when_empty', true); + $app['config']->set('grid.export.allowed_types', ['pdf', 'csv', 'html', 'json', 'xlsx']); // routes - $app['router']->get('users', ['as' => 'users.index', 'uses' => 'Tests\Controller\UsersTestController@index']); - $app['router']->get('users/create', ['as' => 'users.create', 'uses' => 'Tests\Controller\UsersTestController@create']); - $app['router']->post('users/create', ['as' => 'users.store', 'uses' => 'Tests\Controller\UsersTestController@store']); - $app['router']->get('users/:id', ['as' => 'users.show', 'uses' => 'Tests\Controller\UsersTestController@show']); - $app['router']->patch('users/:id', ['as' => 'users.update', 'uses' => 'Tests\Controller\UsersTestController@update']); - $app['router']->delete('users/:id', ['as' => 'users.destroy', 'uses' => 'Tests\Controller\UsersTestController@destroy']); - } - - /** @test */ - public function config_is_loaded() - { - $this->assertEquals(true, Config::get('grid.warn_when_empty')); - } - - /** @test */ - public function can_run_the_migrations() - { - $users = DB::table('users')->where('id', '=', 1)->first(); - $this->assertNotNull($users); - } - - /** - * @test - */ - public function grid_is_generated_using_command() - { - Artisan::call('make:grid', [ - '--model' => User::class, - ]); - - $resultAsText = Artisan::output(); - - $this->assertContains('Finished performing replacements to the stub files', $resultAsText); - } - - /** - * @test - */ - public function grid_is_displayed() - { - $response = $this->get('/users'); - - $content = $response->getContent(); - - $response->assertStatus(200); - $this->assertNotNull($content); - $this->assertContains('Users', $content); - $this->assertContains('
', $content); - $this->assertContains('', $content); - $this->assertContains('50 entries', $content); + $app['router']->get('users', ['as' => 'users.index', 'uses' => 'Tests\Setup\Controller\UsersTestController@index']); + $app['router']->get('users/create', ['as' => 'users.create', 'uses' => 'Tests\Setup\Controller\UsersTestController@create']); + $app['router']->post('users/create', ['as' => 'users.store', 'uses' => 'Tests\Setup\Controller\UsersTestController@store']); + $app['router']->get('users/:id', ['as' => 'users.show', 'uses' => 'Tests\Setup\Controller\UsersTestController@show']); + $app['router']->patch('users/:id', ['as' => 'users.update', 'uses' => 'Tests\Setup\Controller\UsersTestController@update']); + $app['router']->delete('users/:id', ['as' => 'users.destroy', 'uses' => 'Tests\Setup\Controller\UsersTestController@destroy']); + + // customized grid + $app['router']->get('userz', ['as' => 'users.index_2', 'uses' => 'Tests\Setup\Controller\UsersTestController@index_two']); } } \ No newline at end of file diff --git a/tests/TestModels/Role.php b/tests/Setup/TestModels/Role.php similarity index 90% rename from tests/TestModels/Role.php rename to tests/Setup/TestModels/Role.php index b0423ac..016eba6 100644 --- a/tests/TestModels/Role.php +++ b/tests/Setup/TestModels/Role.php @@ -1,6 +1,6 @@ assertEquals(true, Config::get('grid.warn_when_empty')); + } + + /** + * Test to see if migrations can be run + * @test + */ + public function can_run_the_migrations() + { + $users = DB::table('users')->where('id', '=', 1)->first(); + $this->assertNotNull($users); + } + + /** + * Test to see if grid is generated using command option + * @test + */ + public function grid_is_generated_using_command() + { + Artisan::call('make:grid', [ + '--model' => User::class, + ]); + + $resultAsText = Artisan::output(); + + $this->assertContains('Finished performing replacements to the stub files', $resultAsText); + } + + /** + * Test to see if the grid is displayed + * @test + */ + public function grid_is_displayed() + { + $response = $this->get('/users'); + + $content = $response->getContent(); + + $response->assertStatus(200); + $this->assertNotNull($content); + $this->assertContains('Users', $content); + $this->assertContains('
', $content); + $this->assertContains('', $content); + $this->assertContains('50 entries', $content); + } +} \ No newline at end of file