Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions resources/js/components/ui/Publish/Field.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
144 changes: 144 additions & 0 deletions src/Actions/Localize.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

namespace Statamic\Actions;

use Illuminate\Validation\Rule;
use Statamic\Contracts\Entries\Entry;
use Statamic\Facades\Site;
use Statamic\Facades\User;

use function Statamic\trans as __;
use function Statamic\trans_choice;

class Localize extends Action
{
protected $icon = 'earth';

public static function title()
{
return __('Localize');
}

public function visibleTo($item)
{
return $this->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();
}
}
1 change: 1 addition & 0 deletions src/Providers/ExtensionServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class ExtensionServiceProvider extends ServiceProvider
Actions\MoveAssetFolder::class,
Actions\RenameAssetFolder::class,
Actions\Impersonate::class,
Actions\Localize::class,
];

protected $dictionaries = [
Expand Down
203 changes: 203 additions & 0 deletions tests/Actions/LocalizeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
<?php

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;
use Statamic\Facades\User;
use Tests\FakesRoles;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;

class LocalizeTest extends TestCase
{
use FakesRoles;
use PreventSavingStacheItemsToDisk;

public function setUp(): void
{
parent::setUp();

$this->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()
);
}
}
Loading