diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index 0c0e33c..e1434b3 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -2,10 +2,33 @@ namespace App\Http\Controllers; +use App\Product; +use App\Http\Resources\SearchCollection; +use App\Http\Requests\Product\FetchRequest; +use App\Http\Resources\Product as ProductResource; +use App\Repositories\Contracts\ProductRepositoryContract; + use Illuminate\View\View; +use Illuminate\Support\Collection; +use Illuminate\Http\RedirectResponse; class ProductController extends Controller { + /** + * @var \App\Repositories\Contracts\ProductRepositoryContract + */ + private ProductRepositoryContract $repository; + + /** + * ProductController constructor. + * + * @param \App\Repositories\Contracts\ProductRepositoryContract $repository + */ + public function __construct(ProductRepositoryContract $repository) + { + $this->repository = $repository; + } + /** * Display products page. * @@ -13,6 +36,46 @@ class ProductController extends Controller */ public function index(): View { - return view('product.index'); + return view('product.index') + ->with('perPage', new Collection(config('system.per_page'))) + ->with('defaultPerPage', config('system.default_per_page')); + } + + /** + * Fetch records. + * + * @param \App\Http\Requests\Product\FetchRequest $request + * @return \App\Http\Resources\SearchCollection + */ + public function fetch(FetchRequest $request): SearchCollection + { + return new SearchCollection( + $this->repository->search($request), ProductResource::class + ); + } + + /** + * Get edit form. + * + * @param \App\Product $product + * @return \Illuminate\View\View + */ + public function edit(Product $product): View + { + return view('product.edit')->with('product', $product); + } + + /** + * Remove record. + * + * @param \App\Product $product + * @return \Illuminate\Http\RedirectResponse + * @throws \Exception + */ + public function destroy(Product $product): RedirectResponse + { + $product->delete(); + + return new RedirectResponse(route('product')); } } diff --git a/app/Http/Requests/Product/FetchRequest.php b/app/Http/Requests/Product/FetchRequest.php new file mode 100644 index 0000000..580f40e --- /dev/null +++ b/app/Http/Requests/Product/FetchRequest.php @@ -0,0 +1,29 @@ + [ + 'required', + Rule::in(config('system.per_page')), + ], + 'page' => [ + 'required', + 'integer', + ], + 'order_by' => [ + 'required', + 'string', + ], + 'order_field' => [ + Rule::in($this->orderByFields()), + ], + 'order_direction' => [ + Rule::in(['asc', 'desc']), + ], + ], $this->searchParams()); + } + + /** + * Get search parameters. + * + * @return array + */ + protected function searchParams(): array + { + return [ + 'search' => [ + 'present', + ], + ]; + } + + /** + * Get list of available ORDER BY fields. + * + * @return array + */ + abstract protected function orderByFields(): array; + + /** + * Get default ORDER BY field. + * + * @return string + */ + abstract protected function defaultOrderByField(): string; + + /** + * Get default ORDER BY direction. + * + * @return string + */ + protected function defaultOrderByDirection(): string + { + return 'asc'; + } + + /** + * Prepare the data for validation. + * + * @return void + */ + protected function prepareForValidation(): void + { + $this->order_by = $this->order_by ?? + $this->defaultOrderByField().':'.$this->defaultOrderByDirection(); + + [$order, $direction] = explode(':', $this->order_by); + + $this->offsetSet('order_field', $order); + $this->offsetSet('order_direction', $direction); + + $this->per_page = (int)($this->per_page ?? config('system.default_per_page')); + $this->page = (int)($this->page ?? 1); + } + + /** + * Get request parameters. + * + * @return \App\Search\Params + */ + public function requestParams(): Params + { + return new Params( + $this->payload(), $this->per_page, $this->page, $this->order_by + ); + } + + /** + * Get search payload. + * + * @return \App\Search\Payloads\Payload + */ + protected function payload(): Payload + { + return new SearchOnlyPayload($this->search ?? null); + } + + /** + * Get request ORDER BY. + * + * @return \App\Search\OrderBy + */ + public function requestOrder(): OrderBy + { + return new OrderBy($this->order_field, $this->order_direction); + } +} diff --git a/app/Http/Resources/Product.php b/app/Http/Resources/Product.php new file mode 100644 index 0000000..a5830f7 --- /dev/null +++ b/app/Http/Resources/Product.php @@ -0,0 +1,34 @@ + $this->id, + 'name' => $this->name, + 'price' => $this->price, + 'edit_url' => route('product.edit', $this->id), + 'destroy_url' => route('product.destroy', $this->id), + ]; + } +} diff --git a/app/Http/Resources/SearchCollection.php b/app/Http/Resources/SearchCollection.php new file mode 100644 index 0000000..330cb1e --- /dev/null +++ b/app/Http/Resources/SearchCollection.php @@ -0,0 +1,52 @@ +collects = $collects; + $this->meta = $search->meta(); + $this->params = $search->params(); + + parent::__construct($search->records()); + } + + /** + * Transform the resource collection into an array. + * + * @param \Illuminate\Http\Request $request + * @return array + */ + public function toArray($request): array + { + return [ + 'records' => $this->collection, + 'params' => $this->params->toArray(), + 'meta' => $this->meta->toArray(), + ]; + } +} diff --git a/app/Product.php b/app/Product.php new file mode 100644 index 0000000..ffab4cb --- /dev/null +++ b/app/Product.php @@ -0,0 +1,38 @@ + 'float', + ]; +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ee8ca5b..b1e9de4 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use Illuminate\Support\ServiceProvider; +use Illuminate\Http\Resources\Json\Resource; class AppServiceProvider extends ServiceProvider { @@ -11,9 +12,8 @@ class AppServiceProvider extends ServiceProvider * * @return void */ - public function register() + public function register(): void { - // } /** @@ -21,8 +21,8 @@ public function register() * * @return void */ - public function boot() + public function boot(): void { - // + Resource::withoutWrapping(); } } diff --git a/app/Providers/RepositoryServiceProvider.php b/app/Providers/RepositoryServiceProvider.php new file mode 100644 index 0000000..d8e2442 --- /dev/null +++ b/app/Providers/RepositoryServiceProvider.php @@ -0,0 +1,21 @@ +app->bind(ProductRepositoryContract::class, ProductRepository::class); + } +} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 527eee3..b71b774 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -2,8 +2,9 @@ namespace App\Providers; -use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; +use App\Product; use Illuminate\Support\Facades\Route; +use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; class RouteServiceProvider extends ServiceProvider { @@ -30,9 +31,11 @@ class RouteServiceProvider extends ServiceProvider */ public function boot() { - // - parent::boot(); + + Route::patterns(['product_id' => '[0-9]+']); + + Route::model('product_id', Product::class); } /** diff --git a/app/Repositories/Contracts/ProductRepositoryContract.php b/app/Repositories/Contracts/ProductRepositoryContract.php new file mode 100644 index 0000000..f4f9b1f --- /dev/null +++ b/app/Repositories/Contracts/ProductRepositoryContract.php @@ -0,0 +1,17 @@ +requestParams(), $request->requestOrder() + ); + } +} diff --git a/app/Search/Meta.php b/app/Search/Meta.php new file mode 100644 index 0000000..52a136a --- /dev/null +++ b/app/Search/Meta.php @@ -0,0 +1,62 @@ +total = $total; + $this->lastPage = $lastPage; + $this->prevPage = $prevPage; + $this->nextPage = $nextPage; + } + + /** + * @inheritDoc + */ + public function toArray(): array + { + return [ + 'total' => $this->total, + 'prev_page' => $this->prevPage, + 'next_page' => $this->nextPage, + 'last_page' => $this->lastPage, + ]; + } +} diff --git a/app/Search/OrderBy.php b/app/Search/OrderBy.php new file mode 100644 index 0000000..8c5369c --- /dev/null +++ b/app/Search/OrderBy.php @@ -0,0 +1,28 @@ +field = $field; + $this->direction = $direction; + } +} diff --git a/app/Search/Params.php b/app/Search/Params.php new file mode 100644 index 0000000..0232955 --- /dev/null +++ b/app/Search/Params.php @@ -0,0 +1,58 @@ +page = $page; + $this->search = $search; + $this->perPage = $perPage; + $this->orderBy = $orderBy; + } + + /** + * @inheritDoc + */ + public function toArray(): array + { + return array_merge([ + 'per_page' => $this->perPage, + 'page' => $this->page, + 'order_by' => $this->orderBy, + ], $this->search->toArray()); + } +} diff --git a/app/Search/Payloads/Payload.php b/app/Search/Payloads/Payload.php new file mode 100644 index 0000000..446dd8a --- /dev/null +++ b/app/Search/Payloads/Payload.php @@ -0,0 +1,15 @@ +search = $search; + } + + /** + * @inheritDoc + */ + public function hasFilter(): bool + { + return (bool)$this->search; + } + + /** + * @inheritDoc + */ + public function toArray(): array + { + return [ + 'search' => (string)$this->search, + ]; + } +} diff --git a/app/Search/Queries/EloquentSearch.php b/app/Search/Queries/EloquentSearch.php new file mode 100644 index 0000000..1c935a1 --- /dev/null +++ b/app/Search/Queries/EloquentSearch.php @@ -0,0 +1,58 @@ +queryWithoutLimit()->count('id'); + } + + /** + * Get query without limit. + * + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function queryWithoutLimit(): Builder + { + return $this->query()->orderBy($this->order->field, $this->order->direction); + } + + /** + * Get sql query. + * + * @return \Illuminate\Database\Eloquent\Builder + */ + abstract protected function query(): Builder; + + /** + * Get records. + * + * @return \Illuminate\Support\Collection + */ + public function records(): Collection + { + return $this->limit($this->queryWithoutLimit())->get(); + } + + /** + * Add limit query. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function limit(Builder $query): Builder + { + return $query->take($this->params->perPage) + ->skip(($this->params->page - 1) * $this->params->perPage); + } +} diff --git a/app/Search/Queries/ProductSearch.php b/app/Search/Queries/ProductSearch.php new file mode 100644 index 0000000..510394e --- /dev/null +++ b/app/Search/Queries/ProductSearch.php @@ -0,0 +1,26 @@ +params->search->hasFilter()) { + $query->where('name', 'like', '%'.$this->params->search->search.'%'); + } + + return $query; + } +} diff --git a/app/Search/Queries/Search.php b/app/Search/Queries/Search.php new file mode 100644 index 0000000..b39ae74 --- /dev/null +++ b/app/Search/Queries/Search.php @@ -0,0 +1,113 @@ +order = $order; + $this->params = $params; + } + + /** + * Get request parameters. + * + * @return \App\Search\Params + */ + public function params(): Params + { + return $this->params; + } + + /** + * Get meta. + * + * @return \App\Search\Meta + */ + public function meta(): Meta + { + $total = $this->total(); + + $lastPage = $this->lastPage($total); + + if ($lastPage < $this->params->page) { + $this->params->page = $lastPage; + } + + return new Meta( + $total, + $lastPage, + $this->prevPage(), + $this->nextPage($lastPage) + ); + } + + /** + * Get total number of records. + * + * @return int + */ + abstract public function total(): int; + + /** + * Get records. + * + * @return \Illuminate\Support\Collection + */ + abstract public function records(): Collection; + + /** + * Get last page. + * + * @param int $total + * @return int + */ + protected function lastPage(int $total): int + { + return ceil($total / $this->params->perPage) ?: 1; + } + + /** + * Get previous page. + * + * @return int|null + */ + protected function prevPage(): ?int + { + return $this->params->page === 1 ? null : $this->params->page - 1; + } + + /** + * Get next page. + * + * @param int $lastPage + * @return int|null + */ + protected function nextPage(int $lastPage): ?int + { + return $this->params->page < $lastPage ? $this->params->page + 1 : null; + } +} diff --git a/config/app.php b/config/app.php index c9960cd..5a69efa 100644 --- a/config/app.php +++ b/config/app.php @@ -174,6 +174,7 @@ // App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\RouteServiceProvider::class, + App\Providers\RepositoryServiceProvider::class, ], diff --git a/config/system.php b/config/system.php new file mode 100644 index 0000000..5299764 --- /dev/null +++ b/config/system.php @@ -0,0 +1,8 @@ + [10, 20, 30, 50], + + 'default_per_page' => 10, +]; diff --git a/database/seeds/DatabaseSeeder.php b/database/seeds/DatabaseSeeder.php index 91cb6d1..bcadc4a 100644 --- a/database/seeds/DatabaseSeeder.php +++ b/database/seeds/DatabaseSeeder.php @@ -11,6 +11,6 @@ class DatabaseSeeder extends Seeder */ public function run() { - // $this->call(UsersTableSeeder::class); + $this->call(ProductSeeder::class); } } diff --git a/database/seeds/ProductSeeder.php b/database/seeds/ProductSeeder.php new file mode 100644 index 0000000..50a09f1 --- /dev/null +++ b/database/seeds/ProductSeeder.php @@ -0,0 +1,18 @@ +create(); + } +} diff --git a/phpunit.xml b/phpunit.xml index 7b127c3..030cdb5 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -9,7 +9,7 @@ convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false" - stopOnFailure="false"> + stopOnFailure="true"> ./tests/Unit diff --git a/resources/js/app.js b/resources/js/app.js index ceebf61..a01150c 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,19 +1,20 @@ import Vue from 'vue'; -import AngleLeft from './components/Icons/AngleLeft'; -import DoubleAngleLeft from './components/Icons/DoubleAngleLeft'; -import AngleRight from './components/Icons/AngleRight'; -import DoubleAngleRight from './components/Icons/DoubleAngleRight'; -import TimesCircle from './components/Icons/TimesCircle'; +import store from './store/index'; import SelectAngle from './components/Form/SelectAngle'; +import TimesCircle from './components/Icons/TimesCircle'; + +import SearchForm from './components/Search/SearchForm'; +import SearchResults from './components/Search/SearchResults'; +import SearchPagination from './components/Search/SearchPagination'; const app = new Vue({ + store, el: '#app', components: { - AngleLeft, - DoubleAngleLeft, - AngleRight, - DoubleAngleRight, TimesCircle, - SelectAngle + SelectAngle, + SearchForm, + SearchResults, + SearchPagination } }); diff --git a/resources/js/components/Search/SearchForm.vue b/resources/js/components/Search/SearchForm.vue new file mode 100644 index 0000000..d9797d6 --- /dev/null +++ b/resources/js/components/Search/SearchForm.vue @@ -0,0 +1,71 @@ + diff --git a/resources/js/components/Search/SearchPagination.vue b/resources/js/components/Search/SearchPagination.vue new file mode 100644 index 0000000..dcbb22e --- /dev/null +++ b/resources/js/components/Search/SearchPagination.vue @@ -0,0 +1,168 @@ + + diff --git a/resources/js/components/Search/SearchResults.vue b/resources/js/components/Search/SearchResults.vue new file mode 100644 index 0000000..5bce2a1 --- /dev/null +++ b/resources/js/components/Search/SearchResults.vue @@ -0,0 +1,32 @@ + diff --git a/resources/js/core/ApiCaller.js b/resources/js/core/ApiCaller.js new file mode 100644 index 0000000..f0462d6 --- /dev/null +++ b/resources/js/core/ApiCaller.js @@ -0,0 +1,33 @@ +import axios from 'axios'; + +let headers = { + 'X-Requested-With': 'XMLHttpRequest', + Accept: 'application/json', + 'Content-Type': 'application/json' +}; + +let token = document.head.querySelector('meta[name="csrf-token"]'); +if (token) { + headers['X-CSRF-TOKEN'] = token.content; +} + +const client = axios.create({ headers }); + +export default { + request(url, method = 'get', payload = {}, config = {}) { + let data = { + url: url, + method: method.toLowerCase(), + params: {}, + data: {} + }; + + if (['post', 'put', 'patch'].includes(data.method)) { + data.data = payload; + } else { + data.params = payload; + } + + return client.request({ ...data, ...config }); + } +}; diff --git a/resources/js/store/index.js b/resources/js/store/index.js new file mode 100644 index 0000000..cedb5ee --- /dev/null +++ b/resources/js/store/index.js @@ -0,0 +1,10 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; + +import * as search from './modules/search'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + modules: { search } +}); diff --git a/resources/js/store/modules/search.js b/resources/js/store/modules/search.js new file mode 100644 index 0000000..b9504dd --- /dev/null +++ b/resources/js/store/modules/search.js @@ -0,0 +1,110 @@ +import ApiCaller from '../../core/ApiCaller'; + +export const namespaced = true; + +const compact = payload => { + if (typeof payload === 'object') { + payload = JSON.stringify(payload); + } + return payload; +}; + +const expand = payload => { + if (!payload) { + return ''; + } + if (payload[0] === '{') { + payload = JSON.parse(payload); + } + return payload; +}; + +export const state = { + params: {}, + urls: {}, + records: {}, + meta: {} +}; + +export const mutations = { + SET_RECORDS(state, { group, response }) { + state.records = { + ...state.records, + ...{ [group]: response.records } + }; + state.meta = { + ...state.meta, + ...{ [group]: response.meta } + }; + }, + STORE(state, { group, params }) { + state.params = { ...state.params, ...{ [group]: params } }; + window.sessionStorage.setItem(group, compact(params)); + }, + REMOVE(state, group) { + delete state.params[group]; + window.sessionStorage.removeItem(group); + } +}; + +export const actions = { + fetch({ commit, state }, group) { + let url, method; + ({ url, method } = state.urls[group]); + return ApiCaller.request(url, method, state.params[group]).then( + response => { + commit('SET_RECORDS', { group, response: response.data }); + if (response.data.params !== state.params[group]) { + commit('STORE', { group, params: response.data.params }); + } + } + ); + }, + store({ commit, dispatch }, { group, params }) { + commit('STORE', { group, params }); + return dispatch('fetch', group); + }, + remove({ commit }, group) { + commit('REMOVE', group); + }, + reset({ commit, dispatch }, payload) { + return dispatch('store', payload).then(() => { + return state.params[payload.group] || {}; + }); + }, + initiate({ state, dispatch, getters }, { group, url, method, params }) { + state.urls = { ...state.urls, ...{ [group]: { url, method } } }; + + let item = window.sessionStorage.getItem(group); + + if (item) { + state.params = { ...state.params, ...{ [group]: expand(item) } }; + + return dispatch('fetch', group).then(() => { + return state.params[group] || {}; + }); + } + + return dispatch('store', { group, params }).then(() => { + return state.params[group] || {}; + }); + } +}; + +export const getters = { + total: state => group => { + return (state.meta[group] || {}).total || 0; + }, + currentPage: state => group => { + return (state.params[group] || {}).page || 1; + }, + prevPage: state => group => { + return (state.meta[group] || {}).prev_page || null; + }, + nextPage: state => group => { + return (state.meta[group] || {}).next_page || null; + }, + lastPage: state => group => { + return (state.meta[group] || {}).last_page || 1; + } +}; diff --git a/resources/views/product/edit.blade.php b/resources/views/product/edit.blade.php new file mode 100644 index 0000000..f6a43a9 --- /dev/null +++ b/resources/views/product/edit.blade.php @@ -0,0 +1,19 @@ +@extends('app') + +@section('body') + +
+ +
+ +
+ +

Edit {{ $product->name }}

+ +
+ +
+ +
+ +@endsection diff --git a/resources/views/product/index.blade.php b/resources/views/product/index.blade.php index e13eec5..9078a16 100644 --- a/resources/views/product/index.blade.php +++ b/resources/views/product/index.blade.php @@ -12,140 +12,158 @@ -
-
- -
- - - - + + + +
+ +
+ + + + +
-
-
- -
- - + Order by + +
+ + +
-
-
- -
- - + Per page + +
+ + +
-
- + + + -
+ -
-
- Total records: 0 -
-
+
-
-
- Product name +
+
+ Total records: @{{ total }} +
- -
-
-
- Product name -
-
- - Edit - - - Remove - + + +
+ There are no records available
+
-
+ -
- - - - - - - - - - - - - - - - -
+ diff --git a/routes/web.php b/routes/web.php index 82a7d9b..fad2b1a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,3 +3,6 @@ use Illuminate\Support\Facades\Route; Route::get('/', 'ProductController@index')->name('product'); +Route::get('/product/fetch', 'ProductController@fetch')->name('product.fetch'); +Route::get('/product/{product_id}/edit', 'ProductController@edit')->name('product.edit'); +Route::get('/product/{product_id}/destroy', 'ProductController@destroy')->name('product.destroy'); diff --git a/tests/Feature/.gitignore b/tests/Feature/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/tests/Feature/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/Feature/FetchProductTest.php b/tests/Feature/FetchProductTest.php new file mode 100644 index 0000000..79d181e --- /dev/null +++ b/tests/Feature/FetchProductTest.php @@ -0,0 +1,223 @@ + [1, 2, 3]]); + config(['system.default_per_page' => [1]]); + } + + /** + * @test + */ + public function validation_fails_with_empty_request() + { + $response = $this->getJson(route('product.fetch')); + + $response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); + $response->assertExactJson([ + 'errors' => [ + 'search' => [__('validation.present', ['attribute' => 'search'])], + 'order_by' => [__('validation.required', ['attribute' => 'order by'])], + 'per_page' => [__('validation.required', ['attribute' => 'per page'])], + 'page' => [__('validation.required', ['attribute' => 'page'])], + ], + 'message' => 'The given data was invalid.' + ]); + } + + /** + * @test + */ + public function validation_fails_with_invalid_values() + { + $response = $this->getJson(route('product.fetch', [ + 'search' => '', + 'per_page' => 4, + 'page' => 'a', + 'order_by' => 'invalid:sort', + ])); + + $response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); + $response->assertExactJson([ + 'errors' => [ + 'order_field' => [__('validation.in', ['attribute' => 'order field'])], + 'order_direction' => [__('validation.in', ['attribute' => 'order direction'])], + 'per_page' => [__('validation.in', ['attribute' => 'per page'])], + 'page' => [__('validation.integer', ['attribute' => 'page'])], + ], + 'message' => 'The given data was invalid.' + ]); + } + + /** + * @test + */ + public function returns_records_with_default_filter() + { + $products = factory(Product::class, 15)->create()->sortBy('name'); + + + $response = $this->getJson(route('product.fetch', [ + 'search' => '', + 'per_page' => 1, + 'page' => 1, + 'order_by' => 'name:asc', + ])); + + $records = $products->skip(0)->take(1)->map(function (Product $product) { + return array_merge( + $product->only('name', 'id', 'price'), + [ + 'edit_url' => route('product.edit', $product->id), + 'destroy_url' => route('product.destroy', $product->id), + ] + ); + })->values()->toArray(); + + $response->assertStatus(Response::HTTP_OK); + + $response->assertExactJson([ + 'params' => [ + 'search' => '', + 'per_page' => 1, + 'page' => 1, + 'order_by' => 'name:asc', + ], + 'meta' => [ + 'total' => 15, + 'prev_page' => null, + 'next_page' => 2, + 'last_page' => 15, + ], + 'records' => $records, + ]); + } + + /** + * @test + */ + public function returns_filtered_records() + { + $products = collect([ + factory(Product::class)->create([ + 'id' => 1, 'name' => 'Trek Remedy 7 27.5', 'price' => '2200.00' + ]), + factory(Product::class)->create([ + 'id' => 2, 'name' => 'Trek Remedy 8 27.5', 'price' => '2700.00' + ]), + factory(Product::class)->create([ + 'id' => 3, 'name' => 'Trek Remedy 9.7 27.5', 'price' => '3300.00' + ]), + factory(Product::class)->create([ + 'id' => 4, 'name' => 'Yeti SB165 27.5', 'price' => '5599.00' + ]), + factory(Product::class)->create([ + 'id' => 5, 'name' => 'Yeti SB150 29', 'price' => '5699.00' + ]), + factory(Product::class)->create([ + 'id' => 6, 'name' => 'Kona Process 153 CR/DL 27.5', 'price' => '3500.00' + ]), + factory(Product::class)->create([ + 'id' => 7, 'name' => 'Kona Hei Hei 29', 'price' => '3650.00' + ]), + ]); + + $response = $this->getJson(route('product.fetch', [ + 'search' => '27.5', + 'order_by' => 'price:desc', + 'per_page' => 2, + 'page' => 2, + ])); + + $records = $products->whereIn('id', [1, 2, 3, 4, 6])->map(function (Product $product) { + return array_merge( + $product->only('name', 'id', 'price'), + [ + 'edit_url' => route('product.edit', $product->id), + 'destroy_url' => route('product.destroy', $product->id), + ] + ); + })->sortByDesc('price')->skip(2)->take(2)->values()->toArray(); + + $response->assertStatus(Response::HTTP_OK); + + $response->assertExactJson([ + 'params' => [ + 'search' => '27.5', + 'order_by' => 'price:desc', + 'per_page' => 2, + 'page' => 2, + ], + 'meta' => [ + 'total' => 5, + 'prev_page' => 1, + 'next_page' => 3, + 'last_page' => 3, + ], + 'records' => $records, + ]); + } + + /** + * @test + */ + public function overwrites_last_page_if_current_page_exceeds_number_of_available_pages() + { + $products = factory(Product::class, 15)->create()->sortBy('name'); + + + $response = $this->getJson(route('product.fetch', [ + 'search' => '', + 'per_page' => 1, + 'page' => 16, + 'order_by' => 'name:asc', + ])); + + $records = $products->skip(14)->take(1)->map(function (Product $product) { + return array_merge( + $product->only('name', 'id', 'price'), + [ + 'edit_url' => route('product.edit', $product->id), + 'destroy_url' => route('product.destroy', $product->id), + ] + ); + })->values()->toArray(); + + $response->assertStatus(Response::HTTP_OK); + + $response->assertExactJson([ + 'params' => [ + 'search' => '', + 'per_page' => 1, + 'page' => 15, + 'order_by' => 'name:asc', + ], + 'meta' => [ + 'total' => 15, + 'prev_page' => 14, + 'next_page' => null, + 'last_page' => 15, + ], + 'records' => $records, + ]); + } +}