diff --git a/src/Response.php b/src/Response.php index 7e1373a9..14f2f934 100644 --- a/src/Response.php +++ b/src/Response.php @@ -119,83 +119,67 @@ public function resolveProperties(Request $request, array $props): array }); } - $props = $this->resolveArrayableProperties($props, $request); + // Dot-notated props should be resolved last to ensure that + // they are correctly merged with callable props. + uksort($props, function ($key) { + return str_contains($key, '.'); + }); - if($isPartial && $request->hasHeader(Header::PARTIAL_ONLY)) { - $props = $this->resolveOnly($request, $props); - } - - if($isPartial && $request->hasHeader(Header::PARTIAL_EXCEPT)) { - $props = $this->resolveExcept($request, $props); - } + $only = $this->getOnly($request, $isPartial); + $except = $this->getExcept($request, $isPartial); - $props = $this->resolvePropertyInstances($props, $request); + $props = $this->resolvePropertyInstances($props, $request, $only, $except); return $props; } /** - * Resolve all arrayables properties into an array. + * Get the `only` partial props. */ - public function resolveArrayableProperties(array $props, Request $request, bool $unpackDotProps = true): array + public function getOnly(Request $request, bool $isPartial): array { - foreach ($props as $key => $value) { - if ($value instanceof Arrayable) { - $value = $value->toArray(); - } - - if (is_array($value)) { - $value = $this->resolveArrayableProperties($value, $request, false); - } - - if ($unpackDotProps && str_contains($key, '.')) { - Arr::set($props, $key, $value); - unset($props[$key]); - } else { - $props[$key] = $value; - } + if(! $isPartial) { + return []; } - return $props; - } - - /** - * Resolve the `only` partial request props. - */ - public function resolveOnly(Request $request, array $props): array - { - $only = array_merge( + return array_merge( array_filter(explode(',', $request->header(Header::PARTIAL_ONLY, ''))), $this->persisted ); - - $value = []; - - foreach($only as $key) { - Arr::set($value, $key, data_get($props, $key)); - } - - return $value; } /** - * Resolve the `except` partial request props. + * Get the `except` partial props. */ - public function resolveExcept(Request $request, array $props): array + public function getExcept(Request $request, bool $isPartial): array { - $except = array_filter(explode(',', $request->header(Header::PARTIAL_EXCEPT, ''))); - - Arr::forget($props, $except); + if(! $isPartial) { + return []; + } - return $props; + return array_filter(explode(',', $request->header(Header::PARTIAL_EXCEPT, ''))); } /** * Resolve all necessary class instances in the given props. */ - public function resolvePropertyInstances(array $props, Request $request): array + public function resolvePropertyInstances(array $props, Request $request, array $only, array $except, bool $unpackDotProps = true, string $parentKey = ''): array { foreach ($props as $key => $value) { + $prop = $parentKey ? implode('.', [$parentKey, $key]) : $key; + + if($only && ! $this->isPropIncluded($only, $prop)) { + unset($props[$key]); + + continue; + } + + if($except && $this->isPropExcluded($except, $prop)) { + unset($props[$key]); + + continue; + } + if ($value instanceof Closure) { $value = App::call($value); } @@ -212,13 +196,50 @@ public function resolvePropertyInstances(array $props, Request $request): array $value = $value->toResponse($request)->getData(true); } + if ($value instanceof Arrayable) { + $value = $value->toArray(); + } + if (is_array($value)) { - $value = $this->resolvePropertyInstances($value, $request); + $value = $this->resolvePropertyInstances($value, $request, $only, $except, false, $prop); } - $props[$key] = $value; + if ($unpackDotProps && str_contains($key, '.')) { + Arr::set($props, $key, $value); + unset($props[$key]); + } else { + $props[$key] = $value; + } } return $props; } + + /** + * Determine whether a prop should be included in the partial response. + */ + public function isPropIncluded(array $only, string $prop): bool + { + foreach($only as $key) { + if(Str::startsWith($key, $prop) || Str::startsWith($prop, $key)) { + return true; + } + } + + return false; + } + + /** + * Determine whether a prop should be excluded from the partial response. + */ + public function isPropExcluded(array $except, string $prop): bool + { + foreach($except as $key) { + if(Str::startsWith($prop, $key)) { + return true; + } + } + + return false; + } } diff --git a/tests/MiddlewareTest.php b/tests/MiddlewareTest.php index b5df6d9f..38ffa535 100644 --- a/tests/MiddlewareTest.php +++ b/tests/MiddlewareTest.php @@ -214,6 +214,77 @@ public function test_validation_errors_are_scoped_to_error_bag_header(): void $this->withoutExceptionHandling()->get('/', ['X-Inertia-Error-Bag' => 'example']); } + public function test_middleware_can_share_props_with_dot_notation(): void + { + $this->prepareMockEndpoint(null, [ + 'auth.permissions.is_admin' => true, + 'user.verified' => true, + ]); + + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertJson([ + 'props' => [ + 'user' => [ + 'name' => 'Jonathan', + 'verified' => true, + ], + 'auth' => [ + 'permissions' => [ + 'is_admin' => true, + ], + ], + ], + ]); + } + + public function test_include_shared_props_in_partial_response(): void + { + $this->prepareMockEndpoint(null, [ + 'auth.permissions.is_admin' => true, + 'user.verified' => true, + ]); + + $response = $this->get('/', [ + 'X-Inertia' => 'true', + 'X-Inertia-Partial-Component' => 'User/Edit', + 'X-Inertia-Partial-Data' => 'auth', + ]); + + $response->assertJson([ + 'props' => [ + 'auth' => [ + 'permissions' => [ + 'is_admin' => true, + ], + ], + ], + ]); + } + + public function test_exclude_shared_props_from_partial_response(): void + { + $this->prepareMockEndpoint(null, [ + 'auth.permissions.is_admin' => true, + 'user.verified' => true, + ]); + + $response = $this->get('/', [ + 'X-Inertia' => 'true', + 'X-Inertia-Partial-Component' => 'User/Edit', + 'X-Inertia-Partial-Data' => 'user', + 'X-Inertia-Partial-Except' => 'user.verified', + ]); + + $response->assertJson([ + 'props' => [ + 'user' => [ + 'name' => 'Jonathan', + ], + ], + ]); + } + public function test_middleware_can_change_the_root_view_via_a_property(): void { $this->prepareMockEndpoint(null, [], new class() extends Middleware { diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index eb925cbc..d47fb372 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -2,8 +2,8 @@ namespace Inertia\Tests; -use Exception; use Mockery; +use Exception; use Inertia\LazyProp; use Inertia\Response; use Illuminate\View\View; @@ -11,6 +11,7 @@ use Illuminate\Support\Fluent; use Illuminate\Http\JsonResponse; use Illuminate\Support\Collection; +use Inertia\Tests\Stubs\AsArrayable; use Inertia\Tests\Stubs\FakeResource; use Illuminate\Http\Response as BaseResponse; use Illuminate\Pagination\LengthAwarePaginator; @@ -210,6 +211,38 @@ public function test_arrayable_prop_response(): void $this->assertSame('123', $page->version); } + public function test_nested_arrayable_prop_response(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $data = AsArrayable::make([ + 'user' => function () { + return [ + 'name' => 'Taylor Otwell', + 'organizations' => [ + function () { + return AsArrayable::make([ + 'name' => 'Laravel', + ]); + }, + ], + ]; + }, + ]); + + $response = new Response('User/Edit', ['data' => $data], 'app', '123'); + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Edit', $page->component); + $this->assertSame('Taylor Otwell', $page->props->data->user->name); + $this->assertSame('Laravel', $page->props->data->user->organizations[0]->name); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + } + public function test_promise_props_are_resolved(): void { $request = Request::create('/user/123', 'GET'); @@ -345,6 +378,35 @@ public function test_exclude_nested_props_from_partial_response(): void $this->assertSame('value', $page->props->auth->refresh_token); } + public function test_excluded_lazy_props_are_not_evaluated(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'auth']); + $request->headers->add(['X-Inertia-Partial-Except' => 'auth.user']); + + $props = [ + 'auth' => [ + 'user' => function () { + throw new Exception(); + }, + 'refresh_token' => 'value', + ], + 'shared' => function () { + throw new Exception(); + }, + ]; + + $response = new Response('User/Edit', $props); + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertFalse(isset($page->props->auth->user)); + $this->assertFalse(isset($page->props->shared)); + $this->assertSame('value', $page->props->auth->refresh_token); + } + public function test_lazy_props_are_not_included_by_default(): void { $request = Request::create('/users', 'GET'); @@ -401,7 +463,7 @@ public function test_persist_props_on_partial_reload(): void 'data' => [ 'name' => 'Taylor Otwell', 'email' => 'taylor@example.com', - ] + ], ]; $response = new Response('User/Edit', $props, 'app', '123', ['auth.user']); @@ -524,4 +586,97 @@ public function test_the_page_url_doesnt_double_up(): void $this->assertSame('/subpath/product/123', $page->url); } + + public function test_array_doubling(): void + { + $request = Request::create('/years', 'GET'); + + $response = new Response('Years', ['years' => [2022, 2023, 2024]], 'app', '123'); + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame([2022, 2023, 2024], $page['props']['years']); + } + + public function test_mixed_array_shape(): void + { + $request = Request::create('/years', 'GET'); + + $response = new Response('Years', ['years' => [2022, 2023, 'exclude' => [2024, 2025]]], 'app', '123'); + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame([2022, 2023, 'exclude' => [2024, 2025]], $page['props']['years']); + } + + public function test_dot_notation_props_are_merged_with_shared_props(): void + { + $request = Request::create('/test', 'GET'); + + $response = new Response('Test', [ + 'auth' => ['user' => ['name' => 'Jonathan']], + 'auth.user.is_super' => true, + ], 'app', '123'); + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame([ + 'auth' => [ + 'user' => [ + 'name' => 'Jonathan', + 'is_super' => true, + ], + ], + ], $page['props']); + } + + public function test_dot_notation_props_are_merged_with_lazy_shared_props(): void + { + $request = Request::create('/test', 'GET'); + + $response = new Response('Test', [ + 'auth' => function () { + return ['user' => ['name' => 'Jonathan']]; + }, + 'auth.user.is_super' => true, + ], 'app', '123'); + + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame([ + 'auth' => [ + 'user' => [ + 'name' => 'Jonathan', + 'is_super' => true, + ], + ], + ], $page['props']); + } + + public function test_dot_notation_props_are_merged_with_other_dot_notation_props(): void + { + $request = Request::create('/test', 'GET'); + + $response = new Response('Test', [ + 'auth.user' => ['name' => 'Jonathan'], + 'auth.user.is_super' => true, + ], 'app', '123'); + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame([ + 'auth' => [ + 'user' => [ + 'name' => 'Jonathan', + 'is_super' => true, + ], + ], + ], $page['props']); + } } diff --git a/tests/Stubs/AsArrayable.php b/tests/Stubs/AsArrayable.php new file mode 100644 index 00000000..a7f1b266 --- /dev/null +++ b/tests/Stubs/AsArrayable.php @@ -0,0 +1,31 @@ + + */ +class AsArrayable implements Arrayable { + /** @var array */ + protected $data = []; + + public function __construct(array $data) + { + $this->data = $data; + } + + public static function make(array $data): self + { + return new self($data); + } + + /** + * Get the instance as an array. + */ + public function toArray(): array + { + return $this->data; + } +}