Skip to content

Commit 2e15aa9

Browse files
committed
Merge branch '4.x' into 5.x
2 parents fa3a84d + e9348b2 commit 2e15aa9

10 files changed

Lines changed: 159 additions & 4 deletions

File tree

docs/03-resources/07-managing-relationships.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,6 +1023,22 @@ public function table(Table $table): Table
10231023
}
10241024
```
10251025

1026+
<Aside variant="danger">
1027+
`modifyQueryUsing()` scopes the query for records that already belong to the relationship — the table listing, and actions that operate on its rows, such as `DetachAction`, `DissociateAction`, and bulk actions. It is **not** applied to the records available to `AttachAction` or `AssociateAction`, since those records are outside the relationship by definition.
1028+
1029+
To restrict which records may be attached or associated, scope the options using the `recordSelectOptionsQuery()` method on the action. Filament resolves the submitted record against that query, so records outside it are rejected, even if a user tampers with the submitted modal state:
1030+
1031+
```php
1032+
use Filament\Actions\AttachAction;
1033+
use Illuminate\Database\Eloquent\Builder;
1034+
1035+
AttachAction::make()
1036+
->recordSelectOptionsQuery(fn (Builder $query) => $query->where('is_active', true))
1037+
```
1038+
1039+
Learn more about scoping the options to [attach](#scoping-the-options-to-attach) or [associate](#scoping-the-options-to-associate).
1040+
</Aside>
1041+
10261042
## Customizing the relation manager title
10271043

10281044
To set the title of the relation manager, you can use the `$title` property on the relation manager class:

packages/panels/dist/index.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/panels/resources/js/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Mousetrap from '@danharrin/alpine-mousetrap'
22
import sidebar from './stores/sidebar.js'
33
import './dark-mode.js'
44
import './error-notifications.js'
5+
import './reset-restored-resource-create-record-pages.js'
56
import './scroll-sidebar.js'
67
import './unsaved-changes-alert.js'
78

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
document.addEventListener('livewire:init', () => {
2+
// In SPA mode, navigating back to a create page restores it from Livewire's
3+
// history cache, including an `isCreating` state of `true` from before the
4+
// post-creation redirect, which blocks the form from being submitted again.
5+
// A fresh page load always renders `isCreating` as `false`, so a create
6+
// page component can only initialize with `isCreating` as `true` if it was
7+
// restored from the cache. Outside of SPA mode, this cannot happen, since
8+
// Livewire's `DisableBackButtonCacheMiddleware` sends a `no-store` header
9+
// that excludes its pages from the browser's back/forward cache. Assigning
10+
// to `$wire` only mutates the client-side state, which is synced with the
11+
// server during the next request, such as the next creation attempt.
12+
window.Livewire.hook('component.init', ({ component }) => {
13+
if (
14+
!component.el?.classList?.contains('fi-resource-create-record-page')
15+
) {
16+
return
17+
}
18+
19+
if (component.snapshot?.data?.isCreating !== true) {
20+
return
21+
}
22+
23+
component.$wire.isCreating = false
24+
})
25+
})

packages/panels/src/FilamentServiceProvider.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Filament\Auth\Http\Responses\RegistrationResponse;
1919
use Filament\Facades\Filament;
2020
use Filament\Http\Middleware\Authenticate;
21+
use Filament\Http\Middleware\AuthenticateSession;
2122
use Filament\Http\Middleware\DisableBladeIconComponents;
2223
use Filament\Http\Middleware\DispatchServingFilamentEvent;
2324
use Filament\Http\Middleware\IdentifyPageConfiguration;
@@ -104,6 +105,8 @@ public function packageBooted(): void
104105

105106
Livewire::addPersistentMiddleware([
106107
Authenticate::class,
108+
AuthenticateSession::class,
109+
\Illuminate\Session\Middleware\AuthenticateSession::class,
107110
DisableBladeIconComponents::class,
108111
DispatchServingFilamentEvent::class,
109112
IdentifyPageConfiguration::class,

packages/panels/src/Resources/Pages/CreateRecord.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
use Illuminate\Database\Eloquent\Model;
2323
use Illuminate\Support\Facades\Event;
2424
use Illuminate\Support\Js;
25-
use Livewire\Attributes\Locked;
2625
use Throwable;
2726

2827
/**
@@ -47,7 +46,14 @@ class CreateRecord extends Page
4746

4847
protected static bool $canCreateAnother = true;
4948

50-
#[Locked]
49+
/**
50+
* After a successful creation, this stays `true` while the user is redirected, to prevent duplicate
51+
* records from additional clicks of the submit button. In SPA mode, if the user then navigates back,
52+
* the page is restored from Livewire's history cache with this still `true`, which would block the
53+
* form from ever being submitted again. JavaScript detects the restoration and resets this property
54+
* in Livewire's client-side state, which is synced with the server during the next request. It is
55+
* deliberately not `#[Locked]`, since that would prevent the client-side reset from being synced.
56+
*/
5157
public bool $isCreating = false;
5258

