merge: agent session work
This commit is contained in:
commit
9a8b283b1d
@ -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();
|
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -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);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
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">
|
<div class="space-y-4">
|
||||||
<livewire:backup-set-policy-picker-table :backupSetId="$backupSetId" />
|
<livewire:backup-set-policy-picker-table
|
||||||
|
:backupSetId="$backupSetId"
|
||||||
|
/>
|
||||||
</div>
|
</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,
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
@ -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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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');
|
->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