feat(057): Filament v5 - notifications shim, restore vendor notifications asset, polling fixes, tests

This commit is contained in:
Ahmed Darrazi 2026-01-20 21:22:32 +01:00
parent a6ab36aca4
commit aa8d132f3c
12 changed files with 193 additions and 24 deletions

View File

@ -24,8 +24,6 @@ class BackupItemsRelationManager extends RelationManager
{ {
protected static string $relationship = 'items'; protected static string $relationship = 'items';
public ?int $pollUntil = null;
protected $listeners = [ protected $listeners = [
'backup-set-policy-picker:close' => 'closeAddPoliciesModal', 'backup-set-policy-picker:close' => 'closeAddPoliciesModal',
]; ];
@ -33,21 +31,12 @@ class BackupItemsRelationManager extends RelationManager
public function closeAddPoliciesModal(): void public function closeAddPoliciesModal(): void
{ {
$this->unmountAction(); $this->unmountAction();
$this->resetTable();
$this->pollUntil = now()->addSeconds(20)->getTimestamp();
}
public function shouldPollTable(): bool
{
return $this->pollUntil !== null && now()->getTimestamp() < $this->pollUntil;
} }
public function table(Table $table): Table public function table(Table $table): Table
{ {
return $table return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion')) ->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
->poll(fn (): ?string => (filled($this->mountedActions) || ! $this->shouldPollTable()) ? null : '2s')
->columns([ ->columns([
Tables\Columns\TextColumn::make('policy.display_name') Tables\Columns\TextColumn::make('policy.display_name')
->label('Item') ->label('Item')
@ -120,6 +109,12 @@ public function table(Table $table): Table
]) ])
->filters([]) ->filters([])
->headerActions([ ->headerActions([
Actions\Action::make('refreshTable')
->label('Refresh')
->icon('heroicon-o-arrow-path')
->action(function (): void {
$this->resetTable();
}),
Actions\Action::make('addPolicies') Actions\Action::make('addPolicies')
->label('Add Policies') ->label('Add Policies')
->icon('heroicon-o-plus') ->icon('heroicon-o-plus')
@ -218,10 +213,6 @@ public function table(Table $table): Table
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();
$this->resetTable();
$this->pollUntil = now()->addSeconds(20)->getTimestamp();
}), }),
])->icon('heroicon-o-ellipsis-vertical'), ])->icon('heroicon-o-ellipsis-vertical'),
]) ])
@ -312,10 +303,6 @@ public function table(Table $table): Table
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();
$this->resetTable();
$this->pollUntil = now()->addSeconds(20)->getTimestamp();
}), }),
]), ]),
]); ]);

View File

@ -325,8 +325,6 @@ public function table(Table $table): Table
]) ])
->send(); ->send();
$this->resetTable();
$this->dispatch('backup-set-policy-picker:close') $this->dispatch('backup-set-policy-picker:close')
->to(\App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager::class); ->to(\App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager::class);
}), }),

View File

@ -38,6 +38,10 @@ public function panel(Panel $panel): Panel
->colors([ ->colors([
'primary' => Color::Amber, 'primary' => Color::Amber,
]) ])
->renderHook(
PanelsRenderHook::HEAD_END,
fn () => view('filament.partials.livewire-intercept-shim')->render()
)
->renderHook( ->renderHook(
PanelsRenderHook::BODY_END, PanelsRenderHook::BODY_END,
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true) fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)

View File

@ -0,0 +1,71 @@
(() => {
// TenantPilot shim: ensure Livewire interceptMessage callbacks run in a safe order.
// This prevents Filament's notifications asset (and others) from calling an undefined
// animation callback when onSuccess fires before onFinish.
if (typeof window === 'undefined') {
return;
}
const Livewire = window.Livewire;
if (!Livewire || typeof Livewire.interceptMessage !== 'function') {
return;
}
if (Livewire.__tenantpilotInterceptMessageShimApplied) {
return;
}
const original = Livewire.interceptMessage.bind(Livewire);
Livewire.interceptMessage = (handler) => {
if (typeof handler !== 'function') {
return original(handler);
}
return original((context) => {
if (!context || typeof context !== 'object') {
return handler(context);
}
const originalOnFinish = context.onFinish;
const originalOnSuccess = context.onSuccess;
if (typeof originalOnFinish !== 'function' || typeof originalOnSuccess !== 'function') {
return handler(context);
}
const finishCallbacks = [];
const onFinish = (callback) => {
if (typeof callback === 'function') {
finishCallbacks.push(callback);
}
return originalOnFinish(callback);
};
const onSuccess = (callback) => {
return originalOnSuccess((...args) => {
// Ensure any registered finish callbacks are run before success callbacks.
// We don't swallow errors; we just stabilize ordering.
for (const finishCallback of finishCallbacks) {
finishCallback(...args);
}
if (typeof callback === 'function') {
return callback(...args);
}
});
};
return handler({
...context,
onFinish,
onSuccess,
});
});
};
Livewire.__tenantpilotInterceptMessageShimApplied = true;
})();

View File

@ -1,3 +1,5 @@
<div class="space-y-4"> <div class="space-y-4">
<livewire:backup-set-policy-picker-table :backupSetId="$backupSetId" /> <livewire:backup-set-policy-picker-table
:backupSetId="$backupSetId"
/>
</div> </div>

View File

@ -0,0 +1 @@
<script defer src="{{ asset('js/tenantpilot/livewire-intercept-shim.js') }}"></script>

View File

