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 new file mode 100644 index 00000000000..7799a6c1a28 --- /dev/null +++ b/src/Actions/Localize.php @@ -0,0 +1,144 @@ +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 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() + { + /** @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; + } + + $entry->makeLocalization($site)->store(['user' => User::current()]); + $created++; + }); + + if ($created === 0) { + /** @translation */ + return __('No localizations were created.'); + } + + if ($skipped > 0) { + return __('Created :created, 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'), + 'hide_display' => true, + 'type' => 'select', + 'placeholder' => __('Choose a site...'), + 'options' => $options = $this->siteOptions(), + 'validate' => ['required', Rule::in(array_keys($options))], + ], + ]; + } + + 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..49eb77635cb --- /dev/null +++ b/tests/Actions/LocalizeTest.php @@ -0,0 +1,203 @@ +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_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() + { + $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')); + } + + #[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() + ); + } +}