From 35e25e766302c9206830a67f1ed50fdfe1843590 Mon Sep 17 00:00:00 2001 From: Jay George Date: Wed, 26 Aug 2026 15:27:29 +0100 Subject: [PATCH 1/4] Add bulk Localize action for missing entry localizations Let editors pick a target site from the collection index and create localizations only for entries that do not already have one. --- src/Actions/Localize.php | 148 +++++++++++++++++++++ src/Providers/ExtensionServiceProvider.php | 1 + tests/Actions/LocalizeTest.php | 96 +++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 src/Actions/Localize.php create mode 100644 tests/Actions/LocalizeTest.php diff --git a/src/Actions/Localize.php b/src/Actions/Localize.php new file mode 100644 index 00000000000..b31813696b6 --- /dev/null +++ b/src/Actions/Localize.php @@ -0,0 +1,148 @@ +context['view'] === 'list' + && $item instanceof Entry + && Site::multiEnabled() + && $item->collection()->sites()->count() > 1 + && $this->missingSites($item)->isNotEmpty(); + } + + public function visibleToBulk($items) + { + if ($items->whereInstanceOf(Entry::class)->count() !== $items->count()) { + return false; + } + + if (! Site::multiEnabled()) { + return false; + } + + if ($items->map->collectionHandle()->unique()->count() !== 1) { + return false; + } + + $collection = $items->first()->collection(); + + if ($collection->sites()->count() <= 1) { + return false; + } + + return $items->contains(fn (Entry $entry) => $this->missingSites($entry)->isNotEmpty()); + } + + public function authorize($user, $entry) + { + return $user->can('edit', $entry); + } + + public function confirmationText() + { + /** @translation */ + return 'Localize this entry?|Localize these :count entries?'; + } + + public function buttonText() + { + /** @translation */ + return 'Localize|Localize :count entries'; + } + + public function run($entries, $values) + { + $site = $values['site']; + $created = 0; + $skipped = 0; + + $entries->each(function (Entry $entry) use ($site, &$created, &$skipped) { + if ($entry->locale() === $site || $entry->existsIn($site)) { + $skipped++; + + return; + } + + if (! User::current()->can('edit', $entry)) { + $skipped++; + + return; + } + + $entry->makeLocalization($site)->store(['user' => User::current()]); + $created++; + }); + + if ($created === 0) { + /** @translation */ + return __('No localizations were created.'); + } + + if ($skipped > 0) { + return __('Created :created localization(s), skipped :skipped.', [ + 'created' => $created, + 'skipped' => $skipped, + ]); + } + + return trans_choice('Created :count localization|Created :count localizations', $created, [ + 'count' => $created, + ]); + } + + protected function fieldItems() + { + return [ + 'site' => [ + 'display' => __('Site'), + 'instructions' => __('Only entries that are missing this localization will be created.'), + 'type' => 'select', + 'options' => $this->siteOptions(), + 'validate' => 'required', + ], + ]; + } + + private function siteOptions(): array + { + $collection = $this->items->first()?->collection(); + + if (! $collection) { + return []; + } + + return $collection->sites() + ->filter(fn ($handle) => $this->items->contains( + fn (Entry $entry) => $entry->locale() !== $handle && ! $entry->existsIn($handle) + )) + ->mapWithKeys(fn ($handle) => [ + $handle => Site::get($handle)?->name() ?? $handle, + ]) + ->all(); + } + + private function missingSites(Entry $entry) + { + return $entry->collection()->sites() + ->reject($entry->locale()) + ->reject(fn ($site) => $entry->existsIn($site)) + ->values(); + } +} diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php index 5371de78bcf..478cfac097a 100644 --- a/src/Providers/ExtensionServiceProvider.php +++ b/src/Providers/ExtensionServiceProvider.php @@ -50,6 +50,7 @@ class ExtensionServiceProvider extends ServiceProvider Actions\MoveAssetFolder::class, Actions\RenameAssetFolder::class, Actions\Impersonate::class, + Actions\Localize::class, ]; protected $dictionaries = [ diff --git a/tests/Actions/LocalizeTest.php b/tests/Actions/LocalizeTest.php new file mode 100644 index 00000000000..628a00d7dc3 --- /dev/null +++ b/tests/Actions/LocalizeTest.php @@ -0,0 +1,96 @@ +setSites([ + 'en' => ['name' => 'English', 'locale' => 'en_US', 'url' => '/'], + 'fr' => ['name' => 'French', 'locale' => 'fr_FR', 'url' => '/fr/'], + 'de' => ['name' => 'German', 'locale' => 'de_DE', 'url' => '/de/'], + ]); + + Collection::make('test')->sites(['en', 'fr', 'de'])->save(); + } + + #[Test] + public function it_is_visible_for_multisite_entries_missing_localizations() + { + $entry = EntryFactory::id('alfa')->collection('test')->slug('alfa')->locale('en')->create(); + + $action = (new Localize)->context(['view' => 'list'])->items([$entry]); + + $this->assertTrue($action->visibleTo($entry)); + } + + #[Test] + public function it_is_hidden_when_all_localizations_exist() + { + $en = EntryFactory::id('alfa')->collection('test')->slug('alfa')->locale('en')->create(); + EntryFactory::id('alfa-fr')->collection('test')->slug('alfa')->locale('fr')->origin('alfa')->create(); + EntryFactory::id('alfa-de')->collection('test')->slug('alfa')->locale('de')->origin('alfa')->create(); + + $action = (new Localize)->context(['view' => 'list'])->items([$en]); + + $this->assertFalse($action->visibleTo($en)); + } + + #[Test] + public function it_is_hidden_for_single_site_collections() + { + Collection::make('single')->sites(['en'])->save(); + $entry = EntryFactory::id('alfa')->collection('single')->slug('alfa')->locale('en')->create(); + + $action = (new Localize)->context(['view' => 'list'])->items([$entry]); + + $this->assertFalse($action->visibleTo($entry)); + } + + #[Test] + public function it_localizes_entries_missing_the_selected_site() + { + $this->actingAs(User::make()->makeSuper()->save()); + + $alfa = EntryFactory::id('alfa')->collection('test')->slug('alfa')->locale('en')->data(['title' => 'Alfa'])->create(); + $bravo = EntryFactory::id('bravo')->collection('test')->slug('bravo')->locale('en')->data(['title' => 'Bravo'])->create(); + EntryFactory::id('bravo-fr')->collection('test')->slug('bravo')->locale('fr')->origin('bravo')->create(); + + $message = (new Localize)->run(collect([$alfa, $bravo]), ['site' => 'fr']); + + $this->assertTrue($alfa->fresh()->existsIn('fr')); + $this->assertEquals('bravo-fr', $bravo->fresh()->in('fr')->id()); + $this->assertEquals('alfa', $alfa->fresh()->in('fr')->origin()->id()); + $this->assertStringContainsString('Created 1', $message); + $this->assertStringContainsString('skipped 1', $message); + } + + #[Test] + public function it_only_offers_sites_that_are_missing_for_selected_entries() + { + $en = EntryFactory::id('alfa')->collection('test')->slug('alfa')->locale('en')->create(); + EntryFactory::id('alfa-fr')->collection('test')->slug('alfa')->locale('fr')->origin('alfa')->create(); + + $action = (new Localize)->context(['view' => 'list'])->items([$en]); + $siteField = $action->fields()->get('site'); + + $this->assertEquals([ + 'de' => 'German', + ], $siteField->get('options')); + } +} From 8f8676c5a720dec55e379cd9bcb59093810fcf92 Mon Sep 17 00:00:00 2001 From: Jay George Date: Wed, 26 Aug 2026 15:45:29 +0100 Subject: [PATCH 2/4] Tidy Localize action copy and hide stray required asterisk Clarify the confirmation text, drop the Site label, and avoid showing a required asterisk when field labels are hidden. --- resources/js/components/ui/Publish/Field.vue | 3 +-- src/Actions/Localize.php | 9 +++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/resources/js/components/ui/Publish/Field.vue b/resources/js/components/ui/Publish/Field.vue index 90354c09285..7c067bf5801 100644 --- a/resources/js/components/ui/Publish/Field.vue +++ b/resources/js/components/ui/Publish/Field.vue @@ -180,8 +180,7 @@ const shouldShowLabelText = computed(() => !props.config.hide_display); // screen-reader-only label to the control below. const shouldShowLabel = computed( () => - shouldShowLabelText.value || // Need to see the text - isRequired.value || // Need to see the required asterisk + shouldShowLabelText.value || // Need to see the text (and required asterisk, when shown) isLocked.value || // Need to see the avatar isSyncable.value, // Need to see the icon ); diff --git a/src/Actions/Localize.php b/src/Actions/Localize.php index b31813696b6..5fcbb51b0d7 100644 --- a/src/Actions/Localize.php +++ b/src/Actions/Localize.php @@ -58,13 +58,13 @@ public function authorize($user, $entry) public function confirmationText() { /** @translation */ - return 'Localize this entry?|Localize these :count entries?'; + return 'Create missing localizations for this entry? (Existing localizations will be skipped).|Create missing localizations for these :count entries? (Existing localizations will be skipped).'; } public function buttonText() { /** @translation */ - return 'Localize|Localize :count entries'; + return 'Localize|Localize :count Entries'; } public function run($entries, $values) @@ -96,7 +96,7 @@ public function run($entries, $values) } if ($skipped > 0) { - return __('Created :created localization(s), skipped :skipped.', [ + return __('Created :created, skipped :skipped.', [ 'created' => $created, 'skipped' => $skipped, ]); @@ -112,8 +112,9 @@ protected function fieldItems() return [ 'site' => [ 'display' => __('Site'), - 'instructions' => __('Only entries that are missing this localization will be created.'), + 'hide_display' => true, 'type' => 'select', + 'placeholder' => __('Choose a site...'), 'options' => $this->siteOptions(), 'validate' => 'required', ], From 71139cd38b270d4f855fb4e1f826751722f589aa Mon Sep 17 00:00:00 2001 From: Jay George Date: Wed, 26 Aug 2026 16:11:01 +0100 Subject: [PATCH 3/4] Tidy copy --- src/Actions/Localize.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Actions/Localize.php b/src/Actions/Localize.php index 5fcbb51b0d7..3b3e0e4d0d9 100644 --- a/src/Actions/Localize.php +++ b/src/Actions/Localize.php @@ -58,7 +58,7 @@ public function authorize($user, $entry) public function confirmationText() { /** @translation */ - return 'Create missing localizations for this entry? (Existing localizations will be skipped).|Create missing localizations for these :count entries? (Existing localizations will be skipped).'; + return 'Localize this entry to the selected site? Existing localizations will be skipped.|Localize these :count entries to the selected site? Existing localizations will be skipped.'; } public function buttonText() From b1c1d6b39954d13fc34a27e9038d544420195153 Mon Sep 17 00:00:00 2001 From: Jay George Date: Wed, 26 Aug 2026 16:11:37 +0100 Subject: [PATCH 4/4] Tighten Localize action validation and cover bulk visibility Constrain the site field to offered options, drop the unreachable edit soft-skip, and add tests for visibleToBulk, authorize, and site validation. --- src/Actions/Localize.php | 11 +--- tests/Actions/LocalizeTest.php | 107 +++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 8 deletions(-) diff --git a/src/Actions/Localize.php b/src/Actions/Localize.php index 3b3e0e4d0d9..7799a6c1a28 100644 --- a/src/Actions/Localize.php +++ b/src/Actions/Localize.php @@ -2,6 +2,7 @@ namespace Statamic\Actions; +use Illuminate\Validation\Rule; use Statamic\Contracts\Entries\Entry; use Statamic\Facades\Site; use Statamic\Facades\User; @@ -80,12 +81,6 @@ public function run($entries, $values) return; } - if (! User::current()->can('edit', $entry)) { - $skipped++; - - return; - } - $entry->makeLocalization($site)->store(['user' => User::current()]); $created++; }); @@ -115,8 +110,8 @@ protected function fieldItems() 'hide_display' => true, 'type' => 'select', 'placeholder' => __('Choose a site...'), - 'options' => $this->siteOptions(), - 'validate' => 'required', + 'options' => $options = $this->siteOptions(), + 'validate' => ['required', Rule::in(array_keys($options))], ], ]; } diff --git a/tests/Actions/LocalizeTest.php b/tests/Actions/LocalizeTest.php index 628a00d7dc3..49eb77635cb 100644 --- a/tests/Actions/LocalizeTest.php +++ b/tests/Actions/LocalizeTest.php @@ -3,6 +3,7 @@ namespace Tests\Actions; use Facades\Tests\Factories\EntryFactory; +use Illuminate\Support\Facades\Config; use PHPUnit\Framework\Attributes\Test; use Statamic\Actions\Localize; use Statamic\Facades\Collection; @@ -62,6 +63,95 @@ public function it_is_hidden_for_single_site_collections() $this->assertFalse($action->visibleTo($entry)); } + #[Test] + public function it_is_visible_to_bulk_when_any_entry_is_missing_a_localization() + { + $alfa = EntryFactory::id('alfa')->collection('test')->slug('alfa')->locale('en')->create(); + $bravo = EntryFactory::id('bravo')->collection('test')->slug('bravo')->locale('en')->create(); + EntryFactory::id('bravo-fr')->collection('test')->slug('bravo')->locale('fr')->origin('bravo')->create(); + EntryFactory::id('bravo-de')->collection('test')->slug('bravo')->locale('de')->origin('bravo')->create(); + + $items = collect([$alfa, $bravo]); + $action = (new Localize)->context(['view' => 'list'])->items($items); + + $this->assertTrue($action->visibleToBulk($items)); + } + + #[Test] + public function it_is_hidden_from_bulk_when_all_entries_are_fully_localized() + { + $alfa = EntryFactory::id('alfa')->collection('test')->slug('alfa')->locale('en')->create(); + EntryFactory::id('alfa-fr')->collection('test')->slug('alfa')->locale('fr')->origin('alfa')->create(); + EntryFactory::id('alfa-de')->collection('test')->slug('alfa')->locale('de')->origin('alfa')->create(); + + $items = collect([$alfa]); + $action = (new Localize)->context(['view' => 'list'])->items($items); + + $this->assertFalse($action->visibleToBulk($items)); + } + + #[Test] + public function it_is_hidden_from_bulk_when_selection_includes_non_entries() + { + $entry = EntryFactory::id('alfa')->collection('test')->slug('alfa')->locale('en')->create(); + $items = collect([$entry, 'not-an-entry']); + $action = (new Localize)->context(['view' => 'list'])->items($items); + + $this->assertFalse($action->visibleToBulk($items)); + } + + #[Test] + public function it_is_hidden_from_bulk_when_entries_are_from_different_collections() + { + Collection::make('other')->sites(['en', 'fr', 'de'])->save(); + + $alfa = EntryFactory::id('alfa')->collection('test')->slug('alfa')->locale('en')->create(); + $bravo = EntryFactory::id('bravo')->collection('other')->slug('bravo')->locale('en')->create(); + $items = collect([$alfa, $bravo]); + $action = (new Localize)->context(['view' => 'list'])->items($items); + + $this->assertFalse($action->visibleToBulk($items)); + } + + #[Test] + public function it_is_hidden_from_bulk_when_multisite_is_disabled() + { + Config::set('statamic.system.multisite', false); + + $entry = EntryFactory::id('alfa')->collection('test')->slug('alfa')->locale('en')->create(); + $items = collect([$entry]); + $action = (new Localize)->context(['view' => 'list'])->items($items); + + $this->assertFalse($action->visibleToBulk($items)); + } + + #[Test] + public function it_is_hidden_from_bulk_for_single_site_collections() + { + Collection::make('single')->sites(['en'])->save(); + $entry = EntryFactory::id('alfa')->collection('single')->slug('alfa')->locale('en')->create(); + $items = collect([$entry]); + $action = (new Localize)->context(['view' => 'list'])->items($items); + + $this->assertFalse($action->visibleToBulk($items)); + } + + #[Test] + public function it_authorizes_users_who_can_edit_the_entry() + { + $this->setTestRoles([ + 'editor' => ['edit test entries', 'access en site'], + 'viewer' => ['view test entries', 'access en site'], + ]); + + $userWithPermission = tap(User::make()->assignRole('editor'))->save(); + $userWithoutPermission = tap(User::make()->assignRole('viewer'))->save(); + $entry = EntryFactory::id('alfa')->collection('test')->slug('alfa')->locale('en')->create(); + + $this->assertTrue((new Localize)->authorize($userWithPermission, $entry)); + $this->assertFalse((new Localize)->authorize($userWithoutPermission, $entry)); + } + #[Test] public function it_localizes_entries_missing_the_selected_site() { @@ -93,4 +183,21 @@ public function it_only_offers_sites_that_are_missing_for_selected_entries() 'de' => 'German', ], $siteField->get('options')); } + + #[Test] + public function it_rejects_sites_that_are_not_offered() + { + $en = EntryFactory::id('alfa')->collection('test')->slug('alfa')->locale('en')->create(); + EntryFactory::id('alfa-fr')->collection('test')->slug('alfa')->locale('fr')->origin('alfa')->create(); + + $action = (new Localize)->context(['view' => 'list'])->items([$en]); + + $this->assertFalse( + $action->fields()->addValues(['site' => 'fr'])->validator()->validator()->passes() + ); + + $this->assertTrue( + $action->fields()->addValues(['site' => 'de'])->validator()->validator()->passes() + ); + } }