@ -58,6 +58,7 @@ class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center te
timer: null, timer: null,
activeSinceMs: null, activeSinceMs: null,
fastUntilMs: null, fastUntilMs: null,
teardownObserver: null,
init() { init() {
this.onVisibilityChange = this.onVisibilityChange.bind(this); this.onVisibilityChange = this.onVisibilityChange.bind(this);
@ -66,6 +67,16 @@ class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center te
this.onNavigated = this.onNavigated.bind(this); this.onNavigated = this.onNavigated.bind(this);
window.addEventListener('livewire:navigated', this.onNavigated); window.addEventListener('livewire:navigated', this.onNavigated);
// Ensure we always detach listeners when Livewire/Filament removes this
// element (for example during modal unmounts or navigation/morph).
this.teardownObserver = new MutationObserver(() => {
if (!this.$el || this.$el.isConnected !== true) {
this.destroy();
}
});
this.teardownObserver.observe(document.body, { childList: true, subtree: true });
// First sync immediately. // First sync immediately.
this.schedule(0); this.schedule(0);
}, },
@ -74,6 +85,11 @@ class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center te
this.stop(); this.stop();
window.removeEventListener('visibilitychange', this.onVisibilityChange); window.removeEventListener('visibilitychange', this.onVisibilityChange);
window.removeEventListener('livewire:navigated', this.onNavigated); window.removeEventListener('livewire:navigated', this.onNavigated);
if (this.teardownObserver) {
this.teardownObserver.disconnect();
this.teardownObserver = null;
}
}, },
stop() { stop() {
@ -164,7 +180,25 @@ class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center te
return; return;
} }
await this.$wire.refreshRuns(); try {
await this.$wire.refreshRuns();
} catch (error) {
// Livewire will reject pending action promises when requests are
// cancelled (for example during `wire:navigate`). That's expected
// for this background poller, so we swallow cancellation rejections.
const isCancellation = Boolean(
error &&
typeof error === 'object' &&
error.status === null &&
error.body === null &&
error.json === null &&
error.errors === null,
);
if (!isCancellation) {
console.warn('Ops UX widget refreshRuns failed', error);
}
}
const next = this.nextIntervalMs(); const next = this.nextIntervalMs();
if (next === null) { if (next === null) {
@ -181,7 +215,7 @@ class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center te
const delay = Math.max(0, Number(delayMs ?? 0)); const delay = Math.max(0, Number(delayMs ?? 0));
this.timer = setTimeout(() => { this.timer = setTimeout(() => {
this.tick(); this.tick().catch(() => {});
}, delay); }, delay);
}, },
}; };

View File

@ -109,6 +109,15 @@ ## Phase 6: Polish & Cross-Cutting Concerns
- [X] T031 Run full test suite gate via php artisan test (phpunit.xml) - [X] T031 Run full test suite gate via php artisan test (phpunit.xml)
- [X] T032 [P] Update specs/057-filament-v5-upgrade/research.md with final dependency decisions and any deviations - [X] T032 [P] Update specs/057-filament-v5-upgrade/research.md with final dependency decisions and any deviations
### Post-merge hotfixes
- [X] T033 Fix Filament Notifications JS Livewire hook ordering (prevent `TypeError: e is not a function`)
- [X] T034 Add regression test for notifications asset hook usage
- [X] T035 Swallow Livewire cancellation promise rejections in Ops UX widget poller
- [X] T036 Ensure Ops UX widget Alpine poller cleans up on unmount (avoid stale listeners / refresh loops)
- [X] T037 Fix Backup Items modal refresh loop race condition (remove BackupItems relation manager polling; reduce unnecessary refresh churn)
- [X] T038 Add regression test ensuring Backup Items table does not use Livewire polling
--- ---
## Dependencies & Execution Order ## Dependencies & Execution Order

View File

@ -0,0 +1,30 @@
<?php
use App\Filament\Resources\BackupSetResource\Pages\EditBackupSet;
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
use App\Models\BackupItem;
use App\Models\BackupSet;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('does not poll the backup items table (prevents modal refresh loops)', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
'name' => 'Test backup',
]);
BackupItem::factory()->for($backupSet)->for($tenant)->create();
Livewire::test(BackupItemsRelationManager::class, [
'ownerRecord' => $backupSet,
'pageClass' => EditBackupSet::class,
])
->assertDontSee('wire:poll');
});

View File

@ -0,0 +1,17 @@
<?php
it('ships a Filament notifications asset compatible with Livewire v4 message hook order', function () {
$js = file_get_contents(public_path('js/filament/notifications/notifications.js'));
$shim = file_get_contents(public_path('js/tenantpilot/livewire-intercept-shim.js'));
expect($js)
->toContain('onFinish')
->and($js)->toContain('onSuccess')
->and($js)->not->toContain('onRender');
expect($shim)
->toContain('TenantPilot shim')
->and($shim)->toContain('Livewire.interceptMessage')
->and($shim)->toContain('__tenantpilotInterceptMessageShimApplied');
});

View File

@ -0,0 +1,9 @@
<?php
it('loads the Livewire intercept shim on the Filament login page', function () {
$response = $this->get('/admin/login');
$response
->assertSuccessful()
->assertSee('js/tenantpilot/livewire-intercept-shim.js', escape: false);
});

View File

@ -44,3 +44,10 @@
->assertDontSee('Inventory sync'); ->assertDontSee('Inventory sync');
}); });
})->group('ops-ux'); })->group('ops-ux');
it('registers Alpine cleanup for the Ops UX poller to avoid stale listeners across re-renders', function () {
$contents = file_get_contents(resource_path('views/livewire/bulk-operation-progress.blade.php'));
expect($contents)->toContain('new MutationObserver');
expect($contents)->toContain('teardownObserver');
})->group('ops-ux');