diff --git a/composer.json b/composer.json index d8ea1ddf..485a188f 100644 --- a/composer.json +++ b/composer.json @@ -45,6 +45,11 @@ "suggest": { "ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command." }, + "scripts": { + "test": "phpunit", + "lint": "pint", + "analyze": "phpstan analyze --memory-limit=256M" + }, "extra": { "laravel": { "providers": [ diff --git a/src/Mergeable.php b/src/Mergeable.php index f4f5281d..c397b738 100644 --- a/src/Mergeable.php +++ b/src/Mergeable.php @@ -31,4 +31,32 @@ public function shouldDeepMerge(); * @return array */ public function matchesOn(); + + /** + * Determine if the property should be appended at the root level. + * + * @return bool + */ + public function appendsAtRoot(); + + /** + * Determine if the property should be prepended at the root level. + * + * @return bool + */ + public function prependsAtRoot(); + + /** + * Get the paths to append when merging. + * + * @return array + */ + public function appendsAtPaths(): array; + + /** + * Get the paths to prepend when merging. + * + * @return array + */ + public function prependsAtPaths(): array; } diff --git a/src/MergesProps.php b/src/MergesProps.php index af7ee12a..617dadb8 100644 --- a/src/MergesProps.php +++ b/src/MergesProps.php @@ -23,6 +23,25 @@ trait MergesProps */ protected array $matchOn = []; + /** + * Indicates if the property values should be appended or prepended. + */ + protected bool $append = true; + + /** + * The paths to append. + * + * @var array + */ + protected array $appendsAtPaths = []; + + /** + * The paths to prepend. + * + * @var array + */ + protected array $prependsAtPaths = []; + /** * Mark the property for merging. */ @@ -80,4 +99,96 @@ public function matchesOn(): array { return $this->matchOn; } + + /** + * Determine if the property should be appended at the root level. + * + * @return bool + */ + public function appendsAtRoot() + { + return $this->append && $this->mergesAtRoot(); + } + + /** + * Determine if the property should be prepended at the root level. + * + * @return bool + */ + public function prependsAtRoot() + { + return ! $this->append && $this->mergesAtRoot(); + } + + /** + * Determine if the property merges at the root level. + */ + protected function mergesAtRoot(): bool + { + return count($this->appendsAtPaths) === 0 && count($this->prependsAtPaths) === 0; + } + + /** + * Specify that the value should be appended, optionally providing a key to append and a property to match on. + * + * @param bool|string|array $path + */ + public function append(bool|string|array $path = true, ?string $matchOn = null): static + { + match (true) { + is_bool($path) => $this->append = $path, + is_string($path) => $this->appendsAtPaths[] = $path, + is_array($path) => collect($path)->each( + fn ($value, $key) => is_numeric($key) ? $this->append($value) : $this->append($key, $value) + ), + }; + + if (is_string($path) && $matchOn) { + $this->matchOn([...$this->matchOn, "{$path}.{$matchOn}"]); + } + + return $this; + } + + /** + * Specify that the value should be prepended, optionally providing a key to prepend and a property to match on. + * + * @param bool|string|array $path + */ + public function prepend(bool|string|array $path = true, ?string $matchOn = null): static + { + match (true) { + is_bool($path) => $this->append = ! $path, + is_string($path) => $this->prependsAtPaths[] = $path, + is_array($path) => collect($path)->each( + fn ($value, $key) => is_numeric($key) ? $this->prepend($value) : $this->prepend($key, $value) + ), + }; + + if (is_string($path) && $matchOn) { + $this->matchOn([...$this->matchOn, "{$path}.{$matchOn}"]); + } + + return $this; + } + + /** + * Get the paths to append. + * + * @return array + */ + public function appendsAtPaths(): array + { + return $this->appendsAtPaths; + } + + /** + * Get the paths to prepend. + * + * @return array + */ + public function prependsAtPaths(): array + { + return $this->prependsAtPaths; + } } diff --git a/src/ProvidesScrollMetadata.php b/src/ProvidesScrollMetadata.php new file mode 100644 index 00000000..adc2cd23 --- /dev/null +++ b/src/ProvidesScrollMetadata.php @@ -0,0 +1,26 @@ +resolveMergeProps($request), $this->resolveDeferredProps($request), $this->resolveCacheDirections($request), + $this->resolveScrollProps($request), ); if ($request->header(Header::INERTIA)) { @@ -371,6 +373,10 @@ public function resolveAlways(array $props): array public function resolvePropertyInstances(array $props, Request $request, ?string $parentKey = null): array { foreach ($props as $key => $value) { + if ($value instanceof ScrollProp) { + $value->configureMergeIntent($request); + } + $resolveViaApp = collect([ Closure::class, LazyProp::class, @@ -378,6 +384,7 @@ public function resolvePropertyInstances(array $props, Request $request, ?string DeferProp::class, AlwaysProp::class, MergeProp::class, + ScrollProp::class, ])->first(fn ($class) => $value instanceof $class); if ($resolveViaApp) { @@ -439,45 +446,112 @@ public function resolveCacheDirections(Request $request): array } /** - * Resolve merge props configuration for client-side prop merging. + * Get the props that should be considered for merging based on the request headers. * - * @return array + * @return \Illuminate\Support\Collection */ - public function resolveMergeProps(Request $request): array + protected function getMergePropsForRequest(Request $request): Collection { $resetProps = array_filter(explode(',', $request->header(Header::RESET, ''))); $onlyProps = array_filter(explode(',', $request->header(Header::PARTIAL_ONLY, ''))); $exceptProps = array_filter(explode(',', $request->header(Header::PARTIAL_EXCEPT, ''))); - $mergeProps = collect($this->props) + return collect($this->props) ->filter(fn ($prop) => $prop instanceof Mergeable) - ->filter(fn ($prop) => $prop->shouldMerge()) - ->reject(fn ($_, $key) => in_array($key, $resetProps)) - ->filter(fn ($_, $key) => count($onlyProps) === 0 || in_array($key, $onlyProps)) - ->reject(fn ($_, $key) => in_array($key, $exceptProps)); + ->filter(fn (Mergeable $prop) => $prop->shouldMerge()) + ->reject(fn ($_, string $key) => in_array($key, $resetProps)) + ->filter(fn ($_, string $key) => count($onlyProps) === 0 || in_array($key, $onlyProps)) + ->reject(fn ($_, string $key) => in_array($key, $exceptProps)); + } + + /** + * Resolve merge props configuration for client-side prop merging. + * + * @return array + */ + public function resolveMergeProps(Request $request): array + { + $mergeProps = $this->getMergePropsForRequest($request); - $deepMergeProps = $mergeProps - ->filter(fn ($prop) => $prop->shouldDeepMerge()) - ->keys(); + return array_filter([ + 'mergeProps' => $this->resolveAppendMergeProps($mergeProps), + 'prependProps' => $this->resolvePrependMergeProps($mergeProps), + 'deepMergeProps' => $this->resolveDeepMergeProps($mergeProps), + 'matchPropsOn' => $this->resolveMergeMatchingKeys($mergeProps), + ], fn ($prop) => count($prop) > 0); + } - $matchPropsOn = $mergeProps + /** + * Resolve props that should be appended during merging. + * + * @param \Illuminate\Support\Collection $mergeProps + * @return array + */ + protected function resolveAppendMergeProps(Collection $mergeProps): array + { + [$rootAppendProps, $nestedAppendProps] = $mergeProps + ->reject(fn (Mergeable $prop) => $prop->shouldDeepMerge()) + ->partition(fn (Mergeable $prop) => $prop->appendsAtRoot()); + + return $nestedAppendProps + ->flatMap(fn (Mergeable $prop, string $key) => collect($prop->appendsAtPaths())->map(fn ($path) => $key.'.'.$path)) + ->merge($rootAppendProps->keys()) + ->unique() + ->values() + ->toArray(); + } + + /** + * Resolve props that should be prepended during merging. + * + * @param \Illuminate\Support\Collection $mergeProps + * @return array + */ + protected function resolvePrependMergeProps(Collection $mergeProps): array + { + [$rootPrependProps, $nestedPrependProps] = $mergeProps + ->reject(fn (Mergeable $prop) => $prop->shouldDeepMerge()) + ->partition(fn (Mergeable $prop) => $prop->prependsAtRoot()); + + return $nestedPrependProps + ->flatMap(fn (Mergeable $prop, string $key) => collect($prop->prependsAtPaths())->map(fn ($path) => $key.'.'.$path)) + ->merge($rootPrependProps->keys()) + ->unique() + ->values() + ->toArray(); + } + + /** + * Resolve props that should be deep merged. + * + * @param \Illuminate\Support\Collection $mergeProps + * @return array + */ + protected function resolveDeepMergeProps(Collection $mergeProps): array + { + return $mergeProps + ->filter(fn (Mergeable $prop) => $prop->shouldDeepMerge()) + ->keys() + ->toArray(); + } + + /** + * Resolve the matching keys for merge props. + * + * @param \Illuminate\Support\Collection $mergeProps + * @return array + */ + protected function resolveMergeMatchingKeys(Collection $mergeProps): array + { + return $mergeProps ->map(function (Mergeable $prop, $key) { return collect($prop->matchesOn()) ->map(fn ($strategy) => $key.'.'.$strategy) ->toArray(); }) ->flatten() - ->values(); - - $mergeProps = $mergeProps - ->filter(fn ($prop) => ! $prop->shouldDeepMerge()) - ->keys(); - - return array_filter([ - 'mergeProps' => $mergeProps->toArray(), - 'deepMergeProps' => $deepMergeProps->toArray(), - 'matchPropsOn' => $matchPropsOn->toArray(), - ], fn ($prop) => count($prop) > 0); + ->values() + ->toArray(); } /** @@ -508,6 +582,20 @@ public function resolveDeferredProps(Request $request): array return $deferredProps->isNotEmpty() ? ['deferredProps' => $deferredProps->toArray()] : []; } + /** + * Resolve scroll props configuration for client-side infinite scrolling. + * + * @return array + */ + public function resolveScrollProps(Request $request): array + { + $scrollProps = $this->getMergePropsForRequest($request) + ->filter(fn (Mergeable $prop) => $prop instanceof ScrollProp) + ->mapWithKeys(fn (ScrollProp $prop, string $key) => [$key => $prop->metadata()]); + + return $scrollProps->isNotEmpty() ? ['scrollProps' => $scrollProps->toArray()] : []; + } + /** * Determine if the request is a partial request. */ diff --git a/src/ResponseFactory.php b/src/ResponseFactory.php index d24feac9..d3a7626b 100644 --- a/src/ResponseFactory.php +++ b/src/ResponseFactory.php @@ -223,6 +223,21 @@ public function always($value): AlwaysProp return new AlwaysProp($value); } + /** + * Create an scroll property. + * + * @param mixed $value + * + * @template T + * + * @param T $value + * @return ScrollProp + */ + public function scroll($value, string $wrapper = 'data', ProvidesScrollMetadata|callable|null $metadata = null): ScrollProp + { + return new ScrollProp($value, $wrapper, $metadata); + } + /** * Find the component or fail. * diff --git a/src/ScrollMetadata.php b/src/ScrollMetadata.php new file mode 100644 index 00000000..889c0296 --- /dev/null +++ b/src/ScrollMetadata.php @@ -0,0 +1,101 @@ + + */ +class ScrollMetadata implements Arrayable, ProvidesScrollMetadata +{ + /** + * Create a new scroll metadata instance. + */ + public function __construct( + protected string $pageName, + protected int|string|null $previousPage = null, + protected int|string|null $nextPage = null, + protected int|string|null $currentPage = null, + ) { + // + } + + /** + * Create a scroll metadata instance from a Laravel paginator. + */ + public static function fromPaginator(mixed $value): self + { + $paginator = $value instanceof JsonResource ? $value->resource : $value; + + if ($paginator instanceof CursorPaginator) { + return new self( + $cursorName = $paginator->getCursorName(), + $paginator->previousCursor()?->encode(), + $paginator->nextCursor()?->encode(), + $paginator->onFirstPage() ? 1 : (CursorPaginator::resolveCurrentCursor($cursorName)?->encode() ?? 1) + ); + } + + if ($paginator instanceof LengthAwarePaginator || $paginator instanceof Paginator) { + return new self( + $paginator->getPageName(), + $paginator->currentPage() > 1 ? $paginator->currentPage() - 1 : null, + $paginator->hasMorePages() ? $paginator->currentPage() + 1 : null, + $paginator->currentPage(), + ); + } + + throw new InvalidArgumentException('The given value is not a Laravel paginator instance. Use a custom callback to extract pagination metadata.'); + } + + /** + * Get the page name parameter. + */ + public function getPageName(): string + { + return $this->pageName; + } + + /** + * Get the previous page identifier. + */ + public function getPreviousPage(): int|string|null + { + return $this->previousPage; + } + + /** + * Get the next page identifier. + */ + public function getNextPage(): int|string|null + { + return $this->nextPage; + } + + /** + * Get the current page identifier. + */ + public function getCurrentPage(): int|string|null + { + return $this->currentPage; + } + + /** + * Convert the scroll metadata instance to an array. + */ + public function toArray(): array + { + return [ + 'pageName' => $this->getPageName(), + 'previousPage' => $this->getPreviousPage(), + 'nextPage' => $this->getNextPage(), + 'currentPage' => $this->getCurrentPage(), + ]; + } +} diff --git a/src/ScrollProp.php b/src/ScrollProp.php new file mode 100644 index 00000000..1329a54c --- /dev/null +++ b/src/ScrollProp.php @@ -0,0 +1,130 @@ +merge = true; + $this->value = $value; + $this->wrapper = $wrapper; + $this->metadata = $metadata; + } + + /** + * Configure the merge strategy based on the infinite scroll merge intent header. + * + * The frontend InfiniteScroll component sends its merge intent directly, + * eliminating the need for direction-based logic on the backend. + */ + public function configureMergeIntent(?Request $request = null): static + { + $request ??= request(); + + return $request->header(Header::INFINITE_SCROLL_MERGE_INTENT) === 'prepend' + ? $this->prepend($this->wrapper) + : $this->append($this->wrapper); + } + + /** + * Resolve the scroll metadata provider. + */ + protected function resolveMetadataProvider(): ProvidesScrollMetadata + { + if ($this->metadata instanceof ProvidesScrollMetadata) { + return $this->metadata; + } + + $value = $this(); + + if (is_null($this->metadata)) { + return ScrollMetadata::fromPaginator($value); + } + + return call_user_func($this->metadata, $value); + } + + /** + * Get the pagination meta information. + * + * @return array{pageName: string, previousPage: int|string|null, nextPage: int|string|null, currentPage: int|string|null} + */ + public function metadata(): array + { + $metadataProvider = $this->resolveMetadataProvider(); + + return [ + 'pageName' => $metadataProvider->getPageName(), + 'previousPage' => $metadataProvider->getPreviousPage(), + 'nextPage' => $metadataProvider->getNextPage(), + 'currentPage' => $metadataProvider->getCurrentPage(), + ]; + } + + /** + * Resolve the property value. + * + * @return T + */ + public function __invoke() + { + if (isset($this->resolved)) { + return $this->resolved; + } + + return $this->resolved = is_callable($this->value) ? App::call($this->value) : $this->value; + } +} diff --git a/src/Support/Header.php b/src/Support/Header.php index ca6d51fb..ad201b30 100644 --- a/src/Support/Header.php +++ b/src/Support/Header.php @@ -43,4 +43,9 @@ class Header * Header for resetting the page state. */ public const RESET = 'X-Inertia-Reset'; + + /** + * Header for specifying the merge intent when paginating on infinite scroll. + */ + public const INFINITE_SCROLL_MERGE_INTENT = 'X-Inertia-Infinite-Scroll-Merge-Intent'; } diff --git a/tests/InteractsWithUserModels.php b/tests/InteractsWithUserModels.php new file mode 100644 index 00000000..3f22bdbc --- /dev/null +++ b/tests/InteractsWithUserModels.php @@ -0,0 +1,23 @@ +set('database.default', 'sqlite'); + config()->set('database.connections.sqlite.database', ':memory:'); + + DB::statement('DROP TABLE IF EXISTS users'); + DB::statement('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT)'); + DB::table('users')->insert(array_fill(0, 40, ['id' => null])); + } + + protected function tearDownInteractsWithUserModels(): void + { + DB::statement('DROP TABLE users'); + } +} diff --git a/tests/MergePropTest.php b/tests/MergePropTest.php index dfd21213..23865449 100644 --- a/tests/MergePropTest.php +++ b/tests/MergePropTest.php @@ -31,4 +31,131 @@ public function test_can_resolve_bindings_when_invoked(): void $this->assertInstanceOf(Request::class, $mergeProp()); } + + public function test_appends_by_default(): void + { + $mergeProp = new MergeProp([]); + + $this->assertTrue($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame([], $mergeProp->appendsAtPaths()); + $this->assertSame([], $mergeProp->prependsAtPaths()); + $this->assertSame([], $mergeProp->matchesOn()); + } + + public function test_prepends(): void + { + $mergeProp = (new MergeProp([]))->prepend(); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertTrue($mergeProp->prependsAtRoot()); + $this->assertSame([], $mergeProp->appendsAtPaths()); + $this->assertSame([], $mergeProp->prependsAtPaths()); + $this->assertSame([], $mergeProp->matchesOn()); + } + + public function test_appends_with_nested_merge_paths(): void + { + $mergeProp = (new MergeProp([]))->append('data'); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame(['data'], $mergeProp->appendsAtPaths()); + $this->assertSame([], $mergeProp->prependsAtPaths()); + $this->assertSame([], $mergeProp->matchesOn()); + } + + public function test_appends_with_nested_merge_paths_and_match_on(): void + { + $mergeProp = (new MergeProp([]))->append('data', 'id'); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame(['data'], $mergeProp->appendsAtPaths()); + $this->assertSame([], $mergeProp->prependsAtPaths()); + $this->assertSame(['data.id'], $mergeProp->matchesOn()); + } + + public function test_prepends_with_nested_merge_paths(): void + { + $mergeProp = (new MergeProp([]))->prepend('data'); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame([], $mergeProp->appendsAtPaths()); + $this->assertSame(['data'], $mergeProp->prependsAtPaths()); + $this->assertSame([], $mergeProp->matchesOn()); + } + + public function test_prepends_with_nested_merge_paths_and_match_on(): void + { + $mergeProp = (new MergeProp([]))->prepend('data', 'id'); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame([], $mergeProp->appendsAtPaths()); + $this->assertSame(['data'], $mergeProp->prependsAtPaths()); + $this->assertSame(['data.id'], $mergeProp->matchesOn()); + } + + public function test_append_with_nested_merge_paths_as_array(): void + { + $mergeProp = (new MergeProp([]))->append(['data', 'items']); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame(['data', 'items'], $mergeProp->appendsAtPaths()); + $this->assertSame([], $mergeProp->prependsAtPaths()); + $this->assertSame([], $mergeProp->matchesOn()); + } + + public function test_append_with_nested_merge_paths_and_match_on_as_array(): void + { + $mergeProp = (new MergeProp([]))->append(['data' => 'id', 'items' => 'uid']); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame(['data', 'items'], $mergeProp->appendsAtPaths()); + $this->assertSame([], $mergeProp->prependsAtPaths()); + $this->assertSame(['data.id', 'items.uid'], $mergeProp->matchesOn()); + } + + public function test_prepend_with_nested_merge_paths_as_array(): void + { + $mergeProp = (new MergeProp([]))->prepend(['data', 'items']); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame([], $mergeProp->appendsAtPaths()); + $this->assertSame(['data', 'items'], $mergeProp->prependsAtPaths()); + $this->assertSame([], $mergeProp->matchesOn()); + } + + public function test_prepend_with_nested_merge_paths_and_match_on_as_array(): void + { + $mergeProp = (new MergeProp([]))->prepend(['data' => 'id', 'items' => 'uid']); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame([], $mergeProp->appendsAtPaths()); + $this->assertSame(['data', 'items'], $mergeProp->prependsAtPaths()); + $this->assertSame(['data.id', 'items.uid'], $mergeProp->matchesOn()); + } + + public function test_mix_of_append_and_prepend_with_nested_merge_paths_and_match_on_as_array(): void + { + $mergeProp = (new MergeProp([])) + ->append('data') + ->append('users', 'id') + ->append(['items' => 'uid', 'posts']) + ->prepend('categories') + ->prepend('companies', 'id') + ->prepend(['tags' => 'name', 'comments']); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame(['data', 'users', 'items', 'posts'], $mergeProp->appendsAtPaths()); + $this->assertSame(['categories', 'companies', 'tags', 'comments'], $mergeProp->prependsAtPaths()); + $this->assertSame(['users.id', 'items.uid', 'companies.id', 'tags.name'], $mergeProp->matchesOn()); + } } diff --git a/tests/ResponseFactoryTest.php b/tests/ResponseFactoryTest.php index 49b08837..eac6101f 100644 --- a/tests/ResponseFactoryTest.php +++ b/tests/ResponseFactoryTest.php @@ -344,6 +344,35 @@ public function test_can_create_optional_prop(): void $this->assertInstanceOf(OptionalProp::class, $optionalProp); } + public function test_can_create_scroll_prop(): void + { + $factory = new ResponseFactory; + $data = ['item1', 'item2']; + + $scrollProp = $factory->scroll($data); + + $this->assertInstanceOf(\Inertia\ScrollProp::class, $scrollProp); + $this->assertSame($data, $scrollProp()); + } + + public function test_can_create_scroll_prop_with_metadata_provider(): void + { + $factory = new ResponseFactory; + $data = ['item1', 'item2']; + $metadataProvider = new \Inertia\ScrollMetadata('custom', 1, 3, 2); + + $scrollProp = $factory->scroll($data, 'data', $metadataProvider); + + $this->assertInstanceOf(\Inertia\ScrollProp::class, $scrollProp); + $this->assertSame($data, $scrollProp()); + $this->assertEquals([ + 'pageName' => 'custom', + 'previousPage' => 1, + 'nextPage' => 3, + 'currentPage' => 2, + ], $scrollProp->metadata()); + } + public function test_can_create_always_prop(): void { $factory = new ResponseFactory; diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index b5a78722..717eac44 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -174,6 +174,110 @@ public function test_server_response_with_merge_props(): void $this->assertSame('
', $view->render()); } + public function test_server_response_with_merge_props_that_should_prepend(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [ + 'user' => $user, + 'foo' => (new MergeProp('foo value'))->prepend(), + 'bar' => new MergeProp('bar value'), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame(['bar'], $page['mergeProps']); + $this->assertSame(['foo'], $page['prependProps']); + $this->assertFalse($page['clearHistory']); + $this->assertFalse($page['encryptHistory']); + $this->assertSame('
', $view->render()); + } + + public function test_server_response_with_merge_props_that_has_nested_paths_to_append_and_prepend(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [ + 'user' => $user, + 'foo' => (new MergeProp(['data' => [['id' => 1], ['id' => 2]]]))->append('data'), + 'bar' => (new MergeProp(['data' => ['items' => [['uuid' => 1], ['uuid' => 2]]]]))->prepend('data.items'), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame(['foo.data'], $page['mergeProps']); + $this->assertSame(['bar.data.items'], $page['prependProps']); + $this->assertArrayNotHasKey('matchPropsOn', $page); + $this->assertFalse($page['clearHistory']); + $this->assertFalse($page['encryptHistory']); + $this->assertSame('
', $view->render()); + } + + public function test_server_response_with_merge_props_that_has_nested_paths_to_append_and_prepend_with_match_on_strategies(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [ + 'user' => $user, + 'foo' => (new MergeProp(['data' => [['id' => 1], ['id' => 2]]]))->append('data', 'id'), + 'bar' => (new MergeProp(['data' => ['items' => [['uuid' => 1], ['uuid' => 2]]]]))->prepend('data.items', 'uuid'), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame(['foo.data'], $page['mergeProps']); + $this->assertSame(['bar.data.items'], $page['prependProps']); + $this->assertSame(['foo.data.id', 'bar.data.items.uuid'], $page['matchPropsOn']); + $this->assertFalse($page['clearHistory']); + $this->assertFalse($page['encryptHistory']); + $this->assertSame('
', $view->render()); + } + public function test_server_response_with_deep_merge_props(): void { $request = Request::create('/user/123', 'GET'); @@ -210,7 +314,7 @@ public function test_server_response_with_deep_merge_props(): void $this->assertSame('
', $view->render()); } - public function test_server_response_with_merge_strategies(): void + public function test_server_response_with_match_on_props(): void { $request = Request::create('/user/123', 'GET'); diff --git a/tests/ScrollMetadataTest.php b/tests/ScrollMetadataTest.php new file mode 100644 index 00000000..bf9c5433 --- /dev/null +++ b/tests/ScrollMetadataTest.php @@ -0,0 +1,144 @@ +> + */ + public static function wrappedOrUnwrappedProvider(): array + { + return [ + 'wrapped in http resource' => [true], + 'not wrapped in http resource' => [false], + ]; + } + + #[DataProvider('wrappedOrUnwrappedProvider')] + public function test_extract_metadata_from_simple_paginator(bool $wrappedinHttpResource): void + { + $users = User::query()->simplePaginate(15); + + if ($wrappedinHttpResource) { + $users = UserResource::collection($users); + } + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => null, + 'nextPage' => 2, + 'currentPage' => 1, + ], ScrollMetadata::fromPaginator($users)->toArray()); + + request()->merge(['page' => 2]); + $users = User::query()->simplePaginate(15); + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => 1, + 'nextPage' => 3, + 'currentPage' => 2, + ], ScrollMetadata::fromPaginator($users)->toArray()); + + request()->merge(['page' => 3]); + $users = User::query()->simplePaginate(15); + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => 2, + 'nextPage' => null, + 'currentPage' => 3, + ], ScrollMetadata::fromPaginator($users)->toArray()); + } + + #[DataProvider('wrappedOrUnwrappedProvider')] + public function test_extract_metadata_from_length_aware_paginator(bool $wrappedinHttpResource): void + { + $users = User::query()->paginate(15); + + if ($wrappedinHttpResource) { + $users = UserResource::collection($users); + } + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => null, + 'nextPage' => 2, + 'currentPage' => 1, + ], ScrollMetadata::fromPaginator($users)->toArray()); + + request()->merge(['page' => 2]); + $users = User::query()->paginate(15); + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => 1, + 'nextPage' => 3, + 'currentPage' => 2, + ], ScrollMetadata::fromPaginator($users)->toArray()); + + request()->merge(['page' => 3]); + $users = User::query()->paginate(15); + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => 2, + 'nextPage' => null, + 'currentPage' => 3, + ], ScrollMetadata::fromPaginator($users)->toArray()); + } + + #[DataProvider('wrappedOrUnwrappedProvider')] + public function test_extract_metadata_from_cursor_paginator(bool $wrappedinHttpResource): void + { + $users = User::query()->cursorPaginate(15); + + if ($wrappedinHttpResource) { + $users = UserResource::collection($users); + } + + $this->assertEquals([ + 'pageName' => 'cursor', + 'previousPage' => null, + 'nextPage' => $users->nextCursor()?->encode(), + 'currentPage' => 1, + ], $first = ScrollMetadata::fromPaginator($users)->toArray()); + + request()->merge(['cursor' => $first['nextPage']]); + $users = User::query()->cursorPaginate(15); + + $this->assertEquals([ + 'pageName' => 'cursor', + 'previousPage' => $users->previousCursor()?->encode(), + 'nextPage' => $users->nextCursor()?->encode(), + 'currentPage' => $first['nextPage'], + ], $second = ScrollMetadata::fromPaginator($users)->toArray()); + + request()->merge(['cursor' => $second['nextPage']]); + $users = User::query()->cursorPaginate(15); + + $this->assertEquals([ + 'pageName' => 'cursor', + 'previousPage' => $users->previousCursor()?->encode(), + 'nextPage' => null, + 'currentPage' => $second['nextPage'], + ], ScrollMetadata::fromPaginator($users)->toArray()); + } + + public function test_throws_exception_if_not_a_paginator(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The given value is not a Laravel paginator instance. Use a custom callback to extract pagination metadata.'); + + ScrollMetadata::fromPaginator(collect()); + } +} diff --git a/tests/ScrollPropTest.php b/tests/ScrollPropTest.php new file mode 100644 index 00000000..4df47ff6 --- /dev/null +++ b/tests/ScrollPropTest.php @@ -0,0 +1,168 @@ +setUpInteractsWithUserModels(); + } + + public function test_resolves_meta_data(): void + { + $users = User::query()->paginate(15); + $scrollProp = new ScrollProp($users); + + $metadata = $scrollProp->metadata(); + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => null, + 'nextPage' => 2, + 'currentPage' => 1, + ], $metadata); + } + + public function test_resolves_custom_meta_data(): void + { + $users = User::query()->paginate(15); + + $customMetaCallback = fn () => new class implements ProvidesScrollMetadata + { + public function getPageName(): string + { + return 'usersPage'; + } + + public function getPreviousPage(): int + { + return 10; + } + + public function getNextPage(): int + { + return 12; + } + + public function getCurrentPage(): int + { + return 11; + } + }; + + $scrollProp = new ScrollProp($users, 'data', $customMetaCallback); + + $metadata = $scrollProp->metadata(); + + $this->assertEquals([ + 'pageName' => 'usersPage', + 'previousPage' => 10, + 'nextPage' => 12, + 'currentPage' => 11, + ], $metadata); + } + + public function test_can_set_the_merge_intent_based_on_the_merge_intent_header(): void + { + $users = User::query()->paginate(15); + + // Test append intent without header + $appendProp = new ScrollProp($users); + $appendProp->configureMergeIntent(); + $this->assertContains('data', $appendProp->appendsAtPaths()); + $this->assertEmpty($appendProp->prependsAtPaths()); + + // Test append intent with header set to 'down' + request()->headers->set(Header::INFINITE_SCROLL_MERGE_INTENT, 'append'); + $appendProp = new ScrollProp($users); + $appendProp->configureMergeIntent(); + $this->assertContains('data', $appendProp->appendsAtPaths()); + $this->assertEmpty($appendProp->prependsAtPaths()); + + // Test prepend intent + request()->headers->set(Header::INFINITE_SCROLL_MERGE_INTENT, 'prepend'); + $prependProp = new ScrollProp($users); + $prependProp->configureMergeIntent(); + $this->assertContains('data', $prependProp->prependsAtPaths()); + $this->assertEmpty($prependProp->appendsAtPaths()); + + // Test prepend intent with custom wrapper + request()->headers->set(Header::INFINITE_SCROLL_MERGE_INTENT, 'prepend'); + $prependProp = new ScrollProp($users, 'items'); + $prependProp->configureMergeIntent(); + $this->assertContains('items', $prependProp->prependsAtPaths()); + $this->assertEmpty($prependProp->appendsAtPaths()); + } + + public function test_resolves_meta_data_with_callable_provider(): void + { + $callableMetadata = function () { + return new class implements ProvidesScrollMetadata + { + public function getPageName(): string + { + return 'callablePage'; + } + + public function getPreviousPage(): int + { + return 5; + } + + public function getNextPage(): int + { + return 7; + } + + public function getCurrentPage(): int + { + return 6; + } + }; + }; + + $scrollProp = new ScrollProp([], 'data', $callableMetadata); + + $metadata = $scrollProp->metadata(); + + $this->assertEquals([ + 'pageName' => 'callablePage', + 'previousPage' => 5, + 'nextPage' => 7, + 'currentPage' => 6, + ], $metadata); + } + + public function test_scroll_prop_value_is_resolved_only_once(): void + { + $callCount = 0; + + $scrollProp = new ScrollProp(function () use (&$callCount) { + $callCount++; + + return ['item1', 'item2', 'item3']; + }); + + // Call the scroll prop multiple times + $value1 = $scrollProp(); + $value2 = $scrollProp(); + $value3 = $scrollProp(); + + // Verify the callback was only called once + $this->assertEquals(1, $callCount, 'Scroll prop value callback should only be executed once'); + + // Verify all calls return the same result + $this->assertEquals($value1, $value2); + $this->assertEquals($value2, $value3); + $this->assertEquals(['item1', 'item2', 'item3'], $value1); + } +} diff --git a/tests/Stubs/User.php b/tests/Stubs/User.php new file mode 100644 index 00000000..0523e314 --- /dev/null +++ b/tests/Stubs/User.php @@ -0,0 +1,10 @@ +