5359
public function getBreadcrumb(): string

tests/src/Fixtures/Models/User.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ protected function casts(): array
5555

5656
public function canAccessPanel(Panel $panel): bool
5757
{
58-
return in_array($panel->getId(), ['admin', 'slugs', 'app-authentication', 'email-authentication', 'required-multi-factor-authentication']);
58+
return in_array($panel->getId(), ['admin', 'slugs', 'spa', 'app-authentication', 'email-authentication', 'required-multi-factor-authentication']);
5959
}
6060

6161
public function posts(): HasMany
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
namespace Filament\Tests\Fixtures\Providers;
4+
5+
use Filament\Http\Middleware\Authenticate;
6+
use Filament\Http\Middleware\AuthenticateSession;
7+
use Filament\Http\Middleware\DisableBladeIconComponents;
8+
use Filament\Http\Middleware\DispatchServingFilamentEvent;
9+
use Filament\Panel;
10+
use Filament\PanelProvider;
11+
use Filament\Tests\Fixtures\Resources\Tickets\TicketResource;
12+
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
13+
use Illuminate\Cookie\Middleware\EncryptCookies;
14+
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
15+
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
16+
use Illuminate\Routing\Middleware\SubstituteBindings;
17+
use Illuminate\Session\Middleware\StartSession;
18+
use Illuminate\View\Middleware\ShareErrorsFromSession;
19+
20+
class SpaPanelProvider extends PanelProvider
21+
{
22+
public function panel(Panel $panel): Panel
23+
{
24+
return $panel
25+
->id('spa')
26+
->path('spa')
27+
->login()
28+
->spa()
29+
->resources([
30+
TicketResource::class,
31+
])
32+
->middleware([
33+
EncryptCookies::class,
34+
AddQueuedCookiesToResponse::class,
35+
StartSession::class,
36+
AuthenticateSession::class,
37+
ShareErrorsFromSession::class,
38+
class_exists(PreventRequestForgery::class) ? PreventRequestForgery::class : VerifyCsrfToken::class,
39+
SubstituteBindings::class,
40+
DisableBladeIconComponents::class,
41+
DispatchServingFilamentEvent::class,
42+
])
43+
->authMiddleware([
44+
Authenticate::class,
45+
]);
46+
}
47+
}

