Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c529823
Prototype
jaygeorge Aug 24, 2026
93914a8
Adjust combobox size
jaygeorge Aug 24, 2026
1496e57
Correct alignment
jaygeorge Aug 24, 2026
a6c43d0
Improve layout
jaygeorge Aug 24, 2026
8d9ed33
When selecting a value in the combobox, the toggle should turn on, ot…
jaygeorge Aug 24, 2026
8ab1076
Don't say "Sites" Twice
jaygeorge Aug 24, 2026
131e432
Add translation
jaygeorge Aug 24, 2026
113f782
Prevent circular origin loops from hanging the CP, e.g. if Tokyo > Ja…
jaygeorge Aug 24, 2026
8815a25
Add origin_handle to entry revision localizations
jaygeorge Aug 24, 2026
f18bdbe
Do not translate user-defined site group names
jaygeorge Aug 24, 2026
6904666
Remove unused Site::filterByGroup helper
jaygeorge Aug 24, 2026
4714ff2
Reject circular entry origins on save
jaygeorge Aug 24, 2026
47723d1
Add tests for circular origin guards
jaygeorge Aug 24, 2026
f04c41e
Drop non-English Localization translations
jaygeorge Aug 24, 2026
baa594c
Use existing Localizable label for globals sites section
jaygeorge Aug 24, 2026
26cff0f
Drop Socialite TestCase workaround again
jaygeorge Aug 24, 2026
c0e02de
Add checkbox selection to groups too
jaygeorge Aug 24, 2026
b9cb3c4
Fix CI failures for Log facade and Statamic\trans imports
jaygeorge Aug 24, 2026
f019db7
Avoid requiring id() for HasOrigin cycle guards
jaygeorge Aug 24, 2026
809580d
Rename section to Localizations in preparation for changing the colle…
jaygeorge Aug 26, 2026
ae83900
Nudge the site configuration columns so they better align with the co…
jaygeorge Aug 26, 2026
6480f01
Improve sticky headers
jaygeorge Aug 26, 2026
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
401 changes: 326 additions & 75 deletions resources/js/components/globals/Sites.vue

Large diffs are not rendered by default.

92 changes: 84 additions & 8 deletions src/Data/HasOrigin.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,57 @@ trait HasOrigin
protected $origin;
private $cachedKeys;

