Compare commits
No commits in common. "9a8b283b1d16ee009916d9db13d518fdd2489d48" and "a6ab36aca42bd9790dcbb4343b62e29a0000589b" have entirely different histories.
9a8b283b1d
...
a6ab36aca4
@ -24,6 +24,8 @@ 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',
|
||||||
];
|
];
|
||||||
@ -31,12 +33,21 @@ 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')
|
||||||
@ -109,12 +120,6 @@ 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')
|
||||||
@ -213,6 +218,10 @@ 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'),
|
||||||
])
|
])
|
||||||
@ -303,6 +312,10 @@ 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();
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -325,6 +325,8 @@ 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);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -38,10 +38,6 @@ 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)
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
(() => {
|
|
||||||
// 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,5 +1,3 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<livewire:backup-set-policy-picker-table
|
<livewire:backup-set-policy-picker-table :backupSetId="$backupSetId" />
|
||||||
:backupSetId="$backupSetId"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
<script defer src="{{ asset('js/tenantpilot/livewire-intercept-shim.js') }}"></script>
|
|
||||||
@ -58,7 +58,6 @@ 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);
|
||||||
@ -67,16 +66,6 @@ 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);
|
||||||
},
|
},
|
||||||
@ -85,11 +74,6 @@ 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() {
|
||||||
@ -180,25 +164,7 @@ class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center te
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await this.$wire.refreshRuns();
|
||||||
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) {
|
||||||
@ -215,7 +181,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().catch(() => {});
|
this.tick();
|
||||||
}, delay);
|
}, delay);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -109,15 +109,6 @@ ## 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
|
||||||
|
|||||||
@ -1,30 +0,0 @@
|
|||||||
<?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');
|
|
||||||
});
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
<?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');
|
|
||||||
});
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
<?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,10 +44,3 @@
|
|||||||
->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');
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user