diff --git a/src/Http/Controllers/CP/Collections/LocalizeEntryController.php b/src/Http/Controllers/CP/Collections/LocalizeEntryController.php index c830ad8c658..cc84afced5f 100644 --- a/src/Http/Controllers/CP/Collections/LocalizeEntryController.php +++ b/src/Http/Controllers/CP/Collections/LocalizeEntryController.php @@ -2,7 +2,10 @@ namespace Statamic\Http\Controllers\CP\Collections; +use Illuminate\Cache\NoLock; +use Illuminate\Contracts\Cache\LockProvider; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Statamic\Facades\User; use Statamic\Http\Controllers\CP\CpController; @@ -14,13 +17,37 @@ public function __invoke(Request $request, $collection, $entry) $request->validate(['site' => 'required']); - $localized = $entry->makeLocalization($site = $request->site); + $site = $request->site; - $localized->store(['user' => User::fromUser($request->user())]); + // The lock is keyed on the root entry, so concurrent requests from any + // localization in the origin chain wait for each other. + return $this->lock("localize-entry:{$entry->root()->id()}:{$site}", 10)->block(5, function () use ($request, $entry, $site) { + // Look through the whole origin chain, since the origin of a + // localization can be any existing localization. + $existing = $entry->in($site); - return [ - 'handle' => $site, - 'url' => $localized->editUrl(), - ]; + if ($existing) { + return [ + 'handle' => $site, + 'url' => $existing->editUrl(), + ]; + } + + $localized = $entry->makeLocalization($site); + + $localized->store(['user' => User::fromUser($request->user())]); + + return [ + 'handle' => $site, + 'url' => $localized->editUrl(), + ]; + }); + } + + private function lock(string $key, int $seconds) + { + return Cache::getStore() instanceof LockProvider + ? Cache::lock($key, $seconds) + : new NoLock($key, $seconds); } } diff --git a/tests/Feature/Entries/LocalizeEntryTest.php b/tests/Feature/Entries/LocalizeEntryTest.php index 3734a14340b..0da783c6e87 100644 --- a/tests/Feature/Entries/LocalizeEntryTest.php +++ b/tests/Feature/Entries/LocalizeEntryTest.php @@ -3,6 +3,9 @@ namespace Tests\Feature\Entries; use Facades\Tests\Factories\EntryFactory; +use Illuminate\Contracts\Cache\LockProvider; +use Illuminate\Contracts\Cache\Store; +use Illuminate\Support\Facades\Cache; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Collection; use Statamic\Facades\User; @@ -51,6 +54,118 @@ public function it_localizes_an_entry() $this->assertCount(0, $entry->in('fr')->revisions()); } + #[Test] + public function it_returns_the_existing_localization_instead_of_creating_another() + { + $entry = EntryFactory::collection(tap(Collection::make('blog')->revisionsEnabled(false))->save())->id('en-test')->slug('test')->create(); + $existing = EntryFactory::collection('blog')->id('fr-test')->locale('fr')->origin('en-test')->slug('test')->create(); + + $this + ->actingAs($this->user()) + ->localize($entry, ['site' => 'fr']) + ->assertOk() + ->assertJson(['handle' => 'fr', 'url' => $existing->editUrl()]); + + $this->assertCount(1, \Statamic\Facades\Entry::query()->where('origin', 'en-test')->where('site', 'fr')->get()); + } + + #[Test] + public function it_returns_an_existing_localization_from_elsewhere_in_the_origin_chain() + { + $this->setSites([ + 'en' => ['url' => 'http://localhost/', 'locale' => 'en'], + 'de' => ['url' => 'http://localhost/de/', 'locale' => 'de'], + 'fr' => ['url' => 'http://localhost/fr/', 'locale' => 'fr'], + ]); + + $en = EntryFactory::collection(tap(Collection::make('blog')->revisionsEnabled(false))->save())->id('en-test')->slug('test')->create(); + $de = EntryFactory::collection('blog')->id('de-test')->locale('de')->origin('en-test')->slug('test')->create(); + + $first = $this->actingAs($this->user())->localize($de, ['site' => 'fr'])->assertOk(); + $second = $this->actingAs($this->user())->localize($en, ['site' => 'fr'])->assertOk(); + + $this->assertSame($first->json(), $second->json()); + $this->assertCount(1, \Statamic\Facades\Entry::query()->where('collection', 'blog')->where('site', 'fr')->get()); + } + + #[Test] + public function it_localizes_an_entry_when_the_cache_store_does_not_support_locks() + { + Cache::extend('nolock', fn ($app) => Cache::repository(new class(Cache::store('file')->getStore()) implements Store + { + public function __construct(private Store $store) + { + } + + public function get($key) + { + return $this->store->get($key); + } + + public function many(array $keys) + { + return $this->store->many($keys); + } + + public function put($key, $value, $seconds) + { + return $this->store->put($key, $value, $seconds); + } + + public function putMany(array $values, $seconds) + { + return $this->store->putMany($values, $seconds); + } + + public function increment($key, $value = 1) + { + return $this->store->increment($key, $value); + } + + public function decrement($key, $value = 1) + { + return $this->store->decrement($key, $value); + } + + public function forever($key, $value) + { + return $this->store->forever($key, $value); + } + + public function touch($key, $seconds) + { + return $this->store->touch($key, $seconds); + } + + public function forget($key) + { + return $this->store->forget($key); + } + + public function flush() + { + return $this->store->flush(); + } + + public function getPrefix() + { + return $this->store->getPrefix(); + } + })); + + config(['cache.stores.nolock' => ['driver' => 'nolock'], 'cache.default' => 'nolock']); + $this->assertNotInstanceOf(LockProvider::class, Cache::getStore()); + + $entry = EntryFactory::collection(tap(Collection::make('blog')->revisionsEnabled(false))->save())->slug('test')->create(); + + $this + ->actingAs($this->user()) + ->localize($entry, ['site' => 'fr']) + ->assertOk(); + + $this->assertNotNull($entry->fresh()->in('fr')); + } + #[Test] public function site_is_required() { @@ -255,6 +370,7 @@ private function user() 'access cp', 'access en site', 'access fr site', + 'access de site', 'view blog entries', 'edit blog entries', 'view pages entries',