tests/src/Panels/Resources/Pages/CreateRecordTest.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
use Filament\Resources\Events\RecordCreated;
66
use Filament\Resources\Events\RecordSaved;
77
use Filament\Tests\Fixtures\Models\Post;
8+
use Filament\Tests\Fixtures\Models\Ticket;
9+
use Filament\Tests\Fixtures\Models\User;
810
use Filament\Tests\Fixtures\Policies\TicketPolicy;
911
use Filament\Tests\Fixtures\Resources\Posts\Pages\CreateAnotherPreservingDataPost;
1012
use Filament\Tests\Fixtures\Resources\Posts\Pages\CreateAnotherPreservingRepeaterPost;
@@ -16,6 +18,7 @@
1618
use Filament\Tests\Fixtures\Resources\Tickets\TicketResource;
1719
use Filament\Tests\Panels\Resources\TestCase;
1820
use Illuminate\Auth\Access\Response;
21+
use Illuminate\Support\Facades\Artisan;
1922
use Illuminate\Support\Facades\Event;
2023

2124
use function Filament\Tests\livewire;
@@ -409,6 +412,58 @@
409412
app()->bind(TicketPolicy::class . '::create', fn (): bool => true);
410413
});
411414

415+
it('blocks duplicate creation attempts while `$isCreating` after a successful creation', function (): void {
416+
$component = livewire(CreateTicket::class)
417+
->call('create')
418+
->assertRedirect();
419+
420+
expect(Ticket::count())->toBe(1);
421+
422+
$component->call('create');
423+
424+
expect(Ticket::count())->toBe(1);
425+
});
426+
427+
it('can reset `$isCreating` from the client to release the duplicate creation guard when the page is restored from the browser history cache', function (): void {
428+
$component = livewire(CreateTicket::class)
429+
->call('create')
430+
->assertRedirect();
431+
432+
expect(Ticket::count())->toBe(1);
433+
434+
$component
435+
->set('isCreating', false)
436+
->call('create')
437+
->assertRedirect();
438+
439+
expect(Ticket::count())->toBe(2);
440+
});
441+
442+
it('can create a record again in the browser after navigating back to a create page restored from the history cache', function (): void {
443+
retry(10, function (): void {
444+
Artisan::call('filament:assets');
445+
446+
Ticket::query()->delete();
447+
448+
$this->actingAs(User::factory()->create());
449+
450+
visit(TicketResource::getUrl('create', panel: 'spa'))
451+
->assertSee('Create Ticket')
452+
->click('.fi-sc-form button[type="submit"]')
453+
->waitForText('View Ticket')
454+
->assertPathIs('/spa/tickets/*')
455+
->back()
456+
->waitForText('Create Ticket')
457+
->assertPathIs('/spa/tickets/create')
458+
->wait(1)
459+
->click('.fi-sc-form button[type="submit"]')
460+
->waitForText('View Ticket')
461+
->assertPathIs('/spa/tickets/*');
462+
463+
expect(Ticket::count())->toBe(2);
464+
});
465+
});
466+
412467
it('re-authorizes viewAny on Livewire updates after the initial mount of a create page', function (): void {
413468
app()->bind(TicketPolicy::class . '::viewAny', fn (): bool => true);
414469

tests/src/TestCase.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
use Filament\Tests\Fixtures\Providers\RequiredMultiFactorAuthenticationPanelProvider;
3131
use Filament\Tests\Fixtures\Providers\SlugsPanelProvider;
3232
use Filament\Tests\Fixtures\Providers\SlugTenancyPanelProvider;
33+
use Filament\Tests\Fixtures\Providers\SpaPanelProvider;
3334
use Filament\Tests\Fixtures\Providers\TenancyPanelProvider;
3435
use Filament\Tests\Fixtures\Providers\TenantMenuFlatPanelProvider;
3536
use Filament\Tests\Fixtures\Providers\TenantMenuGroupingPanelProvider;
@@ -88,6 +89,7 @@ protected function getPackageProviders($app): array
8889
SingleDomainPanel::class,
8990
SlugsPanelProvider::class,
9091
SlugTenancyPanelProvider::class,
92+
SpaPanelProvider::class,
9193
TenancyPanelProvider::class,
9294
TenantMenuFlatPanelProvider::class,
9395
TenantMenuGroupingPanelProvider::class,

0 commit comments

Comments
 (0)