feat(installer): auto-advance past the migration step (#2283)

## Summary

Migration step now moves itself on. Once migrations report nothing
pending, a 5-second countdown starts, renders inside the Next button
(`Next (5)` → `(1)`), and the wizard footer scrolls into view.

Pause sits to the left of Next for anyone who wants to read the log
first. It occupies the old Back slot — Back is gone from the whole
wizard, since the only thing behind you is a migration that already ran.

Installer only. Updater's existing 10s redirect untouched.

## Test plan

- `vendor/bin/pest tests/Feature/Installer/
tests/Feature/InstallerAccessTest.php` — 12 passed
- `phpstan` on the page — clean
- New `MigrationAutoAdvanceTest` asserts the rendered footer keeps the
seam with Filament's vendor wizard blade intact: Next stays findable via
`data-installer-next`, Pause has replaced Back
- Not covered by tests: the countdown running in a browser, and
`runMigrations()` itself. That method shells out to `php artisan` via
`StreamedCommandsService`, and the subprocess reads `.env` rather than
phpunit's forced sqlite — calling it from a test hits the dev database.
Comment in the test file records this.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added automatic progression to the next installer step after
migrations complete.
* Added a visible countdown with controls to pause and resume the
transition.
* Added automatic scrolling to keep the wizard controls visible during
the countdown.

* **Tests**
* Added coverage for the countdown, navigation controls, and
pause/resume behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Nabeel S. 2026-07-26 22:06:19 -05:00 committed by GitHub
commit 667cbc835d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 171 additions and 0 deletions

View File

@ -25,6 +25,7 @@ use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Components\Form;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\View as ViewComponent;
use Filament\Schemas\Components\Wizard;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema as FilamentSchema;
@ -49,6 +50,12 @@ class Installer extends Page
{
protected static ?string $slug = 'install';
/**
* How long the migration step waits before moving itself on. Long enough to
* hit Pause if the log needs a read, short enough not to feel stalled.
*/
private const int AUTO_ADVANCE_SECONDS = 5;
public string $stream = 'console_output';
public ?array $user = null;
@ -164,6 +171,36 @@ class Installer extends Page
])
->startOnStep(fn (): int => $this->computeStartStep())
->persistStepInQueryString()
->nextAction(fn (Action $action): Action => $action
->label(new HtmlString(
Blade::render(
<<<'BLADE'
{{ __('filament-schemas::components.wizard.actions.next_step.label') }}<span
x-cloak
x-show="$store.installerAutoAdvance?.active"
x-text="` (${$store.installerAutoAdvance?.remaining})`"
></span>
BLADE
)
))
->extraAttributes(['data-installer-next' => 'true']))
// The wizard's only backwards step would be onto an already-run
// migration log, so the slot carries the countdown's Pause
// control instead of a Back button. `.stop` keeps the click from
// reaching the footer wrapper, which would otherwise step back.
->previousAction(fn (Action $action): Action => $action
->label(new HtmlString(
Blade::render(
<<<'BLADE'
<span x-text="$store.installerAutoAdvance?.paused ? @js(__('installer.resume')) : @js(__('installer.pause'))">{{ __('installer.pause') }}</span>
BLADE
)
))
->extraAttributes([
'x-cloak' => true,
'x-show' => '$store.installerAutoAdvance?.active',
'x-on:click.stop' => '$store.installerAutoAdvance.toggle()',
]))
->submitAction(
new HtmlString(
Blade::render(
@ -298,6 +335,16 @@ class Installer extends Page
to: $this->stream
);
// Only hand the step over to the countdown once there is genuinely
// nothing left to run — otherwise it would advance into the step's own
// validation, which halts on pending migrations.
if (count(app(MigrationService::class)->migrationsAvailable()) === 0) {
$this->dispatch(
'installer-migrations-complete',
seconds: self::AUTO_ADVANCE_SECONDS
);
}
return $this->migrationOutput = $output;
}
@ -500,6 +547,11 @@ class Installer extends Page
->viewData([
'stream' => $this->stream,
]),
ViewComponent::make('filament.installer.auto-advance')
->viewData([
'seconds' => self::AUTO_ADVANCE_SECONDS,
]),
])
->afterValidation(function (): void {
if (count(app(MigrationService::class)->migrationsAvailable()) > 0) {

View File

@ -40,6 +40,8 @@ return [
'click_update_to_run' => 'Click "Update" to run the script.',
'update' => 'Update',
'migrations_not_completed' => 'You still have :count migrations to run. Please try again...',
'pause' => 'Pause',
'resume' => 'Resume',
'user_and_airline_setup' => 'User & Airline Setup',
'legacy_importer' => 'phpvms v5 Legacy Importer',
'super_admin_information' => 'Super Admin User Information',

View File

@ -0,0 +1,87 @@
{{--
Drives the installer's "advance to the next step on its own" countdown.
The two controls it talks to the wizard's Pause and Next buttons are
rendered in the wizard footer, which sits outside this step's schema, so the
shared countdown state lives in a global Alpine store rather than an x-data
scope on a common ancestor.
--}}
<script>
(() => {
if (window.installerAutoAdvanceRegistered) {
return
}
window.installerAutoAdvanceRegistered = true
const store = () => window.Alpine?.store('installerAutoAdvance')
const register = () => {
window.Alpine.store('installerAutoAdvance', {
active: false,
paused: false,
remaining: 0,
timer: null,
start(seconds) {
if (this.active) {
return
}
this.active = true
this.paused = false
this.remaining = seconds
this.timer = setInterval(() => {
if (this.paused) {
return
}
this.remaining--
if (this.remaining <= 0) {
this.advance()
}
}, 1000)
requestAnimationFrame(() => {
document
.querySelector('.fi-sc-wizard-footer')
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
},
toggle() {
this.paused = ! this.paused
},
advance() {
this.stop()
document.querySelector('[data-installer-next]')?.click()
},
stop() {
clearInterval(this.timer)
this.timer = null
this.active = false
},
})
}
document.addEventListener('alpine:init', register)
if (window.Alpine) {
register()
}
window.addEventListener('installer-migrations-complete', (event) => {
store()?.start(event.detail?.seconds ?? {{ $seconds }})
})
// Covers both the countdown firing and the user clicking Next early, so
// the paused countdown can't linger into the following step.
window.addEventListener('next-wizard-step', () => store()?.stop())
})()
</script>

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use App\Filament\System\Installer;
use Livewire\Livewire;
/**
* The countdown hangs off the wizard footer that Filament renders for us, so
* these assertions are really about the seam with the vendor component: the
* Next button has to stay findable from JS, and the Back slot has to have been
* taken over by Pause.
*/
it('renders the auto-advance countdown hooks on the wizard footer', function (): void {
Livewire::test(Installer::class)
->assertSee('data-installer-next', escape: false)
->assertSee("Alpine.store('installerAutoAdvance'", escape: false)
->assertSee('$store.installerAutoAdvance.toggle()', escape: false);
});
it('replaces the wizard back button with the pause control', function (): void {
Livewire::test(Installer::class)
->assertSee('Pause')
->assertDontSee(__('filament-schemas::components.wizard.actions.previous_step.label'));
});
// Deliberately not covered here: calling runMigrations(). It shells out to
// `php artisan migrate` through StreamedCommandsService, and the subprocess
// reads .env rather than phpunit's forced sqlite connection -- so the assertion
// would run migrations and DatabaseSeeder against the development database.