From 713f0097b98c4f242fe4f5fced96d154c718131b Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Tue, 3 Mar 2026 12:16:55 +0000 Subject: [PATCH 1/7] wip --- resources/js/cp.js | 2 + resources/js/pages/section-defaults/Edit.vue | 155 ++++++++++ src/Events/SectionDefaultsSaved.php | 11 + src/GetsSectionDefaults.php | 78 ++++-- .../CP/BaseSectionDefaultsController.php | 120 ++++++-- src/Reporting/Chunk.php | 4 + .../LocalizedSectionDefaults.php | 146 ++++++++++ src/SectionDefaults/SectionDefaults.php | 152 ++++++++++ src/Sitemap/Sitemap.php | 9 +- src/Subscriber.php | 9 +- src/Tags/SeoProTags.php | 11 + .../MigrateSectionDefaultsToAddonSettings.php | 56 ++++ ...rateSectionDefaultsToAddonSettingsTest.php | 83 ++++++ .../SectionDefaultsControllerTest.php | 218 ++++++++++++++ tests/Localized/SectionDefaultsTest.php | 230 +++++++++++++++ tests/SectionDefaultsControllerTest.php | 258 +++++++++++++++++ tests/SectionDefaultsTest.php | 265 ++++++++++++++++++ tests/TestCase.php | 10 +- ...rateSectionDefaultsToAddonSettingsTest.php | 225 +++++++++++++++ 19 files changed, 1986 insertions(+), 56 deletions(-) create mode 100644 resources/js/pages/section-defaults/Edit.vue create mode 100644 src/Events/SectionDefaultsSaved.php create mode 100644 src/SectionDefaults/LocalizedSectionDefaults.php create mode 100644 src/SectionDefaults/SectionDefaults.php create mode 100644 src/UpdateScripts/MigrateSectionDefaultsToAddonSettings.php create mode 100644 tests/Localized/MigrateSectionDefaultsToAddonSettingsTest.php create mode 100644 tests/Localized/SectionDefaultsControllerTest.php create mode 100644 tests/Localized/SectionDefaultsTest.php create mode 100644 tests/SectionDefaultsControllerTest.php create mode 100644 tests/SectionDefaultsTest.php create mode 100644 tests/UpdateScripts/MigrateSectionDefaultsToAddonSettingsTest.php diff --git a/resources/js/cp.js b/resources/js/cp.js index 6af63af6..b6990dd2 100644 --- a/resources/js/cp.js +++ b/resources/js/cp.js @@ -3,6 +3,7 @@ import ReportsIndex from './pages/reports/Index.vue'; import ReportsEmpty from './pages/reports/Empty.vue'; import ReportsShow from './pages/reports/Show.vue'; import SectionDefaultsIndex from './pages/section-defaults/Index.vue'; +import SectionDefaultsEdit from './pages/section-defaults/Edit.vue'; import SiteDefaultsEdit from './pages/site-defaults/Edit.vue'; import SeoProFieldtype from './components/fieldtypes/SeoProFieldtype.vue'; import PreviewsFieldtype from "./components/fieldtypes/PreviewsFieldtype.vue"; @@ -15,6 +16,7 @@ Statamic.booting(() => { Statamic.$inertia.register('seo-pro::Reports/Empty', ReportsEmpty); Statamic.$inertia.register('seo-pro::Reports/Show', ReportsShow); Statamic.$inertia.register('seo-pro::SectionDefaults/Index', SectionDefaultsIndex); + Statamic.$inertia.register('seo-pro::SectionDefaults/Edit', SectionDefaultsEdit); Statamic.$inertia.register('seo-pro::SiteDefaults/Edit', SiteDefaultsEdit); Statamic.$components.register('seo_pro-fieldtype', SeoProFieldtype); diff --git a/resources/js/pages/section-defaults/Edit.vue b/resources/js/pages/section-defaults/Edit.vue new file mode 100644 index 00000000..8fa16e6e --- /dev/null +++ b/resources/js/pages/section-defaults/Edit.vue @@ -0,0 +1,155 @@ + + + diff --git a/src/Events/SectionDefaultsSaved.php b/src/Events/SectionDefaultsSaved.php new file mode 100644 index 00000000..0d1c1cf3 --- /dev/null +++ b/src/Events/SectionDefaultsSaved.php @@ -0,0 +1,11 @@ +getCacheKey($parent), function () use ($parent) { - return $parent->cascade('seo'); - }); + $type = $this->getSectionType($parent); + $handle = $parent->handle(); + $locale = method_exists($current, 'locale') ? $current->locale() : null; + + if (! $locale) { + return []; + } + + $localized = SectionDefaults::in($type, $handle, $locale); + + if (! $localized || ! $localized->isEnabled()) { + return false; + } + + return Blink::once($this->getCacheKey($type, $handle, $locale), fn () => $localized->values()->all()); } public function getAugmentedSectionDefaults($current) @@ -27,26 +40,46 @@ public function getAugmentedSectionDefaults($current) return []; } - return Blink::once($this->getCacheKey($parent).'.augmented', function () use ($parent) { - return Blueprint::make() - ->setContents([ - 'tabs' => [ - 'main' => [ - 'sections' => Fields::new()->getConfig(), - ], + $type = $this->getSectionType($parent); + $handle = $parent->handle(); + $locale = method_exists($current, 'locale') ? $current->locale() : null; + + if (! $locale) { + return []; + } + + $localized = SectionDefaults::in($type, $handle, $locale); + + if (! $localized || ! $localized->isEnabled()) { + return []; + } + + $seo = $localized->values()->all(); + + if (empty($seo)) { + return []; + } + + return Blink::once($this->getCacheKey($type, $handle, $locale).'.augmented', fn () => Blueprint::make() + ->setContents([ + 'tabs' => [ + 'main' => [ + 'sections' => Fields::new()->getConfig(), ], - ]) - ->fields() - ->addValues($seo = $parent->cascade('seo') ?: []) - ->augment() - ->values() - ->only(array_keys($seo)); - }); + ], + ]) + ->fields() + ->addValues($seo) + ->augment() + ->values() + ->only(array_keys($seo)) + ->all() + ); } - protected function getCacheKey($parent) + protected function getCacheKey(string $type, string $handle, string $locale): string { - return 'seo-pro.section-defaults.'.get_class($parent).'::'.$parent->handle(); + return "seo-pro.section-defaults.{$type}::{$handle}::{$locale}"; } protected function getSectionParent($current) @@ -59,4 +92,11 @@ protected function getSectionParent($current) return null; } + + protected function getSectionType($parent): string + { + return $parent instanceof \Statamic\Contracts\Entries\Collection + ? 'collections' + : 'taxonomies'; + } } diff --git a/src/Http/Controllers/CP/BaseSectionDefaultsController.php b/src/Http/Controllers/CP/BaseSectionDefaultsController.php index d229b919..388c082a 100644 --- a/src/Http/Controllers/CP/BaseSectionDefaultsController.php +++ b/src/Http/Controllers/CP/BaseSectionDefaultsController.php @@ -3,12 +3,15 @@ namespace Statamic\SeoPro\Http\Controllers\CP; use Illuminate\Http\Request; +use Inertia\Inertia; use Statamic\Contracts\Entries\Collection; use Statamic\Contracts\Taxonomies\Taxonomy; -use Statamic\CP\PublishForm; use Statamic\Facades\Blueprint; +use Statamic\Facades\Site; use Statamic\Http\Controllers\CP\CpController; use Statamic\SeoPro\Fields; +use Statamic\SeoPro\SectionDefaults\LocalizedSectionDefaults; +use Statamic\SeoPro\SectionDefaults\SectionDefaults; use Statamic\Support\Arr; abstract class BaseSectionDefaultsController extends CpController @@ -17,38 +20,88 @@ abstract class BaseSectionDefaultsController extends CpController abstract protected function getSectionItem($handle); - public function edit($handle) + public function edit(Request $request, $handle) { $this->authorize('edit seo section defaults'); $sectionType = static::$sectionType; + $site = $request->site ?? Site::selected()->handle(); $item = $this->getSectionItem($handle); - $seo = Arr::get($item->fileData(), 'inject.seo', []); + $localized = SectionDefaults::in($sectionType, $handle, $site); - if ($seo === false) { - $seo = ['enabled' => false]; + $blueprint = $this->blueprint(); + + [$values, $meta] = $this->extractFromFields($localized, $blueprint); + + if ($hasOrigin = $localized->hasOrigin()) { + [$originValues, $originMeta] = $this->extractFromFields($localized->origin(), $blueprint); } - return PublishForm::make($this->blueprint()) - ->asConfig() - ->icon('folder') - ->title($item->title().' SEO') - ->values($seo) - ->submittingTo(cp_route("seo-pro.section-defaults.{$sectionType}.update", $item)); + $viewData = [ + 'blueprint' => $blueprint->toPublishArray(), + 'initialReference' => $localized->reference(), + 'initialValues' => $values, + 'initialMeta' => $meta, + 'initialEnabled' => $localized->isEnabled(), + 'initialLocalizations' => SectionDefaults::get($sectionType, $handle)->map(function (LocalizedSectionDefaults $loc) use ($localized, $sectionType, $handle): array { + return [ + 'handle' => $loc->locale(), + 'name' => $loc->site()->name(), + 'active' => $loc->locale() === $localized->locale(), + 'origin' => ! $loc->hasOrigin(), + 'url' => cp_route("seo-pro.section-defaults.{$sectionType}.edit", array_filter([ + $handle, + 'site' => Site::multiEnabled() ? $loc->locale() : null, + ])), + ]; + })->values()->all(), + 'initialLocalizedFields' => $localized->defaults()->keys()->all(), + 'initialHasOrigin' => $hasOrigin, + 'initialOriginValues' => $originValues ?? null, + 'initialOriginMeta' => $originMeta ?? null, + 'initialSite' => $site, + 'action' => cp_route("seo-pro.section-defaults.{$sectionType}.update", $handle), + 'title' => $item->title().' SEO', + ]; + + if ($request->wantsJson()) { + return $viewData; + } + + return Inertia::render('seo-pro::SectionDefaults/Edit', $viewData); } public function update($handle, Request $request) { $this->authorize('edit seo section defaults'); - $values = PublishForm::make($this->blueprint())->submit($request->all()); + $sectionType = static::$sectionType; + $site = $request->site ?? Site::selected()->handle(); + + $localized = SectionDefaults::in($sectionType, $handle, $site); + $blueprint = $this->blueprint(); + + $fields = $blueprint->fields()->addValues($request->all()); + $fields->validate(); + $values = collect($fields->process()->values()); - $this->saveSectionItem( - item: $this->getSectionItem($handle), - values: Arr::removeNullValues($values) + if ($localized->hasOrigin()) { + $values = $values->only($request->input('_localized')); + } + + $this->saveSectionDefaults( + type: $sectionType, + handle: $handle, + site: $site, + values: Arr::removeNullValues($values->all()), + localized: $localized, ); + + if ($values->get('enabled') === false) { + $this->removeChildSeo($this->getSectionItem($handle)); + } } protected function blueprint() @@ -62,25 +115,40 @@ protected function blueprint() ]); } - protected function saveSectionItem($item, $values) + protected function saveSectionDefaults(string $type, string $handle, string $site, array $values, LocalizedSectionDefaults $localized): void { $values = collect($values); - $cascade = $item->cascade(); + if ($values->get('enabled') === false) { + SectionDefaults::disable($type, $handle); - if ($disabled = $values->get('enabled') === false) { - $cascade->put('seo', false); - } elseif ($values->except('enabled')->isEmpty()) { - $cascade->forget('seo'); - } else { - $cascade->put('seo', $values->except('enabled')->all()); + return; } - $item->cascade($cascade->all())->save(); + $localized->set($values->except('enabled')->all()); + $localized->save(); + } + + private function extractFromFields(LocalizedSectionDefaults $defaults, \Statamic\Fields\Blueprint $blueprint): array + { + $values = collect(); + $target = $defaults; + + while ($target) { + $values = $target->defaults()->merge($values); + $target = $target->origin(); + } - if ($disabled) { - $this->removeChildSeo($item); + if (! $defaults->isEnabled()) { + $values->put('enabled', false); } + + $fields = $blueprint + ->fields() + ->addValues($values->all()) + ->preProcess(); + + return [$fields->values()->all(), $fields->meta()->all()]; } protected function removeChildSeo($item) diff --git a/src/Reporting/Chunk.php b/src/Reporting/Chunk.php index 4d795858..a1fb7050 100644 --- a/src/Reporting/Chunk.php +++ b/src/Reporting/Chunk.php @@ -79,6 +79,10 @@ protected function createPage($content) return; } + if ($this->getSectionDefaults($content) === false) { + return; + } + $data = (new Cascade) ->withSiteDefaults(SiteDefaults::in($content->locale())->augmented()) ->withSectionDefaults($this->getAugmentedSectionDefaults($content)) diff --git a/src/SectionDefaults/LocalizedSectionDefaults.php b/src/SectionDefaults/LocalizedSectionDefaults.php new file mode 100644 index 00000000..b8aa1e2c --- /dev/null +++ b/src/SectionDefaults/LocalizedSectionDefaults.php @@ -0,0 +1,146 @@ +type}::{$this->handle}::{$this->locale}"; + } + + public function reference(): string + { + return $this->id(); + } + + public function type(): string + { + return $this->type; + } + + public function handle(): string + { + return $this->handle; + } + + public function locale(): string + { + return $this->locale; + } + + public function site(): \Statamic\Sites\Site + { + return Site::get($this->locale()); + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + public function defaults(): Collection + { + return $this->defaults; + } + + public function all(): array + { + return $this->defaults->all(); + } + + public function set(string|array $key, mixed $value = null): self + { + if (is_array($key)) { + $this->defaults = collect($key); + + return $this; + } + + $this->defaults->put($key, $value); + + return $this; + } + + public function save(): void + { + SectionDefaults::save($this); + + SectionDefaultsSaved::dispatch($this); + } + + public function origin(): ?LocalizedSectionDefaults + { + $origin = SectionDefaults::origins()->get($this->locale); + + if (! $origin) { + return null; + } + + return SectionDefaults::in($this->type, $this->handle, $origin); + } + + public function getOriginByString($origin): ?LocalizedSectionDefaults + { + return SectionDefaults::in($this->type, $this->handle, $origin); + } + + public function __get(string $name) + { + // The HasOrigin trait accesses the "data" property, but we call that "defaults". + if ($name === 'data') { + return $this->defaults; + } + } + + public function augmented(): array + { + $blueprint = \Statamic\Facades\Blueprint::make() + ->setContents(['tabs' => ['main' => ['sections' => Fields::new()->getConfig()]]]); + + return $blueprint + ->fields() + ->addValues($this->values()->all()) + ->augment() + ->values() + ->only($this->keys()->all()) + ->all(); + } + + public function editUrl(): string + { + return $this->cpUrl('update'); + } + + public function updateUrl(): string + { + return $this->cpUrl('update'); + } + + private function cpUrl(string $action): string + { + $params = [$this->handle]; + + if (Site::multiEnabled()) { + $params['site'] = $this->locale(); + } + + return cp_route("seo-pro.section-defaults.{$this->type}.{$action}", $params); + } +} diff --git a/src/SectionDefaults/SectionDefaults.php b/src/SectionDefaults/SectionDefaults.php new file mode 100644 index 00000000..fc2ab61a --- /dev/null +++ b/src/SectionDefaults/SectionDefaults.php @@ -0,0 +1,152 @@ +map(fn ($site) => new LocalizedSectionDefaults( + type: $type, + handle: $handle, + locale: $site->handle(), + defaults: collect(), + enabled: false, + )); + } + + return Site::all()->map(function ($site) use ($type, $handle, $raw) { + $values = Site::multiEnabled() + ? Arr::get($raw, "sites.{$site->handle()}", []) + : ($raw ?? []); + + return new LocalizedSectionDefaults( + type: $type, + handle: $handle, + locale: $site->handle(), + defaults: collect($values), + enabled: true, + ); + }); + }); + } + + /** + * Get LocalizedSectionDefaults for a specific locale. + */ + public static function in(string $type, string $handle, string $locale): ?LocalizedSectionDefaults + { + return self::get($type, $handle)->get($locale); + } + + /** + * Check if a section is globally enabled. + */ + public static function isEnabled(string $type, string $handle): bool + { + return self::rawForHandle($type, $handle) !== false; + } + + /** + * Disable a section globally (for all sites). + */ + public static function disable(string $type, string $handle): void + { + $data = Addon::get('statamic/seo-pro')->settings()->get('section_defaults', []); + + Arr::set($data, "{$type}.{$handle}", false); + + Addon::get('statamic/seo-pro')->settings()->set('section_defaults', $data)->save(); + + self::clearCache($type, $handle); + } + + /** + * Save a LocalizedSectionDefaults instance to addon settings. + */ + public static function save(LocalizedSectionDefaults $localized): void + { + $data = Addon::get('statamic/seo-pro')->settings()->get('section_defaults', []); + $type = $localized->type(); + $handle = $localized->handle(); + $locale = $localized->locale(); + $values = $localized->all(); + + if (Site::multiEnabled()) { + // Clear any top-level false (disabled) value before saving site data. + if (Arr::get($data, "{$type}.{$handle}") === false) { + Arr::forget($data, "{$type}.{$handle}"); + } + + if (empty($values)) { + Arr::forget($data, "{$type}.{$handle}.sites.{$locale}"); + + // If no sites have values left, remove the entire handle entry. + $remainingSites = Arr::get($data, "{$type}.{$handle}.sites", []); + if (empty($remainingSites)) { + Arr::forget($data, "{$type}.{$handle}"); + } + } else { + Arr::set($data, "{$type}.{$handle}.sites.{$locale}", $values); + } + } else { + if (empty($values)) { + Arr::forget($data, "{$type}.{$handle}"); + } else { + Arr::set($data, "{$type}.{$handle}", $values); + } + } + + Addon::get('statamic/seo-pro')->settings()->set('section_defaults', $data)->save(); + + self::clearCache($type, $handle); + } + + /** + * Get the origin map, reusing site_defaults_sites from addon settings. + */ + public static function origins(): Collection + { + return Site::all() + ->mapWithKeys(fn ($site) => [$site->handle() => null]) + ->merge(Addon::get('statamic/seo-pro')->settings()->get('site_defaults_sites', [])) + ->map(fn ($origin) => empty($origin) ? null : $origin); + } + + private static function rawForHandle(string $type, string $handle): array|false + { + // Use settings()->raw() rather than settings()->get() because Statamic's Antlers + // resolver converts PHP false to an empty string, losing the disabled state. + $data = Arr::get(Addon::get('statamic/seo-pro')->settings()->raw(), 'section_defaults', []); + $raw = Arr::get($data, "{$type}.{$handle}"); + + if ($raw === false) { + return false; + } + + return is_array($raw) ? $raw : []; + } + + private static function cacheKey(string $type, string $handle): string + { + return "seo-pro::section-defaults::{$type}::{$handle}"; + } + + public static function clearCache(string $type, string $handle): void + { + Blink::forget(self::cacheKey($type, $handle)); + } +} diff --git a/src/Sitemap/Sitemap.php b/src/Sitemap/Sitemap.php index c7795526..47e05ded 100644 --- a/src/Sitemap/Sitemap.php +++ b/src/Sitemap/Sitemap.php @@ -15,6 +15,7 @@ use Statamic\Facades\Taxonomy; use Statamic\SeoPro\Cascade; use Statamic\SeoPro\GetsSectionDefaults; +use Statamic\SeoPro\SectionDefaults\SectionDefaults; use Statamic\SeoPro\SiteDefaults\SiteDefaults; use Statamic\Sites\Site; use Statamic\Support\Traits\Hookable; @@ -137,7 +138,7 @@ protected function publishedEntriesQuery() { $collections = Collection::all() ->map(function ($collection) { - return $collection->cascade('seo') !== false + return SectionDefaults::isEnabled('collections', $collection->handle()) ? $collection->handle() : false; }) @@ -181,7 +182,7 @@ protected function publishedTerms() { return Taxonomy::all() ->flatMap(function ($taxonomy) { - return $taxonomy->cascade('seo') !== false + return SectionDefaults::isEnabled('taxonomies', $taxonomy->handle()) ? $taxonomy ->queryTerms() ->when($this->sites()->isNotEmpty(), fn (Builder $query) => $query->whereIn('site', $this->sites()->map->handle()->all())) @@ -199,12 +200,12 @@ protected function publishedCollectionTerms() { return Collection::all() ->flatMap(function ($collection) { - return $collection->cascade('seo') !== false + return SectionDefaults::isEnabled('collections', $collection->handle()) ? $collection->taxonomies()->map->collection($collection) : collect(); }) ->flatMap(function ($taxonomy) { - return $taxonomy->cascade('seo') !== false + return SectionDefaults::isEnabled('taxonomies', $taxonomy->handle()) ? $taxonomy ->queryTerms() ->when($this->sites()->isNotEmpty(), fn (Builder $query) => $query->whereIn('site', $this->sites()->map->handle()->all())) diff --git a/src/Subscriber.php b/src/Subscriber.php index bbf1d787..78172b35 100644 --- a/src/Subscriber.php +++ b/src/Subscriber.php @@ -4,8 +4,7 @@ use Illuminate\Support\Facades\Cache; use Statamic\Events; -use Statamic\Facades\Collection; -use Statamic\Facades\Taxonomy; +use Statamic\SeoPro\SectionDefaults\SectionDefaults; use Statamic\SeoPro\Sitemap\Sitemap; use Statamic\Support\Str; @@ -68,13 +67,11 @@ protected function seoIsEnabledForSection($event) $namespace = $event->blueprint->namespace(); if (Str::startsWith($namespace, 'collections.')) { - $section = Collection::findByHandle(Str::after($namespace, 'collections.')); + return SectionDefaults::isEnabled('collections', Str::after($namespace, 'collections.')); } elseif (Str::startsWith($namespace, 'taxonomies.')) { - $section = Taxonomy::findByHandle(Str::after($namespace, 'taxonomies.')); + return SectionDefaults::isEnabled('taxonomies', Str::after($namespace, 'taxonomies.')); } else { throw new \Exception('Unknown section type.'); } - - return $section->cascade('seo') !== false; } } diff --git a/src/Tags/SeoProTags.php b/src/Tags/SeoProTags.php index 06dd8fe4..e5b558d6 100755 --- a/src/Tags/SeoProTags.php +++ b/src/Tags/SeoProTags.php @@ -2,6 +2,7 @@ namespace Statamic\SeoPro\Tags; +use Statamic\Facades\Entry; use Statamic\Facades\Image; use Statamic\Facades\Site; use Statamic\SeoPro\Cascade; @@ -28,6 +29,16 @@ public function meta() return; } + // When the seo field is present in the blueprint, augmentable() returns the entry/term. + // When the section is disabled, the seo field is removed from the blueprint, so we + // fall back to looking up the entry by ID from the context. + $current = optional($this->context->get('seo'))->augmentable() + ?? Entry::find($this->context->raw('id')); + + if ($current && $this->getSectionDefaults($current) === false) { + return; + } + return $this->renderMetaHtml($this->metaData(), true); } diff --git a/src/UpdateScripts/MigrateSectionDefaultsToAddonSettings.php b/src/UpdateScripts/MigrateSectionDefaultsToAddonSettings.php new file mode 100644 index 00000000..2a87efa4 --- /dev/null +++ b/src/UpdateScripts/MigrateSectionDefaultsToAddonSettings.php @@ -0,0 +1,56 @@ +isUpdatingTo('7.2.0'); + } + + public function update() + { + $sectionDefaults = []; + + foreach (['collections' => Collection::all(), 'taxonomies' => Taxonomy::all()] as $type => $items) { + foreach ($items as $item) { + $injectSeo = Arr::get($item->fileData(), 'inject.seo'); + + if ($injectSeo === null) { + continue; + } + + $handle = $item->handle(); + + if ($injectSeo === false) { + $sectionDefaults[$type][$handle] = false; + } elseif (is_array($injectSeo) && ! empty($injectSeo)) { + if (Site::multiEnabled()) { + $sectionDefaults[$type][$handle]['sites'][Site::default()->handle()] = $injectSeo; + } else { + $sectionDefaults[$type][$handle] = $injectSeo; + } + } + + // Remove inject.seo from the collection/taxonomy YAML. + $cascade = $item->cascade(); + $cascade->forget('seo'); + $item->cascade($cascade->all())->save(); + } + } + + if (! empty($sectionDefaults)) { + $settings = Addon::get('statamic/seo-pro')->settings(); + $existing = $settings->get('section_defaults', []); + $settings->set('section_defaults', array_merge_recursive($existing, $sectionDefaults))->save(); + } + } +} diff --git a/tests/Localized/MigrateSectionDefaultsToAddonSettingsTest.php b/tests/Localized/MigrateSectionDefaultsToAddonSettingsTest.php new file mode 100644 index 00000000..f900d1af --- /dev/null +++ b/tests/Localized/MigrateSectionDefaultsToAddonSettingsTest.php @@ -0,0 +1,83 @@ +cascade(['seo' => ['title' => '@seo:title']]) + ->save(); + + $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + $defaultHandle = Site::default()->handle(); + + $this->assertEquals( + ['title' => '@seo:title'], + $raw['section_defaults']['collections']['pages']['sites'][$defaultHandle] + ); + } + + #[Test] + public function it_migrates_disabled_collection_as_false_in_multisite() + { + Collection::find('pages') + ->cascade(['seo' => false]) + ->save(); + + $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + // Disabled collections are always stored as false at the top level, not under sites. + $this->assertFalse($raw['section_defaults']['collections']['pages']); + } + + #[Test] + public function it_removes_inject_seo_from_collection_yaml_in_multisite() + { + Collection::find('pages') + ->cascade(['seo' => ['title' => '@seo:title']]) + ->save(); + + $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); + + $collection = Collection::find('pages'); + $this->assertArrayNotHasKey('seo', $collection->cascade()->all()); + } + + #[Test] + public function it_assigns_seo_data_to_default_site_only_in_multisite() + { + Collection::find('pages') + ->cascade(['seo' => ['title' => 'English defaults']]) + ->save(); + + $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + $sites = $raw['section_defaults']['collections']['pages']['sites']; + + // Only the default site should have values after migration. + $this->assertCount(1, $sites); + $this->assertArrayHasKey(Site::default()->handle(), $sites); + $this->assertArrayNotHasKey('french', $sites); + $this->assertArrayNotHasKey('italian', $sites); + $this->assertArrayNotHasKey('british', $sites); + } +} diff --git a/tests/Localized/SectionDefaultsControllerTest.php b/tests/Localized/SectionDefaultsControllerTest.php new file mode 100644 index 00000000..0af41496 --- /dev/null +++ b/tests/Localized/SectionDefaultsControllerTest.php @@ -0,0 +1,218 @@ +settings()->set('site_defaults_sites', [ + 'default' => null, + 'french' => 'default', + 'italian' => 'default', + 'british' => 'default', + ])->save(); + } + + // ------------------------------------------------------------------------- + // Edit (GET) — multi-site + // ------------------------------------------------------------------------- + + #[Test] + public function edit_defaults_to_selected_site() + { + $response = $this + ->actingAs(User::make()->makeSuper()->save()) + ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit'); + + $response->assertOk(); + $this->assertEquals('default', $response->json('initialSite')); + } + + #[Test] + public function can_edit_section_defaults_for_a_specific_site() + { + SectionDefaults::in('collections', 'pages', 'french')->set(['title' => 'French title'])->save(); + + $response = $this + ->actingAs(User::make()->makeSuper()->save()) + ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit?site=french'); + + $response->assertOk(); + $this->assertEquals('french', $response->json('initialSite')); + // seo_pro_source preProcess transforms 'French title' to {source: 'custom', value: 'French title'}. + $this->assertEquals(['source' => 'custom', 'value' => 'French title'], $response->json('initialValues.title')); + } + + #[Test] + public function edit_returns_all_sites_in_localizations() + { + $response = $this + ->actingAs(User::make()->makeSuper()->save()) + ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit'); + + $localizations = $response->json('initialLocalizations'); + + $this->assertCount(4, $localizations); + $handles = collect($localizations)->pluck('handle')->all(); + $this->assertContains('default', $handles); + $this->assertContains('french', $handles); + $this->assertContains('italian', $handles); + $this->assertContains('british', $handles); + } + + #[Test] + public function edit_marks_requested_site_as_active() + { + $response = $this + ->actingAs(User::make()->makeSuper()->save()) + ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit?site=french'); + + $localizations = collect($response->json('initialLocalizations')); + + $active = $localizations->firstWhere('active', true); + $this->assertEquals('french', $active['handle']); + } + + #[Test] + public function edit_indicates_has_origin_for_child_site() + { + $response = $this + ->actingAs(User::make()->makeSuper()->save()) + ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit?site=french'); + + $response->assertOk(); + $this->assertTrue($response->json('initialHasOrigin')); + $this->assertNotNull($response->json('initialOriginValues')); + } + + #[Test] + public function edit_indicates_no_origin_for_root_site() + { + $response = $this + ->actingAs(User::make()->makeSuper()->save()) + ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit?site=default'); + + $response->assertOk(); + $this->assertFalse($response->json('initialHasOrigin')); + $this->assertNull($response->json('initialOriginValues')); + } + + #[Test] + public function edit_origin_values_come_from_parent_site() + { + SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'Origin title'])->save(); + + $response = $this + ->actingAs(User::make()->makeSuper()->save()) + ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit?site=french'); + + $response->assertOk(); + // seo_pro_source preProcess transforms 'Origin title' to {source: 'custom', value: 'Origin title'}. + $this->assertEquals(['source' => 'custom', 'value' => 'Origin title'], $response->json('initialOriginValues.title')); + } + + #[Test] + public function edit_shows_section_disabled_for_all_sites_when_disabled() + { + SectionDefaults::disable('collections', 'pages'); + + foreach (['default', 'french', 'italian', 'british'] as $site) { + $response = $this + ->actingAs(User::make()->makeSuper()->save()) + ->getJson("/cp/seo-pro/section-defaults/collections/pages/edit?site={$site}"); + + $response->assertOk(); + $this->assertFalse($response->json('initialEnabled'), "Expected disabled for site {$site}"); + } + } + + // ------------------------------------------------------------------------- + // Update (PATCH) — multi-site + // ------------------------------------------------------------------------- + + #[Test] + public function can_update_section_defaults_for_a_specific_site() + { + $this + ->actingAs(User::make()->makeSuper()->save()) + ->patchJson('/cp/seo-pro/section-defaults/collections/pages?site=french', [ + 'title' => ['source' => 'custom', 'value' => 'French SEO title'], + 'enabled' => true, + ]) + ->assertOk(); + + SectionDefaults::clearCache('collections', 'pages'); + + $french = SectionDefaults::in('collections', 'pages', 'french'); + $this->assertEquals('French SEO title', $french->all()['title']); + } + + #[Test] + public function updating_one_site_does_not_overwrite_another() + { + SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'English title'])->save(); + + $this + ->actingAs(User::make()->makeSuper()->save()) + ->patchJson('/cp/seo-pro/section-defaults/collections/pages?site=french', [ + 'title' => ['source' => 'custom', 'value' => 'French title'], + 'enabled' => true, + ]) + ->assertOk(); + + SectionDefaults::clearCache('collections', 'pages'); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + $this->assertEquals('English title', $raw['section_defaults']['collections']['pages']['sites']['default']['title']); + $this->assertEquals('French title', $raw['section_defaults']['collections']['pages']['sites']['french']['title']); + } + + #[Test] + public function can_disable_collection_via_update_in_multisite() + { + $this + ->actingAs(User::make()->makeSuper()->save()) + ->patchJson('/cp/seo-pro/section-defaults/collections/pages?site=french', [ + 'enabled' => false, + ]) + ->assertOk(); + + SectionDefaults::clearCache('collections', 'pages'); + + // Disabled state is global — all sites should report disabled. + $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages')); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + $this->assertFalse($raw['section_defaults']['collections']['pages']); + } + + #[Test] + public function update_stores_values_under_sites_key_for_multi_site() + { + $this + ->actingAs(User::make()->makeSuper()->save()) + ->patchJson('/cp/seo-pro/section-defaults/collections/pages?site=default', [ + 'title' => ['source' => 'field', 'value' => 'title'], + 'enabled' => true, + ]) + ->assertOk(); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + $this->assertArrayHasKey('sites', $raw['section_defaults']['collections']['pages']); + $this->assertEquals('@seo:title', $raw['section_defaults']['collections']['pages']['sites']['default']['title']); + } +} diff --git a/tests/Localized/SectionDefaultsTest.php b/tests/Localized/SectionDefaultsTest.php new file mode 100644 index 00000000..2e87d4e6 --- /dev/null +++ b/tests/Localized/SectionDefaultsTest.php @@ -0,0 +1,230 @@ +assertCount(4, $all); + $all->each(fn ($loc) => $this->assertInstanceOf(LocalizedSectionDefaults::class, $loc)); + } + + #[Test] + public function it_stores_values_under_sites_key_for_multi_site() + { + SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'En title'])->save(); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + $this->assertEquals( + ['title' => 'En title'], + $raw['section_defaults']['collections']['pages']['sites']['default'] + ); + } + + #[Test] + public function it_can_save_and_read_back_section_defaults_per_site() + { + SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'English'])->save(); + SectionDefaults::in('collections', 'pages', 'french')->set(['title' => 'French'])->save(); + + SectionDefaults::clearCache('collections', 'pages'); + + $this->assertEquals(['title' => 'English'], SectionDefaults::in('collections', 'pages', 'default')->all()); + $this->assertEquals(['title' => 'French'], SectionDefaults::in('collections', 'pages', 'french')->all()); + } + + #[Test] + public function it_disables_section_globally_for_all_sites() + { + SectionDefaults::disable('collections', 'pages'); + + SectionDefaults::clearCache('collections', 'pages'); + + $all = SectionDefaults::get('collections', 'pages'); + + foreach ($all as $localized) { + $this->assertFalse($localized->isEnabled()); + } + + $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages')); + } + + #[Test] + public function it_clears_disabled_state_when_saving_site_values() + { + SectionDefaults::disable('collections', 'pages'); + + // Saving site values should re-enable the section. + SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'Re-enabled'])->save(); + + SectionDefaults::clearCache('collections', 'pages'); + + $this->assertTrue(SectionDefaults::isEnabled('collections', 'pages')); + + // In multi-site mode the values are stored under sites.{locale}. + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + $this->assertEquals( + ['title' => 'Re-enabled'], + $raw['section_defaults']['collections']['pages']['sites']['default'] + ); + } + + #[Test] + public function it_removes_site_entry_when_saving_empty_values() + { + SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'English'])->save(); + SectionDefaults::in('collections', 'pages', 'french')->set(['title' => 'French'])->save(); + + SectionDefaults::clearCache('collections', 'pages'); + + // Clear the french values. + SectionDefaults::in('collections', 'pages', 'french')->set([])->save(); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + $sites = $raw['section_defaults']['collections']['pages']['sites'] ?? []; + + $this->assertArrayNotHasKey('french', $sites); + $this->assertArrayHasKey('default', $sites); + } + + #[Test] + public function it_removes_handle_entry_when_all_sites_have_empty_values() + { + SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'English'])->save(); + + SectionDefaults::clearCache('collections', 'pages'); + + // Clear the only remaining site. + SectionDefaults::in('collections', 'pages', 'default')->set([])->save(); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + $this->assertArrayNotHasKey('pages', $raw['section_defaults']['collections'] ?? []); + } + + #[Test] + public function it_resolves_origin_from_site_defaults_sites_config() + { + Addon::get('statamic/seo-pro')->settings()->set('site_defaults_sites', [ + 'default' => null, + 'french' => 'default', + 'italian' => 'default', + 'british' => 'default', + ])->save(); + + SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'English origin'])->save(); + + SectionDefaults::clearCache('collections', 'pages'); + + $french = SectionDefaults::in('collections', 'pages', 'french'); + + $this->assertTrue($french->hasOrigin()); + + $origin = $french->origin(); + $this->assertNotNull($origin); + $this->assertSame('default', $origin->locale()); + $this->assertEquals(['title' => 'English origin'], $origin->all()); + } + + #[Test] + public function it_has_no_origin_when_site_defaults_sites_config_is_absent() + { + $localized = SectionDefaults::in('collections', 'pages', 'french'); + + $this->assertFalse($localized->hasOrigin()); + $this->assertNull($localized->origin()); + } + + #[Test] + public function it_inherits_values_from_origin_via_has_origin_trait() + { + Addon::get('statamic/seo-pro')->settings()->set('site_defaults_sites', [ + 'default' => null, + 'french' => 'default', + 'italian' => 'default', + 'british' => 'default', + ])->save(); + + SectionDefaults::in('collections', 'pages', 'default') + ->set(['title' => 'Inherited title', 'description' => 'Inherited desc']) + ->save(); + + SectionDefaults::in('collections', 'pages', 'french') + ->set(['title' => 'French title']) + ->save(); + + SectionDefaults::clearCache('collections', 'pages'); + + $french = SectionDefaults::in('collections', 'pages', 'french'); + + // values() merges own + origin through HasOrigin trait. + $values = $french->values(); + + $this->assertEquals('French title', $values->get('title')); + $this->assertEquals('Inherited desc', $values->get('description')); + } + + #[Test] + public function origins_returns_map_from_site_defaults_sites() + { + Addon::get('statamic/seo-pro')->settings()->set('site_defaults_sites', [ + 'default' => null, + 'french' => 'default', + ])->save(); + + $origins = SectionDefaults::origins(); + + $this->assertNull($origins->get('default')); + $this->assertEquals('default', $origins->get('french')); + } + + #[Test] + public function it_preserves_other_sites_when_saving_one_site() + { + SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'English'])->save(); + SectionDefaults::in('collections', 'pages', 'french')->set(['title' => 'French'])->save(); + + // Update only default. + SectionDefaults::clearCache('collections', 'pages'); + SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'English Updated'])->save(); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + $sites = $raw['section_defaults']['collections']['pages']['sites']; + + $this->assertEquals('English Updated', $sites['default']['title']); + $this->assertEquals('French', $sites['french']['title']); + } + + #[Test] + public function it_preserves_disabled_state_across_all_sites() + { + // Guards against the Antlers resolver converting false to "" in multi-site. + SectionDefaults::disable('collections', 'pages'); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + $this->assertFalse($raw['section_defaults']['collections']['pages']); + + SectionDefaults::clearCache('collections', 'pages'); + $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages')); + } +} diff --git a/tests/SectionDefaultsControllerTest.php b/tests/SectionDefaultsControllerTest.php new file mode 100644 index 00000000..b5de9fde --- /dev/null +++ b/tests/SectionDefaultsControllerTest.php @@ -0,0 +1,258 @@ +actingAs(User::make()->makeSuper()->save()) + ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit'); + + $response->assertOk(); + $response->assertJsonStructure([ + 'blueprint', + 'initialValues', + 'initialMeta', + 'initialEnabled', + 'initialLocalizations', + 'initialLocalizedFields', + 'initialHasOrigin', + 'initialSite', + 'action', + 'title', + ]); + } + + #[Test] + public function edit_collection_returns_correct_title() + { + $response = $this + ->actingAs(User::make()->makeSuper()->save()) + ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit'); + + $response->assertOk(); + $this->assertEquals('Pages SEO', $response->json('title')); + } + + #[Test] + public function edit_collection_shows_section_as_enabled_by_default() + { + $response = $this + ->actingAs(User::make()->makeSuper()->save()) + ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit'); + + $response->assertOk(); + $this->assertTrue($response->json('initialEnabled')); + } + + #[Test] + public function edit_collection_shows_section_as_disabled_when_disabled() + { + SectionDefaults::disable('collections', 'pages'); + + $response = $this + ->actingAs(User::make()->makeSuper()->save()) + ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit'); + + $response->assertOk(); + $this->assertFalse($response->json('initialEnabled')); + } + + #[Test] + public function edit_collection_includes_localizations_array() + { + $response = $this + ->actingAs(User::make()->makeSuper()->save()) + ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit'); + + $localizations = $response->json('initialLocalizations'); + $this->assertCount(1, $localizations); + $this->assertEquals('default', $localizations[0]['handle']); + $this->assertTrue($localizations[0]['active']); + } + + #[Test] + public function edit_collection_includes_saved_values() + { + SectionDefaults::in('collections', 'pages', 'default') + ->set(['title' => '@seo:title']) + ->save(); + + $response = $this + ->actingAs(User::make()->makeSuper()->save()) + ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit'); + + $response->assertOk(); + // seo_pro_source fieldtype preProcess transforms '@seo:title' to {source: 'field', value: 'title'}. + $this->assertEquals(['source' => 'field', 'value' => 'title'], $response->json('initialValues.title')); + } + + // ------------------------------------------------------------------------- + // Edit (GET) — taxonomies + // ------------------------------------------------------------------------- + + #[Test] + public function can_edit_taxonomy_section_defaults() + { + $response = $this + ->actingAs(User::make()->makeSuper()->save()) + ->getJson('/cp/seo-pro/section-defaults/taxonomies/topics/edit'); + + $response->assertOk(); + $this->assertEquals('Topics SEO', $response->json('title')); + } + + // ------------------------------------------------------------------------- + // Update (PATCH) — collections + // ------------------------------------------------------------------------- + + #[Test] + public function can_update_collection_section_defaults() + { + // POST the pre-processed format that the Vue frontend sends. + // seo_pro_source fieldtype: {source: 'field', value: 'title'} → stored as '@seo:title' + // {source: 'custom', value: '...'} → stored as the raw string + $this + ->actingAs(User::make()->makeSuper()->save()) + ->patchJson('/cp/seo-pro/section-defaults/collections/pages', [ + 'title' => ['source' => 'field', 'value' => 'title'], + 'description' => ['source' => 'custom', 'value' => 'My page description'], + 'enabled' => true, + ]) + ->assertOk(); + + SectionDefaults::clearCache('collections', 'pages'); + + $localized = SectionDefaults::in('collections', 'pages', 'default'); + $this->assertTrue($localized->isEnabled()); + $this->assertEquals('@seo:title', $localized->all()['title']); + $this->assertEquals('My page description', $localized->all()['description']); + } + + #[Test] + public function can_disable_collection_via_update() + { + $this + ->actingAs(User::make()->makeSuper()->save()) + ->patchJson('/cp/seo-pro/section-defaults/collections/pages', [ + 'enabled' => false, + ]) + ->assertOk(); + + SectionDefaults::clearCache('collections', 'pages'); + + $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages')); + } + + #[Test] + public function disabling_a_collection_via_update_stores_false_in_settings() + { + $this + ->actingAs(User::make()->makeSuper()->save()) + ->patchJson('/cp/seo-pro/section-defaults/collections/pages', [ + 'enabled' => false, + ]) + ->assertOk(); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + $this->assertFalse($raw['section_defaults']['collections']['pages']); + } + + #[Test] + public function can_update_taxonomy_section_defaults() + { + $this + ->actingAs(User::make()->makeSuper()->save()) + ->patchJson('/cp/seo-pro/section-defaults/taxonomies/topics', [ + 'title' => ['source' => 'field', 'value' => 'title'], + 'enabled' => true, + ]) + ->assertOk(); + + SectionDefaults::clearCache('taxonomies', 'topics'); + + $localized = SectionDefaults::in('taxonomies', 'topics', 'default'); + $this->assertEquals('@seo:title', $localized->all()['title']); + } + + #[Test] + public function enabled_field_is_not_stored_in_section_defaults_values() + { + $this + ->actingAs(User::make()->makeSuper()->save()) + ->patchJson('/cp/seo-pro/section-defaults/collections/pages', [ + 'title' => ['source' => 'field', 'value' => 'title'], + 'enabled' => true, + ]) + ->assertOk(); + + SectionDefaults::clearCache('collections', 'pages'); + + $localized = SectionDefaults::in('collections', 'pages', 'default'); + $this->assertArrayNotHasKey('enabled', $localized->all()); + } + + // ------------------------------------------------------------------------- + // Permissions + // ------------------------------------------------------------------------- + + #[Test] + public function cannot_edit_collection_defaults_without_permission() + { + Role::make('editor')->permissions(['access cp'])->save(); + + $this + ->actingAs(User::make()->assignRole('editor')->save()) + ->getJson('/cp/seo-pro/section-defaults/collections/pages/edit') + ->assertForbidden(); + } + + #[Test] + public function cannot_update_collection_defaults_without_permission() + { + Role::make('editor')->permissions(['access cp'])->save(); + + $this + ->actingAs(User::make()->assignRole('editor')->save()) + ->patchJson('/cp/seo-pro/section-defaults/collections/pages', [ + 'title' => '@seo:title', + 'enabled' => true, + ]) + ->assertForbidden(); + } + + #[Test] + public function cannot_edit_taxonomy_defaults_without_permission() + { + Role::make('editor')->permissions(['access cp'])->save(); + + $this + ->actingAs(User::make()->assignRole('editor')->save()) + ->getJson('/cp/seo-pro/section-defaults/taxonomies/topics/edit') + ->assertForbidden(); + } +} diff --git a/tests/SectionDefaultsTest.php b/tests/SectionDefaultsTest.php new file mode 100644 index 00000000..0fe86675 --- /dev/null +++ b/tests/SectionDefaultsTest.php @@ -0,0 +1,265 @@ +assertCount(1, $all); + + $localized = $all->first(); + $this->assertInstanceOf(LocalizedSectionDefaults::class, $localized); + $this->assertSame('default', $localized->locale()); + $this->assertTrue($localized->isEnabled()); + $this->assertEmpty($localized->all()); + } + + #[Test] + public function it_returns_disabled_localized_defaults_when_section_is_disabled() + { + Addon::get('statamic/seo-pro')->settings() + ->set('section_defaults', ['collections' => ['pages' => false]]) + ->save(); + + $localized = SectionDefaults::in('collections', 'pages', 'default'); + + $this->assertFalse($localized->isEnabled()); + } + + #[Test] + public function it_reports_section_as_enabled_when_no_settings_exist() + { + $this->assertTrue(SectionDefaults::isEnabled('collections', 'pages')); + } + + #[Test] + public function it_reports_section_as_disabled_after_calling_disable() + { + SectionDefaults::disable('collections', 'pages'); + + $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages')); + } + + #[Test] + public function it_stores_false_in_addon_settings_when_disabling() + { + SectionDefaults::disable('collections', 'pages'); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + $this->assertFalse($raw['section_defaults']['collections']['pages']); + } + + #[Test] + public function it_can_save_section_defaults() + { + $locale = 'default'; + $localized = SectionDefaults::in('collections', 'pages', $locale); + $localized->set(['title' => '@seo:title', 'description' => 'My page'])->save(); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + $this->assertEquals( + ['title' => '@seo:title', 'description' => 'My page'], + $raw['section_defaults']['collections']['pages'] + ); + } + + #[Test] + public function it_stores_values_flat_for_single_site() + { + $localized = SectionDefaults::in('collections', 'pages', 'default'); + $localized->set(['title' => '@seo:title'])->save(); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + // No 'sites' nesting in single-site mode. + $this->assertArrayNotHasKey('sites', $raw['section_defaults']['collections']['pages']); + $this->assertEquals('@seo:title', $raw['section_defaults']['collections']['pages']['title']); + } + + #[Test] + public function it_can_read_saved_defaults_back_via_in() + { + $localized = SectionDefaults::in('collections', 'pages', 'default'); + $localized->set(['title' => 'Hello'])->save(); + + SectionDefaults::clearCache('collections', 'pages'); + + $fresh = SectionDefaults::in('collections', 'pages', 'default'); + + $this->assertEquals(['title' => 'Hello'], $fresh->all()); + } + + #[Test] + public function it_removes_handle_entry_when_saving_empty_values() + { + // Save some values first. + SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'Hello'])->save(); + + SectionDefaults::clearCache('collections', 'pages'); + + // Now save empty values — the handle entry should be removed. + SectionDefaults::in('collections', 'pages', 'default')->set([])->save(); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + $this->assertArrayNotHasKey('pages', $raw['section_defaults']['collections'] ?? []); + } + + #[Test] + public function it_clears_blink_cache_on_save() + { + $localized = SectionDefaults::in('collections', 'pages', 'default'); + $localized->set(['title' => 'First'])->save(); + + SectionDefaults::clearCache('collections', 'pages'); + + // Warm the cache. + SectionDefaults::get('collections', 'pages'); + + // Save new values. + $localized->set(['title' => 'Second'])->save(); + + // Should not return stale cache. + $fresh = SectionDefaults::in('collections', 'pages', 'default'); + $this->assertEquals('Second', $fresh->all()['title']); + } + + #[Test] + public function it_re_enables_a_disabled_section_by_saving_values() + { + SectionDefaults::disable('collections', 'pages'); + $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages')); + + SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'Hello'])->save(); + + $this->assertTrue(SectionDefaults::isEnabled('collections', 'pages')); + } + + #[Test] + public function it_can_get_section_defaults_for_a_taxonomy() + { + $localized = SectionDefaults::in('taxonomies', 'topics', 'default'); + $localized->set(['title' => 'Topics title'])->save(); + + SectionDefaults::clearCache('taxonomies', 'topics'); + + $fresh = SectionDefaults::in('taxonomies', 'topics', 'default'); + $this->assertEquals(['title' => 'Topics title'], $fresh->all()); + } + + #[Test] + public function it_can_disable_a_taxonomy_section() + { + SectionDefaults::disable('taxonomies', 'topics'); + + $this->assertFalse(SectionDefaults::isEnabled('taxonomies', 'topics')); + } + + #[Test] + public function it_returns_null_origin_in_single_site_with_no_origins_configured() + { + $localized = SectionDefaults::in('collections', 'pages', 'default'); + + $this->assertNull($localized->origin()); + $this->assertFalse($localized->hasOrigin()); + } + + #[Test] + public function it_returns_values_via_set_method() + { + $localized = SectionDefaults::in('collections', 'pages', 'default'); + + $localized->set('title', 'My Title'); + $localized->set('description', 'My description'); + + $this->assertEquals('My Title', $localized->all()['title']); + $this->assertEquals('My description', $localized->all()['description']); + } + + #[Test] + public function set_with_array_replaces_all_values() + { + $localized = SectionDefaults::in('collections', 'pages', 'default'); + + $localized->set(['title' => 'First', 'description' => 'Desc']); + $localized->set(['title' => 'Replaced']); + + $this->assertEquals(['title' => 'Replaced'], $localized->all()); + } + + #[Test] + public function it_preserves_other_section_defaults_when_saving() + { + SectionDefaults::in('collections', 'articles', 'default')->set(['title' => 'Articles'])->save(); + SectionDefaults::clearCache('collections', 'articles'); + + SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'Pages'])->save(); + SectionDefaults::clearCache('collections', 'pages'); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + $this->assertEquals('Articles', $raw['section_defaults']['collections']['articles']['title']); + $this->assertEquals('Pages', $raw['section_defaults']['collections']['pages']['title']); + } + + #[Test] + public function it_preserves_disabled_state_when_reading_raw_settings() + { + // This test guards against the Antlers resolver converting false to "". + SectionDefaults::disable('collections', 'pages'); + + // Raw settings should have false, not "" or null. + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + $this->assertFalse($raw['section_defaults']['collections']['pages']); + + // isEnabled() must return false. + SectionDefaults::clearCache('collections', 'pages'); + $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages')); + } + + #[Test] + public function it_returns_collection_of_localized_defaults_for_each_site() + { + $all = SectionDefaults::get('collections', 'pages'); + + // Single-site fixture has one site. + $this->assertCount(1, $all); + $this->assertInstanceOf(LocalizedSectionDefaults::class, $all->first()); + } + + #[Test] + public function localized_defaults_is_enabled_returns_false_when_whole_section_is_disabled() + { + SectionDefaults::disable('collections', 'pages'); + SectionDefaults::clearCache('collections', 'pages'); + + $all = SectionDefaults::get('collections', 'pages'); + + foreach ($all as $localized) { + $this->assertFalse($localized->isEnabled()); + } + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index b17ee99a..baaff6dc 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,6 +8,7 @@ use Statamic\Facades\Site; use Statamic\Facades\URL; use Statamic\Facades\YAML; +use Statamic\SeoPro\SectionDefaults\SectionDefaults; use Statamic\SeoPro\SiteDefaults\SiteDefaults; use Statamic\Testing\AddonTestCase; @@ -101,7 +102,14 @@ protected function setSeoInSiteDefaults($seo) protected function setSeoOnCollection($collection, $seo) { - $collection->cascade(['seo' => $seo])->save(); + $locale = \Statamic\Facades\Site::default()->handle(); + $localized = SectionDefaults::in('collections', $collection->handle(), $locale); + + if ($seo === false) { + SectionDefaults::disable('collections', $collection->handle()); + } else { + $localized->set($seo)->save(); + } return $this; } diff --git a/tests/UpdateScripts/MigrateSectionDefaultsToAddonSettingsTest.php b/tests/UpdateScripts/MigrateSectionDefaultsToAddonSettingsTest.php new file mode 100644 index 00000000..9500dc2b --- /dev/null +++ b/tests/UpdateScripts/MigrateSectionDefaultsToAddonSettingsTest.php @@ -0,0 +1,225 @@ +cascade(['seo' => ['title' => '@seo:title', 'description' => 'My desc']]) + ->save(); + + $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + $this->assertEquals( + ['title' => '@seo:title', 'description' => 'My desc'], + $raw['section_defaults']['collections']['pages'] + ); + } + + #[Test] + public function it_migrates_disabled_collection() + { + Collection::find('pages') + ->cascade(['seo' => false]) + ->save(); + + $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + $this->assertFalse($raw['section_defaults']['collections']['pages']); + } + + #[Test] + public function it_skips_collections_without_inject_seo() + { + // The pages collection has no inject.seo in the fixture — run script and verify nothing added. + $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + $this->assertEmpty($raw['section_defaults']['collections']['pages'] ?? []); + } + + #[Test] + public function it_removes_inject_seo_from_collection_yaml_after_migration() + { + Collection::find('pages') + ->cascade(['seo' => ['title' => '@seo:title']]) + ->save(); + + $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); + + // Reload collection from disk and check cascade no longer has 'seo'. + $collection = Collection::find('pages'); + $this->assertNull($collection->cascade('seo')); + $this->assertArrayNotHasKey('seo', $collection->cascade()->all()); + } + + #[Test] + public function it_removes_inject_seo_from_disabled_collection_yaml_after_migration() + { + Collection::find('pages') + ->cascade(['seo' => false]) + ->save(); + + $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); + + $collection = Collection::find('pages'); + $this->assertArrayNotHasKey('seo', $collection->cascade()->all()); + } + + #[Test] + public function it_migrates_multiple_collections() + { + Collection::find('pages') + ->cascade(['seo' => ['title' => 'Pages title']]) + ->save(); + + Collection::find('articles') + ->cascade(['seo' => false]) + ->save(); + + $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + $this->assertEquals( + ['title' => 'Pages title'], + $raw['section_defaults']['collections']['pages'] + ); + + $this->assertFalse($raw['section_defaults']['collections']['articles']); + } + + #[Test] + public function it_skips_empty_inject_seo_array() + { + // An empty inject.seo array should not be migrated. + Collection::find('pages') + ->cascade(['seo' => []]) + ->save(); + + $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + $this->assertEmpty($raw['section_defaults']['collections']['pages'] ?? []); + } + + // ------------------------------------------------------------------------- + // Taxonomies + // ------------------------------------------------------------------------- + + #[Test] + public function it_migrates_taxonomy_with_seo_defaults() + { + Taxonomy::find('topics') + ->cascade(['seo' => ['title' => 'Topics title']]) + ->save(); + + $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + $this->assertEquals( + ['title' => 'Topics title'], + $raw['section_defaults']['taxonomies']['topics'] + ); + } + + #[Test] + public function it_migrates_disabled_taxonomy() + { + Taxonomy::find('topics') + ->cascade(['seo' => false]) + ->save(); + + $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + $this->assertFalse($raw['section_defaults']['taxonomies']['topics']); + } + + #[Test] + public function it_removes_inject_seo_from_taxonomy_yaml_after_migration() + { + Taxonomy::find('topics') + ->cascade(['seo' => ['title' => 'Topics title']]) + ->save(); + + $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); + + $taxonomy = Taxonomy::find('topics'); + $this->assertArrayNotHasKey('seo', $taxonomy->cascade()->all()); + } + + #[Test] + public function it_migrates_both_collections_and_taxonomies() + { + Collection::find('pages') + ->cascade(['seo' => ['title' => 'Pages title']]) + ->save(); + + Taxonomy::find('topics') + ->cascade(['seo' => false]) + ->save(); + + $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + $this->assertEquals( + ['title' => 'Pages title'], + $raw['section_defaults']['collections']['pages'] + ); + + $this->assertFalse($raw['section_defaults']['taxonomies']['topics']); + } + + #[Test] + public function it_merges_with_existing_addon_settings() + { + // Pre-existing addon settings (e.g. site defaults already migrated). + Addon::get('statamic/seo-pro')->settings() + ->set('site_defaults', ['title' => 'My Site']) + ->save(); + + Collection::find('pages') + ->cascade(['seo' => ['title' => '@seo:title']]) + ->save(); + + $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); + + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + + // Site defaults must be preserved. + $this->assertEquals('My Site', $raw['site_defaults']['title']); + + // Section defaults must be added. + $this->assertEquals( + ['title' => '@seo:title'], + $raw['section_defaults']['collections']['pages'] + ); + } +} From e6a454b174c08c2eec955e9ed586e9413767ff30 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Tue, 3 Mar 2026 15:21:38 +0000 Subject: [PATCH 2/7] fallback to inject seo when present, remove on save, avoid migration script --- src/SectionDefaults/SectionDefaults.php | 80 ++++++- .../MigrateSectionDefaultsToAddonSettings.php | 56 ----- ...rateSectionDefaultsToAddonSettingsTest.php | 83 ------- tests/Localized/SectionDefaultsTest.php | 94 ++++++++ tests/SectionDefaultsTest.php | 133 ++++++++++- ...rateSectionDefaultsToAddonSettingsTest.php | 225 ------------------ 6 files changed, 303 insertions(+), 368 deletions(-) delete mode 100644 src/UpdateScripts/MigrateSectionDefaultsToAddonSettings.php delete mode 100644 tests/Localized/MigrateSectionDefaultsToAddonSettingsTest.php delete mode 100644 tests/UpdateScripts/MigrateSectionDefaultsToAddonSettingsTest.php diff --git a/src/SectionDefaults/SectionDefaults.php b/src/SectionDefaults/SectionDefaults.php index fc2ab61a..eadce995 100644 --- a/src/SectionDefaults/SectionDefaults.php +++ b/src/SectionDefaults/SectionDefaults.php @@ -6,7 +6,9 @@ use Illuminate\Support\Collection; use Statamic\Facades\Addon; use Statamic\Facades\Blink; +use Statamic\Facades\Collection as CollectionFacade; use Statamic\Facades\Site; +use Statamic\Facades\Taxonomy as TaxonomyFacade; class SectionDefaults { @@ -72,6 +74,7 @@ public static function disable(string $type, string $handle): void Addon::get('statamic/seo-pro')->settings()->set('section_defaults', $data)->save(); self::clearCache($type, $handle); + self::clearInjectSeo($type, $handle); } /** @@ -113,6 +116,7 @@ public static function save(LocalizedSectionDefaults $localized): void Addon::get('statamic/seo-pro')->settings()->set('section_defaults', $data)->save(); self::clearCache($type, $handle); + self::clearInjectSeo($type, $handle); } /** @@ -131,13 +135,83 @@ private static function rawForHandle(string $type, string $handle): array|false // Use settings()->raw() rather than settings()->get() because Statamic's Antlers // resolver converts PHP false to an empty string, losing the disabled state. $data = Arr::get(Addon::get('statamic/seo-pro')->settings()->raw(), 'section_defaults', []); - $raw = Arr::get($data, "{$type}.{$handle}"); - if ($raw === false) { + // If explicitly present in addon settings, use that value. + if (Arr::has($data, "{$type}.{$handle}")) { + $raw = Arr::get($data, "{$type}.{$handle}"); + + if ($raw === false) { + return false; + } + + return is_array($raw) ? $raw : []; + } + + // Fall back to inject.seo from the collection/taxonomy YAML (legacy storage). + return self::rawFromInjectSeo($type, $handle); + } + + /** + * Read section defaults from the legacy inject.seo location in collection/taxonomy YAML. + */ + private static function rawFromInjectSeo(string $type, string $handle): array|false + { + $item = self::findSectionItem($type, $handle); + + if (! $item) { + return []; + } + + $injectSeo = Arr::get($item->fileData(), 'inject.seo'); + + if ($injectSeo === null) { + return []; + } + + if ($injectSeo === false) { return false; } - return is_array($raw) ? $raw : []; + if (! is_array($injectSeo) || empty($injectSeo)) { + return []; + } + + // inject.seo was always a flat set of values (no per-site support in the old design). + // In multi-site mode, treat these as the default site's values so origin inheritance + // can propagate them to child sites naturally. + if (Site::multiEnabled()) { + return ['sites' => [Site::default()->handle() => $injectSeo]]; + } + + return $injectSeo; + } + + /** + * Strip inject.seo from the collection/taxonomy YAML, migrating it to addon settings. + * Called automatically after saving or disabling via the new API. + */ + private static function clearInjectSeo(string $type, string $handle): void + { + $item = self::findSectionItem($type, $handle); + + if (! $item) { + return; + } + + if (! Arr::has($item->fileData(), 'inject.seo')) { + return; + } + + $cascade = $item->cascade(); + $cascade->forget('seo'); + $item->cascade($cascade->all())->save(); + } + + private static function findSectionItem(string $type, string $handle) + { + return $type === 'collections' + ? CollectionFacade::find($handle) + : TaxonomyFacade::find($handle); } private static function cacheKey(string $type, string $handle): string diff --git a/src/UpdateScripts/MigrateSectionDefaultsToAddonSettings.php b/src/UpdateScripts/MigrateSectionDefaultsToAddonSettings.php deleted file mode 100644 index 2a87efa4..00000000 --- a/src/UpdateScripts/MigrateSectionDefaultsToAddonSettings.php +++ /dev/null @@ -1,56 +0,0 @@ -isUpdatingTo('7.2.0'); - } - - public function update() - { - $sectionDefaults = []; - - foreach (['collections' => Collection::all(), 'taxonomies' => Taxonomy::all()] as $type => $items) { - foreach ($items as $item) { - $injectSeo = Arr::get($item->fileData(), 'inject.seo'); - - if ($injectSeo === null) { - continue; - } - - $handle = $item->handle(); - - if ($injectSeo === false) { - $sectionDefaults[$type][$handle] = false; - } elseif (is_array($injectSeo) && ! empty($injectSeo)) { - if (Site::multiEnabled()) { - $sectionDefaults[$type][$handle]['sites'][Site::default()->handle()] = $injectSeo; - } else { - $sectionDefaults[$type][$handle] = $injectSeo; - } - } - - // Remove inject.seo from the collection/taxonomy YAML. - $cascade = $item->cascade(); - $cascade->forget('seo'); - $item->cascade($cascade->all())->save(); - } - } - - if (! empty($sectionDefaults)) { - $settings = Addon::get('statamic/seo-pro')->settings(); - $existing = $settings->get('section_defaults', []); - $settings->set('section_defaults', array_merge_recursive($existing, $sectionDefaults))->save(); - } - } -} diff --git a/tests/Localized/MigrateSectionDefaultsToAddonSettingsTest.php b/tests/Localized/MigrateSectionDefaultsToAddonSettingsTest.php deleted file mode 100644 index f900d1af..00000000 --- a/tests/Localized/MigrateSectionDefaultsToAddonSettingsTest.php +++ /dev/null @@ -1,83 +0,0 @@ -cascade(['seo' => ['title' => '@seo:title']]) - ->save(); - - $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); - - $raw = Addon::get('statamic/seo-pro')->settings()->raw(); - - $defaultHandle = Site::default()->handle(); - - $this->assertEquals( - ['title' => '@seo:title'], - $raw['section_defaults']['collections']['pages']['sites'][$defaultHandle] - ); - } - - #[Test] - public function it_migrates_disabled_collection_as_false_in_multisite() - { - Collection::find('pages') - ->cascade(['seo' => false]) - ->save(); - - $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); - - $raw = Addon::get('statamic/seo-pro')->settings()->raw(); - - // Disabled collections are always stored as false at the top level, not under sites. - $this->assertFalse($raw['section_defaults']['collections']['pages']); - } - - #[Test] - public function it_removes_inject_seo_from_collection_yaml_in_multisite() - { - Collection::find('pages') - ->cascade(['seo' => ['title' => '@seo:title']]) - ->save(); - - $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); - - $collection = Collection::find('pages'); - $this->assertArrayNotHasKey('seo', $collection->cascade()->all()); - } - - #[Test] - public function it_assigns_seo_data_to_default_site_only_in_multisite() - { - Collection::find('pages') - ->cascade(['seo' => ['title' => 'English defaults']]) - ->save(); - - $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); - - $raw = Addon::get('statamic/seo-pro')->settings()->raw(); - - $sites = $raw['section_defaults']['collections']['pages']['sites']; - - // Only the default site should have values after migration. - $this->assertCount(1, $sites); - $this->assertArrayHasKey(Site::default()->handle(), $sites); - $this->assertArrayNotHasKey('french', $sites); - $this->assertArrayNotHasKey('italian', $sites); - $this->assertArrayNotHasKey('british', $sites); - } -} diff --git a/tests/Localized/SectionDefaultsTest.php b/tests/Localized/SectionDefaultsTest.php index 2e87d4e6..133b72cf 100644 --- a/tests/Localized/SectionDefaultsTest.php +++ b/tests/Localized/SectionDefaultsTest.php @@ -4,6 +4,8 @@ use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Addon; +use Statamic\Facades\Collection; +use Statamic\Facades\Site; use Statamic\SeoPro\SectionDefaults\LocalizedSectionDefaults; use Statamic\SeoPro\SectionDefaults\SectionDefaults; @@ -227,4 +229,96 @@ public function it_preserves_disabled_state_across_all_sites() SectionDefaults::clearCache('collections', 'pages'); $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages')); } + + // ------------------------------------------------------------------------- + // inject.seo fallback (legacy storage) — multi-site + // ------------------------------------------------------------------------- + + #[Test] + public function it_falls_back_to_inject_seo_and_assigns_to_default_site() + { + Collection::find('pages') + ->cascade(['seo' => ['title' => 'Legacy title']]) + ->save(); + + $defaultHandle = Site::default()->handle(); + + $localized = SectionDefaults::in('collections', 'pages', $defaultHandle); + + $this->assertTrue($localized->isEnabled()); + $this->assertEquals(['title' => 'Legacy title'], $localized->all()); + } + + #[Test] + public function inject_seo_fallback_returns_empty_for_non_default_sites() + { + // inject.seo has no per-site support — only the default site gets values. + Collection::find('pages') + ->cascade(['seo' => ['title' => 'Legacy title']]) + ->save(); + + $french = SectionDefaults::in('collections', 'pages', 'french'); + + // French has no own values from inject.seo fallback. + $this->assertEmpty($french->all()); + } + + #[Test] + public function inject_seo_fallback_values_are_inherited_by_child_sites_via_origin() + { + Addon::get('statamic/seo-pro')->settings()->set('site_defaults_sites', [ + 'default' => null, + 'french' => 'default', + 'italian' => 'default', + 'british' => 'default', + ])->save(); + + Collection::find('pages') + ->cascade(['seo' => ['title' => 'Legacy title', 'description' => 'Desc']]) + ->save(); + + $french = SectionDefaults::in('collections', 'pages', 'french'); + + // French has origin=default, so values() includes inherited values. + $inherited = $french->values(); + $this->assertEquals('Legacy title', $inherited->get('title')); + $this->assertEquals('Desc', $inherited->get('description')); + } + + #[Test] + public function it_reports_disabled_from_inject_seo_in_multisite() + { + Collection::find('pages') + ->cascade(['seo' => false]) + ->save(); + + $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages')); + + SectionDefaults::clearCache('collections', 'pages'); + + $all = SectionDefaults::get('collections', 'pages'); + foreach ($all as $localized) { + $this->assertFalse($localized->isEnabled()); + } + } + + #[Test] + public function saving_strips_inject_seo_from_yaml_in_multisite() + { + Collection::find('pages') + ->cascade(['seo' => ['title' => 'Legacy']]) + ->save(); + + SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'Migrated'])->save(); + + $collection = Collection::find('pages'); + $this->assertArrayNotHasKey('seo', $collection->cascade()->all()); + + // Value is now under addon settings sites key. + $raw = Addon::get('statamic/seo-pro')->settings()->raw(); + $this->assertEquals( + ['title' => 'Migrated'], + $raw['section_defaults']['collections']['pages']['sites'][Site::default()->handle()] + ); + } } diff --git a/tests/SectionDefaultsTest.php b/tests/SectionDefaultsTest.php index 0fe86675..2a2166ba 100644 --- a/tests/SectionDefaultsTest.php +++ b/tests/SectionDefaultsTest.php @@ -4,8 +4,8 @@ use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Addon; -use Statamic\Facades\Blink; use Statamic\Facades\Collection; +use Statamic\Facades\Taxonomy; use Statamic\SeoPro\SectionDefaults\LocalizedSectionDefaults; use Statamic\SeoPro\SectionDefaults\SectionDefaults; @@ -262,4 +262,135 @@ public function localized_defaults_is_enabled_returns_false_when_whole_section_i $this->assertFalse($localized->isEnabled()); } } + + // ------------------------------------------------------------------------- + // inject.seo fallback (legacy storage) + // ------------------------------------------------------------------------- + + #[Test] + public function it_falls_back_to_inject_seo_when_no_addon_settings_exist() + { + Collection::find('pages') + ->cascade(['seo' => ['title' => '@seo:title', 'description' => 'Legacy desc']]) + ->save(); + + $localized = SectionDefaults::in('collections', 'pages', 'default'); + + $this->assertTrue($localized->isEnabled()); + $this->assertEquals(['title' => '@seo:title', 'description' => 'Legacy desc'], $localized->all()); + } + + #[Test] + public function it_reports_section_as_disabled_when_inject_seo_is_false() + { + Collection::find('pages') + ->cascade(['seo' => false]) + ->save(); + + $this->assertFalse(SectionDefaults::isEnabled('collections', 'pages')); + } + + #[Test] + public function it_prefers_addon_settings_over_inject_seo() + { + // Set inject.seo on the YAML. + Collection::find('pages') + ->cascade(['seo' => ['title' => 'From YAML']]) + ->save(); + + // Set a different value in addon settings. + Addon::get('statamic/seo-pro')->settings() + ->set('section_defaults', ['collections' => ['pages' => ['title' => 'From Settings']]]) + ->save(); + + $localized = SectionDefaults::in('collections', 'pages', 'default'); + + $this->assertEquals('From Settings', $localized->all()['title']); + } + + #[Test] + public function it_strips_inject_seo_from_yaml_when_saving_to_addon_settings() + { + // Set up legacy inject.seo. + Collection::find('pages') + ->cascade(['seo' => ['title' => 'Legacy']]) + ->save(); + + // Saving via the new API should migrate and strip inject.seo. + SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'Migrated'])->save(); + + $collection = Collection::find('pages'); + $this->assertArrayNotHasKey('seo', $collection->cascade()->all()); + } + + #[Test] + public function it_strips_inject_seo_from_yaml_when_disabling() + { + Collection::find('pages') + ->cascade(['seo' => ['title' => 'Legacy']]) + ->save(); + + SectionDefaults::disable('collections', 'pages'); + + $collection = Collection::find('pages'); + $this->assertArrayNotHasKey('seo', $collection->cascade()->all()); + } + + #[Test] + public function after_lazy_migration_subsequent_reads_use_addon_settings() + { + // Set up legacy inject.seo. + Collection::find('pages') + ->cascade(['seo' => ['title' => 'Legacy']]) + ->save(); + + // Trigger lazy migration by saving. + SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'Migrated'])->save(); + + // Remove inject.seo from the collection manually to confirm it's gone. + $collection = Collection::find('pages'); + $this->assertArrayNotHasKey('seo', $collection->cascade()->all()); + + // Subsequent read comes from addon settings. + SectionDefaults::clearCache('collections', 'pages'); + $localized = SectionDefaults::in('collections', 'pages', 'default'); + $this->assertEquals('Migrated', $localized->all()['title']); + } + + #[Test] + public function it_falls_back_to_inject_seo_for_taxonomies() + { + Taxonomy::find('topics') + ->cascade(['seo' => ['title' => 'Topics Legacy']]) + ->save(); + + $localized = SectionDefaults::in('taxonomies', 'topics', 'default'); + + $this->assertTrue($localized->isEnabled()); + $this->assertEquals(['title' => 'Topics Legacy'], $localized->all()); + } + + #[Test] + public function it_strips_inject_seo_from_taxonomy_yaml_when_saving() + { + Taxonomy::find('topics') + ->cascade(['seo' => ['title' => 'Legacy']]) + ->save(); + + SectionDefaults::in('taxonomies', 'topics', 'default')->set(['title' => 'Migrated'])->save(); + + $taxonomy = Taxonomy::find('topics'); + $this->assertArrayNotHasKey('seo', $taxonomy->cascade()->all()); + } + + #[Test] + public function it_does_not_modify_yaml_when_inject_seo_is_absent() + { + // No inject.seo in the fixture — saving should not throw or modify YAML unexpectedly. + SectionDefaults::in('collections', 'pages', 'default')->set(['title' => 'New'])->save(); + + // Collection YAML should not have gained an inject.seo key. + $collection = Collection::find('pages'); + $this->assertArrayNotHasKey('seo', $collection->cascade()->all()); + } } diff --git a/tests/UpdateScripts/MigrateSectionDefaultsToAddonSettingsTest.php b/tests/UpdateScripts/MigrateSectionDefaultsToAddonSettingsTest.php deleted file mode 100644 index 9500dc2b..00000000 --- a/tests/UpdateScripts/MigrateSectionDefaultsToAddonSettingsTest.php +++ /dev/null @@ -1,225 +0,0 @@ -cascade(['seo' => ['title' => '@seo:title', 'description' => 'My desc']]) - ->save(); - - $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); - - $raw = Addon::get('statamic/seo-pro')->settings()->raw(); - - $this->assertEquals( - ['title' => '@seo:title', 'description' => 'My desc'], - $raw['section_defaults']['collections']['pages'] - ); - } - - #[Test] - public function it_migrates_disabled_collection() - { - Collection::find('pages') - ->cascade(['seo' => false]) - ->save(); - - $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); - - $raw = Addon::get('statamic/seo-pro')->settings()->raw(); - - $this->assertFalse($raw['section_defaults']['collections']['pages']); - } - - #[Test] - public function it_skips_collections_without_inject_seo() - { - // The pages collection has no inject.seo in the fixture — run script and verify nothing added. - $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); - - $raw = Addon::get('statamic/seo-pro')->settings()->raw(); - - $this->assertEmpty($raw['section_defaults']['collections']['pages'] ?? []); - } - - #[Test] - public function it_removes_inject_seo_from_collection_yaml_after_migration() - { - Collection::find('pages') - ->cascade(['seo' => ['title' => '@seo:title']]) - ->save(); - - $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); - - // Reload collection from disk and check cascade no longer has 'seo'. - $collection = Collection::find('pages'); - $this->assertNull($collection->cascade('seo')); - $this->assertArrayNotHasKey('seo', $collection->cascade()->all()); - } - - #[Test] - public function it_removes_inject_seo_from_disabled_collection_yaml_after_migration() - { - Collection::find('pages') - ->cascade(['seo' => false]) - ->save(); - - $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); - - $collection = Collection::find('pages'); - $this->assertArrayNotHasKey('seo', $collection->cascade()->all()); - } - - #[Test] - public function it_migrates_multiple_collections() - { - Collection::find('pages') - ->cascade(['seo' => ['title' => 'Pages title']]) - ->save(); - - Collection::find('articles') - ->cascade(['seo' => false]) - ->save(); - - $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); - - $raw = Addon::get('statamic/seo-pro')->settings()->raw(); - - $this->assertEquals( - ['title' => 'Pages title'], - $raw['section_defaults']['collections']['pages'] - ); - - $this->assertFalse($raw['section_defaults']['collections']['articles']); - } - - #[Test] - public function it_skips_empty_inject_seo_array() - { - // An empty inject.seo array should not be migrated. - Collection::find('pages') - ->cascade(['seo' => []]) - ->save(); - - $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); - - $raw = Addon::get('statamic/seo-pro')->settings()->raw(); - - $this->assertEmpty($raw['section_defaults']['collections']['pages'] ?? []); - } - - // ------------------------------------------------------------------------- - // Taxonomies - // ------------------------------------------------------------------------- - - #[Test] - public function it_migrates_taxonomy_with_seo_defaults() - { - Taxonomy::find('topics') - ->cascade(['seo' => ['title' => 'Topics title']]) - ->save(); - - $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); - - $raw = Addon::get('statamic/seo-pro')->settings()->raw(); - - $this->assertEquals( - ['title' => 'Topics title'], - $raw['section_defaults']['taxonomies']['topics'] - ); - } - - #[Test] - public function it_migrates_disabled_taxonomy() - { - Taxonomy::find('topics') - ->cascade(['seo' => false]) - ->save(); - - $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); - - $raw = Addon::get('statamic/seo-pro')->settings()->raw(); - - $this->assertFalse($raw['section_defaults']['taxonomies']['topics']); - } - - #[Test] - public function it_removes_inject_seo_from_taxonomy_yaml_after_migration() - { - Taxonomy::find('topics') - ->cascade(['seo' => ['title' => 'Topics title']]) - ->save(); - - $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); - - $taxonomy = Taxonomy::find('topics'); - $this->assertArrayNotHasKey('seo', $taxonomy->cascade()->all()); - } - - #[Test] - public function it_migrates_both_collections_and_taxonomies() - { - Collection::find('pages') - ->cascade(['seo' => ['title' => 'Pages title']]) - ->save(); - - Taxonomy::find('topics') - ->cascade(['seo' => false]) - ->save(); - - $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); - - $raw = Addon::get('statamic/seo-pro')->settings()->raw(); - - $this->assertEquals( - ['title' => 'Pages title'], - $raw['section_defaults']['collections']['pages'] - ); - - $this->assertFalse($raw['section_defaults']['taxonomies']['topics']); - } - - #[Test] - public function it_merges_with_existing_addon_settings() - { - // Pre-existing addon settings (e.g. site defaults already migrated). - Addon::get('statamic/seo-pro')->settings() - ->set('site_defaults', ['title' => 'My Site']) - ->save(); - - Collection::find('pages') - ->cascade(['seo' => ['title' => '@seo:title']]) - ->save(); - - $this->runUpdateScript(MigrateSectionDefaultsToAddonSettings::class); - - $raw = Addon::get('statamic/seo-pro')->settings()->raw(); - - // Site defaults must be preserved. - $this->assertEquals('My Site', $raw['site_defaults']['title']); - - // Section defaults must be added. - $this->assertEquals( - ['title' => '@seo:title'], - $raw['section_defaults']['collections']['pages'] - ); - } -} From db17263c1f02045f280522e3675f7660bd3ce10c Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Wed, 4 Mar 2026 09:22:29 +0000 Subject: [PATCH 3/7] add origin inheritance --- lang/en/messages.php | 1 + .../section-defaults/ConfigureModal.vue | 133 +++++++++++++++ resources/js/pages/section-defaults/Edit.vue | 21 ++- routes/cp.php | 3 + .../CP/BaseSectionDefaultsController.php | 3 + .../CP/ConfigureSectionDefaultsController.php | 55 +++++++ src/SectionDefaults/SectionDefaults.php | 18 +- .../ConfigureSectionDefaultsTest.php | 154 ++++++++++++++++++ .../SectionDefaultsControllerTest.php | 13 +- tests/Localized/SectionDefaultsTest.php | 14 +- tests/SectionDefaultsControllerTest.php | 15 +- 11 files changed, 413 insertions(+), 17 deletions(-) create mode 100644 resources/js/components/section-defaults/ConfigureModal.vue create mode 100644 src/Http/Controllers/CP/ConfigureSectionDefaultsController.php create mode 100644 tests/Localized/ConfigureSectionDefaultsTest.php diff --git a/lang/en/messages.php b/lang/en/messages.php index 6b27e97a..5ce87a6d 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -13,6 +13,7 @@ 'section_defaults' => 'Section Defaults', 'section_defaults_description' => 'Override default meta, social, sitemap, and other settings for any particular section.', 'configure_site_defaults' => 'Configure Site Defaults', + 'configure_section_defaults' => 'Configure Section Defaults', 'meta' => 'Meta', 'opengraph' => 'Open Graph', 'social' => 'Social', diff --git a/resources/js/components/section-defaults/ConfigureModal.vue b/resources/js/components/section-defaults/ConfigureModal.vue new file mode 100644 index 00000000..7cb04997 --- /dev/null +++ b/resources/js/components/section-defaults/ConfigureModal.vue @@ -0,0 +1,133 @@ + + + diff --git a/resources/js/pages/section-defaults/Edit.vue b/resources/js/pages/section-defaults/Edit.vue index 8fa16e6e..d3478152 100644 --- a/resources/js/pages/section-defaults/Edit.vue +++ b/resources/js/pages/section-defaults/Edit.vue @@ -1,9 +1,10 @@