diff --git a/src/Http/InertiaResponse.php b/src/Http/InertiaResponse.php index 62aee51..5d22631 100644 --- a/src/Http/InertiaResponse.php +++ b/src/Http/InertiaResponse.php @@ -14,27 +14,31 @@ use Tempest\Http\IsResponse; use Tempest\Http\Request; use Tempest\Http\Response; -use Tempest\Support\Arr\ImmutableArray; +use Tempest\Http\Session\Session; +use Tempest\Support\Arr\ArrayInterface; +use function Tempest\get; use function Tempest\invoke; use function Tempest\Support\arr; -use function Tempest\Support\str; final class InertiaResponse implements Response { use IsResponse; + // @mago-expect maintainability/excessive-parameter-list public function __construct( readonly Request $request, - readonly string $page, + readonly string $component, readonly array $props, readonly string $rootView, readonly string $version, + readonly bool $clearHistory = false, + readonly bool $encryptHistory = false, ) { $deferredProps = self::resolvePropKeysThatShouldDefer( props: $props, request: $request, - component: $page, + component: $component, ); $mergeProps = self::resolvePropKeysThatShouldMerge( @@ -45,14 +49,16 @@ public function __construct( // Build page data immutably $pageData = array_merge( [ - 'component' => $page, + 'component' => $component, 'props' => self::composeProps( - props: $this->props, - request: $this->request, - component: $page, + props: $props, + request: $request, + component: $component, ), 'url' => $request->uri, 'version' => $version, + 'clearHistory' => $clearHistory, + 'encryptHistory' => $encryptHistory, ], count($deferredProps) ? ['deferredProps' => $deferredProps] : [], count($mergeProps) ? ['mergeProps' => $mergeProps] : [], @@ -168,7 +174,7 @@ private static function evaluateProps(array $props, Request $request, bool $unpa $evaluated = ($value instanceof Closure) ? invoke($value) : $value; $evaluated = $evaluated instanceof LazyProp || $evaluated instanceof AlwaysProp ? $evaluated() : $evaluated; - $evaluated = ($evaluated instanceof ImmutableArray) ? $evaluated->toArray() : $evaluated; + $evaluated = ($evaluated instanceof ArrayInterface) ? $evaluated->toArray() : $evaluated; $evaluated = is_array($evaluated) ? self::evaluateProps($evaluated, $request, unpackDotProps: false) : $evaluated; diff --git a/src/Http/Middleware/EncryptHistory.php b/src/Http/Middleware/EncryptHistory.php new file mode 100644 index 0000000..c5bac21 --- /dev/null +++ b/src/Http/Middleware/EncryptHistory.php @@ -0,0 +1,27 @@ +inertia->encryptHistory(); + + return $next($request); + } +} diff --git a/src/Inertia.php b/src/Inertia.php index 01dd91b..7722f7a 100644 --- a/src/Inertia.php +++ b/src/Inertia.php @@ -13,39 +13,73 @@ use Tempest\Http\Request; use Tempest\Http\Response; use Tempest\Http\Responses\Redirect; +use Tempest\Http\Session\Session; use Tempest\Http\Status; #[Singleton] final class Inertia { public function __construct( + private Session $session, private Container $container, private InertiaConfig $config, ) {} - public function share(string|array $key, null|string $value = null): void + public function share(string|array $key, null|string $value = null): self { $this->config->share($key, $value); + + return $this; } - public function flushShared(): void + public function flushShared(): self { $this->config->flushSharedProps(); + + return $this; } public string $version { get => $this->container->invoke($this->config->versionResolver->resolve(...)); } - public function render(string $page, array $props = []): InertiaResponse + public function render(string $component, array $props = []): InertiaResponse { return new InertiaResponse( request: $this->container->get(Request::class), - page: $page, + component: $component, props: array_merge($this->config->sharedProps, $props), rootView: $this->config->rootView, version: $this->version, + clearHistory: $this->session->get( + key: 'inertia.clear_history', + default: false, + ), + encryptHistory: $this->session->get( + key: 'inertia.encrypt_history', + default: false, + ), + ); + } + + public function encryptHistory(): self + { + $this->session->flash( + key: 'inertia.encrypt_history', + value: true, ); + + return $this; + } + + public function clearHistory(): self + { + $this->session->flash( + key: 'inertia.clear_history', + value: true, + ); + + return $this; } public function location(string|Redirect $url): Response diff --git a/src/functions.php b/src/functions.php index b262230..34b7f7c 100644 --- a/src/functions.php +++ b/src/functions.php @@ -11,12 +11,12 @@ /** * @return ($component is null ? Inertia : InertiaResponse) */ - function inertia(string|null $page = null, array $props = []): InertiaResponse|Inertia + function inertia(string|null $component = null, array $props = []): InertiaResponse|Inertia { - if ($page === null) { + if ($component === null) { return get(Inertia::class); } - return get(Inertia::class)->render($page, $props); + return get(Inertia::class)->render($component, $props); } } diff --git a/tests/Fixtures/TestController.php b/tests/Fixtures/TestController.php index 720d5b2..8609699 100644 --- a/tests/Fixtures/TestController.php +++ b/tests/Fixtures/TestController.php @@ -5,47 +5,83 @@ namespace NeoIsRecursive\Inertia\Tests\Fixtures; use NeoIsRecursive\Inertia\Http\InertiaResponse; +use NeoIsRecursive\Inertia\Http\Middleware\EncryptHistory; use NeoIsRecursive\Inertia\Inertia; use NeoIsRecursive\Inertia\Props\AlwaysProp; +use Tempest\Http\Responses\Ok; +use Tempest\Http\Responses\Redirect; +use Tempest\Http\Session\Session; use Tempest\Router\Get; use function NeoIsRecursive\Inertia\inertia; +use function Tempest\uri; final readonly class TestController { public function index(): InertiaResponse { - return inertia(page: 'Index'); + return inertia(component: 'Index'); + } + + #[Get(uri: '/non-inertia-page')] + public function nonInertiaPage(): Ok + { + return new Ok(body: [ + 'message' => 'This is a non-Inertia page.', + ]); } #[Get(uri: '/can-share-props-from-any-where')] public function testCanSharePropsFromAnyWhere(Inertia $inertia): InertiaResponse { - $inertia->share( - key: 'foo', - value: 'bar', - ); - - $inertia->share([ - 'baz' => 'qux', - ]); + $inertia + ->share( + key: 'foo', + value: 'bar', + ) + ->share([ + 'baz' => 'qux', + ]); - return inertia(page: 'User/Edit'); + return inertia(component: 'User/Edit'); } #[Get(uri: '/all-sorts-of-props')] public function testAllSortsOfProps(Inertia $inertia): InertiaResponse { - $inertia->share( + return $inertia->share( key: 'foo', value: 'bar', - ); - - return inertia( - page: 'User/Edit', + )->render( + component: 'User/Edit', props: [ new AlwaysProp(fn() => 'baz'), ], ); } + + #[Get(uri: '/encrypted-history')] + public function testEncryptedHistory(Inertia $inertia): InertiaResponse + { + return $inertia->encryptHistory()->render(component: 'User/Edit'); + } + + #[Get(uri: '/encrypted-history-middleware', middleware: [EncryptHistory::class])] + public function testEncryptedHistoryWithMiddleware(Inertia $inertia): InertiaResponse + { + return $inertia->render(component: 'User/Edit'); + } + + #[Get(uri: '/cleared-history')] + public function testClearedHistory(Inertia $inertia): InertiaResponse + { + return $inertia->clearHistory()->render(component: 'User/Edit'); + } + + #[Get(uri: '/redirect-with-clear-history')] + public function testRedirectWithClearHistory(Inertia $inertia): Redirect + { + $inertia->clearHistory(); + return new Redirect(to: uri([self::class, 'testCanSharePropsFromAnyWhere'])); + } } diff --git a/tests/Integration/HelperTest.php b/tests/Integration/HelperTest.php index 015e203..956a9db 100644 --- a/tests/Integration/HelperTest.php +++ b/tests/Integration/HelperTest.php @@ -20,7 +20,7 @@ public function test_the_helper_function_returns_an_instance_of_the_response_fac public function test_the_helper_function_returns_a_response_instance(): void { static::assertInstanceOf(Response::class, inertia( - page: 'User/Edit', + component: 'User/Edit', props: ['user' => ['name' => 'Jonathan']], )); } diff --git a/tests/Integration/HistoryTest.php b/tests/Integration/HistoryTest.php new file mode 100644 index 0000000..7efd567 --- /dev/null +++ b/tests/Integration/HistoryTest.php @@ -0,0 +1,145 @@ +version; + + $response = $this->http->get(uri([TestController::class, 'testCanSharePropsFromAnyWhere']), headers: [ + Header::INERTIA => 'true', + Header::VERSION => $version, + ]); + + $response->assertOk(); + static::assertArraySubsetValues([ + 'component' => 'User/Edit', + 'encryptHistory' => false, + 'clearHistory' => false, + ], $response->body); + } + + public function test_the_history_can_be_encrypted(): void + { + $version = get(Inertia::class)->version; + + $response = $this->http->get(uri([TestController::class, 'testEncryptedHistory']), headers: [ + Header::INERTIA => 'true', + Header::VERSION => $version, + ]); + + $response->assertOk(); + $this->assertArraySubsetValues([ + 'component' => 'User/Edit', + 'encryptHistory' => true, + ], $response->body); + } + + public function test_the_history_can_be_encrypted_via_middleware(): void + { + static::markTestIncomplete(message: 'This test is incomplete and needs to be fixed.'); + + // Route::middleware([StartSession::class, ExampleMiddleware::class, EncryptHistoryMiddleware::class])->get('/', function () { + // return Inertia::render('User/Edit'); + // }); + // $response = $this->withoutExceptionHandling()->get('/', [ + // 'X-Inertia' => 'true', + // ]); + // $response->assertSuccessful(); + // $response->assertJson([ + // 'component' => 'User/Edit', + // 'encryptHistory' => true, + // ]); + } + + public function test_the_history_can_be_encrypted_via_middleware_alias(): void + { + static::markTestIncomplete(message: 'This test is incomplete and needs to be fixed.'); + + // Route::middleware([StartSession::class, ExampleMiddleware::class, 'inertia.encrypt'])->get('/', function () { + // return Inertia::render('User/Edit'); + // }); + // $response = $this->withoutExceptionHandling()->get('/', [ + // 'X-Inertia' => 'true', + // ]); + // $response->assertSuccessful(); + // $response->assertJson([ + // 'component' => 'User/Edit', + // 'encryptHistory' => true, + // ]); + } + + public function test_the_history_can_be_encrypted_globally(): void + { + static::markTestIncomplete(message: 'This test is incomplete and needs to be fixed.'); + + // Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + // Config::set('inertia.history.encrypt', true); + // return Inertia::render('User/Edit'); + // }); + // $response = $this->withoutExceptionHandling()->get('/', [ + // 'X-Inertia' => 'true', + // ]); + // $response->assertSuccessful(); + // $response->assertJson([ + // 'component' => 'User/Edit', + // 'encryptHistory' => true, + // ]); + } + + public function test_the_history_can_be_encrypted_globally_and_overridden(): void + { + $response = $this->http->get(uri([TestController::class, 'testEncryptedHistoryWithMiddleware']), headers: [ + Header::INERTIA => 'true', + ]); + + $response->assertOk(); + $this->assertArraySubsetValues([ + 'component' => 'User/Edit', + 'encryptHistory' => true, + ], $response->body); + } + + public function test_the_history_can_be_cleared(): void + { + $version = get(Inertia::class)->version; + $response = $this->http->get(uri([TestController::class, 'testClearedHistory']), headers: [ + Header::INERTIA => 'true', + Header::VERSION => $version, + ]); + + $response->assertOk(); + $this->assertArraySubsetValues([ + 'component' => 'User/Edit', + 'clearHistory' => true, + ], $response->body); + } + + public function test_the_history_can_be_cleared_when_redirecting(): void + { + $version = get(Inertia::class)->version; + $response = $this->http->get(uri([TestController::class, 'testRedirectWithClearHistory']), headers: [ + Header::INERTIA => 'true', + Header::VERSION => $version, + ]); + + $response->assertRedirect(uri([TestController::class, 'testCanSharePropsFromAnyWhere'])); + + static::assertTrue(get(Session::class)->get('inertia.clear_history')); + // $response->assertContent('
'); + } +} diff --git a/tests/Integration/InertiaTest.php b/tests/Integration/InertiaTest.php index 8c3fa5a..2856f9d 100644 --- a/tests/Integration/InertiaTest.php +++ b/tests/Integration/InertiaTest.php @@ -22,7 +22,7 @@ final class InertiaTest extends TestCase { private function createFactory(): Inertia { - return new Inertia($this->container, $this->container->get(InertiaConfig::class)); + return $this->container->get(Inertia::class); } public function test_location_response_for_inertia_requests(): void @@ -129,14 +129,40 @@ public function test_location_response_for_non_inertia_requests_using_redirect_r expected: 'https://inertiajs.com', actual: $response->getHeader(name: 'Location')->values[0], ); - // $this->assertSame(get(Session::class), $response->()); - // $this->assertSame($request, $response->getRequest()); + static::assertSame( expected: $response, actual: $redirect, ); } + public function test_returns_conflict_response_for_inertia_requests_with_different_version(): void + { + $response = $this->http->get(uri([TestController::class, 'testCanSharePropsFromAnyWhere']), headers: [ + Header::INERTIA => 'true', + Header::VERSION => 'invalid-version', + ]); + + $response->assertStatus(Status::CONFLICT); + } + + public function test_returns_response_if_it_isnt_an_inertia_request(): void + { + $response = $this->http->get(uri([TestController::class, 'nonInertiaPage'])); + + $response->assertOk(); + $response->assertHeaderContains( + name: 'Vary', + value: Header::INERTIA, + ); + static::assertSame( + expected: [ + 'message' => 'This is a non-Inertia page.', + ], + actual: $response->body, + ); + } + public function test_shared_data_can_be_shared_from_anywhere(): void { $version = get(Inertia::class)->version; @@ -158,6 +184,8 @@ public function test_shared_data_can_be_shared_from_anywhere(): void ], 'url' => uri([TestController::class, 'testCanSharePropsFromAnyWhere']), 'version' => $version, + 'clearHistory' => false, + 'encryptHistory' => false, ], actual: $response->body, ); diff --git a/tests/Integration/ResponseTest.php b/tests/Integration/ResponseTest.php index c903dd3..fca7c79 100644 --- a/tests/Integration/ResponseTest.php +++ b/tests/Integration/ResponseTest.php @@ -28,7 +28,7 @@ public function test_server_response(): void $user = ['name' => 'Jonathan']; $response = new InertiaResponse( $request, - page: 'User/Edit', + component: 'User/Edit', props: ['user' => $user], rootView: __DIR__ . '/../Fixtures/root.view.php', version: '123', @@ -56,7 +56,7 @@ public function test_server_response(): void actual: $page['version'], ); static::assertSame( - expected: '
', + expected: '
', actual: get(ViewRenderer::class)->render($view), ); } @@ -68,7 +68,7 @@ public function test_xhr_response(): void $user = ['name' => 'Jonathan']; $response = new InertiaResponse( $request, - page: 'User/Edit', + component: 'User/Edit', props: ['user' => $user], rootView: 'app', version: '123', @@ -135,7 +135,7 @@ public function test_lazy_resource_response(): void $response = new InertiaResponse( $request, - page: 'User/Index', + component: 'User/Index', props: ['users' => $callable], rootView: 'app', version: '123', @@ -279,7 +279,7 @@ public function test_xhr_partial_response(): void $user = (object) ['name' => 'Jonathan']; $response = new InertiaResponse( $request, - page: 'User/Edit', + component: 'User/Edit', props: ['user' => $user, 'partial' => 'partial-data'], rootView: 'app', version: '123', @@ -323,7 +323,7 @@ public function test_exclude_props_from_partial_response(): void $user = (object) ['name' => 'Jonathan']; $response = new InertiaResponse( $request, - page: 'User/Edit', + component: 'User/Edit', props: [ 'user' => $user, 'partial' => 'partial-data', @@ -368,7 +368,7 @@ public function test_lazy_props_are_not_included_by_default(): void $response = new InertiaResponse( $request, - page: 'Users', + component: 'Users', props: ['users' => [], 'lazy' => $lazyProp], rootView: 'app', version: '123', @@ -398,7 +398,7 @@ public function test_lazy_props_are_included_in_partial_reload(): void $response = new InertiaResponse( $request, - page: 'Users', + component: 'Users', props: ['users' => [], 'lazy' => $lazyProp], rootView: 'app', version: '123', @@ -439,7 +439,13 @@ public function test_always_props_are_included_on_partial_reload(): void }), ]; - $response = new InertiaResponse($request, page: 'User/Edit', props: $props, rootView: 'app', version: '123'); + $response = new InertiaResponse( + $request, + component: 'User/Edit', + props: $props, + rootView: 'app', + version: '123', + ); $page = $response->body; static::assertSame( @@ -472,7 +478,13 @@ public function test_top_level_dot_props_get_unpacked(): void uri: '/products/123', ); - $response = new InertiaResponse($request, page: 'User/Edit', props: $props, rootView: 'app', version: '123'); + $response = new InertiaResponse( + $request, + component: 'User/Edit', + props: $props, + rootView: 'app', + version: '123', + ); $page = $response->body; @@ -507,7 +519,13 @@ public function test_nested_dot_props_do_not_get_unpacked(): void uri: '/products/123', ); - $response = new InertiaResponse($request, page: 'User/Edit', props: $props, rootView: 'app', version: '123'); + $response = new InertiaResponse( + $request, + component: 'User/Edit', + props: $props, + rootView: 'app', + version: '123', + ); $page = $response->body; $auth = $page['props']['auth']; @@ -584,7 +602,7 @@ public function test_prop_as_basic_array(): void $response = new InertiaResponse( $request, - page: 'Years', + component: 'Years', props: ['years' => [2022, 2023, 2024]], rootView: 'app', version: '123', @@ -605,7 +623,7 @@ public function test_dot_notation_props_are_merged_with_shared_props(): void $response = new InertiaResponse( $request, - page: 'Test', + component: 'Test', props: [ 'auth' => ['user' => ['name' => 'Jonathan']], // shared prop 'auth.user.is_super' => true, @@ -636,7 +654,7 @@ public function test_dot_notation_props_are_merged_with_lazy_shared_props(): voi $response = new InertiaResponse( $request, - page: 'Test', + component: 'Test', props: [ 'auth' => function (): array { return ['user' => ['name' => 'Jonathan']]; @@ -670,7 +688,7 @@ public function test_dot_notation_props_are_merged_with_other_dot_notation_props $response = new InertiaResponse( $request, - page: 'Test', + component: 'Test', props: [ 'auth.user' => ['name' => 'Jonathan'], 'auth.user.is_super' => true, @@ -702,7 +720,7 @@ public function test_server_response_with_deferred_prop(): void $user = ['name' => 'Jonathan']; $response = new InertiaResponse( request: $request, - page: 'User/Edit', + component: 'User/Edit', props: [ 'user' => $user, 'foo' => new DeferProp(function () { @@ -740,8 +758,8 @@ public function test_server_response_with_deferred_prop(): void actual: $pageData['deferredProps'], ); - // $this->assertFalse($pageData['clearHistory']); - // $this->assertFalse($pageData['encryptHistory']); + $this->assertFalse($pageData['clearHistory']); + $this->assertFalse($pageData['encryptHistory']); } public function test_server_response_with_deferred_prop_and_multiple_groups(): void @@ -751,7 +769,7 @@ public function test_server_response_with_deferred_prop_and_multiple_groups(): v $user = ['name' => 'Jonathan']; $response = new InertiaResponse( request: $request, - page: 'User/Edit', + component: 'User/Edit', props: [ 'user' => $user, 'foo' => new DeferProp(function (): string { @@ -794,8 +812,8 @@ public function test_server_response_with_deferred_prop_and_multiple_groups(): v actual: $page['deferredProps'], ); - // $this->assertFalse($page['clearHistory']); - // $this->assertFalse($page['encryptHistory']); + $this->assertFalse($page['clearHistory']); + $this->assertFalse($page['encryptHistory']); // $this->assertSame('
', $view->render()); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 991eff8..878c729 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -51,4 +51,12 @@ public function createInertiaRequest(Method $method, string $uri, array $headers $this->container->singleton(Request::class, fn() => $request); return $request; } + + public function assertArraySubsetValues(array $subset, array $array): void + { + foreach ($subset as $key => $value) { + $this->assertArrayHasKey($key, $array); + $this->assertSame($value, $array[$key]); + } + } }