diff --git a/docs/helpers.md b/docs/helpers.md index 2593e585..1670d55a 100644 --- a/docs/helpers.md +++ b/docs/helpers.md @@ -54,7 +54,7 @@ If you're rendering a Turbo Stream inside a your Blade files, you may use the `< ```blade - @include('posts._post', ['post' => $post]) + @include('posts.partials.post', ['post' => $post]) ``` diff --git a/docs/testing.md b/docs/testing.md index cb1b07c3..68991a62 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -247,7 +247,7 @@ class CreatesCommentsTest extends TestCase TurboStream::assertBroadcasted(function (PendingBroadcast $broadcast) use ($todo) { return $broadcast->target === 'comments' && $broadcast->action === 'append' - && $broadcast->partialView === 'comments._comment' + && $broadcast->partialView === 'comments.partials.comment' && $broadcast->partialData['comment']->is($todo->comments->first()) && count($broadcast->channels) === 1 && $broadcast->channels[0]->name === sprintf('private-%s', $todo->broadcastChannel()); diff --git a/docs/turbo-streams.md b/docs/turbo-streams.md index c59e23ab..70c6b1fa 100644 --- a/docs/turbo-streams.md +++ b/docs/turbo-streams.md @@ -62,7 +62,7 @@ Although it's handy to pass a model instance to the `turbo_stream()` function - turbo_stream() ->target('comments') ->action('append') - ->view('comments._comment', ['comment' => $comment]); + ->view('comments.partials.comment', ['comment' => $comment]); ``` There are also shorthand methods which may be used as well: @@ -122,7 +122,7 @@ For both the `before` and `after` methods you need additional calls to specify t ```php turbo_stream() ->before($comment) - ->view('comments._flash_message', [ + ->view('comments.partials.flash_message', [ 'message' => __('Comment created!'), ]); ``` @@ -143,6 +143,40 @@ It will build a `remove` Turbo Stream if the model was just deleted (or if it wa return turbo_stream($comment, 'append'); ``` +## Turbo Stream & Morph + +Both the `update` and `replace` Turbo Stream actions can specify a `[method="morph"]`, so the action will use DOM morphing instead of the default renderer. + +```php +turbo_stream()->replace(dom_id($post, 'comments'), view('comments.partials.comment', [ + 'comment' => $comment, +]))->morph(); +``` + +This would generate the following Turbo Stream HTML: + +```html + + + +``` + +And here's the `update` action version: + +```php +turbo_stream()->update(dom_id($post, 'comments'), view('comments.partials.comment', [ + 'comment' => $comment, +]))->morph(); +``` + +This would generate the following Turbo Stream HTML: + +```html + + + +``` + ## Target Multiple Elements Turbo Stream elements can either have a `target` with a DOM ID or a `targets` attribute with a CSS selector to [match multiple elements](https://turbo.hotwired.dev/reference/streams#targeting-multiple-elements). You may use the `xAll` shorthand methods to set the `targets` attribute instead of `target`: @@ -165,7 +199,7 @@ When creating Turbo Streams using the builders, you may also specify the CSS cla turbo_stream() ->targets('.comment') ->action('append') - ->view('comments._comment', ['comment' => $comment]); + ->view('comments.partials.comment', ['comment' => $comment]); ``` ## Turbo Stream Macros @@ -247,7 +281,7 @@ return turbo_stream([ ->append($comment) ->target(dom_id($comment->post, 'comments')), turbo_stream() - ->update(dom_id($comment->post, 'comments_count'), view('posts._comments_count', [ + ->update(dom_id($comment->post, 'comments_count'), view('posts.partials.comments_count', [ 'post' => $comment->post, ])), ]); @@ -274,7 +308,7 @@ Here's an example of a more complex custom Turbo Stream view: ``` @@ -285,7 +319,7 @@ Remember, these are Blade views, so you have the full power of Blade at your han @if (session()->has('status')) @endif @@ -297,7 +331,7 @@ Similar to the `` Blade component, there's also a ` - @include('comments._comment', ['comment' => $comment]) + @include('comments.partials.comment', ['comment' => $comment]) ``` diff --git a/src/Broadcasting/Limiter.php b/src/Broadcasting/Limiter.php index 21899c87..4c9f165d 100644 --- a/src/Broadcasting/Limiter.php +++ b/src/Broadcasting/Limiter.php @@ -4,9 +4,7 @@ class Limiter { - public function __construct(protected array $keys = [], protected int $delay = 500) - { - } + public function __construct(protected array $keys = [], protected int $delay = 500) {} public function clear(): void { diff --git a/src/Broadcasting/PendingBroadcast.php b/src/Broadcasting/PendingBroadcast.php index 0da7d75a..48e9f036 100644 --- a/src/Broadcasting/PendingBroadcast.php +++ b/src/Broadcasting/PendingBroadcast.php @@ -154,6 +154,22 @@ public function attributes(array $attributes) return $this; } + public function morph(): self + { + return $this->method('morph'); + } + + public function method(?string $method = null): self + { + if ($method) { + return $this->attributes(array_merge($this->attributes, [ + 'method' => $method, + ])); + } + + return $this->attributes(Arr::except($this->attributes, 'method')); + } + public function rendering(Rendering $rendering) { $this->partialView = $rendering->partial; diff --git a/src/Broadcasting/Rendering.php b/src/Broadcasting/Rendering.php index 691e8ecb..c8d948ce 100644 --- a/src/Broadcasting/Rendering.php +++ b/src/Broadcasting/Rendering.php @@ -40,7 +40,7 @@ public static function forContent(View|HtmlString|string $content) public static function empty(): self { - return new self(); + return new self; } public static function forModel(Model $model): self diff --git a/src/Commands/TurboInstallCommand.php b/src/Commands/TurboInstallCommand.php index 8b89672f..123450e1 100644 --- a/src/Commands/TurboInstallCommand.php +++ b/src/Commands/TurboInstallCommand.php @@ -176,6 +176,6 @@ private function existingLayoutFiles() private function phpBinary() { - return (new PhpExecutableFinder())->find(false) ?: 'php'; + return (new PhpExecutableFinder)->find(false) ?: 'php'; } } diff --git a/src/Http/PendingTurboStreamResponse.php b/src/Http/PendingTurboStreamResponse.php index a51cc1e1..980d8aac 100644 --- a/src/Http/PendingTurboStreamResponse.php +++ b/src/Http/PendingTurboStreamResponse.php @@ -12,6 +12,7 @@ use Illuminate\Contracts\Support\Responsable; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Arr; use Illuminate\Support\HtmlString; use Illuminate\Support\Traits\Macroable; @@ -21,6 +22,8 @@ class PendingTurboStreamResponse implements Htmlable, Renderable, Responsable { use Macroable; + private array $defaultActions = ['append', 'prepend', 'update', 'replace', 'before', 'after', 'remove', 'refresh']; + private string $useAction; private ?string $useTarget = null; @@ -37,7 +40,7 @@ class PendingTurboStreamResponse implements Htmlable, Renderable, Responsable public static function forModel(Model $model, ?string $action = null): self { - $builder = new self(); + $builder = new self; // We're treating soft-deleted models as they were deleted. In other words, we // will render the remove Turbo Stream. If you need to treat a soft-deleted @@ -108,6 +111,22 @@ public function attributes(array $attributes): self return $this; } + public function morph(): self + { + return $this->method('morph'); + } + + public function method(?string $method = null): self + { + if ($method) { + return $this->attributes(array_merge($this->useCustomAttributes, [ + 'method' => $method, + ])); + } + + return $this->attributes(Arr::except($this->useCustomAttributes, 'method')); + } + public function append(Model|string $target, $content = null): self { return $this->buildAction( @@ -267,8 +286,7 @@ private function buildActionAll(string $action, Model|string $targets, $content public function broadcastTo($channel, ?callable $callback = null) { - $callback = $callback ?? function () { - }; + $callback = $callback ?? function () {}; return tap($this, function () use ($channel, $callback) { $callback($this->asPendingBroadcast($channel)); @@ -277,8 +295,7 @@ public function broadcastTo($channel, ?callable $callback = null) public function broadcastToPrivateChannel($channel, ?callable $callback = null) { - $callback = $callback ?? function () { - }; + $callback = $callback ?? function () {}; return $this->broadcastTo(null, function (PendingBroadcast $broadcast) use ($channel, $callback) { $broadcast->toPrivateChannel($channel); @@ -288,8 +305,7 @@ public function broadcastToPrivateChannel($channel, ?callable $callback = null) public function broadcastToPresenceChannel($channel, ?callable $callback = null) { - $callback = $callback ?? function () { - }; + $callback = $callback ?? function () {}; return $this->broadcastTo(null, function (PendingBroadcast $broadcast) use ($channel, $callback) { $callback($broadcast->toPresenceChannel($channel)); @@ -327,7 +343,7 @@ private function contentAsRendering() */ public function toResponse($request) { - if (! in_array($this->useAction, ['remove', 'refresh']) && ! $this->partialView && $this->inlineContent === null) { + if (! in_array($this->useAction, ['remove', 'refresh']) && in_array($this->useAction, $this->defaultActions) && ! $this->partialView && $this->inlineContent === null) { throw TurboStreamResponseFailedException::missingPartial(); } diff --git a/src/Models/Broadcasts.php b/src/Models/Broadcasts.php index 3c422a72..e5236807 100644 --- a/src/Models/Broadcasts.php +++ b/src/Models/Broadcasts.php @@ -21,7 +21,7 @@ trait Broadcasts public static function bootBroadcasts() { - static::observe(new ModelObserver()); + static::observe(new ModelObserver); } public static function withoutTurboStreamBroadcasts(callable $callback) diff --git a/src/Models/Naming/Name.php b/src/Models/Naming/Name.php index 565f33b0..ced74a7d 100644 --- a/src/Models/Naming/Name.php +++ b/src/Models/Naming/Name.php @@ -51,7 +51,7 @@ public static function forModel(object $model) public static function build(string $className) { - $name = new static(); + $name = new static; $name->className = $className; $name->classNameWithoutRootNamespace = static::removeRootNamespaces($className); diff --git a/src/Testing/ConvertTestResponseToTurboStreamCollection.php b/src/Testing/ConvertTestResponseToTurboStreamCollection.php index fd333a8f..cd8711c2 100644 --- a/src/Testing/ConvertTestResponseToTurboStreamCollection.php +++ b/src/Testing/ConvertTestResponseToTurboStreamCollection.php @@ -11,7 +11,7 @@ class ConvertTestResponseToTurboStreamCollection public function __invoke(TestResponse $response): Collection { libxml_use_internal_errors(true); - $document = tap(new DOMDocument())->loadHTML($response->content()); + $document = tap(new DOMDocument)->loadHTML($response->content()); $elements = $document->getElementsByTagName('turbo-stream'); $streams = collect(); diff --git a/src/helpers.php b/src/helpers.php index 24703549..054aef76 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -46,7 +46,7 @@ function turbo_stream($model = null, ?string $action = null): MultiplePendingTur } if ($model === null) { - return new PendingTurboStreamResponse(); + return new PendingTurboStreamResponse; } return PendingTurboStreamResponse::forModel($model, $action); diff --git a/tests/Broadcasting/LimiterTest.php b/tests/Broadcasting/LimiterTest.php index 62323e02..6a67b472 100644 --- a/tests/Broadcasting/LimiterTest.php +++ b/tests/Broadcasting/LimiterTest.php @@ -12,7 +12,7 @@ public function debounces() { $this->freezeTime(); - $debouncer = new Limiter(); + $debouncer = new Limiter; $this->assertFalse($debouncer->shouldLimit('my-key')); $this->assertTrue($debouncer->shouldLimit('my-key')); diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php index 2cacbc16..c973ff00 100644 --- a/tests/FunctionsTest.php +++ b/tests/FunctionsTest.php @@ -64,6 +64,33 @@ public function namespaced_turbo_stream_fn() HTML), trim(turbo_stream($this->article)), ); + + $expected = trim(view('articles._article', [ + 'article' => $this->article, + ])->render()); + + $this->assertEquals( + trim(<< + + + HTML), + trim(turbo_stream($this->article->fresh())->morph()), + ); + + // Unsets method + $expected = trim(view('articles._article', [ + 'article' => $this->article, + ])->render()); + + $this->assertEquals( + trim(<< + + + HTML), + trim(turbo_stream($this->article->fresh())->morph()->method()), + ); } /** @test */ @@ -105,6 +132,33 @@ public function global_turbo_stream_fn() HTML), trim(\turbo_stream($this->article)), ); + + $expected = trim(view('articles._article', [ + 'article' => $this->article, + ])->render()); + + $this->assertEquals( + trim(<< + + + HTML), + trim(\turbo_stream($this->article->fresh())->morph()), + ); + + // Unsets method + $expected = trim(view('articles._article', [ + 'article' => $this->article, + ])->render()); + + $this->assertEquals( + trim(<< + + + HTML), + trim(\turbo_stream($this->article->fresh())->morph()->method()), + ); } /** @test */ diff --git a/tests/Http/ResponseMacrosTest.php b/tests/Http/ResponseMacrosTest.php index 72634428..7219b6a7 100644 --- a/tests/Http/ResponseMacrosTest.php +++ b/tests/Http/ResponseMacrosTest.php @@ -250,7 +250,7 @@ public function append_shorthand_passing_string() $response = response() ->turboStream() ->append('some_dom_id', 'Hello World') - ->toResponse(new Request()); + ->toResponse(new Request); $expected = <<<'HTML' @@ -944,7 +944,7 @@ public function builds_multiple_turbo_stream_responses() response()->turboStream()->append($article)->target('append-target-id'), response()->turboStream()->prepend($article)->target('prepend-target-id'), response()->turboStream()->remove($article)->target('remove-target-id'), - ])->toResponse(new Request()); + ])->toResponse(new Request); $expected = collect([ view('turbo-laravel::turbo-stream', [ diff --git a/tests/Http/TurboStreamResponseTest.php b/tests/Http/TurboStreamResponseTest.php index 54aadf31..deee5b77 100644 --- a/tests/Http/TurboStreamResponseTest.php +++ b/tests/Http/TurboStreamResponseTest.php @@ -67,4 +67,26 @@ public function turbo_assert_has_turbo_stream() )) )); } + + /** @test */ + public function turbo_allows_custom_actions_with_no_view() + { + $this->assertEquals( + <<<'HTML' + + + + HTML, + (string) turbo_stream()->action('close-drawer')->target('contact-edit-drawer'), + ); + + $this->assertEquals( + <<<'HTML' + + + + HTML, + turbo_stream()->action('close-drawer')->target('contact-edit-drawer')->toResponse(request())->getContent(), + ); + } } diff --git a/tests/Models/BroadcastsModelTest.php b/tests/Models/BroadcastsModelTest.php index 7c8910d0..c54a7f4d 100644 --- a/tests/Models/BroadcastsModelTest.php +++ b/tests/Models/BroadcastsModelTest.php @@ -767,4 +767,67 @@ public function broadcasts_with_extra_attributes_to_turbo_stream_with_rendering( return true; }); } + + /** @test */ + public function broadcasts_replace_morph() + { + $article = ArticleFactory::new()->create(); + + $article->broadcastReplace()->morph(); + + TurboStream::assertBroadcasted(function (PendingBroadcast $broadcast) use ($article) { + $this->assertCount(1, $broadcast->channels); + $this->assertEquals(sprintf('private-%s', $article->broadcastChannel()), $broadcast->channels[0]->name); + $this->assertEquals("article_{$article->id}", $broadcast->target); + $this->assertEquals('replace', $broadcast->action); + $this->assertEquals('articles._article', $broadcast->partialView); + $this->assertEquals(['article' => $article], $broadcast->partialData); + $this->assertNull($broadcast->targets); + $this->assertEquals(['method' => 'morph'], $broadcast->attributes); + + return true; + }); + } + + /** @test */ + public function broadcasts_update_morph() + { + $article = ArticleFactory::new()->create(); + + $article->broadcastUpdate()->morph(); + + TurboStream::assertBroadcasted(function (PendingBroadcast $broadcast) use ($article) { + $this->assertCount(1, $broadcast->channels); + $this->assertEquals(sprintf('private-%s', $article->broadcastChannel()), $broadcast->channels[0]->name); + $this->assertEquals("article_{$article->id}", $broadcast->target); + $this->assertEquals('update', $broadcast->action); + $this->assertEquals('articles._article', $broadcast->partialView); + $this->assertEquals(['article' => $article], $broadcast->partialData); + $this->assertNull($broadcast->targets); + $this->assertEquals(['method' => 'morph'], $broadcast->attributes); + + return true; + }); + } + + /** @test */ + public function unsets_method_when_overriding_with_null() + { + $article = ArticleFactory::new()->create(); + + $article->broadcastUpdate()->morph()->method(); + + TurboStream::assertBroadcasted(function (PendingBroadcast $broadcast) use ($article) { + $this->assertCount(1, $broadcast->channels); + $this->assertEquals(sprintf('private-%s', $article->broadcastChannel()), $broadcast->channels[0]->name); + $this->assertEquals("article_{$article->id}", $broadcast->target); + $this->assertEquals('update', $broadcast->action); + $this->assertEquals('articles._article', $broadcast->partialView); + $this->assertEquals(['article' => $article], $broadcast->partialData); + $this->assertNull($broadcast->targets); + $this->assertEquals([], $broadcast->attributes); + + return true; + }); + } } diff --git a/tests/Views/ComponentsTest.php b/tests/Views/ComponentsTest.php index 2403bdf0..7b3b402c 100644 --- a/tests/Views/ComponentsTest.php +++ b/tests/Views/ComponentsTest.php @@ -115,6 +115,14 @@ public function streams() ->assertSee('', false) ->assertSee('', false) ->assertDontSee('target='); + + // Morphs... + $this->blade(<<<'BLADE' + +

Hello, World

+
+ BLADE) + ->assertSee('method="morph"', false); } /** @test */ diff --git a/tests/Views/ViewHelpersTest.php b/tests/Views/ViewHelpersTest.php index a515f57f..d92c8b2a 100644 --- a/tests/Views/ViewHelpersTest.php +++ b/tests/Views/ViewHelpersTest.php @@ -56,7 +56,7 @@ public function renders_dom_id() $renderedDomId = Blade::render('
', ['article' => $article]); $renderedDomIdWithPrefix = Blade::render('
', ['article' => $article]); - $rendersDomIdOfNewModel = Blade::render('
', ['article' => new Article()]); + $rendersDomIdOfNewModel = Blade::render('
', ['article' => new Article]); $this->assertEquals('
', trim($renderedDomId)); $this->assertEquals('
', trim($renderedDomIdWithPrefix)); @@ -80,7 +80,7 @@ public function renders_dom_class() $renderedDomClass = Blade::render('
', ['article' => $article]); $renderedDomClassWithPrefix = Blade::render('
', ['article' => $article]); - $rendersDomClassOfNewModel = Blade::render('
', ['article' => new Article()]); + $rendersDomClassOfNewModel = Blade::render('
', ['article' => new Article]); $this->assertEquals('
', trim($renderedDomClass)); $this->assertEquals('
', trim($renderedDomClassWithPrefix)); @@ -113,7 +113,7 @@ public function generates_model_ids_for_models_in_nested_folders() $this->assertEquals('user_profile_'.$profile->id, dom_id($profile)); $this->assertEquals('posts_user_profile_'.$profile->id, dom_id($profile, 'posts')); - $this->assertEquals('create_user_profile', dom_id(new Profile())); + $this->assertEquals('create_user_profile', dom_id(new Profile)); } /** @test */