spec: refine 057 + extend 058 #67
@ -24,8 +24,6 @@ class BackupItemsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'items';
|
||||
|
||||
public ?int $pollUntil = null;
|
||||
|
||||
protected $listeners = [
|
||||
'backup-set-policy-picker:close' => 'closeAddPoliciesModal',
|
||||
];
|
||||
@ -33,21 +31,12 @@ class BackupItemsRelationManager extends RelationManager
|
||||
public function closeAddPoliciesModal(): void
|
||||
{
|
||||
$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
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
|
||||
->poll(fn (): ?string => (filled($this->mountedActions) || ! $this->shouldPollTable()) ? null : '2s')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('policy.display_name')
|
||||
->label('Item')
|
||||
@ -120,6 +109,12 @@ public function table(Table $table): Table
|
||||
])
|
||||
->filters([])
|
||||
->headerActions([
|
||||
Actions\Action::make('refreshTable')
|
||||
->label('Refresh')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->action(function (): void {
|
||||
$this->resetTable();
|
||||
}),
|
||||
Actions\Action::make('addPolicies')
|
||||
->label('Add Policies')
|
||||
->icon('heroicon-o-plus')
|
||||
@ -218,10 +213,6 @@ public function table(Table $table): Table
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
$this->resetTable();
|
||||
|
||||
$this->pollUntil = now()->addSeconds(20)->getTimestamp();
|
||||
}),
|
||||
])->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
@ -312,10 +303,6 @@ public function table(Table $table): Table
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
$this->resetTable();
|
||||
|
||||
$this->pollUntil = now()->addSeconds(20)->getTimestamp();
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
|
||||
@ -325,8 +325,6 @@ public function table(Table $table): Table
|
||||
])
|
||||
->send();
|
||||
|
||||
$this->resetTable();
|
||||
|
||||
$this->dispatch('backup-set-policy-picker:close')
|
||||
->to(\App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager::class);
|
||||
}),
|
||||
|
||||
@ -38,6 +38,10 @@ public function panel(Panel $panel): Panel
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
->renderHook(
|
||||
PanelsRenderHook::HEAD_END,
|
||||
fn () => view('filament.partials.livewire-intercept-shim')->render()
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::BODY_END,
|
||||
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
|
||||
|
||||
71
public/js/tenantpilot/livewire-intercept-shim.js
Normal file
71
public/js/tenantpilot/livewire-intercept-shim.js
Normal 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;
|
||||
})();
|
||||
@ -1,3 +1,5 @@
|
||||
<div class="space-y-4">
|
||||
<livewire:backup-set-policy-picker-table :backupSetId="$backupSetId" />
|
||||
<livewire:backup-set-policy-picker-table
|
||||
:backupSetId="$backupSetId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1 @@
|
||||
<script defer src="{{ asset('js/tenantpilot/livewire-intercept-shim.js') }}"></script>
|
||||
@ -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,
|
||||
activeSinceMs: null,
|
||||
fastUntilMs: null,
|
||||
teardownObserver: null,
|
||||
|
||||
init() {
|
||||
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);
|
||||
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.
|
||||
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();
|
||||
window.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
window.removeEventListener('livewire:navigated', this.onNavigated);
|
||||
|
||||
if (this.teardownObserver) {
|
||||
this.teardownObserver.disconnect();
|
||||
this.teardownObserver = null;
|
||||
}
|
||||
},
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
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));
|
||||
|
||||
this.timer = setTimeout(() => {
|
||||
this.tick();
|
||||
this.tick().catch(() => {});
|
||||
}, delay);
|
||||
},
|
||||
};
|
||||
|
||||
@ -109,6 +109,15 @@ ## Phase 6: Polish & Cross-Cutting Concerns
|
||||
- [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
|
||||
|
||||
### 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
|
||||
|
||||
30
tests/Feature/Filament/BackupItemsNoPollingTest.php
Normal file
30
tests/Feature/Filament/BackupItemsNoPollingTest.php
Normal 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');
|
||||
});
|
||||
17
tests/Feature/Filament/FilamentNotificationsAssetsTest.php
Normal file
17
tests/Feature/Filament/FilamentNotificationsAssetsTest.php
Normal 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');
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
@ -44,3 +44,10 @@
|
||||
->assertDontSee('Inventory sync');
|
||||
});
|
||||
})->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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user