├── .github └── workflows │ └── run-tests.yml ├── .gitignore ├── .styleci.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json ├── phpunit.xml.dist ├── src ├── Console │ └── FlexiblePresenterMakeCommand.php ├── Contracts │ └── FlexiblePresenterContract.php ├── Exceptions │ ├── InvalidPresenterKeys.php │ └── InvalidPresenterPreset.php ├── FlexiblePresenter.php ├── FlexiblePresenterServiceProvider.php └── NoSpecifiedResource.php ├── stubs └── DummyFlexiblePresenter.stub ├── tests ├── FlexiblePresenterTest.php ├── PresenterMakeCommandTestTest.php ├── Support │ ├── Concerns │ │ └── HasFactory.php │ ├── Factories │ │ ├── CommentFactory.php │ │ ├── ImageFactory.php │ │ └── PostFactory.php │ ├── Models │ │ ├── Comment.php │ │ ├── Image.php │ │ └── Post.php │ ├── Paginators │ │ └── CustomPaginator.php │ └── Presenters │ │ ├── CommentPresenter.php │ │ ├── ImagePresenter.php │ │ ├── PostPresenter.php │ │ └── StandalonePresenter.php └── TestCase.php └── webpack.mix.js /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | php-tests: 11 | runs-on: ${{ matrix.os }} 12 | 13 | strategy: 14 | fail-fast: true 15 | matrix: 16 | os: [ubuntu-latest] 17 | php: ['8.1', '8.2'] 18 | laravel: ['10.*', '11.*', '12.*'] 19 | dependency-version: [prefer-lowest, prefer-stable] 20 | include: 21 | - laravel: 10.* 22 | testbench: 8.* 23 | - laravel: 11.* 24 | testbench: 9.* 25 | - laravel: 12.* 26 | testbench: 10.* 27 | exclude: 28 | - laravel: 11.* 29 | php: 8.1 30 | - laravel: 12.* 31 | php: '8.1' 32 | 33 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} - ${{ matrix.os }} 34 | 35 | steps: 36 | - name: Checkout code 37 | uses: actions/checkout@v2 38 | 39 | - name: Setup PHP 40 | uses: shivammathur/setup-php@v2 41 | with: 42 | php-version: ${{ matrix.php }} 43 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick 44 | coverage: none 45 | 46 | - name: Install dependencies 47 | run: | 48 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 49 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-suggest 50 | 51 | - name: Execute tests 52 | run: vendor/bin/phpunit 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | composer.lock 3 | docs 4 | vendor 5 | coverage 6 | .phpunit.result.cache 7 | .phpunit.cache 8 | .idea 9 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel 2 | 3 | enabled: 4 | - length_ordered_imports 5 | - long_list_syntax 6 | 7 | disabled: 8 | - short_list_syntax 9 | - alpha_ordered_imports 10 | - single_class_element_per_statement 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to Flexible Presenter will be documented in this file 4 | 5 | ## 2.0.0 - 2020-05-03 6 | 7 | - removed lazy method (it wasn't that lazy as it turned out). Add support for appending data to outer wrapper on pagination output. 8 | 9 | ## 1.3.1 - 2020-04-19 10 | 11 | - added pagination support 12 | 13 | ## 1.0.0 - 2020-02-28 14 | 15 | - initial release 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | Please read and understand the contribution guide before creating an issue or pull request. 6 | 7 | ## Etiquette 8 | 9 | This project is open source, and as such, the maintainers give their free time to build and maintain the source code held within. They make the code freely available in the hope that it will be of use to other developers. It would be extremely unfair for them to suffer abuse or anger for their hard work. 10 | 11 | Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the world that developers are civilized and selfless people. 12 | 13 | It's the duty of the maintainer to ensure that all submissions to the project are of sufficient quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. 14 | 15 | ## Viability 16 | 17 | When requesting or submitting new features, first consider whether it might be useful to others. Open source projects are used by many developers, who may have entirely different needs to your own. Think about whether or not your feature is likely to be used by other users of the project. 18 | 19 | ## Procedure 20 | 21 | Before filing an issue: 22 | 23 | - Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. 24 | - Check to make sure your feature suggestion isn't already present within the project. 25 | - Check the pull requests tab to ensure that the bug doesn't have a fix in progress. 26 | - Check the pull requests tab to ensure that the feature isn't already in progress. 27 | 28 | Before submitting a pull request: 29 | 30 | - Check the codebase to ensure that your feature doesn't already exist. 31 | - Check the pull requests to ensure that another person hasn't already submitted the feature or fix. 32 | 33 | ## Requirements 34 | 35 | If the project maintainer has any additional requirements, you will find them listed here. 36 | 37 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](https://pear.php.net/package/PHP_CodeSniffer). 38 | 39 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 40 | 41 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 42 | 43 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. 44 | 45 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 46 | 47 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 48 | 49 | **Happy coding**! 50 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Addition pty 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flexible Presenter 2 | 3 | ![Latest Version on Packagist](https://img.shields.io/packagist/v/AdditionApps/flexible-presenter) 4 | ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/AdditionApps/flexible-presenter/Run%20tests?label=Tests) 5 | [![StyleCI](https://github.styleci.io/repos/243743103/shield?branch=master)](https://github.styleci.io/repos/243743103) 6 | 7 | **Easily define just the right data for your Inertia views (or anywhere else you want to, uh, flexibly present).** 8 | 9 | This package allows you to define presenter classes that take logic involved in getting data ready for your view layer out of your controller. It also provides an expressive, fluent API that allows you to modify and reuse your presenters on the fly so that you're only ever providing relevant data. 10 | 11 | This package was built specifically for use with [Inertia](https://inertiajs.com) (:heart_eyes:) because we didn't like the fact we were sending more data than we needed to our views. That said, you're free to use it however you please - it's not dependant on Inertia in any way. 12 | 13 | ## Installation 14 | 15 | You can install the package via composer: 16 | 17 | ```bash 18 | composer require additionapps/flexible-presenter 19 | ``` 20 | 21 | ## Usage 22 | 23 | The package includes an artisan command to create a new presenter: 24 | 25 | ```bash 26 | php artisan make:presenter PostsPresenter 27 | ``` 28 | 29 | This presenter will have the `App\Presenters` namespace and will be saved in `app/Presenters`. 30 | 31 | You can also specify a custom namespace, say, `App\Blog` 32 | 33 | ```bash 34 | php artisan make:presenter "Blog/Presenters/PostsPresenter" 35 | ``` 36 | This presenter will have the `App\Blog\Presenters` namespace and will be saved in `app/Blog/Presenters`. 37 | 38 | ### Defining values 39 | 40 | A presenter is a class in which you can define all the possible fields that you might want to expose to your Inertia views. When you call the class from a controller method you can use methods such as `only` or `except` to define which of these fields you want to expose in that given context. More on the API in a bit. 41 | 42 | The only required method in a presenter class is `values()` which should return an array with **all** the possible fields you might want to display in a view. These could simply be values directly on your model. Note that you can access model properties directly from the `$this` variable, just as you can when using [Laravel API Resources](https://laravel.com/docs/eloquent-resources). 43 | 44 | For now, here's a simple presenter class: 45 | 46 | ```php 47 | class PostPresenter extends FlexiblePresenter 48 | { 49 | public function values() 50 | { 51 | return [ 52 | 'id' => $this->id, 53 | 'title' => $this->title, 54 | 'body' => $this->body, 55 | ]; 56 | } 57 | } 58 | ``` 59 | 60 | Once a presenter is defined, you are free to return it as part of an Inertia response or in any other context that you need the presented data: 61 | 62 | ```php 63 | class PostController extends Controller 64 | { 65 | public function show(Post $post) 66 | { 67 | return Inertia::render('Posts/Show', [ 68 | 'post' => PostPresenter::make($post)->get(), 69 | ]); 70 | } 71 | } 72 | ``` 73 | 74 | ### Lazy evaluation 75 | 76 | You can also define fields that are computed in more complex ways - for example you may want to add a presenter value that is derived from some other data like a relationship. In these situations it can be handy to wrap your value definition in a closure. Doing so will ensure that the value is only computed if it's actually asked for: 77 | 78 | ```php 79 | public function values() 80 | { 81 | return [ 82 | 'id' => $this->id, 83 | 'comment_count' => function () { 84 | return $this->comments->count(); 85 | }, 86 | ]; 87 | } 88 | ``` 89 | 90 | If you are using PHP >= 7.4 you can make things a little more readable by using the new short closure syntax: 91 | 92 | ```php 93 | [ 94 | 'comment_count' => fn() => $this->comments->count(), 95 | ]; 96 | ``` 97 | 98 | Now if we call `PostPresenter::make($post)->only(['id'])` the `comment_count` value will not be evaluated meaning we do not need to worry about ensuring that relationship is loaded on the model. 99 | 100 | ### Nested presenters 101 | 102 | If necessary you can define a value that returns another presenter. This is handy if you want to only return certain values for a relation of the model you're working with: 103 | 104 | ```php 105 | public function values() 106 | { 107 | return [ 108 | 'id' => $this->id, 109 | 'comments' => function () { 110 | return CommentPresenter::collection($this->comments) 111 | ->except('body', 'updated_at'); 112 | }, 113 | ]; 114 | } 115 | ``` 116 | 117 | Note that in the example above we're using lazy evaluation so that we don't have to worry about the `comments` relationship being loaded on the `Post` model. If you know this relationship will always be loaded you could dispense with the closure. 118 | 119 | ### Instantiating presenters 120 | 121 | Once you have defined your presenter class you can use it in your controller (or elsewhere) in your application. There are three methods for creating a new presenter instance: 122 | 123 | #### `PostPresenter::make($post)` 124 | 125 | The `make` method accepts a single resource as a parameter. In the majority of cases this will be an Eloquent model but there is no requirement that you pass a model specifically. For instance you could pass a some other object or even an associative array and then within the `values()` method in your presenter use it like so: 126 | 127 | ```php 128 | public function values() 129 | { 130 | return [ 131 | 'id' => $this->resource['id'], 132 | 'title' => $this->resource['title'], 133 | ]; 134 | } 135 | ``` 136 | 137 | #### `PostPresenter::collection($posts)` 138 | 139 | The `collection` method accepts an Eloquent collection (or a plain array) of resources as a parameter. Each member of the collection will be transformed by the presenter as specified. Again the members of that collection can be Eloquent models, other objects or arrays: 140 | 141 | ```php 142 | $posts = PostPresenter::collection(Post::all()) 143 | ->only('id', 'title') 144 | ->get(); 145 | ``` 146 | 147 | **Using Pagination** 148 | 149 | As well as passing an Eloquent collection or array, you can also pass a Laravel paginator instance. You are free to pass an instance of either `Illuminate\Pagination\Paginator` or `Illuminate\Pagination\LengthAwarePaginator`. You can also pass a custom paginator as long as it extends the `Illuminate\Pagination\AbstractPaginator` class and implements the `Illuminate\Contracts\Support\Arrayable` interface. 150 | 151 | Here's an example of a presenter used with an eloquent collection using simple pagination: 152 | 153 | ```php 154 | $posts = PostPresenter::collection(Post::simplePaginate()) 155 | ->only('id', 'title') 156 | ->get(); 157 | ``` 158 | 159 | Which will output: 160 | 161 | ```php 162 | [ 163 | 'current_page' => 1, 164 | 'data' => [ 165 | [ 166 | 'id' => 1, 167 | 'title' => 'foo', 168 | ], 169 | [ 170 | 'id' => 2, 171 | 'title' => 'bar', 172 | ], 173 | ], 174 | 'first_page_url' => 'http://example.com/list?page=1', 175 | 'from' => 1, 176 | 'next_page_url' => null, 177 | 'path' => 'http://example.com/list', 178 | 'per_page' => 2, 179 | 'prev_page_url' => null, 180 | 'to' => 2, 181 | ]; 182 | ``` 183 | 184 | #### `PostPresenter::new()` 185 | 186 | The `new` method accepts no parameters. This method is useful for when you have no resource or collection to pass into your presenter (perhaps because the presenter itself is responsible for gathering the resources it needs). 187 | 188 | If one of your presenters has a key should return a presented relation as a value, you can use the convenience method `whenLoaded` to conditionally include the relation: 189 | 190 | ```php 191 | // In PostPresenter 192 | return [ 193 | 'comments' => CommentPresenter::collection($this->whenLoaded('comments')), 194 | ]; 195 | ``` 196 | 197 | In the above example the `comments` key will be a collection of presented comments if the relation is loaded and `null` if they are not. 198 | 199 | ### Configuring presenters 200 | 201 | With a new presenter instance you are now free to configure it in whatever way makes sense for your the current context. All the following methods can be chained onto `make()`, `collection()` or `new()`. 202 | 203 | #### `only()` 204 | 205 | The `only` method accepts keys that you want to return from your presenter instance. You can pass these keys as an array (`->only(['id', 'title'])`) or as individual string arguments (`->only('id', 'title')`). 206 | 207 | #### `except()` 208 | 209 | The `except` method accepts keys that you want to exclude from your presenter instance. You can pass these keys as an array (`->except(['id', 'title'])`) or as individual string arguments (`->except('id', 'title')`). 210 | 211 | #### `with()` 212 | 213 | The `with` method accepts a closure that takes as it's only argument the resource that is being transformed. You can use the `with` method to overwrite the values of existing keys or to add entirely new keys to your presenter on-the-fly. It should return an array with the keys you want to overwrite/add along with their values. 214 | 215 | ```php 216 | PostPresenter::make($post)->with(function($post){ 217 | return [ 218 | 'title' => strtoupper($post->title), 219 | 'new_key' => 'Some value', 220 | ]; 221 | }); 222 | ``` 223 | 224 | #### `preset()` 225 | 226 | You may find that there are combinations of presenter values that you use repeatedly throughout your application. If so, rather than explicitly asking for those particular fields each time you can add a 'preset' to your presenter class and ask for that preset instead: 227 | 228 | ```php 229 | PostPresenter::make($post)->preset('summary'); 230 | ``` 231 | 232 | Within your presenter class you should create a method with the name of your preset, prefixed with 'preset'. Given the example above we would create a method like this: 233 | 234 | ```php 235 | public function presetSummary() 236 | { 237 | return $this->only('title', 'published_at'); 238 | } 239 | ``` 240 | 241 | Your preset method should use the same presenter API methods above to build up a set of values. Note that all of these API methods can be chained, so, for example, you can modify a preset on the fly if you wish: 242 | 243 | ```php 244 | PostPresenter::make($post)->preset('summary')->with(function () { 245 | return [ 246 | 'comment_count' => $post->comments->count(), 247 | ]; 248 | }); 249 | ``` 250 | 251 | **Caveat: repeated method calls** 252 | 253 | Just bear in mind that if you use an API method in your preset method (for example `only`) and then chain another `only` method onto it when using your presenter, that the last call to `only` will be the one to take effect: 254 | 255 | ```php 256 | // In PostPresenter... 257 | public function presetSummary() 258 | { 259 | return $this->only('title', 'body'); 260 | } 261 | 262 | // In Controller... 263 | PostPresenter::make($post)->preset('summary'); 264 | // Will return ['title' => 'foo', 'body' => 'bar'] 265 | 266 | PostPresenter::make($post)->preset('summary')->only('id'); 267 | // Will return ['id' => 1] 268 | ``` 269 | 270 | #### `appends()` 271 | 272 | If you are presenting a paginated collection of resources you may want to add some additional key/value pairs to the outer array that wraps your data. To do this, you can use the `appends()` method to specify the keys and values you wish to set. Let's say you're presenting a custom paginator instance that produces this output: 273 | 274 | ```php 275 | [ 276 | 'current_page' => 1, 277 | 'data' => [ 278 | // your presented resources 279 | ], 280 | 'first_page_url' => 'http://example.com/list?page=1', 281 | 'from' => 1, 282 | 'next_page_url' => null, 283 | 'path' => 'http://example.com/list', 284 | 'per_page' => 2, 285 | 'prev_page_url' => null, 286 | 'to' => 2, 287 | 'links' => [ 288 | 'create' => 'some-url', 289 | ], 290 | ]; 291 | ``` 292 | 293 | Using appends you are free to add (or overwrite) the keys in this array: 294 | 295 | ```php 296 | $posts = PostPresenter::collection($paginatedCollection) 297 | ->only('id') 298 | ->appends([ 299 | 'foo' => 'bar', 300 | 'links' => ['store' => 'some-other-url'], 301 | ]) 302 | ->get(); 303 | ``` 304 | 305 | This will now output: 306 | 307 | ```php 308 | [ 309 | 'current_page' => 1, 310 | 'data' => [ 311 | // your presented resources 312 | ], 313 | 'first_page_url' => 'http://example.com/list?page=1', 314 | 'from' => 1, 315 | 'next_page_url' => null, 316 | 'path' => 'http://example.com/list', 317 | 'per_page' => 2, 318 | 'prev_page_url' => null, 319 | 'to' => 2, 320 | 'foo' => 'bar', 321 | 'links' => [ 322 | 'create' => 'some-url', 323 | 'store' => 'some-other-url', 324 | ], 325 | ]; 326 | ``` 327 | 328 | Note that appended values are merged recursively (as in the `links` example above). 329 | 330 | ### Returning values 331 | 332 | Once you've configured an instantiated presenter the way you want, you can get the values as an array by chaining the `get` method: 333 | 334 | ```php 335 | PostPresenter::make($post)->only('id', 'title')->get(); 336 | ``` 337 | 338 | If you wish to return all the defined values in your presenter (without any configuration) then you can use the `all` method: 339 | 340 | ```php 341 | PostPresenter::make($post)->all(); 342 | ``` 343 | 344 | This is equivalent to the following: 345 | 346 | ```php 347 | PostPresenter::make($post)->get(); 348 | ``` 349 | 350 | Finally, all presenters implement the `Arrayable` interface, so you are passing your presenter to a context that looks for this contract, your presenter values will be automatically converted to an array without you having to use `get()` (or `all()`). 351 | 352 | Here's an example using an Inertia response: 353 | 354 | ```php 355 | return Inertia::render('Posts/Show', [ 356 | 'post' => PostPresenter::make($post)->only('title'), 357 | ]); 358 | ``` 359 | In the example above we're defining our Inertia props manually in an array. You can also pass a presenter instance directly - it will be converted into an array of props automatically: 360 | 361 | ```php 362 | return Inertia::render('Posts/Show', PostPresenter::make($post)->only('title')); 363 | ``` 364 | 365 | ## Changelog 366 | 367 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 368 | 369 | ## Contributing 370 | 371 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 372 | 373 | ## Security 374 | 375 | If you discover any security related issues, please email john@addition.com.au instead of using the issue tracker. 376 | 377 | ## Treeware 378 | 379 | You're free to use this package, but if it makes it to your production environment we would highly appreciate you buying or planting the world a tree. 380 | 381 | It’s now common knowledge that one of the best tools to tackle the climate crisis and keep our temperatures from rising above 1.5C is to plant trees. If you contribute to my forest you’ll be creating employment for local families and restoring wildlife habitats. 382 | 383 | You can buy trees at [Offset Earth](https://offset.earth/treeware) 384 | 385 | Read more about Treeware at [Treeware](https://treeware.earth) 386 | 387 | ## Credits 388 | 389 | John Wyles 390 | 391 | All Contributors 392 | 393 | ## License 394 | 395 | The MIT License (MIT). Please see [License File](LICENSE.md) File for more information. 396 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "additionapps/flexible-presenter", 3 | "description": "Easily define just the right data for your InertiaJS views", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Addition", 8 | "email": "info@addition.com.au" 9 | } 10 | ], 11 | "require": { 12 | "php": "^8.1", 13 | "illuminate/pagination": "^10.0|^11.0|^12.0", 14 | "illuminate/support": "^10.0|^11.0|^12.0" 15 | }, 16 | "require-dev": { 17 | "fakerphp/faker": "^1.12", 18 | "laravel/legacy-factories": "^1.0", 19 | "orchestra/testbench": "^8.0|^9.0|^10.0", 20 | "phpunit/phpunit": "^9.0|^10.0|^11.5.3" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "AdditionApps\\FlexiblePresenter\\": "src" 25 | } 26 | }, 27 | "autoload-dev": { 28 | "psr-4": { 29 | "AdditionApps\\FlexiblePresenter\\Tests\\": "tests/" 30 | } 31 | }, 32 | "config": { 33 | "sort-packages": true 34 | }, 35 | "extra": { 36 | "laravel": { 37 | "providers": [ 38 | "AdditionApps\\FlexiblePresenter\\FlexiblePresenterServiceProvider" 39 | ] 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/Console/FlexiblePresenterMakeCommand.php: -------------------------------------------------------------------------------- 1 | option('force')) { 21 | return; 22 | } 23 | } 24 | } 25 | 26 | protected function getStub() 27 | { 28 | return __DIR__.'/../../stubs/DummyFlexiblePresenter.stub'; 29 | } 30 | 31 | protected function getDefaultNamespace($rootNamespace) 32 | { 33 | if ($this->isCustomNamespace()) { 34 | return $rootNamespace; 35 | } 36 | 37 | return $rootNamespace.'\Presenters'; 38 | } 39 | 40 | protected function getOptions(): array 41 | { 42 | return [ 43 | ['force', null, InputOption::VALUE_NONE, 'Create the class even if the flexible presenter already exists'], 44 | ]; 45 | } 46 | 47 | protected function isCustomNamespace(): bool 48 | { 49 | return Str::contains($this->argument('name'), '/'); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Contracts/FlexiblePresenterContract.php: -------------------------------------------------------------------------------- 1 | join(', ', ' and '); 13 | 14 | $message = "Invalid keys passed to {$method}() method. "; 15 | $message .= "The invalid {$invalidKeyPrefix}: {$invalidKeyString}"; 16 | 17 | return new static($message); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidPresenterPreset.php: -------------------------------------------------------------------------------- 1 | withoutResource = true; 51 | } elseif ($data instanceof Collection) { 52 | $this->collection = $data; 53 | } elseif ($data instanceof AbstractPaginator && $data instanceof Arrayable) { 54 | $this->paginationCollection = $data; 55 | } else { 56 | $this->resource = $data; 57 | } 58 | } 59 | 60 | public static function make($resource): self 61 | { 62 | return new static($resource); 63 | } 64 | 65 | public static function collection($collection): self 66 | { 67 | if (is_null($collection)) { 68 | return new static(null); 69 | } elseif ($collection instanceof AbstractPaginator && $collection instanceof Arrayable) { 70 | return new static($collection); 71 | } 72 | 73 | return new static(Collection::wrap($collection)); 74 | } 75 | 76 | public static function new() 77 | { 78 | return new static(new NoSpecifiedResource()); 79 | } 80 | 81 | public function with(callable $callback): self 82 | { 83 | if ($this->resource) { 84 | $this->with = array_merge($this->with, $callback($this->resource)); 85 | } else { 86 | $this->withCallback[] = $callback; 87 | } 88 | 89 | return $this; 90 | } 91 | 92 | public function only(...$includes): self 93 | { 94 | $this->only = collect($includes)->flatten()->all(); 95 | 96 | return $this; 97 | } 98 | 99 | public function except(...$excludes): self 100 | { 101 | $this->except = collect($excludes)->flatten()->all(); 102 | 103 | return $this; 104 | } 105 | 106 | public function lazy($expression) 107 | { 108 | return function () use ($expression) { 109 | return $expression; 110 | }; 111 | } 112 | 113 | public function all(): array 114 | { 115 | return collect($this->values()) 116 | ->mapWithKeys(function ($value, $key) { 117 | return [ 118 | $key => ($value instanceof Closure) ? App::call($value) : $value, 119 | ]; 120 | }) 121 | ->mapWithKeys(function ($value, $key) { 122 | return [ 123 | $key => ($value instanceof Arrayable) ? $value->toArray() : $value, 124 | ]; 125 | }) 126 | ->all(); 127 | } 128 | 129 | public function preset($name): self 130 | { 131 | $method = Str::start(ucfirst($name), 'preset'); 132 | 133 | if (method_exists($this, $method)) { 134 | return $this->$method(); 135 | } 136 | 137 | throw InvalidPresenterPreset::methodNotFound($method); 138 | } 139 | 140 | public function appends(array $values = []) 141 | { 142 | $this->appends = $values; 143 | 144 | return $this; 145 | } 146 | 147 | public function get(): ?array 148 | { 149 | if ($this->noResourceSpecified()) { 150 | return null; 151 | } 152 | 153 | if ($this->collection) { 154 | return $this->buildCollection(); 155 | } elseif ($this->paginationCollection) { 156 | return $this->buildPaginationCollection(); 157 | } 158 | 159 | $this->validateKeys(); 160 | 161 | return collect($this->values()) 162 | ->filter(function ($value, $key) { 163 | return empty($this->only) 164 | ? true 165 | : in_array($key, $this->only); 166 | }) 167 | ->reject(function ($value, $key) { 168 | return empty($this->except) 169 | ? false 170 | : in_array($key, $this->except); 171 | }) 172 | ->merge($this->with) 173 | ->mapWithKeys(function ($value, $key) { 174 | return [ 175 | $key => ($value instanceof Closure) ? App::call($value) : $value, 176 | ]; 177 | }) 178 | ->mapWithKeys(function ($value, $key) { 179 | return [ 180 | $key => ($value instanceof Arrayable) ? $value->toArray() : $value, 181 | ]; 182 | }) 183 | ->all(); 184 | } 185 | 186 | public function toArray(): ?array 187 | { 188 | return $this->get(); 189 | } 190 | 191 | public function whenLoaded(string $relationship) 192 | { 193 | if (! $this->resource->relationLoaded($relationship)) { 194 | return; 195 | } 196 | 197 | return $this->resource->{$relationship}; 198 | } 199 | 200 | protected function noResourceSpecified() 201 | { 202 | return $this->withoutResource === false 203 | && is_null($this->resource) 204 | && is_null($this->collection) 205 | && is_null($this->paginationCollection); 206 | } 207 | 208 | protected function buildCollection(): array 209 | { 210 | return $this->mapCollectionResource($this->collection)->all(); 211 | } 212 | 213 | protected function buildPaginationCollection(): array 214 | { 215 | $paginatedResources = $this->paginationCollection 216 | ->setCollection($this->mapCollectionResource( 217 | $this->paginationCollection->getCollection() 218 | )) 219 | ->toArray(); 220 | 221 | return collect($paginatedResources) 222 | ->mergeRecursive($this->appends) 223 | ->all(); 224 | } 225 | 226 | protected function mapCollectionResource(Collection $collection): Collection 227 | { 228 | return $collection->map(function ($resource) { 229 | $presenter = new static($resource); 230 | $presenter->only = $this->only; 231 | $presenter->except = $this->except; 232 | if (count($this->withCallback)) { 233 | foreach ($this->withCallback as $callable) { 234 | $presenter->with($callable); 235 | } 236 | } 237 | 238 | return $presenter->get(); 239 | }); 240 | } 241 | 242 | protected function validateKeys(): void 243 | { 244 | $validKeys = array_merge( 245 | array_keys($this->values()), 246 | array_keys($this->with) 247 | ); 248 | 249 | $this->allKeysAreValid('only', $validKeys); 250 | $this->allKeysAreValid('except', $validKeys); 251 | } 252 | 253 | protected function allKeysAreValid($method, $validKeys): void 254 | { 255 | if (count($invalidKeys = array_diff($this->{$method}, $validKeys))) { 256 | throw InvalidPresenterKeys::keysNotDefined($invalidKeys, $method); 257 | } 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/FlexiblePresenterServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 13 | $this->commands([ 14 | FlexiblePresenterMakeCommand::class, 15 | ]); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/NoSpecifiedResource.php: -------------------------------------------------------------------------------- 1 | $this->id, 13 | ]; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/FlexiblePresenterTest.php: -------------------------------------------------------------------------------- 1 | create(); 33 | 34 | $presenter = PostPresenter::make($post); 35 | 36 | $this->assertInstanceOf(FlexiblePresenter::class, $presenter); 37 | $this->assertEquals($presenter->resource->id, $post->id); 38 | } 39 | 40 | /** @test */ 41 | public function new_presenter_instance_instantiated_with_collection_method() 42 | { 43 | $post = factory(Post::class, 3)->create(); 44 | 45 | $presenter = PostPresenter::collection($post); 46 | 47 | $this->assertInstanceOf(FlexiblePresenter::class, $presenter); 48 | $this->assertCount(3, $presenter->collection); 49 | } 50 | 51 | /** @test */ 52 | public function new_presenter_instance_instantiated_with_new_method() 53 | { 54 | $presenter = StandalonePresenter::new(); 55 | 56 | $this->assertInstanceOf(FlexiblePresenter::class, $presenter); 57 | $this->assertNull($presenter->resource); 58 | $this->assertNull($presenter->collection); 59 | 60 | $this->assertEquals(['foo' => 'bar'], $presenter->toArray()); 61 | } 62 | 63 | /** @test */ 64 | public function new_presenter_instance_instantiated_input_paginator() 65 | { 66 | $currentPage = 1; 67 | $perPage = 2; 68 | 69 | $posts = factory(Post::class, 3)->create(); 70 | 71 | $paginationCollection = new Paginator( 72 | $posts->forPage($currentPage, $perPage), 73 | $perPage, 74 | $currentPage 75 | ); 76 | 77 | $presenter = PostPresenter::collection($paginationCollection); 78 | 79 | $this->assertInstanceOf(FlexiblePresenter::class, $presenter); 80 | $this->assertInstanceOf(Paginator::class, $presenter->paginationCollection); 81 | $this->assertCount(2, $presenter->paginationCollection->getCollection()); 82 | } 83 | 84 | /** @test */ 85 | public function new_presenter_instance_instantiated_input_length_aware_paginator() 86 | { 87 | $currentPage = 1; 88 | $perPage = 2; 89 | 90 | $posts = factory(Post::class, 3)->create(); 91 | 92 | $paginationCollection = new LengthAwarePaginator( 93 | $posts->forPage($currentPage, $perPage), 94 | $posts->count(), 95 | $perPage, 96 | $currentPage 97 | ); 98 | 99 | $presenter = PostPresenter::collection($paginationCollection); 100 | 101 | $this->assertInstanceOf(FlexiblePresenter::class, $presenter); 102 | $this->assertInstanceOf(LengthAwarePaginator::class, $presenter->paginationCollection); 103 | $this->assertCount(2, $presenter->paginationCollection->getCollection()); 104 | } 105 | 106 | /** @test */ 107 | public function new_keys_can_be_added_using_with_method_when_presenting_resource() 108 | { 109 | $post = $this->createPostAndComments(); 110 | 111 | $return = PostPresenter::make($post) 112 | ->with(function ($post) { 113 | return ['new_key' => 'foo']; 114 | }) 115 | ->get(); 116 | 117 | $this->assertEquals([ 118 | 'id' => $post->id, 119 | 'title' => $post->title, 120 | 'body' => $post->body, 121 | 'published_at' => $post->published_at->toDateString(), 122 | 'comment_count' => 3, 123 | 'new_key' => 'foo', 124 | ], $return); 125 | } 126 | 127 | /** @test */ 128 | public function can_use_chain_with_method_when_presenting_resource() 129 | { 130 | $post = $this->createPostAndComments(); 131 | 132 | $return = PostPresenter::make($post) 133 | ->with(function ($post) { 134 | return [ 135 | 'new_key_1' => 'foo', 136 | 'new_key_2' => 'bar', 137 | ]; 138 | }) 139 | ->with(function ($post) { 140 | return ['new_key_2' => 'baz']; 141 | }) 142 | ->get(); 143 | 144 | $this->assertEquals([ 145 | 'id' => $post->id, 146 | 'title' => $post->title, 147 | 'body' => $post->body, 148 | 'published_at' => $post->published_at->toDateString(), 149 | 'comment_count' => 3, 150 | 'new_key_1' => 'foo', 151 | 'new_key_2' => 'baz', 152 | ], $return); 153 | } 154 | 155 | /** @test */ 156 | public function keys_are_overwritten_using_with_method_when_presenting_resource() 157 | { 158 | $post = $this->createPostAndComments(); 159 | 160 | $return = PostPresenter::make($post) 161 | ->with(function ($post) { 162 | return ['published_at' => $post->published_at->toDayDateTimeString()]; 163 | }) 164 | ->get(); 165 | 166 | $this->assertEquals([ 167 | 'id' => $post->id, 168 | 'title' => $post->title, 169 | 'body' => $post->body, 170 | 'published_at' => $post->published_at->toDayDateTimeString(), 171 | 'comment_count' => 3, 172 | ], $return); 173 | } 174 | 175 | /** @test */ 176 | public function can_chain_with_method() 177 | { 178 | $post = $this->createPostAndComments(); 179 | 180 | $return = CommentPresenter::collection($post->comments) 181 | ->only('id') 182 | ->with(function ($comment) { 183 | return [ 184 | 'new_key_1' => 'foo', 185 | 'new_key_2' => 'bar', 186 | ]; 187 | }) 188 | ->with(function ($comment) { 189 | return [ 190 | 'new_key_2' => 'baz', 191 | ]; 192 | }) 193 | ->get(); 194 | 195 | $this->assertEquals([ 196 | ['id' => 1, 'new_key_1' => 'foo', 'new_key_2' => 'baz'], 197 | ['id' => 2, 'new_key_1' => 'foo', 'new_key_2' => 'baz'], 198 | ['id' => 3, 'new_key_1' => 'foo', 'new_key_2' => 'baz'], 199 | ], $return); 200 | } 201 | 202 | /** @test */ 203 | public function only_given_keys_are_returned_when_presenting_resource() 204 | { 205 | $post = factory(Post::class)->create(); 206 | $presenter = PostPresenter::make($post); 207 | 208 | $usingStringsReturn = $presenter 209 | ->only('id', 'title') 210 | ->get(); 211 | 212 | $this->assertEquals(['id' => $post->id, 'title' => $post->title], $usingStringsReturn); 213 | 214 | $usingArrayReturn = $presenter 215 | ->only(['title', 'body']) 216 | ->get(); 217 | 218 | $this->assertEquals(['title' => $post->title, 'body' => $post->body], $usingArrayReturn); 219 | } 220 | 221 | /** @test */ 222 | public function keys_except_those_given_are_returned_when_presenting_resource() 223 | { 224 | $post = factory(Post::class)->create(); 225 | $presenter = PostPresenter::make($post); 226 | 227 | $usingStringsReturn = $presenter 228 | ->except('body', 'published_at', 'comment_count') 229 | ->get(); 230 | 231 | $this->assertEquals([ 232 | 'id' => $post->id, 233 | 'title' => $post->title, 234 | ], $usingStringsReturn); 235 | 236 | $usingArrayReturn = $presenter 237 | ->except(['id', 'title', 'comment_count']) 238 | ->get(); 239 | 240 | $this->assertEquals([ 241 | 'body' => $post->body, 242 | 'published_at' => $post->published_at->toDateString(), 243 | ], $usingArrayReturn); 244 | } 245 | 246 | public function lazy_keys_are_not_evaluated_unless_requested() 247 | { 248 | $post = $this->createPostAndComments(); 249 | 250 | $presenter = PostPresenter::make($post); 251 | 252 | // Comments have not been loaded on post model 253 | // The following call should result in no DB queries 254 | 255 | DB::enableQueryLog(); 256 | 257 | $presenter->only('id')->get(); 258 | 259 | $this->assertCount(0, count(DB::getQueryLog())); 260 | 261 | DB::flushQueryLog(); 262 | 263 | // Comments will be loaded on post model when comment_count is requested 264 | // The following call should result in one DB query 265 | 266 | DB::enableQueryLog(); 267 | 268 | $presenter->only('id', 'comment_count')->get(); 269 | 270 | $this->assertCount(1, count(DB::getQueryLog())); 271 | 272 | DB::flushQueryLog(); 273 | } 274 | 275 | /** @test */ 276 | public function all_keys_are_returned_for_resource() 277 | { 278 | $post = $this->createPostAndComments(); 279 | 280 | $return = PostPresenter::make($post)->all(); 281 | 282 | $this->assertEquals([ 283 | 'id' => $post->id, 284 | 'title' => $post->title, 285 | 'body' => $post->body, 286 | 'published_at' => $post->published_at->toDateString(), 287 | 'comment_count' => 3, 288 | ], $return); 289 | } 290 | 291 | /** @test */ 292 | public function null_returned_when_no_resource_passed_to_constructor() 293 | { 294 | $makeReturn = PostPresenter::make(null)->get(); 295 | 296 | $this->assertNull($makeReturn); 297 | 298 | $collectionReturn = PostPresenter::collection(null)->get(); 299 | 300 | $this->assertNull($collectionReturn); 301 | } 302 | 303 | /** @test */ 304 | public function only_keys_for_preset_are_returned() 305 | { 306 | $post = factory(Post::class)->create(); 307 | 308 | $return = PostPresenter::make($post)->preset('summary')->get(); 309 | 310 | $this->assertEquals([ 311 | 'title' => $post->title, 312 | 'body' => $post->body, 313 | ], $return); 314 | } 315 | 316 | /** @test */ 317 | public function invalid_keys_given_to_only_method_trigger_exception() 318 | { 319 | $this->expectException(InvalidPresenterKeys::class); 320 | 321 | $post = factory(Post::class)->create(); 322 | 323 | PostPresenter::make($post)->only('bad_key')->get(); 324 | } 325 | 326 | /** @test */ 327 | public function invalid_keys_given_to_except_method_trigger_exception() 328 | { 329 | $this->expectException(InvalidPresenterKeys::class); 330 | 331 | $post = factory(Post::class)->create(); 332 | 333 | PostPresenter::make($post)->except('bad_key')->get(); 334 | } 335 | 336 | /** @test */ 337 | public function collection_of_models_are_presented() 338 | { 339 | $posts = factory(Post::class, 3)->create(); 340 | 341 | $return = PostPresenter::collection($posts)->only('id')->get(); 342 | 343 | $this->assertEquals([ 344 | ['id' => 1], ['id' => 2], ['id' => 3], 345 | ], $return); 346 | } 347 | 348 | /** @test */ 349 | public function paginator_collection_of_models_are_presented() 350 | { 351 | $posts = factory(Post::class, 3)->create(); 352 | 353 | $paginationCollection = new Paginator( 354 | $posts->forPage($currentPage = 1, $perPage = 2), 355 | $perPage, 356 | $currentPage 357 | ); 358 | 359 | $return = PostPresenter::collection($paginationCollection)->only('id')->get(); 360 | 361 | $this->assertEquals([ 362 | 'current_page' => 1, 363 | 'data' => [ 364 | ['id' => 1], 365 | ['id' => 2], 366 | ], 367 | 'first_page_url' => '/?page=1', 368 | 'from' => 1, 369 | 'next_page_url' => null, 370 | 'path' => '/', 371 | 'per_page' => 2, 372 | 'prev_page_url' => null, 373 | 'to' => 2, 374 | ], $return); 375 | } 376 | 377 | /** @test */ 378 | public function length_aware_paginator_collection_of_models_are_presented() 379 | { 380 | $posts = factory(Post::class, 3)->create(); 381 | 382 | $paginationCollection = new LengthAwarePaginator( 383 | $posts->forPage($currentPage = 1, $perPage = 2), 384 | $posts->count(), 385 | $perPage, 386 | $currentPage 387 | ); 388 | 389 | $return = PostPresenter::collection($paginationCollection)->only('id')->get(); 390 | 391 | $this->assertEquals(1, Arr::get($return, 'current_page')); 392 | $this->assertEquals([ 393 | ['id' => 1], 394 | ['id' => 2], 395 | ], Arr::get($return, 'data')); 396 | $this->assertEquals('/?page=1', Arr::get($return, 'first_page_url')); 397 | $this->assertEquals(1, Arr::get($return, 'from')); 398 | $this->assertEquals(2, Arr::get($return, 'last_page')); 399 | $this->assertEquals('/?page=2', Arr::get($return, 'last_page_url')); 400 | $this->assertEquals('/?page=2', Arr::get($return, 'next_page_url')); 401 | $this->assertEquals('/', Arr::get($return, 'path')); 402 | $this->assertEquals('/', Arr::get($return, 'path')); 403 | $this->assertEquals(2, Arr::get($return, 'per_page')); 404 | $this->assertEquals(null, Arr::get($return, 'prev_page_url')); 405 | $this->assertEquals(2, Arr::get($return, 'to')); 406 | $this->assertEquals(3, Arr::get($return, 'total')); 407 | } 408 | 409 | /** @test */ 410 | public function extra_key_value_pairs_are_appended_to_wrapped_presenter_when_keys_do_not_exist() 411 | { 412 | $posts = factory(Post::class, 3)->create(); 413 | 414 | $paginationCollection = new Paginator( 415 | $posts->forPage($currentPage = 1, $perPage = 2), 416 | $perPage, 417 | $currentPage 418 | ); 419 | 420 | $return = PostPresenter::collection($paginationCollection) 421 | ->only('id') 422 | ->appends(['foo' => 'bar', 'baz' => 'qux']) 423 | ->get(); 424 | 425 | $this->assertEquals([ 426 | 'current_page' => 1, 427 | 'data' => [ 428 | ['id' => 1], 429 | ['id' => 2], 430 | ], 431 | 'first_page_url' => '/?page=1', 432 | 'from' => 1, 433 | 'next_page_url' => null, 434 | 'path' => '/', 435 | 'per_page' => 2, 436 | 'prev_page_url' => null, 437 | 'to' => 2, 438 | 'foo' => 'bar', 439 | 'baz' => 'qux', 440 | ], $return); 441 | } 442 | 443 | /** @test */ 444 | public function extra_key_value_pairs_are_appended_to_wrapped_presenter_recursively() 445 | { 446 | $posts = factory(Post::class, 3)->create(); 447 | 448 | $paginationCollection = new CustomPaginator( 449 | $posts->forPage($currentPage = 1, $perPage = 2), 450 | $perPage, 451 | $currentPage 452 | ); 453 | 454 | $return = PostPresenter::collection($paginationCollection) 455 | ->only('id') 456 | ->appends([ 457 | 'foo' => ['test' => 'foo'], 458 | 'links' => ['link_2' => 'bar'], 459 | ]) 460 | ->get(); 461 | 462 | $this->assertEquals([ 463 | 'current_page' => 1, 464 | 'data' => [ 465 | ['id' => 1], 466 | ['id' => 2], 467 | ], 468 | 'first_page_url' => '/?page=1', 469 | 'from' => 1, 470 | 'next_page_url' => null, 471 | 'path' => '/', 472 | 'per_page' => 2, 473 | 'prev_page_url' => null, 474 | 'to' => 2, 475 | 'foo' => [ 476 | 'test' => 'foo', 477 | ], 478 | 'links' => [ 479 | 'link_1' => 'foo', 480 | 'link_2' => 'bar', 481 | ], 482 | ], $return); 483 | } 484 | 485 | /** @test */ 486 | public function another_presenter_can_be_used_as_a_value_when_presenting_resource() 487 | { 488 | $post = $this->createPostAndComments(); 489 | 490 | $return = PostPresenter::make($post)->only('title')->with(function ($post) { 491 | return [ 492 | 'comments' => CommentPresenter::collection($post->comments)->only('id'), 493 | ]; 494 | })->get(); 495 | 496 | $this->assertEquals([ 497 | 'title' => $post->title, 498 | 'comments' => [ 499 | ['id' => 1], 500 | ['id' => 2], 501 | ['id' => 3], 502 | ], 503 | ], $return); 504 | } 505 | 506 | /** @test */ 507 | public function returns_null_if_relation_not_loaded_on_resource() 508 | { 509 | $post = factory(Post::class)->create(); 510 | 511 | $return = PostPresenter::make($post)->preset('conditionalRelations')->get(); 512 | 513 | $this->assertNull($return['comments']); 514 | } 515 | 516 | /** @test */ 517 | public function returns_presented_relation_if_loaded_on_resource() 518 | { 519 | $post = $this->createPostAndComments(); 520 | 521 | $post->load('comments'); 522 | 523 | $return = PostPresenter::make($post)->preset('conditionalRelations')->get(); 524 | 525 | $this->assertCount(3, $return['comments']); 526 | } 527 | 528 | /** @test */ 529 | public function can_use_pivot_data_on_nested_presenter_resource() 530 | { 531 | $post = factory(Post::class)->create(); 532 | $images = factory(Image::class, 3)->create(); 533 | 534 | $attachments = $images->mapWithKeys(function ($image) { 535 | return [$image->id => ['test' => 'foo_'.$image->id]]; 536 | })->all(); 537 | $post->images()->attach($attachments); 538 | $post->load('images'); 539 | 540 | $return = PostPresenter::make($post)->preset('pivotRelations')->get(); 541 | 542 | $this->assertCount(3, $return['images']); 543 | $this->assertEquals([ 544 | 'id' => 1, 545 | 'url' => 'foo', 546 | 'test' => 'foo_1', 547 | ], $return['images'][0]); 548 | $this->assertEquals([ 549 | 'id' => 2, 550 | 'url' => 'foo', 551 | 'test' => 'foo_2', 552 | ], $return['images'][1]); 553 | $this->assertEquals([ 554 | 'id' => 3, 555 | 'url' => 'foo', 556 | 'test' => 'foo_3', 557 | ], $return['images'][2]); 558 | } 559 | 560 | private function createPostAndComments() 561 | { 562 | $post = factory(Post::class)->create(); 563 | factory(Comment::class, 3)->create(['post_id' => $post->id]); 564 | 565 | return $post; 566 | } 567 | } 568 | -------------------------------------------------------------------------------- /tests/PresenterMakeCommandTestTest.php: -------------------------------------------------------------------------------- 1 | artisan('make:presenter', [ 13 | 'name' => 'PostPresenter', 14 | '--force' => true, 15 | ])->assertExitCode(0); 16 | 17 | $shouldOutputFilePath = $this->app['path'].'/Presenters/PostPresenter.php'; 18 | 19 | $this->assertTrue(File::exists($shouldOutputFilePath), 'File exists in default app/Presenters folder'); 20 | 21 | $contents = File::get($shouldOutputFilePath); 22 | 23 | $this->assertStringContainsString('namespace App\Presenters;', $contents); 24 | 25 | $this->assertStringContainsString('class PostPresenter extends FlexiblePresenter', $contents); 26 | } 27 | 28 | /** @test */ 29 | public function it_can_create_a_view_model_with_a_custom_namespace() 30 | { 31 | $this->artisan('make:presenter', [ 32 | 'name' => 'Blog/PostPresenter', 33 | '--force' => true, 34 | ])->assertExitCode(0); 35 | 36 | $shouldOutputFilePath = $this->app['path'].'/Blog/PostPresenter.php'; 37 | 38 | $this->assertTrue(File::exists($shouldOutputFilePath), 'File exists in custom app/Blog folder'); 39 | 40 | $contents = File::get($shouldOutputFilePath); 41 | 42 | $this->assertStringContainsString('namespace App\Blog;', $contents); 43 | 44 | $this->assertStringContainsString('class PostPresenter extends FlexiblePresenter', $contents); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Support/Concerns/HasFactory.php: -------------------------------------------------------------------------------- 1 | count(is_numeric($parameters[0] ?? null) ? $parameters[0] : null) 19 | ->state(is_array($parameters[0] ?? null) ? $parameters[0] : ($parameters[1] ?? [])); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Support/Factories/CommentFactory.php: -------------------------------------------------------------------------------- 1 | define(Comment::class, function (Faker\Generator $faker) { 6 | return [ 7 | 'body' => 'bar', 8 | ]; 9 | }); 10 | -------------------------------------------------------------------------------- /tests/Support/Factories/ImageFactory.php: -------------------------------------------------------------------------------- 1 | define(Image::class, function (Faker\Generator $faker) { 6 | return [ 7 | 'url' => 'foo', 8 | ]; 9 | }); 10 | -------------------------------------------------------------------------------- /tests/Support/Factories/PostFactory.php: -------------------------------------------------------------------------------- 1 | define(Post::class, function (Faker\Generator $faker) { 7 | return [ 8 | 'title' => 'foo', 9 | 'body' => 'bar', 10 | 'published_at' => Carbon::now(), 11 | ]; 12 | }); 13 | -------------------------------------------------------------------------------- /tests/Support/Models/Comment.php: -------------------------------------------------------------------------------- 1 | belongsToMany(Post::class) 17 | ->withPivot('test'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/Support/Models/Post.php: -------------------------------------------------------------------------------- 1 | hasMany(Comment::class); 17 | } 18 | 19 | public function images() 20 | { 21 | return $this->belongsToMany(Image::class) 22 | ->withPivot('test'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Support/Paginators/CustomPaginator.php: -------------------------------------------------------------------------------- 1 | $this->currentPage(), 14 | 'data' => $this->items->toArray(), 15 | 'first_page_url' => $this->url(1), 16 | 'from' => $this->firstItem(), 17 | 'next_page_url' => $this->nextPageUrl(), 18 | 'path' => $this->path(), 19 | 'per_page' => $this->perPage(), 20 | 'prev_page_url' => $this->previousPageUrl(), 21 | 'to' => $this->lastItem(), 22 | 'links' => [ 23 | 'link_1' => 'foo', 24 | ], 25 | ]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Support/Presenters/CommentPresenter.php: -------------------------------------------------------------------------------- 1 | $this->id, 13 | 'body' => $this->body, 14 | ]; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/Support/Presenters/ImagePresenter.php: -------------------------------------------------------------------------------- 1 | $this->id, 13 | 'url' => $this->url, 14 | 'test' => $this->pivot->test, 15 | ]; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/Support/Presenters/PostPresenter.php: -------------------------------------------------------------------------------- 1 | $this->id, 13 | 'title' => $this->title, 14 | 'body' => $this->body, 15 | 'published_at' => $this->published_at->toDateString(), 16 | 'comment_count' => function () { 17 | return $this->comments->count(); 18 | }, 19 | ]; 20 | } 21 | 22 | public function presetSummary() 23 | { 24 | return $this->only('title', 'body'); 25 | } 26 | 27 | public function presetConditionalRelations() 28 | { 29 | return $this->with(function () { 30 | return [ 31 | 'comments' => CommentPresenter::collection($this->whenLoaded('comments')), 32 | ]; 33 | }); 34 | } 35 | 36 | public function presetPivotRelations() 37 | { 38 | return $this->with(function () { 39 | return [ 40 | 'images' => ImagePresenter::collection($this->whenLoaded('images')), 41 | ]; 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Support/Presenters/StandalonePresenter.php: -------------------------------------------------------------------------------- 1 | 'bar', 13 | ]; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | basePath = realpath(__DIR__.'/..'); 17 | $this->withFactories($this->basePath.'/tests/Support/Factories'); 18 | $this->setUpDatabase($this->app); 19 | } 20 | 21 | protected function getPackageProviders($app): array 22 | { 23 | return [ 24 | FlexiblePresenterServiceProvider::class, 25 | ]; 26 | } 27 | 28 | protected function getEnvironmentSetUp($app) 29 | { 30 | $app['config']->set('database.default', 'sqlite'); 31 | $app['config']->set('database.connections.sqlite', [ 32 | 'driver' => 'sqlite', 33 | 'database' => ':memory:', 34 | 'prefix' => '', 35 | ]); 36 | } 37 | 38 | protected function setUpDatabase($app) 39 | { 40 | $app['db'] 41 | ->connection() 42 | ->getSchemaBuilder() 43 | ->create('posts', function (Blueprint $table) { 44 | $table->bigIncrements('id'); 45 | $table->string('title')->nullable(); 46 | $table->string('body')->nullable(); 47 | $table->dateTime('published_at')->nullable(); 48 | $table->timestamps(); 49 | }); 50 | 51 | $app['db'] 52 | ->connection() 53 | ->getSchemaBuilder() 54 | ->create('comments', function (Blueprint $table) { 55 | $table->bigIncrements('id'); 56 | $table->bigInteger('post_id')->nullable(); 57 | $table->string('body')->nullable(); 58 | $table->timestamps(); 59 | }); 60 | 61 | $app['db'] 62 | ->connection() 63 | ->getSchemaBuilder() 64 | ->create('images', function (Blueprint $table) { 65 | $table->bigIncrements('id'); 66 | $table->string('url')->nullable(); 67 | $table->timestamps(); 68 | }); 69 | 70 | $app['db'] 71 | ->connection() 72 | ->getSchemaBuilder() 73 | ->create('image_post', function (Blueprint $table) { 74 | $table->bigInteger('post_id')->nullable(); 75 | $table->bigInteger('image_id')->nullable(); 76 | $table->string('test')->nullable(); 77 | }); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | const mix = require('laravel-mix'); 2 | 3 | /* 4 | |-------------------------------------------------------------------------- 5 | | Mix Asset Management 6 | |-------------------------------------------------------------------------- 7 | | 8 | | Mix provides a clean, fluent API for defining some Webpack build steps 9 | | for your Laravel application. By default, we are compiling the Sass 10 | | file for the application as well as bundling up all the JS files. 11 | | 12 | */ 13 | 14 | mix 15 | .setPublicPath('public') 16 | .js('resources/js/app.js', 'public/js') 17 | .sass('resources/scss/app.scss', 'public/css') 18 | .version(); --------------------------------------------------------------------------------