Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ A third-party [Laravel Livewire](https://laravel-livewire.com/) integration for
+ [Static caching](#static-caching)
+ [`@script` and `@assets`](#--script--and---assets-)
+ [Computed Properties](#computed-properties)
+ [Cascade](#cascade)
+ [Multi-Site / Localization](#multi-site---localization)
+ [Lazy Components](#lazy-components)
+ [Paginating Data](#paginating-data)
Expand Down Expand Up @@ -192,6 +193,40 @@ public function entries() {
{{ /entries }}
```

### Cascade
Normally all the variables in the Cascade are only available on initial render and get lost between Livewire requests. This means you'd need pass in the required ones into the component yourself.
To make our lives a bit easier, you can add the `#[Cascade]` attribute to your component. <br> This is only needed for Antlers views and mirrors the logic of Blade's [`@cascade`](https://statamic.dev/blade#cascade-directive) directive.

```php
use Livewire\Component;
use MarcoRieser\Livewire\Attributes\Cascade;

#[Cascade]
class ShowArticle extends Component
{
...
}
```

Now you can access the variables from the Cascade directly in your Antlers view, even on subsequent renders:

```antlers
<h1>{{ title }}</h1>
{{ seo_title }}
```

You can also limit which cascade keys are exposed (and provide defaults):

```php
#[Cascade([
'title',
'seo_title' => 'Fallback title',
])]
class ShowArticle extends Component {}
```

For subsequent requests, the addon restores the Cascade using the original Livewire URL, so site, request, and content data resolve as expected.

### Multi-Site / Localization
By default, your current site is persisted between Livewire requests automatically.
In case you want to implement your own logic, you can disable `localization` in your published `config/statamic-livewire.php` config.
Expand Down
40 changes: 40 additions & 0 deletions src/Attributes/Cascade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace MarcoRieser\Livewire\Attributes;

use Illuminate\Support\Arr;
use Livewire\Features\SupportAttributes\Attribute as LivewireAttribute;
use Statamic\Exceptions\CascadeDataNotFoundException;
use Statamic\Facades\Cascade as CascadeFacade;

#[\Attribute]
class Cascade extends LivewireAttribute
{
public function __construct(public array $keys = []) {}

public function getCascadeData(): array
{
if (! $data = CascadeFacade::toArray()) {
$data = CascadeFacade::hydrate()->toArray();
}

if (! $this->keys) {
return $data;
}

return collect($this->keys)
->mapWithKeys(function ($default, $key) use ($data) {
if (is_numeric($key)) {
$key = $default;
$default = null;

if (! array_key_exists($key, $data)) {
throw new CascadeDataNotFoundException($key);
}
}

return [$key => Arr::get($data, $key, $default)];
})
->all();
}
}
44 changes: 44 additions & 0 deletions src/Hooks/CascadeVariablesAutoloader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace MarcoRieser\Livewire\Hooks;

use Illuminate\View\View;
use Livewire\Component;
use Livewire\ComponentHook;
use Livewire\Livewire;
use MarcoRieser\Livewire\Attributes\Cascade as CascadeAttribute;
use Statamic\View\Antlers\Engine as AntlersEngine;

class CascadeVariablesAutoloader extends ComponentHook
{
public function render($view, $data): void
{
/** @var Component $component */
if (! ($component = Livewire::current())) {
return;
}

if (! $this->isUsingAntlers($view)) {
return;
}

/** @var ?CascadeAttribute $attribute */
$attribute = $component
->getAttributes()
->whereInstanceOf(CascadeAttribute::class)
->first();

if (! $attribute) {
return;
}

$cascade = $attribute->getCascadeData();

$view->with(array_merge($cascade, $data));
}

protected function isUsingAntlers(View $view): bool
{
return $view->getEngine() instanceof AntlersEngine;
}
}
44 changes: 44 additions & 0 deletions src/Http/Middleware/HydrateCascadeByLivewireUrl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace MarcoRieser\Livewire\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Request as RequestFacade;
use Livewire\Livewire;
use Statamic\Facades\Cascade;
use Statamic\Facades\Data;
use Statamic\Facades\Site;
use Symfony\Component\HttpFoundation\Response;

class HydrateCascadeByLivewireUrl
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$this->hydrateSite();
$this->hydrateRequest();
$this->hydrateContent();

return $next($request);
}

protected function hydrateSite(): void
{
Cascade::withSite(Site::current());
}

protected function hydrateRequest(): void
{
Cascade::withRequest(RequestFacade::create(uri: Livewire::originalUrl(), method: Livewire::originalMethod()));
}

protected function hydrateContent(): void
{
Cascade::withContent(Data::findByRequestUrl(Livewire::originalUrl()));
}
}
32 changes: 26 additions & 6 deletions src/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@
use Illuminate\Routing\Router;
use Livewire\Livewire;
use Livewire\Mechanisms\HandleComponents\Synthesizers\Synth;
use MarcoRieser\Livewire\Hooks\CascadeVariablesAutoloader;
use MarcoRieser\Livewire\Hooks\ComputedPropertiesAutoloader;
use MarcoRieser\Livewire\Hooks\SynthesizerAugmentor;
use MarcoRieser\Livewire\Http\Middleware\HydrateCascadeByLivewireUrl;
use MarcoRieser\Livewire\Http\Middleware\ResolveCurrentSiteByLivewireUrl;
use Statamic\Http\Middleware\Localize;
use Statamic\Providers\AddonServiceProvider;

class ServiceProvider extends AddonServiceProvider
{
protected array $middlewares = [];

protected $tags = [
'MarcoRieser\Livewire\Tags\Livewire',
];
Expand All @@ -24,13 +28,16 @@ public function register(): void

$this->registerSynthesizerAugmentation();
$this->registerComputedPropertiesAutoloader();
$this->registerCascadeVariablesAutoloader();
}

public function bootAddon(): void
{
$this->bootLocalization();
$this->bootCascadeRestoration();
$this->bootReplacers();
$this->bootSynthesizers();
$this->bootMiddlewares();
}

protected function bootLocalization(): void
Expand All @@ -39,12 +46,13 @@ protected function bootLocalization(): void
return;
}

collect($this->app->make(Router::class)->getRoutes()->getRoutes())
->filter(fn (Route $route) => $route->named('*livewire.update'))
->each(fn (Route $route) => $route->middleware([
ResolveCurrentSiteByLivewireUrl::class,
Localize::class,
]));
$this->middlewares[] = ResolveCurrentSiteByLivewireUrl::class;
$this->middlewares[] = Localize::class;
}

protected function bootCascadeRestoration(): void
{
$this->middlewares[] = HydrateCascadeByLivewireUrl::class;
}

protected function bootReplacers(): void
Expand Down Expand Up @@ -75,4 +83,16 @@ protected function registerComputedPropertiesAutoloader(): void
{
Livewire::componentHook(ComputedPropertiesAutoloader::class);
}

protected function registerCascadeVariablesAutoloader(): void
{
Livewire::componentHook(CascadeVariablesAutoloader::class);
}

protected function bootMiddlewares(): void
{
collect($this->app->make(Router::class)->getRoutes()->getRoutes())
->filter(fn (Route $route) => $route->named('*livewire.update'))
->each(fn (Route $route) => $route->middleware($this->middlewares));
}
}
135 changes: 135 additions & 0 deletions tests/Features/CascadeVariables/CascadeVariablesAutoloaderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

namespace MarcoRieser\Livewire\Tests\Hooks;

use Illuminate\View\ViewException;
use Livewire\Component;
use Livewire\Livewire;
use MarcoRieser\Livewire\Attributes\Cascade;
use MarcoRieser\Livewire\Tests\TestCase;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Testing\Concerns\PreventsSavingStacheItemsToDisk;

class CascadeVariablesAutoloaderTest extends TestCase
{
use PreventsSavingStacheItemsToDisk;

#[Test]
public function cascade_variables_are_autoloaded_in_antlers()
{
$component = $this->getAntlersLivewireComponent();

$testable = Livewire::test($component);

$testable->assertViewHas('homepage', '/');
$testable->assertViewHas('environment', 'testing');
}

#[Test]
public function cascade_variables_are_not_autoloaded_in_blade()
{
$component = $this->getBladeLivewireComponent();

$testable = Livewire::test($component);

$testable->assertViewMissing('homepage');
$testable->assertViewMissing('environment');
}

#[Test]
public function cascade_variables_are_autoloaded_selectively()
{
$component = $this->getSelectedLivewireComponent();

$testable = Livewire::test($component);

$testable->assertViewHas('homepage', '/');
$testable->assertViewHas('my_global', true);
$testable->assertViewMissing('environment');
}

#[Test]
public function cascade_variables_throw_exception_when_invalid()
{
$this->expectException(ViewException::class);
$this->expectExceptionMessage('Cascade data [my_invalid] not found');

$component = $this->getInvalidLivewireComponent();

Livewire::test($component);
}

#[Test]
public function cascade_variables_are_not_autoloaded_when_attribute_excluded()
{
$component = $this->getExcludedLivewireComponent();

$testable = Livewire::test($component);

$testable->assertViewMissing('homepage');
$testable->assertViewMissing('environment');
}

protected function getAntlersLivewireComponent(): Component
{
return new
#[Cascade]
class extends Component
{
public function render()
{
return view('antlers');
}
};
}

protected function getBladeLivewireComponent(): Component
{
return new
#[Cascade]
class extends Component
{
public function render()
{
return view('blade');
}
};
}

protected function getSelectedLivewireComponent(): Component
{
return new
#[Cascade(['homepage', 'my_global' => true])]
class extends Component
{
public function render()
{
return view('antlers');
}
};
}

protected function getInvalidLivewireComponent(): Component
{
return new
#[Cascade(['my_invalid'])]
class extends Component
{
public function render()
{
return view('antlers');
}
};
}

protected function getExcludedLivewireComponent(): Component
{
return new class extends Component
{
public function render()
{
return view('antlers');
}
};
}
}