public function keys()
public function keys(array $visited = [])
{
if ($this->cachedKeys) {
if (empty($visited) && $this->cachedKeys) {
return $this->cachedKeys;
}

$key = $this->originVisitKey();

if (in_array($key, $visited, true)) {
return collect();
}

$visited[] = $key;

$originFallbackKeys = method_exists($this, 'getOriginFallbackValues') ? $this->getOriginFallbackValues()->keys() : collect();

$originKeys = $this->hasOrigin() ? $this->origin()->keys() : collect();
$originKeys = $this->hasOrigin() ? $this->origin()->keys($visited) : collect();

$computedKeys = method_exists($this, 'computedKeys') ? $this->computedKeys() : [];

return $this->cachedKeys = collect()
$keys = collect()
->merge($originFallbackKeys)
->merge($originKeys)
->merge($this->data->keys())
->merge($computedKeys);

if (count($visited) === 1) {
$this->cachedKeys = $keys;
}

return $keys;
}

public function values()
{
return $this->getValues(false);
}

public function getValues($wrapComputed)
public function getValues($wrapComputed, array $visited = [])
{
$key = $this->originVisitKey();

if (in_array($key, $visited, true)) {
return collect();
}

$visited[] = $key;

$originFallbackValues = method_exists($this, 'getOriginFallbackValues') ? $this->getOriginFallbackValues() : collect();

$originValues = $this->hasOrigin() ? $this->origin()->values() : collect();
$originValues = $this->hasOrigin() ? $this->origin()->getValues($wrapComputed, $visited) : collect();

$computedData = method_exists($this, 'getComputedData') ? $this->getComputedData($wrapComputed) : [];

Expand All @@ -51,11 +73,19 @@ public function getValues($wrapComputed)
->merge($computedData);
}

public function value($key)
public function value($key, array $visited = [])
{
$visitKey = $this->originVisitKey();

if (in_array($visitKey, $visited, true)) {
return null;
}

$visited[] = $visitKey;

$originFallbackValue = method_exists($this, 'getOriginFallbackValue') ? $this->getOriginFallbackValue($key) : null;

$originValue = $this->hasOrigin() ? $this->origin()->value($key) : $originFallbackValue;
$originValue = $this->hasOrigin() ? $this->origin()->value($key, $visited) : $originFallbackValue;

$value = $this->has($key) ? $this->get($key) : $originValue;

Expand Down Expand Up @@ -111,14 +141,60 @@ public function isRoot()
return ! $this->hasOrigin();
}

public function hasOriginCycle(): bool
{
$seen = [];
$entry = $this;

while ($entry->hasOrigin()) {
$key = $entry->originVisitKey();

if (isset($seen[$key])) {
return true;
}

$seen[$key] = true;

$entry = $entry->origin();

if (! $entry) {
break;
}
}

return false;
}

public function root()
{
$entry = $this;
$seen = [];

while ($entry->hasOrigin()) {
$key = $entry->originVisitKey();

if (isset($seen[$key])) {
break;
}

$seen[$key] = true;

$entry = $entry->origin();
}

return $entry;
}

protected function originVisitKey()
{
if (method_exists($this, 'id')) {
$id = $this->id();

if ($id !== null) {
return $id;
}
}

return 'object:'.spl_object_id($this);
}
}
20 changes: 20 additions & 0 deletions src/Entries/Entry.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Traits\Localizable;
use Illuminate\Validation\ValidationException;
use LogicException;
use Statamic\Contracts\Auth\Protect\Protectable;
use Statamic\Contracts\Data\Augmentable;
Expand Down Expand Up @@ -54,6 +55,8 @@
use Statamic\Support\Traits\FluentlyGetsAndSets;
use Statamic\View\Cascade;

use function Statamic\trans as __;

class Entry implements Arrayable, ArrayAccess, Augmentable, BulkAugmentable, ContainsQueryableValues, Contract, Localization, Protectable, ResolvesValuesContract, Responsable, SearchableContract
{
use ContainsComputedData, ContainsData, ExistsAsFile, FluentlyGetsAndSets, HasAugmentedInstance, Localizable, Publishable, Revisable, Searchable, TracksLastModified, TracksQueriedColumns, TracksQueriedRelations;
Expand Down Expand Up @@ -399,6 +402,12 @@ public function saveQuietly()

public function save()
{
if ($this->hasOriginCycle()) {
throw ValidationException::withMessages([
'origin' => __('Origin sites cannot reference each other in a loop.'),
]);
}

$isNew = is_null(Facades\Entry::find($this->id()));

$withEvents = $this->withEvents;
Expand Down Expand Up @@ -848,10 +857,21 @@ public function in($locale)
public function ancestors()
{
$ancestors = collect();
$seen = [];

$origin = $this->origin();

while ($origin) {
$id = $origin->id();

if ($id !== null && isset($seen[$id])) {
break;
}

if ($id !== null) {
$seen[$id] = true;
}

$ancestors->push($origin);
$origin = $origin->origin();
}
Expand Down
1 change: 0 additions & 1 deletion src/Facades/Site.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
* @method static mixed authorized()
* @method static mixed default()
* @method static bool hasMultiple()
* @method static \Illuminate\Support\Collection filterByGroup($handles, ?string $siteHandle)
* @method static mixed get($handle)
* @method static mixed findByUrl($url)
* @method static mixed current()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ public function show(Request $request, $collection, $entry, $revision)
'exists' => $exists,
'root' => $exists ? $localized->isRoot() : false,
'origin' => $exists ? $localized->id() === optional($entry->origin())->id() : null,
'origin_handle' => $exists ? optional($localized->origin())->locale() : null,
'published' => $exists ? $localized->published() : false,
'url' => $exists ? $localized->editUrl() : null,
];
Expand Down
25 changes: 24 additions & 1 deletion src/Http/Controllers/CP/Globals/GlobalsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ public function update(Request $request, $set)
->filter(fn ($site) => $site['enabled'])
->mapWithKeys(fn ($site) => [$site['handle'] => $site['origin']]);

$this->validateAcyclicOrigins($sites);

$set->sites($sites);
}

Expand All @@ -118,6 +120,27 @@ public function update(Request $request, $set)
return response('', 204);
}

