From e511ff2c82277c8f5f65f38711cc023c4982fcce Mon Sep 17 00:00:00 2001 From: Jim Brouwer Date: Sun, 8 Jun 2025 02:24:35 +0200 Subject: [PATCH 1/2] Deep merge shared props in ResponseFactory - Add DeepMergesSharedProps trait to flatten and deep merge overlapping shared props within a single Inertia response - Update ResponseFactory to apply deep merging when rendering components and sharing props --- src/DeepMergesSharedProps.php | 71 ++++++++ src/ResponseFactory.php | 17 +- tests/DeepMergesSharedPropsTest.php | 240 ++++++++++++++++++++++++++++ tests/ResponseFactoryTest.php | 30 ++++ 4 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 src/DeepMergesSharedProps.php create mode 100644 tests/DeepMergesSharedPropsTest.php diff --git a/src/DeepMergesSharedProps.php b/src/DeepMergesSharedProps.php new file mode 100644 index 00000000..1b2f6333 --- /dev/null +++ b/src/DeepMergesSharedProps.php @@ -0,0 +1,71 @@ + $prop) { + $propArray = $this->attemptArrayCast($prop); + $sharedPropArray = $this->attemptArrayCast(Arr::get($sharedProps, $key)); + + $shouldFlattenPropArray = is_int($key) && is_array($propArray); + if ($shouldFlattenPropArray) { + $sharedProps = $this->deepMergeSharedProps($propArray, $sharedProps); + + continue; + } + + $shouldOverride = ! is_array($propArray) || ! is_array($sharedPropArray); + if ($shouldOverride) { + Arr::set($sharedProps, $key, $propArray); + + continue; + } + + $shouldConcatenate = $this->isIndexedArray($propArray) && $this->isIndexedArray($sharedPropArray); + if ($shouldConcatenate) { + Arr::set($sharedProps, $key, array_merge($sharedPropArray, $propArray)); + + continue; + } + + Arr::set($sharedProps, $key, $this->deepMergeSharedProps($propArray, $sharedPropArray)); + } + + return $sharedProps; + } + + protected function isIndexedArray(array $array): bool + { + return array_keys($array) === range(0, count($array) - 1); + } + + protected function attemptArrayCast(mixed $value): mixed + { + if ($value instanceof Closure) { + $reflection = new ReflectionFunction($value); + + if (! $reflection->getNumberOfRequiredParameters()) { + $value = call_user_func($value); + } + } + + if ($value instanceof Arrayable) { + return $value->toArray(); + } + + return $value; + } +} diff --git a/src/ResponseFactory.php b/src/ResponseFactory.php index 74fd4e8b..cfb27754 100644 --- a/src/ResponseFactory.php +++ b/src/ResponseFactory.php @@ -16,6 +16,7 @@ class ResponseFactory { + use DeepMergesSharedProps; use Macroable; /** @var string */ @@ -46,13 +47,13 @@ public function setRootView(string $name): void */ public function share($key, $value = null): void { - if (is_array($key)) { - $this->sharedProps = array_merge($this->sharedProps, $key); - } elseif ($key instanceof Arrayable) { - $this->sharedProps = array_merge($this->sharedProps, $key->toArray()); - } else { - Arr::set($this->sharedProps, $key, $value); - } + $value = match (true) { + is_string($key) => [$key => $value], + is_array($key) => $key, + $key instanceof Arrayable => $value->toArray(), + }; + + $this->sharedProps = $this->deepMergeSharedProps($value, $this->sharedProps); } /** @@ -159,7 +160,7 @@ public function render(string $component, $props = []): Response return new Response( $component, - array_merge($this->sharedProps, $props), + $this->deepMergeSharedProps($props, $this->sharedProps), $this->rootView, $this->getVersion(), $this->encryptHistory ?? config('inertia.history.encrypt', false), diff --git a/tests/DeepMergesSharedPropsTest.php b/tests/DeepMergesSharedPropsTest.php new file mode 100644 index 00000000..daa7595f --- /dev/null +++ b/tests/DeepMergesSharedPropsTest.php @@ -0,0 +1,240 @@ +sharedPropsDeepMerger = new class + { + use DeepMergesSharedProps; + + public function handle(array $props, array $sharedProps = []): array + { + return $this->deepMergeSharedProps($props, $sharedProps); + } + }; + } + + public function test_it_merges_props(): void + { + $props = ['auth.user.can' => ['edit']]; + $sharedProps = []; + Arr::set($sharedProps, 'auth.user.can', ['view']); + + $result = $this->sharedPropsDeepMerger->handle($props, $sharedProps); + + $this->assertEquals([ + 'auth' => [ + 'user' => [ + 'can' => [ + 'view', + 'edit', + ], + ], + ], + ], $result); + } + + public function test_it_adds_props_with_different_keys_without_merging(): void + { + $props = ['user' => ['John Doe']]; + $sharedProps = ['page' => ['user.show']]; + + $result = $this->sharedPropsDeepMerger->handle($props, $sharedProps); + + $this->assertEquals([ + 'user' => ['John Doe'], + 'page' => ['user.show'], + ], $result); + } + + public function test_it_overrides_existing_values(): void + { + $props = ['page' => 'user.index']; + $sharedProps = ['page' => 'user.show']; + + $result = $this->sharedPropsDeepMerger->handle($props, $sharedProps); + + $this->assertEquals(['page' => 'user.index'], $result); + } + + public function test_it_flattens_nested_props_when_the_root_key_is_not_associative(): void + { + $result = $this->sharedPropsDeepMerger->handle([ + 'user' => 'John Doe', + ['gender' => 'male'], + [['age' => 40]], + 'hobbies' => ['tennis', 'chess'], + ], ['id' => 1]); + + $this->assertEquals([ + 'id' => 1, + 'user' => 'John Doe', + 'gender' => 'male', + 'age' => 40, + 'hobbies' => ['tennis', 'chess'], + ], $result); + } + + public function test_it_can_handle_an_arrayable(): void + { + $arrayable = new Collection([ + 'auth.user' => 'John Doe', + ]); + + $result = $this->sharedPropsDeepMerger->handle([$arrayable]); + + $this->assertEquals([ + 'auth' => [ + 'user' => 'John Doe', + ], + ], $result); + } + + public function test_it_can_merge_arrayables(): void + { + $result = $this->sharedPropsDeepMerger->handle( + [ + 'auth.user' => new Collection(['name' => 'John Doe']), + ], + [ + 'auth' => [ + 'user' => new Collection(['id' => 1]), + ], + ], + ); + + $this->assertEquals([ + 'auth' => [ + 'user' => [ + 'id' => 1, + 'name' => 'John Doe', + ], + ], + ], $result); + } + + public function test_it_can_merge_callables(): void + { + $result = $this->sharedPropsDeepMerger->handle( + [ + 'auth' => fn (): array => [ + 'user' => [ + 'name' => 'John Doe', + ], + ], + ], + [ + 'auth' => fn (): array => [ + 'user' => [ + 'id' => 1, + ], + ], + ], + ); + + $this->assertEquals([ + 'auth' => [ + 'user' => [ + 'id' => 1, + 'name' => 'John Doe', + ], + ], + ], $result); + } + + public function test_it_overrides_values_with_unmergeable_types(): void + { + $props = [ + 'resource' => new FakeResource(['Replacement']), + 'scalar' => 'Replacement', + 'uncallable' => fn (string $value): string => 'Replacement', + ]; + $sharedProps = [ + 'resource' => new FakeResource(['Original']), + 'scalar' => 'Original', + 'uncallable' => fn (string $value): string => 'Original', + ]; + + $result = $this->sharedPropsDeepMerger->handle($props, $sharedProps); + + $this->assertEquals($props, $result); + } + + public function test_it_deep_merges_arrayables_and_arrays(): void + { + $result = $this->sharedPropsDeepMerger->handle([ + new Collection([ + 'auth.user' => [ + 'name' => 'John Doe', + ], + ]), + [ + 'auth.user.can' => [ + 'edit_profile', + ], + ], + ], [ + 'auth' => [ + 'user' => new Collection([ + 'id' => 1, + 'can' => new Collection(['delete_profile']), + ]), + ], + ]); + + $this->assertEquals([ + 'auth' => [ + 'user' => [ + 'name' => 'John Doe', + 'id' => 1, + 'can' => [ + 'delete_profile', + 'edit_profile', + ], + ], + ], + ], $result); + } + + public function test_it_flattens_and_merges_nested_props(): void + { + $result = $this->sharedPropsDeepMerger->handle([ + [ + 'auth.user.can.manage_profiles' => true, + ], + ], [ + 'auth' => [ + 'user' => [ + 'can' => [ + 'edit_profile' => false, + 'delete_profile' => false, + ], + ], + ], + ]); + + $this->assertEquals([ + 'auth' => [ + 'user' => [ + 'can' => [ + 'edit_profile' => false, + 'delete_profile' => false, + 'manage_profiles' => true, + ], + ], + ], + ], $result); + } +} diff --git a/tests/ResponseFactoryTest.php b/tests/ResponseFactoryTest.php index 65e7df46..e33ad2e0 100644 --- a/tests/ResponseFactoryTest.php +++ b/tests/ResponseFactoryTest.php @@ -146,6 +146,36 @@ public function test_shared_data_can_be_shared_from_anywhere(): void ]); } + public function test_shared_data_can_be_merged(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('auth.user.can.access_user_management', true); + Inertia::share('auth.user.can.delete_user', false); + + return Inertia::render('User/Show', [ + 'auth.user.can' => ['edit_user' => false], + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Show', + 'props' => [ + 'auth' => [ + 'user' => [ + 'can' => [ + 'access_user_management' => true, + 'delete_user' => false, + 'edit_user' => false, + ], + ], + ], + ], + ]); + } + public function test_dot_props_are_merged_from_shared(): void { Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { From 2c2d1e8e50b815a95fb48628056760952efefc73 Mon Sep 17 00:00:00 2001 From: Jim Brouwer Date: Tue, 10 Jun 2025 23:27:57 +0200 Subject: [PATCH 2/2] Implement configurable merge strategy for shared props - Adds support for merge strategy pattern when sharing props - Default behavior remains unchanged via `ShallowMergeStrategy` - Allows consumers to override globally or per call with a custom strategy --- ...sSharedProps.php => DeepMergeStrategy.php} | 30 ++- src/Inertia.php | 1 + src/MergeStrategy.php | 10 + src/ResponseFactory.php | 30 ++- src/ServiceProvider.php | 1 + src/ShallowMergeStrategy.php | 22 ++ tests/DeepMergeStrategyTest.php | 222 ++++++++++++++++ tests/DeepMergesSharedPropsTest.php | 240 ------------------ tests/ResponseFactoryTest.php | 127 +++++++-- tests/ShallowMergeStrategyTest.php | 116 +++++++++ 10 files changed, 514 insertions(+), 285 deletions(-) rename src/{DeepMergesSharedProps.php => DeepMergeStrategy.php} (63%) create mode 100644 src/MergeStrategy.php create mode 100644 src/ShallowMergeStrategy.php create mode 100644 tests/DeepMergeStrategyTest.php delete mode 100644 tests/DeepMergesSharedPropsTest.php create mode 100644 tests/ShallowMergeStrategyTest.php diff --git a/src/DeepMergesSharedProps.php b/src/DeepMergeStrategy.php similarity index 63% rename from src/DeepMergesSharedProps.php rename to src/DeepMergeStrategy.php index 1b2f6333..d5e54963 100644 --- a/src/DeepMergesSharedProps.php +++ b/src/DeepMergeStrategy.php @@ -7,44 +7,52 @@ use Illuminate\Support\Arr; use ReflectionFunction; -trait DeepMergesSharedProps +class DeepMergeStrategy implements MergeStrategy { /** * Recursively merges multiple shared Inertia props within the current request. * This method ensures that overlapping keys between multiple sets of props * are merged deeply instead of overwritten, preserving nested structures. */ - protected function deepMergeSharedProps(array $props, array $sharedProps = []): array + public function merge(array $original, string|array|Arrayable $key, mixed $value = null): array { - foreach ($props as $key => $prop) { + $mergedProps = $original; + + $newProps = match (true) { + is_string($key) => [$key => $value], + is_array($key) => $key, + $key instanceof Arrayable => $value->toArray(), + }; + + foreach ($newProps as $key => $prop) { $propArray = $this->attemptArrayCast($prop); - $sharedPropArray = $this->attemptArrayCast(Arr::get($sharedProps, $key)); + $mergedPropArray = $this->attemptArrayCast(Arr::get($mergedProps, $key)); $shouldFlattenPropArray = is_int($key) && is_array($propArray); if ($shouldFlattenPropArray) { - $sharedProps = $this->deepMergeSharedProps($propArray, $sharedProps); + $mergedProps = $this->merge($propArray, $mergedProps); continue; } - $shouldOverride = ! is_array($propArray) || ! is_array($sharedPropArray); + $shouldOverride = ! is_array($propArray) || ! is_array($mergedPropArray); if ($shouldOverride) { - Arr::set($sharedProps, $key, $propArray); + Arr::set($mergedProps, $key, $propArray); continue; } - $shouldConcatenate = $this->isIndexedArray($propArray) && $this->isIndexedArray($sharedPropArray); + $shouldConcatenate = $this->isIndexedArray($propArray) && $this->isIndexedArray($mergedPropArray); if ($shouldConcatenate) { - Arr::set($sharedProps, $key, array_merge($sharedPropArray, $propArray)); + Arr::set($mergedProps, $key, array_merge($mergedPropArray, $propArray)); continue; } - Arr::set($sharedProps, $key, $this->deepMergeSharedProps($propArray, $sharedPropArray)); + Arr::set($mergedProps, $key, $this->merge($propArray, $mergedPropArray)); } - return $sharedProps; + return $mergedProps; } protected function isIndexedArray(array $array): bool diff --git a/src/Inertia.php b/src/Inertia.php index 104f03ac..3c567a8b 100644 --- a/src/Inertia.php +++ b/src/Inertia.php @@ -8,6 +8,7 @@ * @method static void setRootView(string $name) * @method static void share(string|array|\Illuminate\Contracts\Support\Arrayable $key, mixed $value = null) * @method static mixed getShared(string|null $key = null, mixed $default = null) + * @method static self setSharedPropMerger(MergeStrategy $mergeStrategy) * @method static void clearHistory() * @method static void encryptHistory($encrypt = true) * @method static void flushShared() diff --git a/src/MergeStrategy.php b/src/MergeStrategy.php new file mode 100644 index 00000000..946fbae0 --- /dev/null +++ b/src/MergeStrategy.php @@ -0,0 +1,10 @@ + [$key => $value], - is_array($key) => $key, - $key instanceof Arrayable => $value->toArray(), - }; + if ($value instanceof MergeStrategy) { + $this->sharedProps = $value->merge($this->sharedProps, $key); + + return; + } + + if (is_null($this->sharedPropMerger)) { + $this->sharedPropMerger = App::make(MergeStrategy::class); + } - $this->sharedProps = $this->deepMergeSharedProps($value, $this->sharedProps); + $this->sharedProps = $this->sharedPropMerger->merge($this->sharedProps, $key, $value); } /** @@ -69,6 +74,13 @@ public function getShared(?string $key = null, $default = null) return $this->sharedProps; } + public function setSharedPropMerger(MergeStrategy $mergeStrategy): self + { + $this->sharedPropMerger = $mergeStrategy; + + return $this; + } + /** * @return void */ @@ -158,9 +170,11 @@ public function render(string $component, $props = []): Response $props = $props->toArray(); } + $this->share($props); + return new Response( $component, - $this->deepMergeSharedProps($props, $this->sharedProps), + $this->sharedProps, $this->rootView, $this->getVersion(), $this->encryptHistory ?? config('inertia.history.encrypt', false), diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index b0e0e0c8..906df6b6 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -20,6 +20,7 @@ public function register(): void { $this->app->singleton(ResponseFactory::class); $this->app->bind(Gateway::class, HttpGateway::class); + $this->app->bind(MergeStrategy::class, ShallowMergeStrategy::class); $this->mergeConfigFrom( __DIR__.'/../config/inertia.php', diff --git a/src/ShallowMergeStrategy.php b/src/ShallowMergeStrategy.php new file mode 100644 index 00000000..59e15a57 --- /dev/null +++ b/src/ShallowMergeStrategy.php @@ -0,0 +1,22 @@ +toArray()); + } else { + Arr::set($original, $key, $value); + } + + return $original; + } +} diff --git a/tests/DeepMergeStrategyTest.php b/tests/DeepMergeStrategyTest.php new file mode 100644 index 00000000..33d8d18d --- /dev/null +++ b/tests/DeepMergeStrategyTest.php @@ -0,0 +1,222 @@ + ['view'], + ]; + $input = [ + 'can' => ['edit'], + ]; + + $result = App::make(DeepMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'can' => [ + 'view', + 'edit', + ], + ], $result); + } + + public function test_it_merges_props_by_key(): void + { + $original = [ + 'can' => ['view'], + ]; + + $result = App::make(DeepMergeStrategy::class)->merge($original, 'can', ['edit']); + + $this->assertEquals([ + 'can' => [ + 'view', + 'edit', + ], + ], $result); + } + + public function test_it_adds_props_with_different_keys_without_merging(): void + { + $original = ['pages' => ['user.show']]; + $input = ['users' => ['John Doe']]; + + $result = App::make(DeepMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'users' => ['John Doe'], + 'pages' => ['user.show'], + ], $result); + } + + public function test_it_overrides_existing_values(): void + { + $original = ['page' => 'user.show']; + $input = ['page' => 'user.index']; + + $result = App::make(DeepMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'page' => 'user.index', + ], $result); + } + + public function test_it_flattens_nested_props_when_the_root_key_is_not_associative(): void + { + $original = [ + 'user' => [ + 'id' => 1, + ], + ]; + $input = [ + 'user' => [ + 'name' => 'John Doe', + 'hobbies' => ['tennis', 'chess'], + ], + [ + 'user' => [ + 'gender' => 'male', + 'age' => 40, + ], + ], + ]; + + $result = App::make(DeepMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'user' => [ + 'id' => 1, + 'name' => 'John Doe', + 'gender' => 'male', + 'age' => 40, + 'hobbies' => ['tennis', 'chess'], + ], + ], $result); + } + + public function test_it_can_merge_arrayables(): void + { + $original = ['user' => new Collection(['id' => 1])]; + $input = ['user' => new Collection(['name' => 'John Doe'])]; + + $result = App::make(DeepMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'user' => [ + 'id' => 1, + 'name' => 'John Doe', + ], + ], $result); + } + + public function test_it_can_merge_callables(): void + { + $original = [ + 'user' => fn (): array => [ + 'id' => 1, + ], + ]; + $input = [ + 'user' => fn (): array => [ + 'name' => 'John Doe', + ], + ]; + + $result = App::make(DeepMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'user' => [ + 'id' => 1, + 'name' => 'John Doe', + ], + ], $result); + } + + public function test_it_overrides_values_with_unmergeable_types(): void + { + $original = [ + 'resource' => new FakeResource(['Original']), + 'scalar' => 'Original', + 'uncallable' => fn (string $value): string => 'Original', + ]; + $input = [ + 'resource' => new FakeResource(['Replacement']), + 'scalar' => 'Replacement', + 'uncallable' => fn (string $value): string => 'Replacement', + ]; + + $result = App::make(DeepMergeStrategy::class)->merge($original, $input); + + $this->assertEquals($input, $result); + } + + public function test_it_deep_merges_arrayables_and_arrays(): void + { + $original = [ + 'auth' => [ + 'user' => new Collection([ + 'id' => 1, + 'can' => new Collection(['delete_profile']), + ]), + ], + ]; + $input = [ + 'auth.user' => new Collection([ + 'name' => 'John Doe', + ]), + 'auth.user.can' => [ + 'edit_profile', + ], + ]; + + $result = App::make(DeepMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'auth' => [ + 'user' => [ + 'name' => 'John Doe', + 'id' => 1, + 'can' => [ + 'delete_profile', + 'edit_profile', + ], + ], + ], + ], $result); + } + + public function test_it_flattens_and_merges_nested_props(): void + { + $original = [ + 'can' => [ + 'edit_profile' => false, + 'delete_profile' => false, + ], + ]; + $input = [ + [ + 'can' => [ + 'manage_profiles' => true, + ], + ], + ]; + + $result = App::make(DeepMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'can' => [ + 'edit_profile' => false, + 'delete_profile' => false, + 'manage_profiles' => true, + ], + ], $result); + } +} diff --git a/tests/DeepMergesSharedPropsTest.php b/tests/DeepMergesSharedPropsTest.php deleted file mode 100644 index daa7595f..00000000 --- a/tests/DeepMergesSharedPropsTest.php +++ /dev/null @@ -1,240 +0,0 @@ -sharedPropsDeepMerger = new class - { - use DeepMergesSharedProps; - - public function handle(array $props, array $sharedProps = []): array - { - return $this->deepMergeSharedProps($props, $sharedProps); - } - }; - } - - public function test_it_merges_props(): void - { - $props = ['auth.user.can' => ['edit']]; - $sharedProps = []; - Arr::set($sharedProps, 'auth.user.can', ['view']); - - $result = $this->sharedPropsDeepMerger->handle($props, $sharedProps); - - $this->assertEquals([ - 'auth' => [ - 'user' => [ - 'can' => [ - 'view', - 'edit', - ], - ], - ], - ], $result); - } - - public function test_it_adds_props_with_different_keys_without_merging(): void - { - $props = ['user' => ['John Doe']]; - $sharedProps = ['page' => ['user.show']]; - - $result = $this->sharedPropsDeepMerger->handle($props, $sharedProps); - - $this->assertEquals([ - 'user' => ['John Doe'], - 'page' => ['user.show'], - ], $result); - } - - public function test_it_overrides_existing_values(): void - { - $props = ['page' => 'user.index']; - $sharedProps = ['page' => 'user.show']; - - $result = $this->sharedPropsDeepMerger->handle($props, $sharedProps); - - $this->assertEquals(['page' => 'user.index'], $result); - } - - public function test_it_flattens_nested_props_when_the_root_key_is_not_associative(): void - { - $result = $this->sharedPropsDeepMerger->handle([ - 'user' => 'John Doe', - ['gender' => 'male'], - [['age' => 40]], - 'hobbies' => ['tennis', 'chess'], - ], ['id' => 1]); - - $this->assertEquals([ - 'id' => 1, - 'user' => 'John Doe', - 'gender' => 'male', - 'age' => 40, - 'hobbies' => ['tennis', 'chess'], - ], $result); - } - - public function test_it_can_handle_an_arrayable(): void - { - $arrayable = new Collection([ - 'auth.user' => 'John Doe', - ]); - - $result = $this->sharedPropsDeepMerger->handle([$arrayable]); - - $this->assertEquals([ - 'auth' => [ - 'user' => 'John Doe', - ], - ], $result); - } - - public function test_it_can_merge_arrayables(): void - { - $result = $this->sharedPropsDeepMerger->handle( - [ - 'auth.user' => new Collection(['name' => 'John Doe']), - ], - [ - 'auth' => [ - 'user' => new Collection(['id' => 1]), - ], - ], - ); - - $this->assertEquals([ - 'auth' => [ - 'user' => [ - 'id' => 1, - 'name' => 'John Doe', - ], - ], - ], $result); - } - - public function test_it_can_merge_callables(): void - { - $result = $this->sharedPropsDeepMerger->handle( - [ - 'auth' => fn (): array => [ - 'user' => [ - 'name' => 'John Doe', - ], - ], - ], - [ - 'auth' => fn (): array => [ - 'user' => [ - 'id' => 1, - ], - ], - ], - ); - - $this->assertEquals([ - 'auth' => [ - 'user' => [ - 'id' => 1, - 'name' => 'John Doe', - ], - ], - ], $result); - } - - public function test_it_overrides_values_with_unmergeable_types(): void - { - $props = [ - 'resource' => new FakeResource(['Replacement']), - 'scalar' => 'Replacement', - 'uncallable' => fn (string $value): string => 'Replacement', - ]; - $sharedProps = [ - 'resource' => new FakeResource(['Original']), - 'scalar' => 'Original', - 'uncallable' => fn (string $value): string => 'Original', - ]; - - $result = $this->sharedPropsDeepMerger->handle($props, $sharedProps); - - $this->assertEquals($props, $result); - } - - public function test_it_deep_merges_arrayables_and_arrays(): void - { - $result = $this->sharedPropsDeepMerger->handle([ - new Collection([ - 'auth.user' => [ - 'name' => 'John Doe', - ], - ]), - [ - 'auth.user.can' => [ - 'edit_profile', - ], - ], - ], [ - 'auth' => [ - 'user' => new Collection([ - 'id' => 1, - 'can' => new Collection(['delete_profile']), - ]), - ], - ]); - - $this->assertEquals([ - 'auth' => [ - 'user' => [ - 'name' => 'John Doe', - 'id' => 1, - 'can' => [ - 'delete_profile', - 'edit_profile', - ], - ], - ], - ], $result); - } - - public function test_it_flattens_and_merges_nested_props(): void - { - $result = $this->sharedPropsDeepMerger->handle([ - [ - 'auth.user.can.manage_profiles' => true, - ], - ], [ - 'auth' => [ - 'user' => [ - 'can' => [ - 'edit_profile' => false, - 'delete_profile' => false, - ], - ], - ], - ]); - - $this->assertEquals([ - 'auth' => [ - 'user' => [ - 'can' => [ - 'edit_profile' => false, - 'delete_profile' => false, - 'manage_profiles' => true, - ], - ], - ], - ], $result); - } -} diff --git a/tests/ResponseFactoryTest.php b/tests/ResponseFactoryTest.php index e33ad2e0..1d57abee 100644 --- a/tests/ResponseFactoryTest.php +++ b/tests/ResponseFactoryTest.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Route; use Inertia\AlwaysProp; +use Inertia\DeepMergeStrategy; use Inertia\DeferProp; use Inertia\Inertia; use Inertia\LazyProp; @@ -146,14 +147,15 @@ public function test_shared_data_can_be_shared_from_anywhere(): void ]); } - public function test_shared_data_can_be_merged(): void + public function test_dot_props_are_merged_from_shared(): void { Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { - Inertia::share('auth.user.can.access_user_management', true); - Inertia::share('auth.user.can.delete_user', false); + Inertia::share('auth.user', [ + 'name' => 'Jonathan', + ]); - return Inertia::render('User/Show', [ - 'auth.user.can' => ['edit_user' => false], + return Inertia::render('User/Edit', [ + 'auth.user.can.create_group' => false, ]); }); @@ -161,14 +163,13 @@ public function test_shared_data_can_be_merged(): void $response->assertSuccessful(); $response->assertJson([ - 'component' => 'User/Show', + 'component' => 'User/Edit', 'props' => [ 'auth' => [ 'user' => [ + 'name' => 'Jonathan', 'can' => [ - 'access_user_management' => true, - 'delete_user' => false, - 'edit_user' => false, + 'create_group' => false, ], ], ], @@ -176,10 +177,31 @@ public function test_shared_data_can_be_merged(): void ]); } - public function test_dot_props_are_merged_from_shared(): void + public function test_shared_data_can_resolve_closure_arguments(): void { + Inertia::share('query', fn (HttpRequest $request) => $request->query()); + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { - Inertia::share('auth.user', [ + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/?foo=bar', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'query' => [ + 'foo' => 'bar', + ], + ], + ]); + } + + public function test_dot_props_with_callbacks_are_merged_from_shared(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('auth.user', fn () => [ 'name' => 'Jonathan', ]); @@ -206,36 +228,88 @@ public function test_dot_props_are_merged_from_shared(): void ]); } - public function test_shared_data_can_resolve_closure_arguments(): void + public function test_shared_props_use_shallow_merge_by_default(): void { - Inertia::share('query', fn (HttpRequest $request) => $request->query()); + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('auth.user.can', [ + 'delete_user' => false, + ]); + + return Inertia::render('User/Show', [ + 'auth.user.can' => [ + 'edit_user' => false, + ], + ]); + }); + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Show', + 'props' => [ + 'auth' => [ + 'user' => [ + 'can' => [ + 'edit_user' => false, + ], + ], + ], + ], + ]); + } + + public function test_can_override_shared_prop_merger_globally(): void + { Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { - return Inertia::render('User/Edit'); + Inertia::setSharedPropMerger(new DeepMergeStrategy); + + Inertia::share('auth.user.can', [ + 'delete_user' => false, + ]); + + return Inertia::render('User/Show', [ + 'auth.user.can' => [ + 'edit_user' => false, + ], + ]); }); - $response = $this->withoutExceptionHandling()->get('/?foo=bar', ['X-Inertia' => 'true']); + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); $response->assertSuccessful(); $response->assertJson([ - 'component' => 'User/Edit', + 'component' => 'User/Show', 'props' => [ - 'query' => [ - 'foo' => 'bar', + 'auth' => [ + 'user' => [ + 'can' => [ + 'delete_user' => false, + 'edit_user' => false, + ], + ], ], ], ]); } - public function test_dot_props_with_callbacks_are_merged_from_shared(): void + public function test_merge_strategy_can_be_applied_per_share_call(): void { Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { - Inertia::share('auth.user', fn () => [ - 'name' => 'Jonathan', + Inertia::share([ + 'auth.user.id' => 1, + 'auth.user.can' => [ + 'delete_user' => false, + ], ]); + Inertia::share([ + 'auth.user.can' => [ + 'edit_user' => false, + ], + ], new DeepMergeStrategy); - return Inertia::render('User/Edit', [ - 'auth.user.can.create_group' => false, + return Inertia::render('User/Show', [ + 'auth.user.name' => 'John Doe', ]); }); @@ -243,13 +317,14 @@ public function test_dot_props_with_callbacks_are_merged_from_shared(): void $response->assertSuccessful(); $response->assertJson([ - 'component' => 'User/Edit', + 'component' => 'User/Show', 'props' => [ 'auth' => [ 'user' => [ - 'name' => 'Jonathan', + 'name' => 'John Doe', 'can' => [ - 'create_group' => false, + 'delete_user' => false, + 'edit_user' => false, ], ], ], diff --git a/tests/ShallowMergeStrategyTest.php b/tests/ShallowMergeStrategyTest.php new file mode 100644 index 00000000..abce82f4 --- /dev/null +++ b/tests/ShallowMergeStrategyTest.php @@ -0,0 +1,116 @@ + [ + 'view', + ], + ]; + $input = [ + 'user' => [ + 'name' => 'John Doe', + ], + ]; + + $result = App::make(ShallowMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'can' => [ + 'view', + ], + 'user' => [ + 'name' => 'John Doe', + ], + ], $result); + } + + public function test_it_performs_a_shallow_merge(): void + { + $original = [ + 'can' => [ + 'view', + ], + ]; + $input = [ + 'can' => [ + 'edit', + ], + ]; + + $result = App::make(ShallowMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'can' => [ + 'edit', + ], + ], $result); + } + + public function test_it_can_handle_arrayables(): void + { + $original = [ + 'can' => [ + 'edit', + ], + ]; + $input = new Collection([ + 'user' => [ + 'name' => 'John Doe', + ], + ]); + + $result = App::make(ShallowMergeStrategy::class)->merge($original, $input); + + $this->assertEquals([ + 'can' => [ + 'edit', + ], + 'user' => [ + 'name' => 'John Doe', + ], + ], $result); + } + + public function test_it_can_append_values_when_using_a_specific_key(): void + { + $original = [ + 'user' => [ + 'id' => 1, + ], + ]; + + $result = App::make(ShallowMergeStrategy::class)->merge($original, 'user.name', 'John Doe'); + + $this->assertEquals([ + 'user' => [ + 'id' => 1, + 'name' => 'John Doe', + ], + ], $result); + } + + public function test_it_replaces_the_original_value_for_known_keys(): void + { + $original = [ + 'can' => [ + 'view', + ], + ]; + + $result = App::make(ShallowMergeStrategy::class)->merge($original, 'can', ['edit']); + + $this->assertEquals([ + 'can' => [ + 'edit', + ], + ], $result); + } +}