From ac2b8a7eb73c7dcffb254520769d2029fabb9969 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Mar 2026 12:12:49 -0400 Subject: [PATCH 001/461] [6.x] Bump reka-ui (#14368) --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index f5cde1e88f4..b2987e54286 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,7 +70,7 @@ "pusher-js": "^8.4.0-rc2", "qs": "^6.14.2", "read-time-estimate": "0.0.3", - "reka-ui": "^2.6.1", + "reka-ui": "^2.9.2", "resize-observer-polyfill": "^1.5.1", "semver": "^7.7.1", "speakingurl": "^14.0.1", From 0b82eee597e64bcbcf0202f13df179bacbf580a1 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 26 Mar 2026 17:13:36 +0100 Subject: [PATCH 002/461] [6.x] Ability to disable two-factor authentication (#14263) Co-authored-by: Jason Varga --- config/users.php | 12 ++ routes/cp.php | 31 ++--- routes/web.php | 25 ++-- src/Auth/TwoFactor/TwoFactor.php | 11 ++ src/Auth/User.php | 8 +- src/Facades/TwoFactor.php | 18 +++ .../Controllers/CP/Auth/LoginController.php | 3 +- .../Controllers/CP/Users/UsersController.php | 3 +- src/Http/Controllers/User/LoginController.php | 3 +- .../RedirectIfTwoFactorSetupIncomplete.php | 4 +- tests/Auth/LoginTest.php | 29 +++++ tests/Auth/UserContractTests.php | 17 +++ tests/Feature/Users/EditUserTest.php | 28 ++++- tests/Feature/Users/TwoFactorRoutesTest.php | 108 ++++++++++++++++++ tests/Tags/User/LoginFormTest.php | 41 +++++++ 15 files changed, 311 insertions(+), 30 deletions(-) create mode 100644 src/Auth/TwoFactor/TwoFactor.php create mode 100644 src/Facades/TwoFactor.php create mode 100644 tests/Feature/Users/TwoFactorRoutesTest.php diff --git a/config/users.php b/config/users.php index b0c9b2e7a0f..70e266eb280 100644 --- a/config/users.php +++ b/config/users.php @@ -181,6 +181,18 @@ 'elevated_session_duration' => 15, + /* + |-------------------------------------------------------------------------- + | Two-Factor Authentication + |-------------------------------------------------------------------------- + | + | Here you may disable two-factor authentication entirely. This can be + | useful on local or staging environments, or when using OAuth. + | + */ + + 'two_factor_enabled' => env('STATAMIC_TWO_FACTOR_ENABLED', true), + /* |-------------------------------------------------------------------------- | Enforce Two-Factor Authentication diff --git a/routes/cp.php b/routes/cp.php index 3dc02a2ac59..4e0bd217a07 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -1,6 +1,7 @@ name('password.reset'); Route::post('password/reset', [ResetPasswordController::class, 'reset'])->name('password.reset.action'); - Route::get('two-factor-challenge', [TwoFactorChallengeController::class, 'index'])->name('two-factor-challenge'); - Route::post('two-factor-challenge', [TwoFactorChallengeController::class, 'store']); + if (TwoFactor::enabled()) { + Route::get('two-factor-challenge', [TwoFactorChallengeController::class, 'index'])->name('two-factor-challenge'); + Route::post('two-factor-challenge', [TwoFactorChallengeController::class, 'store']); - Route::get('two-factor-setup', TwoFactorSetupController::class) - ->withoutMiddleware(RedirectIfTwoFactorSetupIncomplete::class) - ->name('two-factor-setup'); + Route::get('two-factor-setup', TwoFactorSetupController::class) + ->withoutMiddleware(RedirectIfTwoFactorSetupIncomplete::class) + ->name('two-factor-setup'); + } } Route::get('logout', [LoginController::class, 'logout'])->name('logout'); @@ -345,14 +348,16 @@ Route::post('users/actions/list', [UserActionController::class, 'bulkActions'])->name('users.actions.bulk'); Route::resource('users', UsersController::class)->except('destroy'); Route::patch('users/{user}/password', [PasswordController::class, 'update'])->name('users.password.update'); - Route::withoutMiddleware(RedirectIfTwoFactorSetupIncomplete::class)->middleware(RequireElevatedSession::class)->group(function () { - Route::get('two-factor/enable', [TwoFactorAuthenticationController::class, 'enable'])->name('users.two-factor.enable'); - Route::delete('two-factor', [TwoFactorAuthenticationController::class, 'disable'])->name('users.two-factor.disable'); - Route::post('two-factor/confirm', [TwoFactorAuthenticationController::class, 'confirm'])->name('users.two-factor.confirm'); - Route::get('two-factor/recovery-codes', [TwoFactorRecoveryCodesController::class, 'show'])->name('users.two-factor.recovery-codes.show'); - Route::post('two-factor/recovery-codes', [TwoFactorRecoveryCodesController::class, 'store'])->name('users.two-factor.recovery-codes.generate'); - Route::get('two-factor/recovery-codes/download', [TwoFactorRecoveryCodesController::class, 'download'])->name('users.two-factor.recovery-codes.download'); - }); + if (TwoFactor::enabled()) { + Route::withoutMiddleware(RedirectIfTwoFactorSetupIncomplete::class)->middleware(RequireElevatedSession::class)->group(function () { + Route::get('two-factor/enable', [TwoFactorAuthenticationController::class, 'enable'])->name('users.two-factor.enable'); + Route::delete('two-factor', [TwoFactorAuthenticationController::class, 'disable'])->name('users.two-factor.disable'); + Route::post('two-factor/confirm', [TwoFactorAuthenticationController::class, 'confirm'])->name('users.two-factor.confirm'); + Route::get('two-factor/recovery-codes', [TwoFactorRecoveryCodesController::class, 'show'])->name('users.two-factor.recovery-codes.show'); + Route::post('two-factor/recovery-codes', [TwoFactorRecoveryCodesController::class, 'store'])->name('users.two-factor.recovery-codes.generate'); + Route::get('two-factor/recovery-codes/download', [TwoFactorRecoveryCodesController::class, 'download'])->name('users.two-factor.recovery-codes.download'); + }); + } Route::get('account', AccountController::class)->name('account'); Route::resource('user-groups', UserGroupsController::class); Route::resource('roles', RolesController::class); diff --git a/routes/web.php b/routes/web.php index 6dc58b73132..220088c94e8 100755 --- a/routes/web.php +++ b/routes/web.php @@ -5,6 +5,7 @@ use Illuminate\Support\Facades\Route; use Statamic\Auth\Protect\Protectors\Password\Controller as PasswordProtectController; use Statamic\Facades\OAuth; +use Statamic\Facades\TwoFactor; use Statamic\Http\Controllers\ActivateAccountController; use Statamic\Http\Controllers\ForgotPasswordController; use Statamic\Http\Controllers\FormController; @@ -50,17 +51,19 @@ Route::get('password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('password.reset'); Route::post('password/reset', [ResetPasswordController::class, 'reset'])->name('password.reset.action'); - Route::get('two-factor-setup', TwoFactorSetupController::class)->name('two-factor-setup'); - Route::get('two-factor-challenge', [TwoFactorChallengeController::class, 'index'])->name('two-factor-challenge'); - Route::post('two-factor-challenge', [TwoFactorChallengeController::class, 'store']); - - Route::withoutMiddleware(RedirectIfTwoFactorSetupIncomplete::class)->group(function () { - Route::get('two-factor/enable', [TwoFactorAuthenticationController::class, 'enable'])->name('users.two-factor.enable'); - Route::post('two-factor/confirm', [TwoFactorAuthenticationController::class, 'confirm'])->name('users.two-factor.confirm'); - Route::get('two-factor/recovery-codes', [TwoFactorRecoveryCodesController::class, 'show'])->name('users.two-factor.recovery-codes.show'); - Route::post('two-factor/recovery-codes', [TwoFactorRecoveryCodesController::class, 'store'])->name('users.two-factor.recovery-codes.generate'); - Route::get('two-factor/recovery-codes/download', [TwoFactorRecoveryCodesController::class, 'download'])->name('users.two-factor.recovery-codes.download'); - }); + if (TwoFactor::enabled()) { + Route::get('two-factor-setup', TwoFactorSetupController::class)->name('two-factor-setup'); + Route::get('two-factor-challenge', [TwoFactorChallengeController::class, 'index'])->name('two-factor-challenge'); + Route::post('two-factor-challenge', [TwoFactorChallengeController::class, 'store']); + + Route::withoutMiddleware(RedirectIfTwoFactorSetupIncomplete::class)->group(function () { + Route::get('two-factor/enable', [TwoFactorAuthenticationController::class, 'enable'])->name('users.two-factor.enable'); + Route::post('two-factor/confirm', [TwoFactorAuthenticationController::class, 'confirm'])->name('users.two-factor.confirm'); + Route::get('two-factor/recovery-codes', [TwoFactorRecoveryCodesController::class, 'show'])->name('users.two-factor.recovery-codes.show'); + Route::post('two-factor/recovery-codes', [TwoFactorRecoveryCodesController::class, 'store'])->name('users.two-factor.recovery-codes.generate'); + Route::get('two-factor/recovery-codes/download', [TwoFactorRecoveryCodesController::class, 'download'])->name('users.two-factor.recovery-codes.download'); + }); + } }); Route::group(['prefix' => 'auth', 'middleware' => [CPAuthGuard::class]], function () { diff --git a/src/Auth/TwoFactor/TwoFactor.php b/src/Auth/TwoFactor/TwoFactor.php new file mode 100644 index 00000000000..c71eebd8f12 --- /dev/null +++ b/src/Auth/TwoFactor/TwoFactor.php @@ -0,0 +1,11 @@ +set('two_factor_recovery_codes', encrypt(str_replace( $code, - TwoFactor\RecoveryCode::generate(), + RecoveryCode::generate(), decrypt($this->two_factor_recovery_codes) )))->save(); diff --git a/src/Facades/TwoFactor.php b/src/Facades/TwoFactor.php new file mode 100644 index 00000000000..021ef99d149 --- /dev/null +++ b/src/Facades/TwoFactor.php @@ -0,0 +1,18 @@ +validateCredentials($request)); - if ($user->hasEnabledTwoFactorAuthentication()) { + if (TwoFactor::enabled() && $user->hasEnabledTwoFactorAuthentication()) { return $this->twoFactorChallengeResponse($request, $user); } diff --git a/src/Http/Controllers/CP/Users/UsersController.php b/src/Http/Controllers/CP/Users/UsersController.php index d83f2108770..999016b6866 100644 --- a/src/Http/Controllers/CP/Users/UsersController.php +++ b/src/Http/Controllers/CP/Users/UsersController.php @@ -11,6 +11,7 @@ use Statamic\Facades\CP\Toast; use Statamic\Facades\Scope; use Statamic\Facades\Search; +use Statamic\Facades\TwoFactor; use Statamic\Facades\User; use Statamic\Facades\UserGroup; use Statamic\Http\Controllers\CP\CpController; @@ -277,7 +278,7 @@ public function edit(Request $request, $user) 'canEditPassword' => User::fromUser($request->user())->can('editPassword', $user), 'requiresCurrentPassword' => $isCurrentUser = $request->user()->id === $user->id(), 'itemActions' => Action::for($user, ['view' => 'form']), - 'twoFactor' => $isCurrentUser ? [ + 'twoFactor' => $isCurrentUser && TwoFactor::enabled() ? [ 'isEnforced' => $user->isTwoFactorAuthenticationRequired(), 'wasSetup' => $user->hasEnabledTwoFactorAuthentication(), 'routes' => [ diff --git a/src/Http/Controllers/User/LoginController.php b/src/Http/Controllers/User/LoginController.php index 509bfe94d63..2d7b0af73ce 100644 --- a/src/Http/Controllers/User/LoginController.php +++ b/src/Http/Controllers/User/LoginController.php @@ -6,6 +6,7 @@ use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Statamic\Facades\TwoFactor; use Statamic\Facades\URL; use Statamic\Facades\User; use Statamic\Http\Controllers\Concerns\HandlesLogins; @@ -22,7 +23,7 @@ public function login(UserLoginRequest $request) $user = User::fromUser($this->validateCredentials($request)); - if ($user->hasEnabledTwoFactorAuthentication()) { + if (TwoFactor::enabled() && $user->hasEnabledTwoFactorAuthentication()) { return $this->twoFactorChallengeResponse($request, $user); } diff --git a/src/Http/Middleware/RedirectIfTwoFactorSetupIncomplete.php b/src/Http/Middleware/RedirectIfTwoFactorSetupIncomplete.php index 26923972888..873edcfa0c1 100644 --- a/src/Http/Middleware/RedirectIfTwoFactorSetupIncomplete.php +++ b/src/Http/Middleware/RedirectIfTwoFactorSetupIncomplete.php @@ -4,6 +4,7 @@ use Closure; use Illuminate\Http\Request; +use Statamic\Facades\TwoFactor; use Statamic\Facades\User; class RedirectIfTwoFactorSetupIncomplete @@ -11,7 +12,8 @@ class RedirectIfTwoFactorSetupIncomplete public function handle(Request $request, Closure $next) { if ( - ($user = User::fromUser($request->user())) + TwoFactor::enabled() + && ($user = User::fromUser($request->user())) && $user->isTwoFactorAuthenticationRequired() && ! $user->hasEnabledTwoFactorAuthentication() ) { diff --git a/tests/Auth/LoginTest.php b/tests/Auth/LoginTest.php index 250494e32ac..fd9a8064584 100644 --- a/tests/Auth/LoginTest.php +++ b/tests/Auth/LoginTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Event; +use Orchestra\Testbench\Attributes\DefineEnvironment; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use Statamic\Auth\TwoFactor\RecoveryCode; @@ -93,6 +94,29 @@ public function it_redirects_to_the_two_factor_challenge_page() Event::assertDispatched(TwoFactorAuthenticationChallenged::class, fn ($event) => $event->user->id === $user->id); } + #[Test] + #[DefineEnvironment('disableTwoFactor')] + public function it_skips_two_factor_challenge_when_two_factor_is_disabled() + { + Event::fake(); + + $user = $this->userWithTwoFactorEnabled(); + + $this->withoutExceptionHandling(); + + $this + ->assertGuest() + ->post(cp_route('login'), [ + 'email' => $user->email(), + 'password' => 'secret', + ]) + ->assertRedirect(cp_route('index')); + + $this->assertAuthenticatedAs($user); + + Event::assertNotDispatched(TwoFactorAuthenticationChallenged::class); + } + #[Test] public function it_redirects_to_referer_url() { @@ -170,6 +194,11 @@ public function it_cant_logout_when_unauthenticated() $this->assertGuest(); } + protected function disableTwoFactor($app) + { + $app['config']->set('statamic.users.two_factor_enabled', false); + } + private function user() { return tap(User::make()->makeSuper()->email('david@hasselhoff.com')->password('secret'))->save(); diff --git a/tests/Auth/UserContractTests.php b/tests/Auth/UserContractTests.php index 724af5aa1ae..7f6d9b2cd6d 100644 --- a/tests/Auth/UserContractTests.php +++ b/tests/Auth/UserContractTests.php @@ -669,6 +669,23 @@ public function it_determines_if_the_user_has_enabled_two_factor_authentication( $this->assertTrue($user->hasEnabledTwoFactorAuthentication()); } + #[Test] + public function it_does_not_require_two_factor_when_globally_disabled_even_if_user_has_setup() + { + config()->set('statamic.users.two_factor_enabled', false); + config()->set('statamic.users.two_factor_enforced_roles', ['*']); + + $user = $this->makeUser() + ->makeSuper() + ->set('two_factor_secret', 'secret') + ->set('two_factor_confirmed_at', now()->timestamp); + + $user->save(); + + $this->assertTrue($user->hasEnabledTwoFactorAuthentication()); + $this->assertFalse($user->isTwoFactorAuthenticationRequired()); + } + #[Test] public function it_gets_recovery_codes() { diff --git a/tests/Feature/Users/EditUserTest.php b/tests/Feature/Users/EditUserTest.php index af6ebd93ae6..28562d070e1 100644 --- a/tests/Feature/Users/EditUserTest.php +++ b/tests/Feature/Users/EditUserTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Users; use Inertia\Testing\AssertableInertia as Assert; +use Orchestra\Testbench\Attributes\DefineEnvironment; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\User; @@ -73,6 +74,23 @@ public function it_provides_2fa_data() ); } + #[Test] + #[DefineEnvironment('disableTwoFactor')] + public function it_doesnt_provide_2fa_data_when_two_factor_is_disabled() + { + $this->setTestRoles(['test' => ['access cp', 'edit users']]); + $me = tap(User::make()->email('admin@domain.com')->assignRole('test'))->save(); + + $this + ->actingAsWithElevatedSession($me) + ->get($me->editUrl()) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('users/Edit') + ->where('twoFactor', null) + ); + } + #[Test] public function it_doesnt_provide_2fa_data_when_editing_someone_else() { @@ -84,6 +102,14 @@ public function it_doesnt_provide_2fa_data_when_editing_someone_else() ->actingAsWithElevatedSession($me) ->get($user->editUrl()) ->assertOk() - ->assertViewHas('twoFactor', fn ($twoFactor) => $twoFactor === null); + ->assertInertia(fn (Assert $page) => $page + ->component('users/Edit') + ->where('twoFactor', null) + ); + } + + protected function disableTwoFactor($app) + { + $app['config']->set('statamic.users.two_factor_enabled', false); } } diff --git a/tests/Feature/Users/TwoFactorRoutesTest.php b/tests/Feature/Users/TwoFactorRoutesTest.php new file mode 100644 index 00000000000..1968e0c062e --- /dev/null +++ b/tests/Feature/Users/TwoFactorRoutesTest.php @@ -0,0 +1,108 @@ +booted(function () { + Route::get('/test-frontend-route', function () { + return 'ok'; + })->middleware('statamic.web'); + }); + } + + #[Test] + #[DefineEnvironment('disableTwoFactor')] + public function two_factor_routes_are_not_registered_when_two_factor_is_disabled() + { + $this->assertFalse(Route::has('statamic.cp.two-factor-challenge')); + $this->assertFalse(Route::has('statamic.cp.two-factor-setup')); + $this->assertFalse(Route::has('statamic.cp.users.two-factor.enable')); + $this->assertFalse(Route::has('statamic.cp.users.two-factor.confirm')); + $this->assertFalse(Route::has('statamic.cp.users.two-factor.disable')); + $this->assertFalse(Route::has('statamic.cp.users.two-factor.recovery-codes.show')); + $this->assertFalse(Route::has('statamic.cp.users.two-factor.recovery-codes.generate')); + $this->assertFalse(Route::has('statamic.cp.users.two-factor.recovery-codes.download')); + $this->assertFalse(Route::has('statamic.two-factor-challenge')); + $this->assertFalse(Route::has('statamic.two-factor-setup')); + $this->assertFalse(Route::has('statamic.users.two-factor.enable')); + $this->assertFalse(Route::has('statamic.users.two-factor.confirm')); + $this->assertFalse(Route::has('statamic.users.two-factor.recovery-codes.show')); + $this->assertFalse(Route::has('statamic.users.two-factor.recovery-codes.generate')); + $this->assertFalse(Route::has('statamic.users.two-factor.recovery-codes.download')); + } + + #[Test] + public function cp_two_factor_setup_middleware_redirects_when_two_factor_is_enforced() + { + config()->set('statamic.users.two_factor_enforced_roles', ['*']); + + $user = tap(User::make()->makeSuper()->email('admin@domain.com'))->save(); + + $this + ->actingAs($user) + ->get(cp_route('dashboard')) + ->assertRedirect(cp_route('two-factor-setup', ['referer' => cp_route('dashboard')])); + } + + #[Test] + #[DefineEnvironment('disableTwoFactor')] + public function cp_two_factor_setup_middleware_does_not_redirect_when_two_factor_is_disabled() + { + config()->set('statamic.users.two_factor_enforced_roles', ['*']); + + $user = tap(User::make()->makeSuper()->email('admin@domain.com'))->save(); + + $this + ->actingAs($user) + ->get(cp_route('dashboard')) + ->assertOk(); + } + + #[Test] + public function frontend_two_factor_setup_middleware_redirects_when_two_factor_is_enforced() + { + config()->set('statamic.users.two_factor_enforced_roles', ['*']); + + $user = tap(User::make()->makeSuper()->email('admin@domain.com'))->save(); + + $this + ->actingAs($user) + ->get('/test-frontend-route') + ->assertRedirect(route('statamic.two-factor-setup', ['referer' => url('/test-frontend-route')])); + } + + #[Test] + #[DefineEnvironment('disableTwoFactor')] + public function frontend_two_factor_setup_middleware_does_not_redirect_when_two_factor_is_disabled() + { + config()->set('statamic.users.two_factor_enforced_roles', ['*']); + + $user = tap(User::make()->makeSuper()->email('admin@domain.com'))->save(); + + $this + ->actingAs($user) + ->get('/test-frontend-route') + ->assertOk(); + } + + protected function disableTwoFactor($app) + { + $app['config']->set('statamic.users.two_factor_enabled', false); + } +} diff --git a/tests/Tags/User/LoginFormTest.php b/tests/Tags/User/LoginFormTest.php index 343d329ebff..5ae2eebcb0e 100644 --- a/tests/Tags/User/LoginFormTest.php +++ b/tests/Tags/User/LoginFormTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Event; +use Orchestra\Testbench\Attributes\DefineEnvironment; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use Statamic\Auth\TwoFactor\RecoveryCode; @@ -322,4 +323,44 @@ public function it_redirects_to_the_two_factor_challenge_page() Event::assertDispatched(TwoFactorAuthenticationChallenged::class, fn ($event) => $event->user->id === 1); } + + #[Test] + #[DefineEnvironment('disableTwoFactor')] + public function it_skips_two_factor_challenge_when_two_factor_is_disabled() + { + Event::fake(); + + $this->assertFalse(auth()->check()); + + User::make() + ->id(1) + ->email('san@holo.com') + ->password('chewy') + ->data([ + 'two_factor_confirmed_at' => now()->timestamp, + 'two_factor_secret' => encrypt(app(TwoFactorAuthenticationProvider::class)->generateSecretKey()), + 'two_factor_recovery_codes' => encrypt(json_encode(Collection::times(8, function () { + return RecoveryCode::generate(); + })->all())), + ]) + ->save(); + + $this + ->assertGuest() + ->post('/!/auth/login', [ + 'token' => 'test-token', + 'email' => 'san@holo.com', + 'password' => 'chewy', + ]) + ->assertLocation('/'); + + $this->assertTrue(auth()->check()); + + Event::assertNotDispatched(TwoFactorAuthenticationChallenged::class); + } + + protected function disableTwoFactor($app) + { + $app['config']->set('statamic.users.two_factor_enabled', false); + } } From 624a56fecd4fc6b00efd6bcacbf9c044e4325b9d Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 26 Mar 2026 17:22:26 +0100 Subject: [PATCH 003/461] [6.x] Fix CP Nav active state when trailing slashes are enforced (#14363) --- src/CP/Navigation/NavItem.php | 1 + tests/CP/Navigation/ActiveNavItemTest.php | 36 +++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/CP/Navigation/NavItem.php b/src/CP/Navigation/NavItem.php index 89f2742be0f..eca3577846f 100644 --- a/src/CP/Navigation/NavItem.php +++ b/src/CP/Navigation/NavItem.php @@ -149,6 +149,7 @@ protected function generateActivePatternForCpUrl($url) $cpUrl = url(config('statamic.cp.route')).'/'; $relativeUrl = str_replace($cpUrl, '', URL::removeQueryAndFragment($url)); + $relativeUrl = rtrim($relativeUrl, '/'); return $relativeUrl.'(/(.*)?|$)'; } diff --git a/tests/CP/Navigation/ActiveNavItemTest.php b/tests/CP/Navigation/ActiveNavItemTest.php index 4dc1c30fc50..16ddce80159 100644 --- a/tests/CP/Navigation/ActiveNavItemTest.php +++ b/tests/CP/Navigation/ActiveNavItemTest.php @@ -12,6 +12,7 @@ use Statamic\Facades; use Statamic\Facades\Blink; use Statamic\Facades\CP\Nav; +use Statamic\Facades\URL; use Statamic\Facades\User; use Tests\FakesRoles; use Tests\PreventSavingStacheItemsToDisk; @@ -35,6 +36,13 @@ public function setUp(): void Facades\Form::shouldReceive('all')->andReturn(collect()); } + public function tearDown(): void + { + URL::enforceTrailingSlashes(false); + + parent::tearDown(); + } + protected function resolveApplicationConfiguration($app) { parent::resolveApplicationConfiguration($app); @@ -408,6 +416,34 @@ public function it_resolves_extension_children_closure_and_can_check_when_parent $this->assertTrue($this->getItemByDisplay($seoPro->children(), 'Section Defaults')->isActive()); } + #[Test] + public function it_properly_handles_is_active_checks_when_trailing_slashes_are_enforced() + { + URL::enforceTrailingSlashes(); + + Nav::clearCachedUrls(); + + $parent = Nav::create('parent') + ->section('test') + ->url('http://localhost/cp/parent') + ->children([ + $hello = Nav::create('hello')->url('http://localhost/cp/hello'), + $world = Nav::create('world')->url('http://localhost/cp/world'), + ]); + + // Test active status with trailing slash in request URL + Request::swap(Request::create('http://localhost/cp/hello/')); + $this->assertTrue($parent->isActive()); + $this->assertTrue($hello->isActive()); + $this->assertFalse($world->isActive()); + + // Test active status on descendant with trailing slash + Request::swap(Request::create('http://localhost/cp/hello/nested/path/')); + $this->assertTrue($parent->isActive()); + $this->assertTrue($hello->isActive()); + $this->assertFalse($world->isActive()); + } + #[Test] public function it_properly_handles_various_edge_cases_when_checking_is_active_on_descendants_of_nav_children() { From b10f8eebb646f6aa69ba1640ab9843e27cd95453 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:27:16 -0400 Subject: [PATCH 004/461] [6.x] Bump picomatch from 2.3.1 to 2.3.2 (#14360) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index b2987e54286..0196f369d49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6284,9 +6284,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -6683,9 +6683,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -8640,9 +8640,9 @@ } }, "node_modules/vite-plugin-full-reload/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { From 22a7b20ecac86b0a1754c8d062a3da7fd93f383b Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Mar 2026 12:48:37 -0400 Subject: [PATCH 005/461] [6.x] Skip laravel-vite-plugin during Vitest runs (#14370) Co-authored-by: Claude Opus 4.6 --- vite.config.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vite.config.js b/vite.config.js index 3c09664c045..cb557a7c331 100644 --- a/vite.config.js +++ b/vite.config.js @@ -14,6 +14,7 @@ export default defineConfig(({ mode, command }) => { const isRunningBuild = command === 'build'; const isProdBuild = isRunningBuild && mode === 'production'; const isProdDevBuild = isRunningBuild && mode === 'development'; + const isTesting = !!process.env.VITEST; return { base: './', @@ -25,7 +26,7 @@ export default defineConfig(({ mode, command }) => { plugins: [ tsconfigPaths(), tailwindcss(), - laravel({ + !isTesting && laravel({ valetTls: env.VALET_TLS, input: ['resources/css/app.css', 'resources/js/index.js'], refresh: true, From d6a494d8d837a4d20b2b416f52d93e956e5d1847 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 26 Mar 2026 17:56:37 +0100 Subject: [PATCH 006/461] [6.x] Only change date for localizations with an explicit date set (#14362) --- src/Console/Commands/MigrateDatesToUtc.php | 6 ++ .../Commands/MigrateDatesToUtcTest.php | 82 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/src/Console/Commands/MigrateDatesToUtc.php b/src/Console/Commands/MigrateDatesToUtc.php index 9a862e5166c..5b52e8af223 100644 --- a/src/Console/Commands/MigrateDatesToUtc.php +++ b/src/Console/Commands/MigrateDatesToUtc.php @@ -120,6 +120,12 @@ private function updateDateFields($item, Fields $fields, ?string $dottedPrefix = && empty($dottedPrefix) && $field->handle() === 'date' ) { + // Skip localized entries that inherit their date from the origin. + // The origin will be migrated separately and the inheritance will continue to work. + if (! $item->hasExplicitDate()) { + return; + } + // When entries are constructed, the datestamp from the filename would be provided but treated as UTC. // We need them to be adjusted back to the existing timezone. $item->date(Carbon::createFromFormat( diff --git a/tests/Console/Commands/MigrateDatesToUtcTest.php b/tests/Console/Commands/MigrateDatesToUtcTest.php index 98a459fe50b..89d31988c98 100644 --- a/tests/Console/Commands/MigrateDatesToUtcTest.php +++ b/tests/Console/Commands/MigrateDatesToUtcTest.php @@ -91,6 +91,88 @@ public function it_converts_entry_date_field_in_entries_when_app_timezone_is_utc $this->it_converts_entry_date_field_in_entries(); } + #[Test] + public function it_skips_localized_entries_that_inherit_date_from_origin() + { + $this->setSites([ + 'en' => ['url' => '/', 'locale' => 'en_US', 'name' => 'English'], + 'fr' => ['url' => '/fr/', 'locale' => 'fr_FR', 'name' => 'French'], + 'it' => ['url' => '/it/', 'locale' => 'it_IT', 'name' => 'Italian'], + ]); + + $collection = tap(Collection::make('articles')->dated(true)->sites(['en', 'fr', 'it']))->save(); + + $collection->entryBlueprint()->setContents([ + 'fields' => [ + ['handle' => 'date', 'field' => ['type' => 'date', 'time_enabled' => true]], + ], + ])->save(); + + $origin = Entry::make()->id('origin')->locale('en')->collection('articles')->date('2025-01-01-1200'); + $origin->save(); + + $french = Entry::make()->id('fr-entry')->locale('fr')->collection('articles')->origin($origin); + $french->save(); + + $italian = Entry::make()->id('it-entry')->locale('it')->collection('articles')->origin($origin); + $italian->save(); + + // All entries should have the same date before migration + $this->assertEquals('2025-01-01T12:00:00+00:00', $origin->date()->toIso8601String()); + $this->assertEquals('2025-01-01T12:00:00+00:00', $french->date()->toIso8601String()); + $this->assertEquals('2025-01-01T12:00:00+00:00', $italian->date()->toIso8601String()); + + $this->migrateDatesToUtc(); + + $origin = Entry::find('origin'); + $french = Entry::find('fr-entry'); + $italian = Entry::find('it-entry'); + + // Origin should be migrated + $this->assertEquals('2025-01-01T17:00:00+00:00', $origin->date()->toIso8601String()); + + // Localized entries should still have the same date as origin (inherited) + $this->assertEquals('2025-01-01T17:00:00+00:00', $french->date()->toIso8601String()); + $this->assertEquals('2025-01-01T17:00:00+00:00', $italian->date()->toIso8601String()); + + $this->assertFalse($french->hasExplicitDate()); + $this->assertFalse($italian->hasExplicitDate()); + } + + #[Test] + public function it_migrates_localized_entries_that_have_their_own_date() + { + $this->setSites([ + 'en' => ['url' => '/', 'locale' => 'en_US', 'name' => 'English'], + 'fr' => ['url' => '/fr/', 'locale' => 'fr_FR', 'name' => 'French'], + ]); + + $collection = tap(Collection::make('articles')->dated(true)->sites(['en', 'fr']))->save(); + + $collection->entryBlueprint()->setContents([ + 'fields' => [ + ['handle' => 'date', 'field' => ['type' => 'date', 'time_enabled' => true]], + ], + ])->save(); + + $origin = Entry::make()->id('origin')->locale('en')->collection('articles')->date('2025-01-01-1200'); + $origin->save(); + + $french = Entry::make()->id('fr-entry')->locale('fr')->collection('articles')->origin($origin)->date('2025-01-02-1400'); + $french->save(); + + $this->assertTrue($french->hasExplicitDate()); + + $this->migrateDatesToUtc(); + + $origin = Entry::find('origin'); + $french = Entry::find('fr-entry'); + + // Both should be migrated + $this->assertEquals('2025-01-01T17:00:00+00:00', $origin->date()->toIso8601String()); + $this->assertEquals('2025-01-02T19:00:00+00:00', $french->date()->toIso8601String()); + } + #[Test] #[DataProvider('dateFieldsProvider')] public function it_converts_date_fields_in_terms(string $fieldHandle, array $field, $original, $expected) From 948b3ddb26779cc5e6de0362430880acb95163a1 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Mar 2026 15:18:01 -0400 Subject: [PATCH 007/461] [6.x] Ability to select the date formatting locale (#14372) --- lang/en/messages.php | 2 + resources/js/bootstrap/fieldtypes.js | 2 + resources/js/bootstrap/statamic.js | 7 ++ resources/js/components/DateFormatter.js | 15 +++ .../fieldtypes/FormattingLocalesFieldtype.vue | 98 +++++++++++++++++++ .../js/tests/components/DateFormatter.test.js | 27 +++++ src/Fieldtypes/FormattingLocales.php | 10 ++ src/Preferences/CorePreferences.php | 8 +- src/Providers/ExtensionServiceProvider.php | 1 + 9 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 resources/js/components/fieldtypes/FormattingLocalesFieldtype.vue create mode 100644 src/Fieldtypes/FormattingLocales.php diff --git a/lang/en/messages.php b/lang/en/messages.php index 42d340c728b..ae79894ece6 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -198,6 +198,8 @@ 'phpinfo_utility_description' => 'Check PHP configuration settings and installed modules.', 'plus_count_more' => '+ :count more', 'preference_confirm_dirty_navigation_instructions' => 'Whether you should get a warning when trying to leave the page when there are unsaved changes.', + 'preference_formatting_locale_invalid' => 'Please enter a valid locale code.', + 'preference_formatting_locale_instructions' => 'The locale used for formatting values in the control panel, such as dates and numbers.', 'preference_locale_instructions' => 'The preferred language for the control panel.', 'preference_start_page_instructions' => 'The page to be shown when logging into the control panel.', 'preference_strict_accessibility_instructions' => 'We\'ve designed the control panel with accessibility in mind, aiming to meet WCAG 2.2 guidelines where possible. Enable this option to apply stricter accessibility rules by increasing form border contrast.', diff --git a/resources/js/bootstrap/fieldtypes.js b/resources/js/bootstrap/fieldtypes.js index dea6d98c382..eef6b3ff979 100644 --- a/resources/js/bootstrap/fieldtypes.js +++ b/resources/js/bootstrap/fieldtypes.js @@ -17,6 +17,7 @@ import Routes from '../components/collections/Routes.vue'; import TitleFormats from '../components/collections/TitleFormats.vue'; import ColorFieldtype from '../components/fieldtypes/ColorFieldtype.vue'; import DateFieldtype from '../components/fieldtypes/DateFieldtype.vue'; +import FormattingLocalesFieldtype from '../components/fieldtypes/FormattingLocalesFieldtype.vue'; import DateIndexFieldtype from '../components/fieldtypes/DateIndexFieldtype.vue'; import DictionaryFieldtype from '../components/fieldtypes/DictionaryFieldtype.vue'; import DictionaryIndexFieldtype from '../components/fieldtypes/DictionaryIndexFieldtype.vue'; @@ -88,6 +89,7 @@ export default function registerFieldtypes(app) { app.component('collection_title_formats-fieldtype', TitleFormats); app.component('color-fieldtype', ColorFieldtype); app.component('date-fieldtype', DateFieldtype); + app.component('formatting_locales-fieldtype', FormattingLocalesFieldtype); app.component('date-fieldtype-index', DateIndexFieldtype); app.component('dictionary-fieldtype', DictionaryFieldtype); app.component('dictionary-fieldtype-index', DictionaryIndexFieldtype); diff --git a/resources/js/bootstrap/statamic.js b/resources/js/bootstrap/statamic.js index 65aec1b9b25..b0ed5f36c18 100644 --- a/resources/js/bootstrap/statamic.js +++ b/resources/js/bootstrap/statamic.js @@ -176,6 +176,13 @@ export default { contrast.initialize(this.initialConfig.user?.preferences?.strict_accessibility); preferences.initialize(this.initialConfig.user?.preferences, this.initialConfig.defaultPreferences); + const formattingLocale = this.initialConfig.user?.preferences?.formatting_locale; + if (formattingLocale === 'language') { + dateFormatter.setDefaultLocale(this.initialConfig.translationLocale); + } else if (formattingLocale) { + dateFormatter.setDefaultLocale(formattingLocale); + } + bootingCallbacks.forEach((callback) => callback(this)); bootingCallbacks = []; diff --git a/resources/js/components/DateFormatter.js b/resources/js/components/DateFormatter.js index 28dc8d91073..a476cbbfec8 100644 --- a/resources/js/components/DateFormatter.js +++ b/resources/js/components/DateFormatter.js @@ -102,6 +102,21 @@ export default class DateFormatter { return this.date(date).options(options).toString(); } + withLocale(locale, callback) { + const previousLocale = DateFormatter.defaultLocale; + this.setDefaultLocale(locale); + + try { + return callback(this); + } finally { + this.setDefaultLocale(previousLocale); + } + } + + setDefaultLocale(locale) { + DateFormatter.defaultLocale = locale; + } + get locale() { return DateFormatter.defaultLocale ?? Intl.DateTimeFormat().resolvedOptions().locale; } diff --git a/resources/js/components/fieldtypes/FormattingLocalesFieldtype.vue b/resources/js/components/fieldtypes/FormattingLocalesFieldtype.vue new file mode 100644 index 00000000000..d2b77f2f279 --- /dev/null +++ b/resources/js/components/fieldtypes/FormattingLocalesFieldtype.vue @@ -0,0 +1,98 @@ + + + diff --git a/resources/js/tests/components/DateFormatter.test.js b/resources/js/tests/components/DateFormatter.test.js index 341bbaba67a..fcd69f68578 100644 --- a/resources/js/tests/components/DateFormatter.test.js +++ b/resources/js/tests/components/DateFormatter.test.js @@ -47,6 +47,27 @@ test('it can statically format', () => { expect(DateFormatter.format('1995-03-13T22:45:19Z', { year: 'numeric' })).toBe('1995'); }); +test('it can temporarily format with locale using callback', () => { + const formatter = new DateFormatter(); + DateFormatter.defaultLocale = 'en-us'; + + const result = formatter.withLocale('de', (instance) => instance.format('2021-12-25T12:13:14Z', 'datetime')); + + expect(result).toBe('25.12.2021, 12:13'); + expect(DateFormatter.defaultLocale).toBe('en-us'); +}); + +test('it resets locale after withLocale callback throws', () => { + const formatter = new DateFormatter(); + DateFormatter.defaultLocale = 'en-us'; + + expect(() => formatter.withLocale('de', () => { + throw new Error('boom'); + })).toThrow('boom'); + + expect(DateFormatter.defaultLocale).toBe('en-us'); +}); + test('it can format on the instance', () => { expect(new DateFormatter().format('1995-03-13T22:45:19Z')).toBe('3/13/1995, 10:45 PM'); expect(new DateFormatter().format('1995-03-13T22:45:19Z', { year: 'numeric' })).toBe('1995'); @@ -186,6 +207,12 @@ test('it can get the locale', () => { expect(new DateFormatter().locale).toBe('fr'); }); +test('it can set the default locale via setDefaultLocale', () => { + new DateFormatter().setDefaultLocale('de'); + expect(DateFormatter.defaultLocale).toBe('de'); + expect(new DateFormatter().locale).toBe('de'); +}); + test.each([ ['en', 'date', '12/25/2021'], ['en', 'time', '12:13 PM'], diff --git a/src/Fieldtypes/FormattingLocales.php b/src/Fieldtypes/FormattingLocales.php new file mode 100644 index 00000000000..1773bac203a --- /dev/null +++ b/src/Fieldtypes/FormattingLocales.php @@ -0,0 +1,10 @@ + 'select', - 'display' => __('Locale'), + 'display' => __('Language'), 'instructions' => __('statamic::messages.preference_locale_instructions'), 'clearable' => true, 'label_html' => true, 'options' => $this->localeOptions(), ]); + Preference::register('formatting_locale', [ + 'type' => 'formatting_locales', + 'display' => __('Formatting Locale'), + 'instructions' => __('statamic::messages.preference_formatting_locale_instructions'), + ]); + Preference::register('start_page', [ 'type' => 'text', 'display' => __('Start Page'), diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php index 64136db7780..b5f71dcc8a1 100644 --- a/src/Providers/ExtensionServiceProvider.php +++ b/src/Providers/ExtensionServiceProvider.php @@ -83,6 +83,7 @@ class ExtensionServiceProvider extends ServiceProvider Fieldtypes\FieldDisplay::class, Fieldtypes\Files::class, Fieldtypes\Floatval::class, + Fieldtypes\FormattingLocales::class, Fieldtypes\GlobalSetSites::class, Fieldtypes\Grid::class, Fieldtypes\Group::class, From 1652e74b5618e55febc50bb96c68a3704d99b568 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Mar 2026 20:30:27 -0400 Subject: [PATCH 008/461] [6.x] Number formatter (#14373) --- .storybook/preview.ts | 2 + packages/cms/src/api.js | 1 + packages/cms/src/index.js | 1 + resources/js/api.js | 2 + resources/js/bootstrap/cms/core.js | 1 + resources/js/bootstrap/statamic.js | 11 +- resources/js/components/DateFormatter.js | 20 +- resources/js/components/FormattingLocale.js | 13 + resources/js/components/NumberFormatter.js | 113 ++++++++ resources/js/components/ui/Pagination.vue | 5 +- resources/js/tests/Package.test.js | 2 + .../tests/components/NumberFormatter.test.js | 241 ++++++++++++++++++ 12 files changed, 404 insertions(+), 8 deletions(-) create mode 100644 resources/js/components/FormattingLocale.js create mode 100644 resources/js/components/NumberFormatter.js create mode 100644 resources/js/tests/components/NumberFormatter.test.js diff --git a/.storybook/preview.ts b/.storybook/preview.ts index a9819150f55..43fcd3da74d 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -8,6 +8,7 @@ import './theme.css'; import {translate} from '@/translations/translator'; import registerUiComponents from '@/bootstrap/ui'; import DateFormatter from '@/components/DateFormatter'; +import NumberFormatter from '@/components/NumberFormatter'; import cleanCodeSnippet from './clean-code-snippet'; import PortalVue from 'portal-vue'; import FullscreenHeader from '@/components/publish/FullscreenHeader.vue'; @@ -63,6 +64,7 @@ setup(async (app) => { app.config.globalProperties.__ = translate; app.config.globalProperties.$date = new DateFormatter; + app.config.globalProperties.$number = new NumberFormatter; app.config.globalProperties.cp_url = (url) => url; app.config.globalProperties.$portals = portals; app.config.globalProperties.$stacks = stacks; diff --git a/packages/cms/src/api.js b/packages/cms/src/api.js index d0168535b77..40e8654a1ed 100644 --- a/packages/cms/src/api.js +++ b/packages/cms/src/api.js @@ -14,6 +14,7 @@ export const { hooks, inertia, keys, + numberFormatter, permissions, portals, preferences, diff --git a/packages/cms/src/index.js b/packages/cms/src/index.js index 843018baed9..eef9b728733 100644 --- a/packages/cms/src/index.js +++ b/packages/cms/src/index.js @@ -8,6 +8,7 @@ export const { IndexFieldtypeMixin, InlineEditForm, DateFormatter, + NumberFormatter, ItemActions, RelatedItem, RestoreRevision, diff --git a/resources/js/api.js b/resources/js/api.js index 403e49d154a..8e2ad4c0c9d 100644 --- a/resources/js/api.js +++ b/resources/js/api.js @@ -14,6 +14,7 @@ import Reveal from './components/Reveal.js'; import Echo from './components/Echo.js'; import Permission from './components/Permission.js'; import DateFormatter from './components/DateFormatter.js'; +import NumberFormatter from './components/NumberFormatter.js'; import CommandPalette from './components/CommandPalette.js'; import ColorMode from './components/ColorMode.js'; import Contrast from './components/Contrast.js'; @@ -39,6 +40,7 @@ export const reveal = new Reveal(); export const echo = new Echo(); export const permissions = new Permission(); export const dateFormatter = new DateFormatter(); +export const numberFormatter = new NumberFormatter(); export const commandPalette = new CommandPalette(); export const colorMode = new ColorMode(); export const contrast = new Contrast(); diff --git a/resources/js/bootstrap/cms/core.js b/resources/js/bootstrap/cms/core.js index ffe704b444e..e0fbd25b601 100644 --- a/resources/js/bootstrap/cms/core.js +++ b/resources/js/bootstrap/cms/core.js @@ -3,6 +3,7 @@ export { default as IndexFieldtype } from '../../components/fieldtypes/index-fie export { default as FieldtypeMixin } from '../../components/fieldtypes/Fieldtype.vue'; export { default as IndexFieldtypeMixin } from '../../components/fieldtypes/IndexFieldtype.vue'; export { default as DateFormatter } from '../../components/DateFormatter.js'; +export { default as NumberFormatter } from '../../components/NumberFormatter.js'; export { default as ItemActions } from '../../components/actions/ItemActions.vue'; export { default as InlineEditForm } from '../../components/inputs/relationship/InlineEditForm.vue'; export { default as RelatedItem } from '../../components/inputs/relationship/Item.vue'; diff --git a/resources/js/bootstrap/statamic.js b/resources/js/bootstrap/statamic.js index b0ed5f36c18..ba48aaa3a17 100644 --- a/resources/js/bootstrap/statamic.js +++ b/resources/js/bootstrap/statamic.js @@ -19,6 +19,7 @@ import VueComponentDebug from 'vue-component-debug'; import { registerIconSetFromStrings } from '@ui'; import Layout from '@/pages/layout/Layout.vue'; import { setTranslations, setLocale } from '@/translations/translator.js'; +import { setDefaultLocale as setFormattingLocale } from '@/components/FormattingLocale.js'; import { keys, components, @@ -35,6 +36,7 @@ import { echo, permissions, dateFormatter, + numberFormatter, commandPalette, colorMode, contrast, @@ -123,6 +125,10 @@ export default { return dateFormatter; }, + get $number() { + return numberFormatter; + }, + get $progress() { return progress; }, @@ -178,9 +184,9 @@ export default { const formattingLocale = this.initialConfig.user?.preferences?.formatting_locale; if (formattingLocale === 'language') { - dateFormatter.setDefaultLocale(this.initialConfig.translationLocale); + setFormattingLocale(this.initialConfig.translationLocale); } else if (formattingLocale) { - dateFormatter.setDefaultLocale(formattingLocale); + setFormattingLocale(formattingLocale); } bootingCallbacks.forEach((callback) => callback(this)); @@ -281,6 +287,7 @@ export default { $echo: echo, $permissions: permissions, $date: dateFormatter, + $number: numberFormatter, $commandPalette: commandPalette, $colorMode: colorMode, $contrast: contrast, diff --git a/resources/js/components/DateFormatter.js b/resources/js/components/DateFormatter.js index a476cbbfec8..f08f3150ab1 100644 --- a/resources/js/components/DateFormatter.js +++ b/resources/js/components/DateFormatter.js @@ -1,3 +1,5 @@ +import { getLocale, getDefaultLocale, setDefaultLocale } from './FormattingLocale.js'; + export default class DateFormatter { #date; #options; @@ -102,23 +104,31 @@ export default class DateFormatter { return this.date(date).options(options).toString(); } + static get defaultLocale() { + return getDefaultLocale(); + } + + static set defaultLocale(locale) { + setDefaultLocale(locale); + } + withLocale(locale, callback) { - const previousLocale = DateFormatter.defaultLocale; - this.setDefaultLocale(locale); + const previousLocale = getDefaultLocale(); + setDefaultLocale(locale); try { return callback(this); } finally { - this.setDefaultLocale(previousLocale); + setDefaultLocale(previousLocale); } } setDefaultLocale(locale) { - DateFormatter.defaultLocale = locale; + setDefaultLocale(locale); } get locale() { - return DateFormatter.defaultLocale ?? Intl.DateTimeFormat().resolvedOptions().locale; + return getLocale(); } #normalizeDate(date) { diff --git a/resources/js/components/FormattingLocale.js b/resources/js/components/FormattingLocale.js new file mode 100644 index 00000000000..2d30ad2a199 --- /dev/null +++ b/resources/js/components/FormattingLocale.js @@ -0,0 +1,13 @@ +let defaultLocale = null; + +export function getLocale() { + return defaultLocale ?? Intl.DateTimeFormat().resolvedOptions().locale; +} + +export function getDefaultLocale() { + return defaultLocale; +} + +export function setDefaultLocale(locale) { + defaultLocale = locale; +} diff --git a/resources/js/components/NumberFormatter.js b/resources/js/components/NumberFormatter.js new file mode 100644 index 00000000000..4f26b6042b4 --- /dev/null +++ b/resources/js/components/NumberFormatter.js @@ -0,0 +1,113 @@ +import { getLocale, getDefaultLocale, setDefaultLocale } from './FormattingLocale.js'; + +export default class NumberFormatter { + #number; + #rangeEnd; + #options; + #presets = { + decimal: { + style: 'decimal', + }, + percent: { + style: 'percent', + }, + }; + + constructor(number, options) { + if (Array.isArray(number)) { + this.#number = this.#normalizeNumber(number[0]); + this.#rangeEnd = this.#normalizeNumber(number[1]); + } else { + this.#number = this.#normalizeNumber(number); + } + + this.#options = this.#normalizeOptions(options); + } + + number(value) { + return new NumberFormatter(value, this.#options); + } + + options(options) { + const value = this.#rangeEnd !== undefined ? [this.#number, this.#rangeEnd] : this.#number; + + return new NumberFormatter(value, options); + } + + toString() { + try { + if (this.#rangeEnd !== undefined) { + return Intl.NumberFormat(this.locale, this.#options).formatRange(this.#number, this.#rangeEnd); + } + + return Intl.NumberFormat(this.locale, this.#options).format(this.#number); + } catch (e) { + return 'Invalid Number'; + } + } + + static format(number, options) { + return new NumberFormatter(number, options).toString(); + } + + format(number, options) { + return this.number(number).options(options).toString(); + } + + formatRange(start, end, options) { + return this.number([start, end]).options(options ?? this.#options).toString(); + } + + static formatRange(start, end, options) { + return new NumberFormatter([start, end], options).toString(); + } + + static get defaultLocale() { + return getDefaultLocale(); + } + + static set defaultLocale(locale) { + setDefaultLocale(locale); + } + + withLocale(locale, callback) { + const previousLocale = getDefaultLocale(); + setDefaultLocale(locale); + + try { + return callback(this); + } finally { + setDefaultLocale(previousLocale); + } + } + + setDefaultLocale(locale) { + setDefaultLocale(locale); + } + + get locale() { + return getLocale(); + } + + #normalizeNumber(number) { + if (number === null || number === undefined) return 0; + + const n = Number(number); + + if (Number.isNaN(n)) throw new Error('Invalid Number'); + + return n; + } + + #normalizeOptions(options) { + if (!options) options = 'decimal'; + + if (typeof options === 'string') { + if (!this.#presets[options]) throw new Error(`Invalid number format: ${options}`); + + return this.#presets[options]; + } + + return options; + } +} diff --git a/resources/js/components/ui/Pagination.vue b/resources/js/components/ui/Pagination.vue index f5f1f323ac1..b68003b280e 100644 --- a/resources/js/components/ui/Pagination.vue +++ b/resources/js/components/ui/Pagination.vue @@ -155,7 +155,10 @@ function getRange(start, end) {
- {{ __(':start-:end of :total', { start: fromItem, end: toItem, total: totalItems }) }} + {{ __(':range of :total', { + range: $number.formatRange(fromItem, toItem), + total: $number.format(totalItems) + }) }}
diff --git a/resources/js/tests/Package.test.js b/resources/js/tests/Package.test.js index 5d43ccfb22c..1a277085281 100644 --- a/resources/js/tests/Package.test.js +++ b/resources/js/tests/Package.test.js @@ -27,6 +27,7 @@ it('exports core', async () => { 'IndexFieldtypeMixin', 'InlineEditForm', 'ItemActions', + 'NumberFormatter', 'RelatedItem', 'RestoreRevision', 'RevisionHistory', @@ -62,6 +63,7 @@ it('exports api', async () => { 'hooks', 'inertia', 'keys', + 'numberFormatter', 'permissions', 'portals', 'preferences', diff --git a/resources/js/tests/components/NumberFormatter.test.js b/resources/js/tests/components/NumberFormatter.test.js new file mode 100644 index 00000000000..3be37bd475b --- /dev/null +++ b/resources/js/tests/components/NumberFormatter.test.js @@ -0,0 +1,241 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import NumberFormatter from '@/components/NumberFormatter.js'; + +beforeEach(() => { + NumberFormatter.defaultLocale = 'en-us'; +}); + +test('it can cast to string', () => { + const formatter = new NumberFormatter(); + expect(`${formatter}`).toBe('0'); +}); + +test('it can set up options before hand', () => { + const formatted = new NumberFormatter() + .options({ + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + .toString(); + + expect(formatted).toBe('0.00'); +}); + +test('it can pass the number separately', () => { + const formatter = new NumberFormatter().number(99.5); + expect(formatter.toString()).toBe('99.5'); +}); + +test('it can statically format', () => { + expect(NumberFormatter.format(0.25, 'percent')).toBe('25%'); + expect(NumberFormatter.format(1234.5, { minimumFractionDigits: 2 })).toBe('1,234.50'); +}); + +test('it can temporarily format with locale using callback', () => { + const formatter = new NumberFormatter(); + NumberFormatter.defaultLocale = 'en-us'; + + const result = formatter.withLocale('de', (instance) => instance.format(1234.567, 'decimal')); + + expect(result).toBe('1.234,567'); + expect(NumberFormatter.defaultLocale).toBe('en-us'); +}); + +test('it resets locale after withLocale callback throws', () => { + const formatter = new NumberFormatter(); + NumberFormatter.defaultLocale = 'en-us'; + + expect(() => formatter.withLocale('de', () => { + throw new Error('boom'); + })).toThrow('boom'); + + expect(NumberFormatter.defaultLocale).toBe('en-us'); +}); + +test('it can format on the instance', () => { + expect(new NumberFormatter().format(1234.5)).toBe('1,234.5'); + expect(new NumberFormatter().format(1234.5, { maximumFractionDigits: 0 })).toBe('1,235'); +}); + +describe('numbers can be provided in various ways', () => { + const n = 1234.5; + const expectedFormat = '1,234.5'; + const expectedZero = '0'; + + test.each([ + { + name: 'constructor with number literal', + value: () => new NumberFormatter(n), + expected: expectedFormat, + }, + { + name: 'constructor with numeric string', + value: () => new NumberFormatter('1234.5'), + expected: expectedFormat, + }, + { + name: 'constructor with null', + value: () => new NumberFormatter(null), + expected: expectedZero, + }, + { + name: 'constructor with undefined', + value: () => new NumberFormatter(undefined), + expected: expectedZero, + }, + { + name: 'number with number literal', + value: () => new NumberFormatter().number(n), + expected: expectedFormat, + }, + { + name: 'number with numeric string', + value: () => new NumberFormatter().number('1234.5'), + expected: expectedFormat, + }, + { + name: 'number with null', + value: () => new NumberFormatter().number(null), + expected: expectedZero, + }, + { + name: 'format with number literal', + value: () => new NumberFormatter().format(n), + expected: expectedFormat, + }, + { + name: 'format with numeric string', + value: () => new NumberFormatter().format('1234.5'), + expected: expectedFormat, + }, + { + name: 'format with null', + value: () => new NumberFormatter().format(null), + expected: expectedZero, + }, + { + name: 'static format with number literal', + value: () => NumberFormatter.format(n), + expected: expectedFormat, + }, + { + name: 'static format with numeric string', + value: () => NumberFormatter.format('1234.5'), + expected: expectedFormat, + }, + { + name: 'static format with null', + value: () => NumberFormatter.format(null), + expected: expectedZero, + }, + ])('by $name', ({ value, expected }) => { + value = value(); + expect(`${value}`).toBe(expected); + }); +}); + +test.each([ + ['constructor with NaN', () => new NumberFormatter(NaN)], + ['constructor with invalid string', () => new NumberFormatter('foo')], + ['number() with NaN', () => new NumberFormatter().number(NaN)], + ['number() with invalid string', () => new NumberFormatter().number('foo')], + ['format() with NaN', () => new NumberFormatter().format(NaN)], + ['format() with invalid string', () => new NumberFormatter().format('foo')], + ['static format with NaN', () => NumberFormatter.format(NaN)], + ['static format with invalid string', () => NumberFormatter.format('foo')], +])('it throws for invalid number: %s', (label, fn) => { + expect(fn).toThrow('Invalid Number'); +}); + +test('it can get the locale', () => { + expect(new NumberFormatter().locale).toBe('en-us'); + NumberFormatter.defaultLocale = 'fr'; + expect(new NumberFormatter().locale).toBe('fr'); +}); + +test('it can set the default locale via setDefaultLocale', () => { + new NumberFormatter().setDefaultLocale('de'); + expect(NumberFormatter.defaultLocale).toBe('de'); + expect(new NumberFormatter().locale).toBe('de'); +}); + +test.each([ + ['en', 'decimal', 1234.567, '1,234.567'], + ['en', 'percent', 0.2534, '25%'], + ['de', 'decimal', 1234.567, '1.234,567'], + ['de', 'percent', 0.2534, '25\u00a0%'], +])('it has format presets (%s %s)', (locale, preset, number, expected) => { + NumberFormatter.defaultLocale = locale; + expect(new NumberFormatter(number, preset).toString()).toBe(expected); +}); + +test('an invalid preset throws an error', () => { + expect(() => new NumberFormatter().options('foo')).toThrow('Invalid number format: foo'); +}); + +describe('formatRange', () => { + test('it can format a range with default options', () => { + expect(new NumberFormatter().formatRange(1000, 5000)).toBe('1,000–5,000'); + }); + + test('it can format a range with a preset', () => { + expect(new NumberFormatter().formatRange(0.1, 0.85, 'percent')).toBe('10% – 85%'); + }); + + test('it can format a range with custom options', () => { + expect(new NumberFormatter().formatRange(100, 999.99, { style: 'currency', currency: 'USD' })).toBe('$100.00 – $999.99'); + }); + + test('it uses the instance options when none are provided', () => { + expect(new NumberFormatter(0, 'percent').formatRange(0.1, 0.5)).toBe('10% – 50%'); + }); + + test('it can format a range with a different locale', () => { + NumberFormatter.defaultLocale = 'de'; + expect(new NumberFormatter().formatRange(1000, 5000)).toBe('1.000–5.000'); + }); + + test('it can statically format a range', () => { + expect(NumberFormatter.formatRange(1000, 5000)).toBe('1,000–5,000'); + expect(NumberFormatter.formatRange(0.1, 0.85, 'percent')).toBe('10% – 85%'); + }); + + test('it normalizes string inputs', () => { + expect(new NumberFormatter().formatRange('1000', '5000')).toBe('1,000–5,000'); + }); +}); + +describe('range via array syntax', () => { + test('it formats a range when an array is provided to the constructor', () => { + expect(new NumberFormatter([1, 2]).toString()).toBe('1–2'); + }); + + test('it formats a range when an array is provided to number()', () => { + expect(new NumberFormatter().number([1, 2]).toString()).toBe('1–2'); + }); + + test('it can use a preset while formatting a range', () => { + expect(new NumberFormatter([0.1, 0.2], 'percent').toString()).toBe('10% – 20%'); + }); + + test('it preserves range when options() is chained', () => { + expect(new NumberFormatter([1234.5, 1234.9]).options({ minimumFractionDigits: 2, maximumFractionDigits: 2 }).toString()).toBe('1,234.50–1,234.90'); + }); + + test('it resets locale after withLocale callback throws', () => { + const formatter = new NumberFormatter(); + NumberFormatter.defaultLocale = 'en-us'; + + expect(() => formatter.withLocale('de', (instance) => { + expect(instance.number([1234.567, 1234.9]).toString()).toBe('1.234,567–1.234,9'); + throw new Error('boom'); + })).toThrow('boom'); + + expect(NumberFormatter.defaultLocale).toBe('en-us'); + }); + + test('it throws for NaN range endpoints', () => { + expect(() => new NumberFormatter([NaN, 2])).toThrow('Invalid Number'); + expect(() => new NumberFormatter([1, 'foo'])).toThrow('Invalid Number'); + }); +}); From 48a1de0b181d2f19992010663d29ea4044197066 Mon Sep 17 00:00:00 2001 From: Philipp Daun Date: Fri, 27 Mar 2026 15:27:01 +0100 Subject: [PATCH 009/461] [6.x] Bring back responsive button groups (#13336) Co-authored-by: Jay George Co-authored-by: Jason Varga --- .../fieldtypes/ButtonGroupFieldtype.vue | 36 +--- resources/js/components/ui/Button/Group.vue | 161 ++++++++++++++---- .../ui/Listing/BulkActionsFloatingToolbar.vue | 2 +- resources/js/stories/Button.stories.ts | 112 ++++++++++++ resources/js/stories/docs/Button.mdx | 9 + 5 files changed, 251 insertions(+), 69 deletions(-) diff --git a/resources/js/components/fieldtypes/ButtonGroupFieldtype.vue b/resources/js/components/fieldtypes/ButtonGroupFieldtype.vue index 36e89887648..f3412db2232 100644 --- a/resources/js/components/fieldtypes/ButtonGroupFieldtype.vue +++ b/resources/js/components/fieldtypes/ButtonGroupFieldtype.vue @@ -1,5 +1,5 @@ diff --git a/resources/js/components/ui/Combobox/Scrollbar.vue b/resources/js/components/ui/Combobox/Scrollbar.vue deleted file mode 100644 index a568c91f69e..00000000000 --- a/resources/js/components/ui/Combobox/Scrollbar.vue +++ /dev/null @@ -1,128 +0,0 @@ - - - \ No newline at end of file diff --git a/resources/js/components/ui/Select/Select.vue b/resources/js/components/ui/Select/Select.vue index e5c5b2c5875..78a1f0aeebe 100644 --- a/resources/js/components/ui/Select/Select.vue +++ b/resources/js/components/ui/Select/Select.vue @@ -5,6 +5,11 @@ import Combobox from '../Combobox/Combobox.vue'; const emit = defineEmits(['update:modelValue']); const props = defineProps({ + /** When `true`, the dropdown will expand to fit longer option labels. */ + adaptiveWidth: { type: Boolean, default: false }, + /** The preferred alignment against the trigger. May change when collisions occur.

Options: `start`, `center`, `end` */ + align: { type: String, default: 'start' }, + /** When `true`, the selected value will be clearable. */ clearable: { type: Boolean, default: false }, disabled: { type: Boolean, default: false }, /** Icon name. [Browse available icons](/?path=/story/components-icon--all-icons) */ @@ -52,6 +57,8 @@ const usingOptionSlot = !!slots['option']; :searchable="false" :size :variant + :align + :adaptive-width @update:modelValue="emit('update:modelValue', $event)" > -
+
[], }, + uploader: { + type: Object, + default: null, + }, }, setup() { @@ -476,8 +480,9 @@ export default { this.loadAssets(); }, - path() { + path(path) { this.loadAssets(); + this.$emit('path-changed', path); }, searchQuery() { @@ -676,7 +681,7 @@ export default { }, openFileBrowser() { - this.$refs.uploader.browse(); + (this.uploader || this.$refs.internalUploader).browse(); }, selectFolder(path) { diff --git a/resources/js/components/assets/Selector.vue b/resources/js/components/assets/Selector.vue index baef4d97f4c..ef93aeb5b7b 100644 --- a/resources/js/components/assets/Selector.vue +++ b/resources/js/components/assets/Selector.vue @@ -1,75 +1,96 @@ diff --git a/resources/js/pages/auth/Login.vue b/resources/js/pages/auth/Login.vue index 0473fdca1bd..5153a67c68c 100644 --- a/resources/js/pages/auth/Login.vue +++ b/resources/js/pages/auth/Login.vue @@ -15,7 +15,6 @@ const props = defineProps([ 'passkeyVerifyUrl', 'oauthEnabled', 'providers', - 'referer', 'submitUrl', 'forgotPasswordUrl', ]) @@ -42,11 +41,11 @@ const submit = () => { errors.value = {}; }, onSuccess: (page) => { - if (page.component === 'auth/two-factor/Challenge') { - return; - } + if (page.component === 'auth/two-factor/Challenge') { + return; + } - window.location.href = props.referer; + window.location.href = page.url; }, onError: () => processing.value = false }); diff --git a/resources/js/pages/auth/Unauthorized.vue b/resources/js/pages/auth/Unauthorized.vue index cf83538f7ad..8a032fbc033 100644 --- a/resources/js/pages/auth/Unauthorized.vue +++ b/resources/js/pages/auth/Unauthorized.vue @@ -2,7 +2,6 @@ import Head from '@/pages/layout/Head.vue'; import Outside from '@/pages/layout/Outside.vue'; import { AuthCard, Button } from '@ui'; -import { Link } from '@inertiajs/vue3'; defineOptions({ layout: Outside }); @@ -19,7 +18,7 @@ defineProps(['isLoggedIn', 'loginUrl', 'logoutUrl']); >
+
+ +
+
{ expect(dateField.vm.datePickerValue).toBe(null); }); + +test.each([ + ['UTC'], + ['America/New_York'], + ['Australia/Sydney'], +])('date-only format is not affected by timezone (%s)', async (tz) => { + process.env.TZ = tz; + + const dateField = makeDateField({ + value: '2025-12-25', + meta: { formatHasTime: false }, + }); + + const value = dateField.vm.datePickerValue; + expect(value.year).toBe(2025); + expect(value.month).toBe(12); + expect(value.day).toBe(25); + expect(value.timeZone).toBeUndefined(); +}); + +test('date-only format formats date correctly', async () => { + process.env.TZ = 'America/New_York'; + + const dateField = makeDateField({ + value: '2025-12-25', + meta: { formatHasTime: false }, + }); + + const { CalendarDate } = await import('@internationalized/date'); + const formatted = dateField.vm.formatDateOnly(new CalendarDate(2025, 6, 5)); + + expect(formatted).toBe('2025-06-05'); +}); + +test('date-only range format is not affected by timezone', async () => { + process.env.TZ = 'America/New_York'; + + const dateField = makeDateField({ + value: { start: '2025-12-25', end: '2025-12-31' }, + meta: { formatHasTime: false }, + config: { + mode: 'range', + earliest_date: { date: null, time: null }, + latest_date: { date: null, time: null }, + }, + }); + + const value = dateField.vm.datePickerValue; + expect(value.start.year).toBe(2025); + expect(value.start.month).toBe(12); + expect(value.start.day).toBe(25); + expect(value.end.year).toBe(2025); + expect(value.end.month).toBe(12); + expect(value.end.day).toBe(31); +}); diff --git a/src/Fieldtypes/Date.php b/src/Fieldtypes/Date.php index c4dc409b1e2..3868b7c3e91 100644 --- a/src/Fieldtypes/Date.php +++ b/src/Fieldtypes/Date.php @@ -150,6 +150,10 @@ private function preProcessSingle($value) $value = $value['start']; } + if (! $this->formatHasTime()) { + return $this->parseSavedToCarbon($value)->format('Y-m-d'); + } + return $this->parseSaved($value)->toIso8601ZuluString('millisecond'); } @@ -165,6 +169,13 @@ private function preProcessRange($value) if (! is_array($value)) { $carbon = $this->parseSavedToCarbon($value); + if (! $this->formatHasTime()) { + return [ + 'start' => $carbon->copy()->format('Y-m-d'), + 'end' => $carbon->copy()->format('Y-m-d'), + ]; + } + return [ 'start' => $carbon->copy()->startOfDay()->utc()->toIso8601ZuluString('millisecond'), 'end' => $carbon->copy()->endOfDay()->utc()->toIso8601ZuluString('millisecond'), @@ -222,6 +233,12 @@ private function processRange($data) private function processDateTime($value) { + if (! $this->formatHasTime()) { + $date = Carbon::parse($value, config('app.timezone')); + + return $this->formatAndCast($date, $this->saveFormat()); + } + $date = Carbon::parse($value, 'UTC'); return $this->formatAndCast($date, $this->saveFormat()); @@ -246,8 +263,8 @@ public function preProcessIndex($value) } return [ - 'start' => $this->parseSaved($value['start'])->toIso8601ZuluString('millisecond'), - 'end' => $this->parseSaved($value['end'])->toIso8601ZuluString('millisecond'), + 'start' => $this->preProcessIndexDate($value['start']), + 'end' => $this->preProcessIndexDate($value['end']), ...$common, ]; } @@ -258,11 +275,20 @@ public function preProcessIndex($value) } return [ - 'date' => $this->parseSaved($value)->toIso8601ZuluString('millisecond'), + 'date' => $this->preProcessIndexDate($value), ...$common, ]; } + private function preProcessIndexDate($value) + { + if (! $this->formatHasTime()) { + return $this->parseSavedToCarbon($value)->format('Y-m-d'); + } + + return $this->parseSaved($value)->toIso8601ZuluString('millisecond'); + } + private function saveFormat() { return $this->config('format', $this->defaultFormat()); @@ -362,6 +388,16 @@ private function parseSavedToCarbon($value): Carbon } } + public function formatHasTime(): bool + { + return DateFormat::containsTime($this->saveFormat()); + } + + public function preload() + { + return ['formatHasTime' => $this->formatHasTime()]; + } + public function timeEnabled() { return $this->config('time_enabled'); diff --git a/src/Rules/DateFieldtype.php b/src/Rules/DateFieldtype.php index 16a5af4545f..436df34a22a 100644 --- a/src/Rules/DateFieldtype.php +++ b/src/Rules/DateFieldtype.php @@ -88,7 +88,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void private function validDateFormat($value) { - $format = 'Y-m-d\TH:i:s.v\Z'; + $format = $this->fieldtype->formatHasTime() + ? 'Y-m-d\TH:i:s.v\Z' + : 'Y-m-d'; $date = DateTime::createFromFormat('!'.$format, $value); diff --git a/tests/Fieldtypes/DateTest.php b/tests/Fieldtypes/DateTest.php index bbb49a4e2e0..e98f110fe1c 100644 --- a/tests/Fieldtypes/DateTest.php +++ b/tests/Fieldtypes/DateTest.php @@ -214,6 +214,36 @@ public static function processProvider() ['start' => '2012-08-29T00:00:00Z', 'end' => '2013-09-27T23:59:00Z'], ['start' => '2012-08-28 20:00', 'end' => '2013-09-27 19:59'], ], + 'date-only format' => [ + 'UTC', + ['format' => 'Y-m-d'], + '2012-08-29', + '2012-08-29', + ], + 'date-only format in a different timezone' => [ + 'America/New_York', + ['format' => 'Y-m-d'], + '2012-08-29', + '2012-08-29', + ], + 'date-only custom format' => [ + 'UTC', + ['format' => 'Y--m--d'], + '2012-08-29', + '2012--08--29', + ], + 'date-only format range' => [ + 'UTC', + ['mode' => 'range', 'format' => 'Y-m-d'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ], + 'date-only format range in a different timezone' => [ + 'America/New_York', + ['mode' => 'range', 'format' => 'Y-m-d'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ], ]; } @@ -294,6 +324,30 @@ public static function preProcessProvider() '2012-08-29 13:43', '2012-08-29T17:43:00.000Z', ], + 'date-only format' => [ + 'UTC', + ['format' => 'Y-m-d'], + '2012-08-29', + '2012-08-29', + ], + 'date-only format in a different timezone' => [ + 'America/New_York', + ['format' => 'Y-m-d'], + '2012-08-29', + '2012-08-29', + ], + 'date-only format in a positive offset timezone' => [ + 'Australia/Sydney', + ['format' => 'Y-m-d'], + '2012-08-29', + '2012-08-29', + ], + 'date-only custom format' => [ + 'America/New_York', + ['format' => 'Y--m--d'], + '2012--08--29', + '2012-08-29', + ], 'null range' => [ 'UTC', ['mode' => 'range'], @@ -318,6 +372,18 @@ public static function preProcessProvider() ['start' => '2012-08-29 00:00', 'end' => '2013-09-27 23:59'], ['start' => '2012-08-29T04:00:00.000Z', 'end' => '2013-09-28T03:59:00.000Z'], ], + 'date-only format range' => [ + 'UTC', + ['mode' => 'range', 'format' => 'Y-m-d'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ], + 'date-only format range in a different timezone' => [ + 'America/New_York', + ['mode' => 'range', 'format' => 'Y-m-d'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ], 'range where single date has been provided' => [ 'UTC', // e.g. If it was once a non-range field. @@ -326,11 +392,11 @@ public static function preProcessProvider() '2012-08-29', ['start' => '2012-08-29T00:00:00.000Z', 'end' => '2012-08-29T23:59:59.999Z'], ], - 'range where single date has been provided with custom format' => [ + 'range where single date has been provided with date-only custom format' => [ 'UTC', ['mode' => 'range', 'format' => 'Y--m--d'], '2012--08--29', - ['start' => '2012-08-29T00:00:00.000Z', 'end' => '2012-08-29T23:59:59.999Z'], + ['start' => '2012-08-29', 'end' => '2012-08-29'], ], 'date where range has been provided' => [ 'UTC', @@ -459,6 +525,30 @@ public static function preProcessIndexProvider() ['start' => '2012-08-29 00:00', 'end' => '2013-09-27 00:00'], ['start' => '2012-08-29T00:00:00.000Z', 'end' => '2013-09-27T00:00:00.000Z', 'mode' => 'range', 'time_enabled' => true], ], + 'date-only format' => [ + 'UTC', + ['format' => 'Y-m-d'], + '2012-08-29', + ['date' => '2012-08-29', 'mode' => 'single', 'time_enabled' => false], + ], + 'date-only format in a different timezone' => [ + 'America/New_York', + ['format' => 'Y-m-d'], + '2012-08-29', + ['date' => '2012-08-29', 'mode' => 'single', 'time_enabled' => false], + ], + 'date-only format range' => [ + 'UTC', + ['mode' => 'range', 'format' => 'Y-m-d'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ['start' => '2012-08-29', 'end' => '2013-09-27', 'mode' => 'range', 'time_enabled' => false], + ], + 'date-only format range in a different timezone' => [ + 'America/New_York', + ['mode' => 'range', 'format' => 'Y-m-d'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ['start' => '2012-08-29', 'end' => '2013-09-27', 'mode' => 'range', 'time_enabled' => false], + ], ]; } @@ -603,6 +693,16 @@ public static function validationProvider() '2024-01-29', ['Not a valid date.'], ], + 'valid date-only format' => [ + ['format' => 'Y-m-d'], + '2024-01-29', + [], + ], + 'invalid date-only format' => [ + ['format' => 'Y-m-d'], + 'marchtember oneteenth', + ['Not a valid date.'], + ], 'ridiculous invalid date format' => [ [], 'marchtember oneteenth', From 7a85c71033d7c71c97029f955e11614074d31eba Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 4 May 2026 16:23:15 -0400 Subject: [PATCH 170/461] [6.x] Avoid rendering time for dates in listings when appropriate (#14599) Co-authored-by: Claude Opus 4.7 (1M context) --- .../fieldtypes/DateIndexFieldtype.vue | 8 +++- .../fieldtypes/DateIndexFieldtype.test.js | 44 ++++++++++++++++++- src/Fieldtypes/Date.php | 1 + tests/Fieldtypes/DateTest.php | 36 +++++++-------- 4 files changed, 68 insertions(+), 21 deletions(-) diff --git a/resources/js/components/fieldtypes/DateIndexFieldtype.vue b/resources/js/components/fieldtypes/DateIndexFieldtype.vue index 03ee4672f1d..470cad507c1 100644 --- a/resources/js/components/fieldtypes/DateIndexFieldtype.vue +++ b/resources/js/components/fieldtypes/DateIndexFieldtype.vue @@ -11,12 +11,16 @@ const emit = defineEmits(Fieldtype.emits); const props = defineProps(Fieldtype.props); const { expose } = Fieldtype.use(emit, props); +const showTimeInValue = computed(() => props.value?.format_has_time && props.value?.time_enabled); + +const showTooltip = computed(() => props.value?.format_has_time); + const formatted = computed(() => { if (!props.value) { return null; } - const formatter = new DateFormatter().options(props.value.time_enabled ? 'datetime' : 'date'); + const formatter = new DateFormatter().options(showTimeInValue.value ? 'datetime' : 'date'); if (props.value.mode === 'range') { let start = new Date(props.value.start); @@ -29,7 +33,7 @@ const formatted = computed(() => { }); const tooltip = computed(() => { - if (!props.value) { + if (!props.value || !showTooltip.value) { return null; } diff --git a/resources/js/tests/components/fieldtypes/DateIndexFieldtype.test.js b/resources/js/tests/components/fieldtypes/DateIndexFieldtype.test.js index ebe642ec7d7..7a4832c421e 100644 --- a/resources/js/tests/components/fieldtypes/DateIndexFieldtype.test.js +++ b/resources/js/tests/components/fieldtypes/DateIndexFieldtype.test.js @@ -49,6 +49,7 @@ test.each([ date: '2025-12-25T02:13:00Z', mode: 'single', time_enabled: true, + format_has_time: true, }); expect(dateIndexField.vm.formatted).toBe(expected); @@ -88,7 +89,11 @@ test.each([ ])('date and time is formatted to the users browser language (%s)', async (lang, expected) => { DateFormatter.defaultLocale = lang; - const dateIndexField = makeDateIndexField({ date: '2025-12-25T13:29:00Z', time_enabled: true }); + const dateIndexField = makeDateIndexField({ + date: '2025-12-25T13:29:00Z', + time_enabled: true, + format_has_time: true, + }); expect(dateIndexField.vm.formatted).toBe(expected); }); @@ -108,3 +113,40 @@ test.each([ expect(dateIndexField.vm.formatted).toBe(expected); }); + +test('date-only format omits time from value and tooltip', async () => { + const dateIndexField = makeDateIndexField({ + date: '2025-12-25', + mode: 'single', + time_enabled: false, + format_has_time: false, + }); + + expect(dateIndexField.vm.formatted).toBe('12/25/2025'); + expect(dateIndexField.vm.tooltip).toBeNull(); +}); + +test('time-disabled but time-aware format shows date in value and time in tooltip', async () => { + const dateIndexField = makeDateIndexField({ + date: '2025-12-25T02:13:00Z', + mode: 'single', + time_enabled: false, + format_has_time: true, + }); + + expect(dateIndexField.vm.formatted).toBe('12/25/2025'); + expect(dateIndexField.vm.tooltip).not.toBeNull(); + expect(dateIndexField.vm.tooltip).toContain('2025'); +}); + +test('time-enabled value shows time in both value and tooltip', async () => { + const dateIndexField = makeDateIndexField({ + date: '2025-12-25T02:13:00Z', + mode: 'single', + time_enabled: true, + format_has_time: true, + }); + + expect(dateIndexField.vm.formatted).toBe('12/25/2025, 2:13 AM'); + expect(dateIndexField.vm.tooltip).not.toBeNull(); +}); diff --git a/src/Fieldtypes/Date.php b/src/Fieldtypes/Date.php index 3868b7c3e91..d08324d503b 100644 --- a/src/Fieldtypes/Date.php +++ b/src/Fieldtypes/Date.php @@ -253,6 +253,7 @@ public function preProcessIndex($value) $common = [ 'mode' => $this->config('mode', 'single'), 'time_enabled' => $this->config('time_enabled'), + 'format_has_time' => $this->formatHasTime(), ]; if ($this->config('mode') === 'range') { diff --git a/tests/Fieldtypes/DateTest.php b/tests/Fieldtypes/DateTest.php index e98f110fe1c..107e18646a0 100644 --- a/tests/Fieldtypes/DateTest.php +++ b/tests/Fieldtypes/DateTest.php @@ -436,37 +436,37 @@ public static function preProcessIndexProvider() 'UTC', [], '2012-08-29 00:00', - ['date' => '2012-08-29T00:00:00.000Z', 'mode' => 'single', 'time_enabled' => false], + ['date' => '2012-08-29T00:00:00.000Z', 'mode' => 'single', 'time_enabled' => false, 'format_has_time' => true], ], 'date with custom format' => [ 'UTC', ['format' => 'Y--m--d H/i'], '2012--08--29 00/00', - ['date' => '2012-08-29T00:00:00.000Z', 'mode' => 'single', 'time_enabled' => false], + ['date' => '2012-08-29T00:00:00.000Z', 'mode' => 'single', 'time_enabled' => false, 'format_has_time' => true], ], 'date in a different timezone' => [ 'America/New_York', // -0400 [], '2012-08-29 00:00', - ['date' => '2012-08-29T04:00:00.000Z', 'mode' => 'single', 'time_enabled' => false], + ['date' => '2012-08-29T04:00:00.000Z', 'mode' => 'single', 'time_enabled' => false, 'format_has_time' => true], ], 'date with time' => [ 'UTC', ['time_enabled' => true], '2012-08-29 13:43', - ['date' => '2012-08-29T13:43:00.000Z', 'mode' => 'single', 'time_enabled' => true], + ['date' => '2012-08-29T13:43:00.000Z', 'mode' => 'single', 'time_enabled' => true, 'format_has_time' => true], ], 'date with time and custom format' => [ 'UTC', ['time_enabled' => true, 'format' => 'Y--m--d H:i'], '2012--08--29 13:43', - ['date' => '2012-08-29T13:43:00.000Z', 'mode' => 'single', 'time_enabled' => true], + ['date' => '2012-08-29T13:43:00.000Z', 'mode' => 'single', 'time_enabled' => true, 'format_has_time' => true], ], 'date with time in a different timezone' => [ 'America/New_York', // -0400 ['time_enabled' => true], '2012-08-29 13:43', - ['date' => '2012-08-29T17:43:00.000Z', 'mode' => 'single', 'time_enabled' => true], + ['date' => '2012-08-29T17:43:00.000Z', 'mode' => 'single', 'time_enabled' => true, 'format_has_time' => true], ], 'null range' => [ 'UTC', @@ -478,19 +478,19 @@ public static function preProcessIndexProvider() 'UTC', ['mode' => 'range'], ['start' => '2012-08-29 00:00', 'end' => '2013-09-27 00:00'], - ['start' => '2012-08-29T00:00:00.000Z', 'end' => '2013-09-27T00:00:00.000Z', 'mode' => 'range', 'time_enabled' => false], + ['start' => '2012-08-29T00:00:00.000Z', 'end' => '2013-09-27T00:00:00.000Z', 'mode' => 'range', 'time_enabled' => false, 'format_has_time' => true], ], 'range with custom format' => [ 'UTC', ['mode' => 'range', 'format' => 'Y--m--d H/i'], ['start' => '2012--08--29 00/00', 'end' => '2013--09--27 00/00'], - ['start' => '2012-08-29T00:00:00.000Z', 'end' => '2013-09-27T00:00:00.000Z', 'mode' => 'range', 'time_enabled' => false], + ['start' => '2012-08-29T00:00:00.000Z', 'end' => '2013-09-27T00:00:00.000Z', 'mode' => 'range', 'time_enabled' => false, 'format_has_time' => true], ], 'range in a different timezone' => [ 'America/New_York', // -4000 ['mode' => 'range'], ['start' => '2012-08-29 00:00', 'end' => '2013-09-27 00:00'], - ['start' => '2012-08-29T04:00:00.000Z', 'end' => '2013-09-27T04:00:00.000Z', 'mode' => 'range', 'time_enabled' => false], + ['start' => '2012-08-29T04:00:00.000Z', 'end' => '2013-09-27T04:00:00.000Z', 'mode' => 'range', 'time_enabled' => false, 'format_has_time' => true], ], 'range where single date has been provided' => [ // e.g. If it was once a non-range field. @@ -498,56 +498,56 @@ public static function preProcessIndexProvider() 'UTC', ['mode' => 'range'], '2012-08-29', - ['start' => '2012-08-29T00:00:00.000Z', 'end' => '2012-08-29T00:00:00.000Z', 'mode' => 'range', 'time_enabled' => false], + ['start' => '2012-08-29T00:00:00.000Z', 'end' => '2012-08-29T00:00:00.000Z', 'mode' => 'range', 'time_enabled' => false, 'format_has_time' => true], ], 'range where single date has been provided with custom format' => [ 'UTC', ['mode' => 'range', 'format' => 'Y--m--d H/i'], '2012--08--29 00/00', - ['start' => '2012-08-29T00:00:00.000Z', 'end' => '2012-08-29T00:00:00.000Z', 'mode' => 'range', 'time_enabled' => false], + ['start' => '2012-08-29T00:00:00.000Z', 'end' => '2012-08-29T00:00:00.000Z', 'mode' => 'range', 'time_enabled' => false, 'format_has_time' => true], ], 'date where range has been provided' => [ // e.g. If it was once a range field. Use the start date. 'UTC', [], ['start' => '2012-08-29 00:00', 'end' => '2013-09-27 00:00'], - ['date' => '2012-08-29T00:00:00.000Z', 'mode' => 'single', 'time_enabled' => false], + ['date' => '2012-08-29T00:00:00.000Z', 'mode' => 'single', 'time_enabled' => false, 'format_has_time' => true], ], 'date where range has been provided with custom format' => [ 'UTC', ['format' => 'Y--m--d H/i'], ['start' => '2012--08--29 00/00', 'end' => '2013--09--27 00/00'], - ['date' => '2012-08-29T00:00:00.000Z', 'mode' => 'single', 'time_enabled' => false], + ['date' => '2012-08-29T00:00:00.000Z', 'mode' => 'single', 'time_enabled' => false, 'format_has_time' => true], ], 'range where time has been enabled' => [ 'UTC', ['mode' => 'range', 'time_enabled' => true], ['start' => '2012-08-29 00:00', 'end' => '2013-09-27 00:00'], - ['start' => '2012-08-29T00:00:00.000Z', 'end' => '2013-09-27T00:00:00.000Z', 'mode' => 'range', 'time_enabled' => true], + ['start' => '2012-08-29T00:00:00.000Z', 'end' => '2013-09-27T00:00:00.000Z', 'mode' => 'range', 'time_enabled' => true, 'format_has_time' => true], ], 'date-only format' => [ 'UTC', ['format' => 'Y-m-d'], '2012-08-29', - ['date' => '2012-08-29', 'mode' => 'single', 'time_enabled' => false], + ['date' => '2012-08-29', 'mode' => 'single', 'time_enabled' => false, 'format_has_time' => false], ], 'date-only format in a different timezone' => [ 'America/New_York', ['format' => 'Y-m-d'], '2012-08-29', - ['date' => '2012-08-29', 'mode' => 'single', 'time_enabled' => false], + ['date' => '2012-08-29', 'mode' => 'single', 'time_enabled' => false, 'format_has_time' => false], ], 'date-only format range' => [ 'UTC', ['mode' => 'range', 'format' => 'Y-m-d'], ['start' => '2012-08-29', 'end' => '2013-09-27'], - ['start' => '2012-08-29', 'end' => '2013-09-27', 'mode' => 'range', 'time_enabled' => false], + ['start' => '2012-08-29', 'end' => '2013-09-27', 'mode' => 'range', 'time_enabled' => false, 'format_has_time' => false], ], 'date-only format range in a different timezone' => [ 'America/New_York', ['mode' => 'range', 'format' => 'Y-m-d'], ['start' => '2012-08-29', 'end' => '2013-09-27'], - ['start' => '2012-08-29', 'end' => '2013-09-27', 'mode' => 'range', 'time_enabled' => false], + ['start' => '2012-08-29', 'end' => '2013-09-27', 'mode' => 'range', 'time_enabled' => false, 'format_has_time' => false], ], ]; } From 0555419454ce5466d50bfd15cf822b58b0c8946a Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 4 May 2026 16:55:31 -0400 Subject: [PATCH 171/461] [6.x] Allow overriding date format preset options (#14600) Co-authored-by: Claude Opus 4.7 (1M context) --- resources/js/components/DateFormatter.js | 8 ++++++++ .../js/tests/components/DateFormatter.test.js | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/resources/js/components/DateFormatter.js b/resources/js/components/DateFormatter.js index f08f3150ab1..5cccccccb97 100644 --- a/resources/js/components/DateFormatter.js +++ b/resources/js/components/DateFormatter.js @@ -148,6 +148,14 @@ export default class DateFormatter { return this.#presets[options]; } + if (options.preset) { + const { preset, ...overrides } = options; + + if (!this.#presets[preset]) throw new Error(`Invalid date format: ${preset}`); + + return { ...this.#presets[preset], ...overrides }; + } + return options; } } diff --git a/resources/js/tests/components/DateFormatter.test.js b/resources/js/tests/components/DateFormatter.test.js index fcd69f68578..4c3ccd80c9d 100644 --- a/resources/js/tests/components/DateFormatter.test.js +++ b/resources/js/tests/components/DateFormatter.test.js @@ -229,6 +229,20 @@ test('an invalid preset throws an error', () => { expect(() => new DateFormatter().options('foo')).toThrow('Invalid date format: foo'); }); +test('it can override preset options', () => { + expect(new DateFormatter().options({ preset: 'datetime', month: 'short' }).toString()).toBe( + 'Dec 25, 2021, 12:13 PM', + ); + + expect(new DateFormatter().options({ preset: 'datetime', timeZone: 'Australia/Sydney' }).toString()).toBe( + '12/25/2021, 11:13 PM', + ); +}); + +test('an invalid preset key throws an error when overriding', () => { + expect(() => new DateFormatter().options({ preset: 'foo', month: 'short' })).toThrow('Invalid date format: foo'); +}); + test.each([ // All the different diffs of time. It defaults to 'year' specificity. ['now', 'en', { relative: true }, '2021-12-25T12:13:14Z', 'now'], From 24822b19303fff7f67c9d5fd2d34cf13a52f71b3 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 4 May 2026 17:36:55 -0400 Subject: [PATCH 172/461] [6.x] Display timezone in DateRangePicker (#14601) Co-authored-by: Claude Opus 4.7 (1M context) --- .../ui/DateRangePicker/DateRangePicker.vue | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/resources/js/components/ui/DateRangePicker/DateRangePicker.vue b/resources/js/components/ui/DateRangePicker/DateRangePicker.vue index 6eb2e0db61b..9576d91e6e0 100644 --- a/resources/js/components/ui/DateRangePicker/DateRangePicker.vue +++ b/resources/js/components/ui/DateRangePicker/DateRangePicker.vue @@ -1,4 +1,5 @@
+
From 515778b730eed3de97ada1f84ea99af1caa674a6 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 4 May 2026 19:05:52 -0400 Subject: [PATCH 173/461] [6.x] Fix date-only index fieldtype shifting across timezones (#14602) Co-authored-by: Claude Opus 4.7 (1M context) --- .../fieldtypes/DateIndexFieldtype.vue | 5 ++- .../fieldtypes/DateIndexFieldtype.test.js | 37 ++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/resources/js/components/fieldtypes/DateIndexFieldtype.vue b/resources/js/components/fieldtypes/DateIndexFieldtype.vue index 470cad507c1..9c998c9c5d4 100644 --- a/resources/js/components/fieldtypes/DateIndexFieldtype.vue +++ b/resources/js/components/fieldtypes/DateIndexFieldtype.vue @@ -20,7 +20,10 @@ const formatted = computed(() => { return null; } - const formatter = new DateFormatter().options(showTimeInValue.value ? 'datetime' : 'date'); + const options = { preset: showTimeInValue.value ? 'datetime' : 'date' }; + if (!props.value.format_has_time) options.timeZone = 'UTC'; + + const formatter = new DateFormatter().options(options); if (props.value.mode === 'range') { let start = new Date(props.value.start); diff --git a/resources/js/tests/components/fieldtypes/DateIndexFieldtype.test.js b/resources/js/tests/components/fieldtypes/DateIndexFieldtype.test.js index 7a4832c421e..8e4c06c0429 100644 --- a/resources/js/tests/components/fieldtypes/DateIndexFieldtype.test.js +++ b/resources/js/tests/components/fieldtypes/DateIndexFieldtype.test.js @@ -30,10 +30,41 @@ test.each([ ])('date is localized to the users timezone (%s)', async (tz, expected) => { process.env.TZ = tz; + const dateIndexField = makeDateIndexField({ + date: '2025-12-25T02:13:00Z', + mode: 'single', + format_has_time: true, + }); + + expect(dateIndexField.vm.formatted).toBe(expected); +}); + +test.each([ + ['UTC', '12/25/2025'], + ['America/New_York', '12/25/2025'], +])('date-only value does not shift across timezones (%s)', async (tz, expected) => { + process.env.TZ = tz; + const dateIndexField = makeDateIndexField({ date: '2025-12-25', - time: '02:13', mode: 'single', + format_has_time: false, + }); + + expect(dateIndexField.vm.formatted).toBe(expected); +}); + +test.each([ + ['UTC', '12/25/2025 – 12/28/2025'], + ['America/New_York', '12/25/2025 – 12/28/2025'], +])('date-only range does not shift across timezones (%s)', async (tz, expected) => { + process.env.TZ = tz; + + const dateIndexField = makeDateIndexField({ + start: '2025-12-25', + end: '2025-12-28', + mode: 'range', + format_has_time: false, }); expect(dateIndexField.vm.formatted).toBe(expected); @@ -65,6 +96,7 @@ test.each([ start: '2025-12-25T02:13:00Z', end: '2025-12-28T03:59:00Z', mode: 'range', + format_has_time: true, }); expect(dateIndexField.vm.formatted).toBe(expected); @@ -77,7 +109,7 @@ test.each([ ])('date is formatted to the users browser language (%s)', async (lang, expected) => { DateFormatter.defaultLocale = lang; - const dateIndexField = makeDateIndexField({ date: '2025-12-25T13:29:00Z' }); + const dateIndexField = makeDateIndexField({ date: '2025-12-25T13:29:00Z', format_has_time: true }); expect(dateIndexField.vm.formatted).toBe(expected); }); @@ -109,6 +141,7 @@ test.each([ start: '2025-12-25T02:13:00Z', end: '2025-12-28T03:59:00Z', mode: 'range', + format_has_time: true, }); expect(dateIndexField.vm.formatted).toBe(expected); From 2430355e57307f00cffd04ca336e6e86651e301a Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 5 May 2026 09:50:32 -0400 Subject: [PATCH 174/461] [6.x] Memoize preprocessed fields in Validator (#14605) Co-authored-by: Claude Opus 4.7 (1M context) --- src/Fields/Validator.php | 13 ++++++++++--- tests/Fields/ValidatorTest.php | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/Fields/Validator.php b/src/Fields/Validator.php index 175fa13854a..2f6c4d93989 100644 --- a/src/Fields/Validator.php +++ b/src/Fields/Validator.php @@ -9,6 +9,7 @@ class Validator { protected $fields; + protected $preProcessedFields; protected $replacements = []; protected $extraRules = []; protected $customMessages = []; @@ -22,10 +23,16 @@ public function make() public function fields($fields) { $this->fields = $fields; + $this->preProcessedFields = null; return $this; } + protected function preProcessedFields() + { + return $this->preProcessedFields ??= $this->fields->preProcessValidatables(); + } + public function withRules($rules) { $this->extraRules = $rules; @@ -66,7 +73,7 @@ private function fieldRules() return collect(); } - return $this->fields->preProcessValidatables()->all()->reduce(function ($carry, $field) { + return $this->preProcessedFields()->all()->reduce(function ($carry, $field) { if (request()->isPrecognitive() && $field->type() == 'assets') { return $carry; } @@ -102,7 +109,7 @@ public function withReplacements($replacements) public function validator() { return LaravelValidator::make( - $this->fields->preProcessValidatables()->values()->all(), + $this->preProcessedFields()->values()->all(), $this->rules(), $this->customMessages, $this->attributes() @@ -116,7 +123,7 @@ public function validate() public function attributes() { - return $this->fields->preProcessValidatables()->all()->reduce(function ($carry, $field) { + return $this->preProcessedFields()->all()->reduce(function ($carry, $field) { return $carry->merge($field->validationAttributes()); }, collect())->all(); } diff --git a/tests/Fields/ValidatorTest.php b/tests/Fields/ValidatorTest.php index 095ac46aa12..2468b290663 100644 --- a/tests/Fields/ValidatorTest.php +++ b/tests/Fields/ValidatorTest.php @@ -241,6 +241,22 @@ public function it_does_not_make_replacements_in_regex_rules() ], $validation->rules()); } + #[Test] + public function it_only_pre_processes_validatables_once_per_validator_call() + { + $field = Mockery::mock(Field::class); + $field->shouldReceive('setValidationContext')->with([])->andReturnSelf(); + $field->shouldReceive('rules')->andReturn(['one' => ['required']]); + $field->shouldReceive('validationAttributes')->andReturn(['one' => 'One']); + + $fields = Mockery::mock(Fields::class); + $fields->shouldReceive('preProcessValidatables')->once()->andReturnSelf(); + $fields->shouldReceive('all')->andReturn(collect([$field])); + $fields->shouldReceive('values')->andReturn(collect(['one' => 'foo'])); + + (new Validator)->fields($fields)->validator(); + } + #[Test] public function it_replaces_this_in_sets() { From b7f7f2169d8f22c747e655a2e3ec45d5f7335ff0 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 5 May 2026 14:42:24 -0400 Subject: [PATCH 175/461] [6.x] Show date fieldtype when searching for range (#14606) Co-authored-by: Claude Sonnet 4.6 --- src/Fieldtypes/Date.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Fieldtypes/Date.php b/src/Fieldtypes/Date.php index d08324d503b..e4e2bae948d 100644 --- a/src/Fieldtypes/Date.php +++ b/src/Fieldtypes/Date.php @@ -18,7 +18,7 @@ class Date extends Fieldtype { protected $categories = ['special']; - protected $keywords = ['datetime', 'time']; + protected $keywords = ['datetime', 'time', 'range']; const DEFAULT_DATE_FORMAT = 'Y-m-d'; const DEFAULT_DATETIME_FORMAT = 'Y-m-d H:i'; From e550ad130a578e4604763562133160532e1510c3 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 5 May 2026 15:06:51 -0400 Subject: [PATCH 176/461] [6.x] Fix error when typing in a required date range field (#14607) Co-authored-by: Claude Sonnet 4.6 --- .../js/components/fieldtypes/DateFieldtype.vue | 4 ++++ .../ui/DateRangePicker/DateRangePicker.vue | 2 +- .../components/fieldtypes/DateFieldtype.test.js | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/resources/js/components/fieldtypes/DateFieldtype.vue b/resources/js/components/fieldtypes/DateFieldtype.vue index 4bb8b416601..722f16d53fe 100644 --- a/resources/js/components/fieldtypes/DateFieldtype.vue +++ b/resources/js/components/fieldtypes/DateFieldtype.vue @@ -143,6 +143,10 @@ export default { return this.update(null); } + if (this.isRange && (!value.start || !value.end)) { + return; + } + if (!this.formatHasTime) { if (this.isRange) { return this.update({ diff --git a/resources/js/components/ui/DateRangePicker/DateRangePicker.vue b/resources/js/components/ui/DateRangePicker/DateRangePicker.vue index 9576d91e6e0..f2b606116d0 100644 --- a/resources/js/components/ui/DateRangePicker/DateRangePicker.vue +++ b/resources/js/components/ui/DateRangePicker/DateRangePicker.vue @@ -110,7 +110,7 @@ const timeZoneTooltip = computed(() => formatTimeZone('long'));
-
From f92e17dd03d357865632fd55a29e9f24f7747567 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 7 May 2026 18:53:07 +0100 Subject: [PATCH 184/461] [6.x] Fix read-only/disabled states in `Radio` component (#14621) --- resources/js/components/ui/Radio/Item.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/js/components/ui/Radio/Item.vue b/resources/js/components/ui/Radio/Item.vue index 5a922efb589..64ef4c14901 100644 --- a/resources/js/components/ui/Radio/Item.vue +++ b/resources/js/components/ui/Radio/Item.vue @@ -7,6 +7,7 @@ const props = defineProps({ disabled: { type: Boolean, default: false }, /** Label text to display next to the radio button */ label: { type: String, default: null }, + readOnly: { type: Boolean, default: false }, /** Value of the radio button */ value: { type: [String, Number, Boolean], required: true }, }); @@ -19,7 +20,7 @@ const id = useId(); - + - - + + + diff --git a/src/Http/Controllers/CP/Collections/CollectionsController.php b/src/Http/Controllers/CP/Collections/CollectionsController.php index aab2c39d3fe..aca8ca0ce81 100644 --- a/src/Http/Controllers/CP/Collections/CollectionsController.php +++ b/src/Http/Controllers/CP/Collections/CollectionsController.php @@ -94,7 +94,6 @@ private function collections() 'editable' => User::current()->can('edit', $collection), 'blueprint_editable' => User::current()->can('configure fields'), 'available_in_selected_site' => $collection->sites()->contains(Site::selected()->handle()), - 'actions' => Action::for($collection), 'actions_url' => cp_route('collections.actions.run'), 'icon' => $collection->icon(), 'create_label' => $collection->createLabel(), diff --git a/src/Http/Resources/CP/Submissions/ListedSubmission.php b/src/Http/Resources/CP/Submissions/ListedSubmission.php index 4e7e64a3aa9..2f81734d40e 100644 --- a/src/Http/Resources/CP/Submissions/ListedSubmission.php +++ b/src/Http/Resources/CP/Submissions/ListedSubmission.php @@ -3,7 +3,6 @@ namespace Statamic\Http\Resources\CP\Submissions; use Illuminate\Http\Resources\Json\JsonResource; -use Statamic\Facades\Action; use Statamic\Facades\User; class ListedSubmission extends JsonResource @@ -36,7 +35,6 @@ public function toArray($request) ])), 'url' => cp_route('forms.submissions.show', [$form->handle(), $this->resource->id()]), 'deleteable' => User::current()->can('delete', $this->resource), - 'actions' => Action::for($this->resource), ]; } diff --git a/src/Http/Resources/CP/Taxonomies/ListedTerm.php b/src/Http/Resources/CP/Taxonomies/ListedTerm.php index a6852f834e7..1aa5e99c459 100644 --- a/src/Http/Resources/CP/Taxonomies/ListedTerm.php +++ b/src/Http/Resources/CP/Taxonomies/ListedTerm.php @@ -3,7 +3,6 @@ namespace Statamic\Http\Resources\CP\Taxonomies; use Illuminate\Http\Resources\Json\JsonResource; -use Statamic\Facades\Action; use Statamic\Facades\User; class ListedTerm extends JsonResource @@ -28,7 +27,6 @@ public function columns($columns) public function toArray($request) { $term = $this->resource; - $taxonomy = $term->taxonomy(); return [ 'id' => $term->id(), @@ -45,7 +43,6 @@ public function toArray($request) 'taxonomy' => $term->taxonomy()->toArray(), 'viewable' => User::current()->can('view', $term), 'editable' => User::current()->can('edit', $term), - 'actions' => Action::for($term, ['taxonomy' => $taxonomy->handle()]), ]; } diff --git a/tests/Feature/Collections/ViewCollectionListingTest.php b/tests/Feature/Collections/ViewCollectionListingTest.php index 873f280e1e8..17ac7701d4c 100644 --- a/tests/Feature/Collections/ViewCollectionListingTest.php +++ b/tests/Feature/Collections/ViewCollectionListingTest.php @@ -109,6 +109,23 @@ public function it_shows_a_list_of_collections() */ } + #[Test] + public function it_does_not_eager_load_actions_in_listing() + { + $this->createCollection('bar'); + + $user = tap(User::make()->makeSuper())->save(); + + $this + ->actingAs($user) + ->get(cp_route('collections.index')) + ->assertSuccessful() + ->assertInertia(fn ($page) => $page + ->component('collections/Index') + ->has('collections', 1) + ->missing('collections.0.actions')); + } + #[Test] public function it_shows_no_results_when_there_are_no_collections() { diff --git a/tests/Feature/Forms/ViewSubmissionsListingTest.php b/tests/Feature/Forms/ViewSubmissionsListingTest.php new file mode 100644 index 00000000000..5f98dc46917 --- /dev/null +++ b/tests/Feature/Forms/ViewSubmissionsListingTest.php @@ -0,0 +1,37 @@ +fakeStacheDirectory.'/forms'; + } + + #[Test] + public function it_does_not_eager_load_actions_in_submissions_listing() + { + $user = tap(User::make()->makeSuper())->save(); + $form = tap(Form::make('test'))->save(); + FormSubmission::make()->form($form)->data(['foo' => 'bar'])->save(); + + $this + ->actingAs($user) + ->getJson(cp_route('forms.submissions.index', $form->handle())) + ->assertSuccessful() + ->assertJsonCount(1, 'data') + ->assertJsonMissingPath('data.0.actions'); + } +} diff --git a/tests/Feature/Taxonomies/ViewTermsListingTest.php b/tests/Feature/Taxonomies/ViewTermsListingTest.php new file mode 100644 index 00000000000..48ab1e4873f --- /dev/null +++ b/tests/Feature/Taxonomies/ViewTermsListingTest.php @@ -0,0 +1,31 @@ +makeSuper())->save(); + + $taxonomy = tap(Taxonomy::make('tags'))->save(); + tap(Term::make()->taxonomy('tags')->inDefaultLocale()->slug('alfa')->data(['title' => 'alfa']))->save(); + + $this + ->actingAs($user) + ->getJson(cp_route('taxonomies.terms.index', $taxonomy->handle())) + ->assertSuccessful() + ->assertJsonCount(1, 'data') + ->assertJsonMissingPath('data.0.actions'); + } +} From fbf96af23bac643142dca3e79cbdbe928d2c4645 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 7 May 2026 21:58:03 +0100 Subject: [PATCH 186/461] [6.x] Fix dated entries not syncing with origin revisions (#14216) --- src/Entries/Entry.php | 4 +- tests/Feature/Entries/EntryRevisionsTest.php | 149 +++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php index bc599901a7d..95bc7406f37 100644 --- a/src/Entries/Entry.php +++ b/src/Entries/Entry.php @@ -775,7 +775,9 @@ public function makeFromRevision($revision) ->slug($attrs['slug']); if ($this->collection()->dated() && ($date = Arr::get($attrs, 'date'))) { - $entry->date(Carbon::createFromTimestamp($date, config('app.timezone'))); + if ($this->isRoot() || $this->blueprint()->field('date')->isLocalizable()) { + $entry->date(Carbon::createFromTimestamp($date, config('app.timezone'))); + } } return $entry; diff --git a/tests/Feature/Entries/EntryRevisionsTest.php b/tests/Feature/Entries/EntryRevisionsTest.php index c949e73d327..5ffe0a5a334 100644 --- a/tests/Feature/Entries/EntryRevisionsTest.php +++ b/tests/Feature/Entries/EntryRevisionsTest.php @@ -550,6 +550,155 @@ private function restore($entry, $payload) return $this->post($entry->restoreRevisionUrl(), $payload); } + #[Test] + public function localized_entry_with_non_localizable_date_gets_origin_date_when_reconstructed_from_revision() + { + $this->setSites([ + 'en' => ['url' => 'http://localhost/', 'locale' => 'en'], + 'fr' => ['url' => 'http://localhost/fr/', 'locale' => 'fr'], + ]); + + $this->setTestBlueprint('test', [ + 'foo' => ['type' => 'text'], + 'date' => ['type' => 'date', 'localizable' => false], + ]); + $this->setTestRoles(['test' => ['access cp', 'publish blog entries']]); + $user = User::make()->id('user-1')->assignRole('test')->save(); + + $this->collection->sites(['en', 'fr'])->save(); + + $origin = EntryFactory::id('1') + ->slug('test') + ->collection('blog') + ->locale('en') + ->published(true) + ->date('2010-12-25') + ->data([ + 'blueprint' => 'test', + 'title' => 'Title', + 'foo' => 'bar', + ])->create(); + + $localized = EntryFactory::id('2') + ->slug('test') + ->collection('blog') + ->locale('fr') + ->origin($origin) + ->published(true) + ->data(['blueprint' => 'test']) + ->create(); + + $this->assertEquals('2010-12-25', $origin->date()->format('Y-m-d')); + $this->assertEquals('2010-12-25', $localized->date()->format('Y-m-d')); + + tap($localized->makeWorkingCopy(), function ($copy) { + $attrs = $copy->attributes(); + $attrs['data']['foo'] = 'foo modified in localized working copy'; + $copy->attributes($attrs); + })->save(); + + $this->assertTrue($localized->hasWorkingCopy()); + $this->assertEquals('2010-12-25', $localized->fromWorkingCopy()->date()->format('Y-m-d')); + + tap($origin->makeWorkingCopy(), function ($copy) { + $attrs = $copy->attributes(); + $attrs['date'] = Carbon::parse('2020-06-15')->timestamp; + $copy->attributes($attrs); + })->save(); + + $this + ->actingAs($user) + ->publish($origin, ['message' => 'Publish origin with new date']) + ->assertOk(); + + $origin = Entry::find('1'); + $this->assertEquals('2020-06-15', $origin->date()->format('Y-m-d')); + + $localized = Entry::find('2'); + $this->assertEquals('2020-06-15', $localized->date()->format('Y-m-d')); + + $this->assertEquals( + '2020-06-15', + $localized->fromWorkingCopy()->date()->format('Y-m-d'), + 'Localized entry reconstructed from working copy should use origin\'s current date when date field is not localizable' + ); + } + + #[Test] + public function localized_entry_with_localizable_date_keeps_its_own_date_when_reconstructed_from_revision() + { + $this->setSites([ + 'en' => ['url' => 'http://localhost/', 'locale' => 'en'], + 'fr' => ['url' => 'http://localhost/fr/', 'locale' => 'fr'], + ]); + + $this->setTestBlueprint('test', [ + 'foo' => ['type' => 'text'], + 'date' => ['type' => 'date', 'localizable' => true], + ]); + $this->setTestRoles(['test' => ['access cp', 'publish blog entries']]); + $user = User::make()->id('user-1')->assignRole('test')->save(); + + $this->collection->sites(['en', 'fr'])->save(); + + $origin = EntryFactory::id('1') + ->slug('test') + ->collection('blog') + ->locale('en') + ->published(true) + ->date('2010-12-25') + ->data([ + 'blueprint' => 'test', + 'title' => 'Title', + 'foo' => 'bar', + ])->create(); + + $localized = EntryFactory::id('2') + ->slug('test') + ->collection('blog') + ->locale('fr') + ->origin($origin) + ->published(true) + ->date('2015-03-10') + ->data(['blueprint' => 'test']) + ->create(); + + $this->assertEquals('2010-12-25', $origin->date()->format('Y-m-d')); + $this->assertEquals('2015-03-10', $localized->date()->format('Y-m-d')); + + tap($localized->makeWorkingCopy(), function ($copy) { + $attrs = $copy->attributes(); + $attrs['data']['foo'] = 'foo modified in localized working copy'; + $copy->attributes($attrs); + })->save(); + + $this->assertTrue($localized->hasWorkingCopy()); + $this->assertEquals('2015-03-10', $localized->fromWorkingCopy()->date()->format('Y-m-d')); + + tap($origin->makeWorkingCopy(), function ($copy) { + $attrs = $copy->attributes(); + $attrs['date'] = Carbon::parse('2020-06-15')->timestamp; + $copy->attributes($attrs); + })->save(); + + $this + ->actingAs($user) + ->publish($origin, ['message' => 'Publish origin with new date']) + ->assertOk(); + + $origin = Entry::find('1'); + $this->assertEquals('2020-06-15', $origin->date()->format('Y-m-d')); + + $localized = Entry::find('2'); + $this->assertEquals('2015-03-10', $localized->date()->format('Y-m-d')); + + $this->assertEquals( + '2015-03-10', + $localized->fromWorkingCopy()->date()->format('Y-m-d'), + 'Localized entry reconstructed from working copy should keep its own date when date field is localizable' + ); + } + private function setTestBlueprint($handle, $fields) { $blueprint = Blueprint::makeFromFields($fields)->setHandle($handle); From 9cf407b8425e084066525e56d0cab233bb0e530b Mon Sep 17 00:00:00 2001 From: Andrii Trush <14265776+andrii-trush@users.noreply.github.com> Date: Thu, 7 May 2026 23:48:37 +0200 Subject: [PATCH 187/461] [6.x] Fix DateRangePicker crash when selecting first date in a range (#13512) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Jason Varga --- .../js/components/ui/DateRangePicker/DateRangePicker.vue | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/resources/js/components/ui/DateRangePicker/DateRangePicker.vue b/resources/js/components/ui/DateRangePicker/DateRangePicker.vue index f2b606116d0..8ece9d03130 100644 --- a/resources/js/components/ui/DateRangePicker/DateRangePicker.vue +++ b/resources/js/components/ui/DateRangePicker/DateRangePicker.vue @@ -78,6 +78,12 @@ const placeholder = parseAbsoluteToLocal(new Date().toISOString()); const calendarEvents = computed(() => ({ 'update:model-value': (event) => { if (props.granularity === 'day') { + + // Avoid fatal error `Cannot set properties of undefined (setting 'hour')` + if (event.end == null) { + return + } + event.start.hour = 0; event.start.minute = 0; event.start.second = 0; From 4190d33e71b7940309aff45275dcc5225c4ee0a0 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 7 May 2026 22:57:18 +0100 Subject: [PATCH 188/461] [6.x] Fix moving custom section to 1st position in CP Nav (#12993) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Jason Varga --- src/CP/Navigation/NavTransformer.php | 25 ++- tests/CP/Navigation/NavTransformerTest.php | 175 +++++++++++++++++++++ 2 files changed, 198 insertions(+), 2 deletions(-) diff --git a/src/CP/Navigation/NavTransformer.php b/src/CP/Navigation/NavTransformer.php index 00911c75f03..67f91148278 100644 --- a/src/CP/Navigation/NavTransformer.php +++ b/src/CP/Navigation/NavTransformer.php @@ -285,6 +285,25 @@ protected function getReorderedItems($originalList, $newList): bool|array */ protected function calculateMinimumItemsForReorder($originalList, $newList): int { + // When the new list contains items not in the original (e.g. custom sections), + // we must include enough of newList to anchor the position of any custom items + // that appear before original items. Custom items at the end auto-append. + $originalSet = collect($originalList); + $newListValues = collect($newList)->values(); + $lastCustomPositionInMiddle = 0; + + foreach ($newListValues as $index => $item) { + if (! $originalSet->contains($item)) { + $hasOriginalItemsAfter = $newListValues + ->slice($index + 1) + ->contains(fn ($futureItem) => $originalSet->contains($futureItem)); + + if ($hasOriginalItemsAfter) { + $lastCustomPositionInMiddle = $index + 1; + } + } + } + $continueRejecting = true; $minimumItemsCount = collect($originalList) @@ -299,7 +318,9 @@ protected function calculateMinimumItemsForReorder($originalList, $newList): int }) ->count(); - return max(1, $minimumItemsCount - 1); + $minimumFromReordering = max(1, $minimumItemsCount - 1); + + return max($minimumFromReordering, $lastCustomPositionInMiddle); } /** @@ -333,7 +354,7 @@ protected function minify() ->values() ->all(); - $this->config = $reorder + $this->config = ! empty($reorder) ? array_filter(compact('reorder', 'sections')) : $sections; diff --git a/tests/CP/Navigation/NavTransformerTest.php b/tests/CP/Navigation/NavTransformerTest.php index fa867884bbc..ed82fe9f67c 100644 --- a/tests/CP/Navigation/NavTransformerTest.php +++ b/tests/CP/Navigation/NavTransformerTest.php @@ -1315,6 +1315,181 @@ public function it_can_transform_complex_json_payload_copied_from_actual_vue_sub $this->assertEquals($expected, $transformed); } + + #[Test] + public function it_preserves_reorder_array_when_moving_custom_section_to_first_position() + { + $transformed = $this->transform([ + ['display_original' => 'Top Level'], + [ + 'display' => '⭐ Favorites', + 'action' => '@create', + 'items' => [ + [ + 'id' => 'favorites::edit_homepage', + 'manipulations' => [ + 'action' => '@create', + 'display' => 'Edit homepage', + 'url' => '/cp', + 'icon' => 'edit', + ], + ], + ], + ], + ['display_original' => 'Content'], + ['display_original' => 'Fields'], + ['display_original' => 'Tools'], + ['display_original' => 'Settings'], + ['display_original' => 'Users'], + ]); + + $expected = [ + 'reorder' => [ + 'favorites', + ], + 'sections' => [ + 'favorites' => [ + 'display' => '⭐ Favorites', + 'action' => '@create', + 'items' => [ + 'favorites::edit_homepage' => [ + 'action' => '@create', + 'display' => 'Edit homepage', + 'url' => '/cp', + 'icon' => 'edit', + ], + ], + ], + ], + ]; + + $this->assertEquals($expected, $transformed); + } + + #[Test] + public function it_preserves_reorder_array_when_inserting_custom_section_into_middle_of_list() + { + $transformed = $this->transform([ + ['display_original' => 'Top Level'], + ['display_original' => 'Content'], + ['display_original' => 'Fields'], + [ + 'display' => '⭐ Favorites', + 'action' => '@create', + 'items' => [ + [ + 'id' => 'favorites::edit_homepage', + 'manipulations' => [ + 'action' => '@create', + 'display' => 'Edit homepage', + 'url' => '/cp', + ], + ], + ], + ], + ['display_original' => 'Tools'], + ['display_original' => 'Settings'], + ['display_original' => 'Users'], + ]); + + $expected = [ + 'reorder' => [ + 'content', + 'fields', + 'favorites', + ], + 'sections' => [ + 'favorites' => [ + 'display' => '⭐ Favorites', + 'action' => '@create', + 'items' => [ + 'favorites::edit_homepage' => [ + 'action' => '@create', + 'display' => 'Edit homepage', + 'url' => '/cp', + ], + ], + ], + ], + ]; + + $this->assertEquals($expected, $transformed); + } + + #[Test] + public function it_preserves_reorder_array_with_multiple_custom_sections_in_the_middle() + { + $transformed = $this->transform([ + ['display_original' => 'Top Level'], + [ + 'display' => '⭐ Favorites', + 'action' => '@create', + 'items' => [ + [ + 'id' => 'favorites::edit_homepage', + 'manipulations' => [ + 'action' => '@create', + 'display' => 'Edit homepage', + 'url' => '/cp', + ], + ], + ], + ], + ['display_original' => 'Content'], + [ + 'display' => '🔖 Bookmarks', + 'action' => '@create', + 'items' => [ + [ + 'id' => 'bookmarks::edit_about', + 'manipulations' => [ + 'action' => '@create', + 'display' => 'Edit about', + 'url' => '/about', + ], + ], + ], + ], + ['display_original' => 'Fields'], + ['display_original' => 'Tools'], + ['display_original' => 'Settings'], + ['display_original' => 'Users'], + ]); + + $expected = [ + 'reorder' => [ + 'favorites', + 'content', + 'bookmarks', + ], + 'sections' => [ + 'favorites' => [ + 'display' => '⭐ Favorites', + 'action' => '@create', + 'items' => [ + 'favorites::edit_homepage' => [ + 'action' => '@create', + 'display' => 'Edit homepage', + 'url' => '/cp', + ], + ], + ], + 'bookmarks' => [ + 'display' => '🔖 Bookmarks', + 'action' => '@create', + 'items' => [ + 'bookmarks::edit_about' => [ + 'action' => '@create', + 'display' => 'Edit about', + 'url' => '/about', + ], + ], + ], + ], + ]; + + $this->assertEquals($expected, $transformed); + } } class IncrementalIdHasher From 9247e3c7565e94060429e05dbd629edcd72156da Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Fri, 8 May 2026 16:03:55 +0100 Subject: [PATCH 189/461] [6.x] Fix read-only state in publish forms (#14623) Co-authored-by: Claude Opus 4.6 (1M context) --- resources/js/components/entries/PublishForm.vue | 1 + resources/js/components/globals/PublishForm.vue | 1 + resources/js/components/terms/PublishForm.vue | 1 + 3 files changed, 3 insertions(+) diff --git a/resources/js/components/entries/PublishForm.vue b/resources/js/components/entries/PublishForm.vue index d8d8b15a28d..bb2231865eb 100644 --- a/resources/js/components/entries/PublishForm.vue +++ b/resources/js/components/entries/PublishForm.vue @@ -82,6 +82,7 @@ :origin-meta="originMeta" :errors="errors" :site="site" + :read-only="readOnly" v-model:modified-fields="localizedFields" :track-dirty-state="trackDirtyState" :sync-field-confirmation-text="syncFieldConfirmationText" diff --git a/resources/js/components/globals/PublishForm.vue b/resources/js/components/globals/PublishForm.vue index 98f9c5de445..601e36008b3 100644 --- a/resources/js/components/globals/PublishForm.vue +++ b/resources/js/components/globals/PublishForm.vue @@ -80,6 +80,7 @@ :origin-meta="originMeta" :errors="errors" :site="site" + :read-only="readOnly" v-model:modified-fields="localizedFields" :sync-field-confirmation-text="syncFieldConfirmationText" remember-tab diff --git a/resources/js/components/terms/PublishForm.vue b/resources/js/components/terms/PublishForm.vue index 491de0da6ac..45241177764 100644 --- a/resources/js/components/terms/PublishForm.vue +++ b/resources/js/components/terms/PublishForm.vue @@ -64,6 +64,7 @@ :origin-meta="originMeta" :errors="errors" :site="site" + :read-only="readOnly" v-model:modified-fields="localizedFields" :sync-field-confirmation-text="syncFieldConfirmationText" :remember-tab="!isInline" From d50e5c17e0b29beadae6ead6db1f9cb3dcfad20c Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 8 May 2026 11:43:55 -0400 Subject: [PATCH 190/461] [6.x] Add Timezones components (#14612) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Cursor --- .storybook/preview.ts | 35 ++--- packages/cms/src/ui.js | 2 + resources/js/bootstrap/cms/ui.js | 2 + .../fieldtypes/DateIndexFieldtype.vue | 44 +++--- .../components/ui/DatePicker/DatePicker.vue | 23 ++- .../ui/DateRangePicker/DateRangePicker.vue | 28 ++-- .../js/components/ui/TimezoneHoverCard.vue | 20 +++ resources/js/components/ui/Timezones.vue | 72 ++++++++++ resources/js/components/ui/index.js | 2 + resources/js/stories/Timezones.stories.ts | 136 ++++++++++++++++++ resources/js/stories/docs/Timezones.mdx | 21 +++ resources/js/tests/Package.test.js | 2 + .../fieldtypes/DateIndexFieldtype.test.js | 13 +- .../View/Composers/JavascriptComposer.php | 1 + 14 files changed, 329 insertions(+), 72 deletions(-) create mode 100644 resources/js/components/ui/TimezoneHoverCard.vue create mode 100644 resources/js/components/ui/Timezones.vue create mode 100644 resources/js/stories/Timezones.stories.ts create mode 100644 resources/js/stories/docs/Timezones.mdx diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 277835ad7db..72fa779fa8f 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -14,9 +14,25 @@ import PortalVue from 'portal-vue'; import FullscreenHeader from '@/components/publish/FullscreenHeader.vue'; import Portal from '@/components/portals/Portal.vue'; import PortalTargets from '@/components/portals/PortalTargets.vue'; -import {keys, portals, slug, stacks} from '@api'; +import {config as statamicConfig, keys, portals, slug, stacks} from '@api'; import useGlobalEventBus from '@/composables/global-event-bus'; +const storybookConfig = { + linkToDocs: true, + paginationSize: 50, + paginationSizeOptions: [10, 25, 50, 100, 500], + sites: [{ + handle: 'default', + lang: 'en', + }], + selectedSite: 'default', + lang: 'en', + translationLocale: 'en', + displayTimezone: 'UTC', + asciiReplaceExtraSymbols: false, + charmap: { currency: {}, currency_short: {} }, +}; + // Intercept Inertia navigation and log to Actions tab. router.on('before', (event) => { action('inertia navigate')(event.detail.visit.url); @@ -26,25 +42,12 @@ router.on('before', (event) => { setup(async (app) => { window.__ = translate; window.__n = translateChoice; + statamicConfig.initialize(storybookConfig); window.Statamic = { $config: { get(key) { - const config = { - linkToDocs: true, - paginationSize: 50, - paginationSizeOptions: [10, 25, 50, 100, 500], - sites: [{ - handle: 'default', - lang: 'en', - }], - selectedSite: 'default', - lang: 'en', - asciiReplaceExtraSymbols: false, - charmap: { currency: {}, currency_short: {} }, - }; - - return config[key] ?? null; + return storybookConfig[key] ?? null; } }, $commandPalette: { diff --git a/packages/cms/src/ui.js b/packages/cms/src/ui.js index a06c3dd7952..a84b8d0e411 100644 --- a/packages/cms/src/ui.js +++ b/packages/cms/src/ui.js @@ -117,6 +117,8 @@ export const { Text, Textarea, TimePicker, + TimezoneHoverCard, + Timezones, ToggleGroup, ToggleItem, Widget, diff --git a/resources/js/bootstrap/cms/ui.js b/resources/js/bootstrap/cms/ui.js index 010b54d241e..2b8a3e7cd1e 100644 --- a/resources/js/bootstrap/cms/ui.js +++ b/resources/js/bootstrap/cms/ui.js @@ -117,6 +117,8 @@ export { Text, Textarea, TimePicker, + TimezoneHoverCard, + Timezones, ToggleGroup, ToggleItem, Widget, diff --git a/resources/js/components/fieldtypes/DateIndexFieldtype.vue b/resources/js/components/fieldtypes/DateIndexFieldtype.vue index 837e141736d..c966dda42f8 100644 --- a/resources/js/components/fieldtypes/DateIndexFieldtype.vue +++ b/resources/js/components/fieldtypes/DateIndexFieldtype.vue @@ -1,10 +1,14 @@
- + + +
diff --git a/resources/js/components/ui/TimezoneHoverCard.vue b/resources/js/components/ui/TimezoneHoverCard.vue new file mode 100644 index 00000000000..0a8a2cd85ce --- /dev/null +++ b/resources/js/components/ui/TimezoneHoverCard.vue @@ -0,0 +1,20 @@ + + + diff --git a/resources/js/components/ui/Timezones.vue b/resources/js/components/ui/Timezones.vue new file mode 100644 index 00000000000..71852401a89 --- /dev/null +++ b/resources/js/components/ui/Timezones.vue @@ -0,0 +1,72 @@ + + + diff --git a/resources/js/components/ui/index.js b/resources/js/components/ui/index.js index 8021a93cd62..d19fd06b96a 100644 --- a/resources/js/components/ui/index.js +++ b/resources/js/components/ui/index.js @@ -85,6 +85,8 @@ export { default as TabTrigger } from './Tabs/Trigger.vue'; export { default as Text } from './Text.vue'; export { default as Textarea } from './Textarea.vue'; export { default as TimePicker } from './TimePicker/TimePicker.vue'; +export { default as TimezoneHoverCard } from './TimezoneHoverCard.vue'; +export { default as Timezones } from './Timezones.vue'; export { default as ToggleGroup } from './Toggle/Group.vue'; export { default as ToggleItem } from './Toggle/Item.vue'; diff --git a/resources/js/stories/Timezones.stories.ts b/resources/js/stories/Timezones.stories.ts new file mode 100644 index 00000000000..b47e1adacaf --- /dev/null +++ b/resources/js/stories/Timezones.stories.ts @@ -0,0 +1,136 @@ +import type {Meta, StoryObj} from '@storybook/vue3'; +import {Timezones, TimezoneHoverCard} from '@ui'; + +const exampleDate = '2026-05-05T12:00:00.000Z'; +const exampleRange = { start: '2026-05-05T12:00:00.000Z', end: '2026-05-08T17:30:00.000Z' }; + +const meta = { + title: 'Overlays/Timezones', + component: Timezones, + argTypes: { + date: { + control: 'text', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const defaultCode = ` + +`; + +export const _DocsIntro: Story = { + tags: ['!dev'], + args: { + date: exampleDate, + }, + parameters: { + docs: { + source: { code: `` } + } + }, + render: (args) => ({ + components: { Timezones }, + setup() { + return { date: args.date }; + }, + template: defaultCode, + }), +}; + +const hoverCardCode = ` + + Hover the underlined text + +`; + +export const _HoverCard: Story = { + tags: ['!dev'], + args: { + date: exampleDate, + }, + parameters: { + docs: { + source: { code: hoverCardCode } + } + }, + render: (args) => ({ + components: { TimezoneHoverCard }, + setup() { + return { date: args.date }; + }, + template: ` +
+ ${hoverCardCode} +
+ `, + }), +}; + +const rangeCode = ` + +`; + +export const _Range: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: rangeCode } + } + }, + render: () => ({ + components: { Timezones }, + setup() { + return { range: exampleRange }; + }, + template: ``, + }), +}; + +const customDateCode = ` + + +`; + +export const _Invalid: Story = { + args: { + date: "not a date", + }, + render: (args) => ({ + components: { Timezones }, + setup() { + return { date: args.date }; + }, + template: ` +
+

Nothing should render below:

+ +
+ `, + }), +}; + +export const _CustomDate: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: customDateCode } + } + }, + render: () => ({ + components: { Timezones }, + setup() { + return { + isoString: exampleDate, + dateObject: new Date(exampleDate), + }; + }, + template: ` +
+ ${customDateCode} +
+ `, + }), +}; diff --git a/resources/js/stories/docs/Timezones.mdx b/resources/js/stories/docs/Timezones.mdx new file mode 100644 index 00000000000..5c1f60e253b --- /dev/null +++ b/resources/js/stories/docs/Timezones.mdx @@ -0,0 +1,21 @@ +import { Canvas, Meta, ArgTypes } from '@storybook/addon-docs/blocks'; +import * as TimezonesStories from '../Timezones.stories'; + + + +# Timezones +Displays a given instant rendered across the user's local timezone, the configured app timezone, and UTC. + +## Overview + + +## Range +Pass a `{ start, end }` object to render a date range. The value column uses locale-aware formatting that collapses repeated parts (e.g. shared month or year). + + +## Hover Card +This is a convenience wrapper component that displays the timezones in a hover card. You can provide any of [HoverCard](?path=/docs/overlays-hovercard--docs)'s props. + + +## Arguments + diff --git a/resources/js/tests/Package.test.js b/resources/js/tests/Package.test.js index 552869b9b56..4aa0bbc5e71 100644 --- a/resources/js/tests/Package.test.js +++ b/resources/js/tests/Package.test.js @@ -208,6 +208,8 @@ it('exports ui', async () => { 'Tabs', 'Textarea', 'TimePicker', + 'TimezoneHoverCard', + 'Timezones', 'ToggleGroup', 'ToggleItem', 'registerIconSet', diff --git a/resources/js/tests/components/fieldtypes/DateIndexFieldtype.test.js b/resources/js/tests/components/fieldtypes/DateIndexFieldtype.test.js index 8e4c06c0429..12818d49fd2 100644 --- a/resources/js/tests/components/fieldtypes/DateIndexFieldtype.test.js +++ b/resources/js/tests/components/fieldtypes/DateIndexFieldtype.test.js @@ -147,7 +147,7 @@ test.each([ expect(dateIndexField.vm.formatted).toBe(expected); }); -test('date-only format omits time from value and tooltip', async () => { +test('date-only format omits time and hides the timezone hover card', async () => { const dateIndexField = makeDateIndexField({ date: '2025-12-25', mode: 'single', @@ -156,10 +156,10 @@ test('date-only format omits time from value and tooltip', async () => { }); expect(dateIndexField.vm.formatted).toBe('12/25/2025'); - expect(dateIndexField.vm.tooltip).toBeNull(); + expect(dateIndexField.vm.showTimezoneCard).toBe(false); }); -test('time-disabled but time-aware format shows date in value and time in tooltip', async () => { +test('time-disabled but time-aware format shows date in value and exposes the timezone hover card', async () => { const dateIndexField = makeDateIndexField({ date: '2025-12-25T02:13:00Z', mode: 'single', @@ -168,11 +168,10 @@ test('time-disabled but time-aware format shows date in value and time in toolti }); expect(dateIndexField.vm.formatted).toBe('12/25/2025'); - expect(dateIndexField.vm.tooltip).not.toBeNull(); - expect(dateIndexField.vm.tooltip).toContain('2025'); + expect(dateIndexField.vm.showTimezoneCard).toBe(true); }); -test('time-enabled value shows time in both value and tooltip', async () => { +test('time-enabled value shows time in value and exposes the timezone hover card', async () => { const dateIndexField = makeDateIndexField({ date: '2025-12-25T02:13:00Z', mode: 'single', @@ -181,5 +180,5 @@ test('time-enabled value shows time in both value and tooltip', async () => { }); expect(dateIndexField.vm.formatted).toBe('12/25/2025, 2:13 AM'); - expect(dateIndexField.vm.tooltip).not.toBeNull(); + expect(dateIndexField.vm.showTimezoneCard).toBe(true); }); diff --git a/src/Http/View/Composers/JavascriptComposer.php b/src/Http/View/Composers/JavascriptComposer.php index c2251917c38..1285b29f764 100644 --- a/src/Http/View/Composers/JavascriptComposer.php +++ b/src/Http/View/Composers/JavascriptComposer.php @@ -82,6 +82,7 @@ private function protectedVariables() 'setPreviewImages' => Sets::previewImageConfig(), 'linkToDocs' => config('statamic.cp.link_to_docs'), 'defaultTheme' => $this->defaultTheme(), + 'displayTimezone' => Statamic::displayTimezone(), ]; } From 6037d832703596b3558afc9961822963414103b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wiktor=20Mr=C3=B3wczy=C5=84ski?= <51246921+wiktorm12@users.noreply.github.com> Date: Fri, 8 May 2026 17:44:11 +0200 Subject: [PATCH 191/461] [6.x] Fix perPage limit in relationship stack listings (#14629) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Jason Varga --- src/Fieldtypes/Entries.php | 2 +- src/Fieldtypes/Terms.php | 2 +- src/Fieldtypes/Users.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Fieldtypes/Entries.php b/src/Fieldtypes/Entries.php index 89a0204f185..9fc3881511a 100644 --- a/src/Fieldtypes/Entries.php +++ b/src/Fieldtypes/Entries.php @@ -181,7 +181,7 @@ public function getIndexItems($request) $query->orderBy($sort, $this->getSortDirection($request)); } - $results = ($paginate = $request->boolean('paginate', true)) ? $query->paginate() : $query->get(); + $results = ($paginate = $request->boolean('paginate', true)) ? $query->paginate($request->integer('perPage', 15)) : $query->get(); $items = $results->map(fn ($item) => $item instanceof Result ? $item->getSearchable() : $item); diff --git a/src/Fieldtypes/Terms.php b/src/Fieldtypes/Terms.php index 1d532010809..d9ce93ce683 100644 --- a/src/Fieldtypes/Terms.php +++ b/src/Fieldtypes/Terms.php @@ -297,7 +297,7 @@ public function getIndexItems($request) $query->orderBy($sort, $this->getSortDirection($request)); } - return $request->boolean('paginate', true) ? $query->paginate() : $query->get(); + return $request->boolean('paginate', true) ? $query->paginate($request->integer('perPage', 15)) : $query->get(); } private function authorizeTaxonomyAccess(array $taxonomies): void diff --git a/src/Fieldtypes/Users.php b/src/Fieldtypes/Users.php index 64f1cd03929..0f154e948cf 100644 --- a/src/Fieldtypes/Users.php +++ b/src/Fieldtypes/Users.php @@ -169,7 +169,7 @@ public function getIndexItems($request) }; if ($request->boolean('paginate', true)) { - $users = $query->paginate(); + $users = $query->paginate($request->integer('perPage', 15)); $users->getCollection()->transform($userFields); From 5606f95fd30788d5bda7cbb9c0bd11ff65e4cbcb Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 8 May 2026 12:20:59 -0400 Subject: [PATCH 192/461] changelog --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34d6b80ed84..c0bfc93cae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Release Notes +## 6.17.0 (2026-05-08) + +### What's new +- Add configurable timezone for date fields in the Control Panel [#14554](https://github.com/statamic/cms/issues/14554) by @duncanmcclean +- Add `disabled` and `readOnly` props to `TimePicker` component [#14620](https://github.com/statamic/cms/issues/14620) by @duncanmcclean +- Add Timezones components [#14612](https://github.com/statamic/cms/issues/14612) by @jasonvarga +- Allow overriding date format preset options [#14600](https://github.com/statamic/cms/issues/14600) by @jasonvarga + +### What's fixed +- Dirty state fixes [#14592](https://github.com/statamic/cms/issues/14592) by @jackmcdade +- Localize timezone tooltip in DatePicker [#14596](https://github.com/statamic/cms/issues/14596) by @jasonvarga +- Fix asset browser actions inside selector stack [#14565](https://github.com/statamic/cms/issues/14565) by @duncanmcclean +- Fix date-only formats shifting days due to timezone conversion [#14552](https://github.com/statamic/cms/issues/14552) by @duncanmcclean +- Avoid rendering time for dates in listings when appropriate [#14599](https://github.com/statamic/cms/issues/14599) by @jasonvarga +- Display timezone in DateRangePicker [#14601](https://github.com/statamic/cms/issues/14601) by @jasonvarga +- Fix date-only index fieldtype shifting across timezones [#14602](https://github.com/statamic/cms/issues/14602) by @jasonvarga +- Memoize preprocessed fields in Validator [#14605](https://github.com/statamic/cms/issues/14605) by @jasonvarga +- Show date fieldtype when searching for range [#14606](https://github.com/statamic/cms/issues/14606) by @jasonvarga +- Fix error when typing in a required date range field [#14607](https://github.com/statamic/cms/issues/14607) by @jasonvarga +- Fix HoverCard arrow not displaying [#14611](https://github.com/statamic/cms/issues/14611) by @jasonvarga +- Adjust translation method usage [#14610](https://github.com/statamic/cms/issues/14610) by @jasonvarga +- Fix stale asset listings across queued jobs [#14617](https://github.com/statamic/cms/issues/14617) by @ryanmitchell +- Fix replicator fields using wrong site context [#14616](https://github.com/statamic/cms/issues/14616) by @ryanmitchell +- Fix read-only/disabled states in `Radio` component [#14621](https://github.com/statamic/cms/issues/14621) by @duncanmcclean +- Lazy-load actions for collections, submissions & terms [#14097](https://github.com/statamic/cms/issues/14097) by @duncanmcclean +- Fix dated entries not syncing with origin revisions [#14216](https://github.com/statamic/cms/issues/14216) by @duncanmcclean +- Fix DateRangePicker crash when selecting first date in a range [#13512](https://github.com/statamic/cms/issues/13512) by @andrii-trush +- Fix moving custom section to 1st position in CP Nav [#12993](https://github.com/statamic/cms/issues/12993) by @duncanmcclean +- Fix read-only state in publish forms [#14623](https://github.com/statamic/cms/issues/14623) by @duncanmcclean +- Fix perPage limit in relationship stack listings [#14629](https://github.com/statamic/cms/issues/14629) by @wiktorm12 +- Bump postcss from 8.5.9 to 8.5.13 [#14595](https://github.com/statamic/cms/issues/14595) by @dependabot +- Bump axios from 1.15.0 to 1.16.0 [#14613](https://github.com/statamic/cms/issues/14613) by @dependabot + + + ## 6.16.0 (2026-05-01) ### What's new From 846d4a55dd6e8204fed40eeb6170b0e551804796 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 8 May 2026 13:23:38 -0400 Subject: [PATCH 193/461] [6.x] Fix CP login bouncing after Inertia auto-follow (#14632) Co-authored-by: Claude Opus 4.7 (1M context) --- resources/js/pages/auth/Login.vue | 7 ---- .../Controllers/CP/Auth/LoginController.php | 10 +++-- tests/Auth/LoginTest.php | 38 +++++++++++++++++++ 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/resources/js/pages/auth/Login.vue b/resources/js/pages/auth/Login.vue index 5153a67c68c..0a910807bd4 100644 --- a/resources/js/pages/auth/Login.vue +++ b/resources/js/pages/auth/Login.vue @@ -40,13 +40,6 @@ const submit = () => { processing.value = true; errors.value = {}; }, - onSuccess: (page) => { - if (page.component === 'auth/two-factor/Challenge') { - return; - } - - window.location.href = page.url; - }, onError: () => processing.value = false }); } diff --git a/src/Http/Controllers/CP/Auth/LoginController.php b/src/Http/Controllers/CP/Auth/LoginController.php index 14d67e38cb5..736077df7cd 100644 --- a/src/Http/Controllers/CP/Auth/LoginController.php +++ b/src/Http/Controllers/CP/Auth/LoginController.php @@ -111,9 +111,13 @@ public function redirectPath() protected function authenticated(Request $request, $user) { - return $request->expectsJson() - ? response('Authenticated') - : redirect()->intended($this->redirectPath()); + if ($request->expectsJson()) { + return response('Authenticated'); + } + + $url = redirect()->intended($this->redirectPath())->getTargetUrl(); + + return $request->inertia() ? Inertia::location($url) : redirect($url); } protected function credentials(Request $request) diff --git a/tests/Auth/LoginTest.php b/tests/Auth/LoginTest.php index 1676fe45f1a..8f4bd741145 100644 --- a/tests/Auth/LoginTest.php +++ b/tests/Auth/LoginTest.php @@ -134,6 +134,44 @@ public function it_redirects_to_intended_url() $this->assertAuthenticatedAs($user); } + #[Test] + public function inertia_login_returns_a_full_page_redirect() + { + // Inertia would otherwise auto-follow a 302 with X-Inertia headers and swap to + // the dashboard component without the protected props it needs to render. + // Returning 409 + X-Inertia-Location forces a full browser navigation instead. + $user = $this->user(); + + $this + ->assertGuest() + ->post(cp_route('login'), [ + 'email' => $user->email(), + 'password' => 'secret', + ], ['X-Inertia' => 'true']) + ->assertStatus(409) + ->assertHeader('X-Inertia-Location', cp_route('index')); + + $this->assertAuthenticatedAs($user); + } + + #[Test] + public function inertia_login_redirects_to_intended_url_via_full_page_redirect() + { + $user = $this->user(); + + $this + ->assertGuest() + ->session(['url.intended' => 'http://localhost/cp/cp/collections']) + ->post(cp_route('login'), [ + 'email' => $user->email(), + 'password' => 'secret', + ], ['X-Inertia' => 'true']) + ->assertStatus(409) + ->assertHeader('X-Inertia-Location', 'http://localhost/cp/cp/collections'); + + $this->assertAuthenticatedAs($user); + } + #[Test] public function it_stores_the_intended_url_when_redirected_to_login() { From 4a20fe339f2ae7eddae9827cfd8c3426ed256afa Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Fri, 8 May 2026 14:17:53 -0400 Subject: [PATCH 194/461] [6.x] Customizable crop ratios (#14630) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Jason Varga --- config/assets.php | 19 +++ .../components/assets/Editor/CropEditor.vue | 14 +- src/Assets/CropAspectRatios.php | 123 ++++++++++++++++++ .../View/Composers/JavascriptComposer.php | 2 + tests/Assets/CropAspectRatiosTest.php | 93 +++++++++++++ .../View/Composers/JavascriptComposerTest.php | 40 ++++++ 6 files changed, 281 insertions(+), 10 deletions(-) create mode 100644 src/Assets/CropAspectRatios.php create mode 100644 tests/Assets/CropAspectRatiosTest.php create mode 100644 tests/Http/View/Composers/JavascriptComposerTest.php diff --git a/config/assets.php b/config/assets.php index ddeab7d3b36..9cdb6af4be9 100644 --- a/config/assets.php +++ b/config/assets.php @@ -182,6 +182,25 @@ 'focal_point_editor' => true, + /* + |-------------------------------------------------------------------------- + | Crop Aspect Ratios + |-------------------------------------------------------------------------- + | + | Configure the aspect ratio presets available in the Control Panel image + | crop editor. Ratios may be provided as "W:H" strings, keyed values, or + | arrays with custom labels and ratio values. + | + */ + + 'crop_aspect_ratios' => [ + '16:9', + '4:3', + '3:2', + '2:1', + '1:1', + ], + /* |-------------------------------------------------------------------------- | Enforce Lowercase Filenames diff --git a/resources/js/components/assets/Editor/CropEditor.vue b/resources/js/components/assets/Editor/CropEditor.vue index 93898f30f14..ea9865f35ff 100644 --- a/resources/js/components/assets/Editor/CropEditor.vue +++ b/resources/js/components/assets/Editor/CropEditor.vue @@ -39,13 +39,7 @@ const uploading = ref(false); const pendingBlob = ref(null); const pendingMimeType = ref(null); -const aspectRatios = ref([ - { label: '16:9', value: 16 / 9 }, - { label: '4:3', value: 4 / 3 }, - { label: '3:2', value: 3 / 2 }, - { label: '2:1', value: 2 / 1 }, - { label: '1:1', value: 1 }, -]); +const aspectRatios = ref(Statamic.$config.get('cropAspectRatios') || []); watch(() => props.open, (newValue) => { if (newValue) { @@ -455,8 +449,8 @@ function close() { -
-
+
+