private function validateAcyclicOrigins($origins)
{
$origins = collect($origins);

foreach ($origins as $start => $origin) {
$seen = [];
$current = $start;

while ($current) {
if (isset($seen[$current])) {
throw \Illuminate\Validation\ValidationException::withMessages([
'sites' => __('Origin sites cannot reference each other in a loop.'),
]);
}

$seen[$current] = true;
$current = $origins->get($current);
}
}
}

public function create()
{
$this->authorize('create', GlobalSetContract::class);
Expand Down Expand Up @@ -202,7 +225,7 @@ protected function editFormBlueprint($set)

if (Site::multiEnabled()) {
$fields['sites'] = [
'display' => __('Sites'),
'display' => __('Localizations'),
'fields' => [
'sites' => [
'type' => 'global_set_sites',
Expand Down
2 changes: 1 addition & 1 deletion src/Routing/Routable.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public function slug($slug = null)
return null;
}

$lang = method_exists($this, 'site') ? $this->site()->lang() : null;
$lang = method_exists($this, 'site') ? $this->site()?->lang() : null;

return Str::slug($slug, '-', $lang);
})->args(func_get_args());
Expand Down
23 changes: 0 additions & 23 deletions src/Sites/Sites.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,29 +58,6 @@ public function hasMultiple()
return $this->sites->count() > 1;
}

public function filterByGroup($handles, ?string $siteHandle)
{
if (! $siteHandle || ! ($site = $this->get($siteHandle))) {
return collect($handles);
}

$groupKey = $site->groupHandle() ?? $site->group();

if (! $groupKey) {
return collect($handles);
}

return collect($handles)->filter(function ($handle) use ($groupKey) {
$other = $this->get($handle);

if (! $other) {
return false;
}

return ($other->groupHandle() ?? $other->group()) === $groupKey;
});
}

public function get($handle)
{
return $this->sites->get($handle);
Expand Down
39 changes: 39 additions & 0 deletions tests/Data/Entries/EntryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,45 @@ public function values_fall_back_to_the_origin_then_the_collection()
$this->assertEquals('from origin', $entry->value('test'));
}

#[Test]
public function it_rejects_saving_when_origins_form_a_cycle()
{
Collection::make('test')->sites(['en', 'fr'])->save();

$this->setSites([
'en' => ['locale' => 'en_US', 'url' => '/'],
'fr' => ['locale' => 'fr_FR', 'url' => '/fr'],
]);

$english = EntryFactory::id('en-entry')->locale('en')->collection('test')->create();
$french = EntryFactory::id('fr-entry')->locale('fr')->collection('test')->origin($english)->create();

$english->origin($french);

$this->expectException(\Illuminate\Validation\ValidationException::class);

$english->save();
}

#[Test]
public function ancestors_stop_when_origins_form_a_cycle()
{
Collection::make('test')->sites(['en', 'fr'])->save();

$this->setSites([
'en' => ['locale' => 'en_US', 'url' => '/'],
'fr' => ['locale' => 'fr_FR', 'url' => '/fr'],
]);

$english = EntryFactory::id('en-entry')->locale('en')->collection('test')->create();
$french = EntryFactory::id('fr-entry')->locale('fr')->collection('test')->origin($english)->create();

$english->origin($french);

$this->assertEquals(['fr-entry', 'en-entry'], $english->ancestors()->map->id()->all());
$this->assertEquals(['en-entry', 'fr-entry'], $french->ancestors()->map->id()->all());
}

#[Test]
public function it_gets_values_from_origin_and_collection()
{
Expand Down
Loading
Loading