From 2966bdb2ea161cf4b846f05490008f1d7d5ba2eb Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Wed, 18 Mar 2026 14:58:03 -0400 Subject: [PATCH 1/6] wip --- .../js/components/blueprints/Sections.vue | 12 ++- resources/js/pages/fieldsets/Edit.vue | 96 ++++++++++--------- src/Fields/Fieldset.php | 54 ++++++++--- src/Fields/Tab.php | 84 +++++++++++++++- .../CP/Fields/FieldsetController.php | 54 +++++++++-- tests/Feature/Fieldsets/EditFieldsetTest.php | 38 +++++++- .../Feature/Fieldsets/UpdateFieldsetTest.php | 82 ++++++++++++++++ tests/Fields/FieldsetTest.php | 87 +++++++++++++++++ tests/Fields/TabTest.php | 83 ++++++++++++++++ 9 files changed, 522 insertions(+), 68 deletions(-) diff --git a/resources/js/components/blueprints/Sections.vue b/resources/js/components/blueprints/Sections.vue index 4d8baa188e3..8ee2f4285e8 100644 --- a/resources/js/components/blueprints/Sections.vue +++ b/resources/js/components/blueprints/Sections.vue @@ -16,10 +16,14 @@ @deleted="deleteSection(i)" /> - +
+ +
diff --git a/resources/js/pages/fieldsets/Edit.vue b/resources/js/pages/fieldsets/Edit.vue index cd936dd2eda..c99b289d63e 100644 --- a/resources/js/pages/fieldsets/Edit.vue +++ b/resources/js/pages/fieldsets/Edit.vue @@ -23,26 +23,18 @@ - - - + diff --git a/src/Fields/Fieldset.php b/src/Fields/Fieldset.php index cf01cabb96a..0a7df50d1b1 100644 --- a/src/Fields/Fieldset.php +++ b/src/Fields/Fieldset.php @@ -2,6 +2,7 @@ namespace Statamic\Fields; +use Illuminate\Support\Collection as IlluminateCollection; use Statamic\CommandPalette\Category; use Statamic\CommandPalette\Link; use Statamic\Events\FieldsetCreated; @@ -64,17 +65,12 @@ public function initialPath($path = null) public function setContents(array $contents) { - $fields = Arr::get($contents, 'fields', []); + $contents['fields'] = $this->normalizeFields(Arr::get($contents, 'fields', [])); - // Support legacy syntax - if (! empty($fields) && array_keys($fields)[0] !== 0) { - $fields = collect($fields)->map(function ($field, $handle) { - return compact('handle', 'field'); - })->values()->all(); + if (array_key_exists('sections', $contents)) { + $contents['sections'] = $this->normalizeSections(Arr::get($contents, 'sections', [])); } - $contents['fields'] = $fields; - $this->contents = $contents; return $this; @@ -100,11 +96,23 @@ public function validateRecursion() public function fields(): Fields { - $fields = Arr::get($this->contents, 'fields', []); + $fields = $this->hasSections() + ? $this->sections()->flatMap(fn ($section) => Arr::get($section, 'fields', []))->values()->all() + : Arr::get($this->contents, 'fields', []); return new Fields($fields); } + public function sections(): IlluminateCollection + { + return collect(Arr::get($this->contents, 'sections', [])); + } + + public function hasSections(): bool + { + return $this->sections()->isNotEmpty(); + } + public function field(string $handle): ?Field { return $this->fields()->get($handle); @@ -159,9 +167,12 @@ public function importedBy(): array })->values(); $fieldsets = \Statamic\Facades\Fieldset::all() - ->filter(fn (Fieldset $fieldset) => isset($fieldset->contents()['fields'])) ->filter(function (Fieldset $fieldset) { - return collect($fieldset->contents()['fields']) + $fields = $fieldset->hasSections() + ? $fieldset->sections()->flatMap(fn ($section) => Arr::get($section, 'fields', []))->all() + : Arr::get($fieldset->contents(), 'fields', []); + + return collect($fields) ->filter(fn ($field) => $this->fieldImportsFieldset($field)) ->isNotEmpty(); }) @@ -311,4 +322,25 @@ public static function __callStatic($method, $parameters) { return Facades\Fieldset::{$method}(...$parameters); } + + private function normalizeFields(array $fields): array + { + // Support legacy syntax where handles are array keys. + if (! empty($fields) && array_keys($fields)[0] !== 0) { + $fields = collect($fields)->map(function ($field, $handle) { + return compact('handle', 'field'); + })->values()->all(); + } + + return $fields; + } + + private function normalizeSections(array $sections): array + { + return collect($sections)->map(function ($section) { + $section['fields'] = $this->normalizeFields(Arr::get($section, 'fields', [])); + + return $section; + })->all(); + } } diff --git a/src/Fields/Tab.php b/src/Fields/Tab.php index bbc50f2a2bc..10e42d664ed 100644 --- a/src/Fields/Tab.php +++ b/src/Fields/Tab.php @@ -2,6 +2,7 @@ namespace Statamic\Fields; +use Statamic\Facades\Fieldset as FieldsetRepository; use Statamic\Support\Arr; use Statamic\Support\Str; @@ -81,7 +82,7 @@ public function toPublishArray() 'display' => $this->display(), 'instructions' => $this->instructions(), 'handle' => $this->handle, - 'sections' => $this->sections()->map->toPublishArray()->all(), + 'sections' => $this->expandedSections(), ]; } @@ -94,4 +95,85 @@ public function instructions() { return Arr::get($this->contents, 'instructions'); } + + private function expandedSections(): array + { + return $this->sections()->reduce(function ($carry, Section $section) { + $fields = Arr::get($section->contents(), 'fields', []); + + if (empty($fields)) { + return $carry->push($section->toPublishArray()); + } + + $sectionContents = $section->contents(); + $sectionMeta = Arr::except($sectionContents, ['fields']); + $bufferedFields = []; + + foreach ($fields as $field) { + if (! $this->isSectionedFieldsetImport($field)) { + $bufferedFields[] = $field; + + continue; + } + + if (! empty($bufferedFields)) { + $carry->push((new Section($sectionMeta + ['fields' => $bufferedFields]))->toPublishArray()); + $bufferedFields = []; + } + + $importedSections = FieldsetRepository::find($field['import'])->sections(); + + foreach ($importedSections as $importedSection) { + $carry->push($this->toImportedPublishSection($importedSection, $field)); + } + } + + if (! empty($bufferedFields)) { + $carry->push((new Section($sectionMeta + ['fields' => $bufferedFields]))->toPublishArray()); + } + + return $carry; + }, collect())->all(); + } + + private function isSectionedFieldsetImport(array $field): bool + { + if (! isset($field['import'])) { + return false; + } + + $fieldset = FieldsetRepository::find($field['import']); + + return $fieldset && $fieldset->hasSections(); + } + + private function toImportedPublishSection(array $section, array $import): array + { + $fields = (new Fields(Arr::get($section, 'fields', [])))->all(); + + if ($overrides = $import['config'] ?? null) { + $fields = $fields->map(function ($field, $handle) use ($overrides) { + return $field->setConfig(array_merge($field->config(), $overrides[$handle] ?? [])); + }); + } + + if ($prefix = Arr::get($import, 'prefix')) { + $fields = $fields->mapWithKeys(function ($field) use ($prefix) { + $field = clone $field; + $handle = $prefix.$field->handle(); + $prefix = $prefix.$field->prefix(); + + return [$handle => $field->setHandle($handle)->setPrefix($prefix)]; + }); + } + + return Arr::removeNullValues([ + 'display' => Arr::get($section, 'display'), + 'instructions' => Arr::get($section, 'instructions'), + 'collapsible' => ($collapsible = Arr::get($section, 'collapsible')) ?: null, + 'collapsed' => ($collapsible && Arr::get($section, 'collapsed')) ?: null, + ]) + [ + 'fields' => $fields->map->toPublishArray()->values()->all(), + ]; + } } diff --git a/src/Http/Controllers/CP/Fields/FieldsetController.php b/src/Http/Controllers/CP/Fields/FieldsetController.php index 0254a72f06f..a0f1ca6b3df 100644 --- a/src/Http/Controllers/CP/Fields/FieldsetController.php +++ b/src/Http/Controllers/CP/Fields/FieldsetController.php @@ -112,9 +112,7 @@ public function edit($fieldset) $vue = [ 'title' => $fieldset->title(), 'handle' => $fieldset->handle(), - 'fields' => collect(Arr::get($fieldset->contents(), 'fields'))->map(function ($field, $i) { - return array_merge(FieldTransformer::toVue($field), ['_id' => $i]); - })->all(), + 'sections' => $this->sectionsToVue($fieldset), ]; return Inertia::render('fieldsets/Edit', [ @@ -130,15 +128,33 @@ public function update(Request $request, $fieldset) $request->validate([ 'title' => 'required', + 'sections' => 'array', 'fields' => 'array', ]); - $fieldset->setContents(array_merge($fieldset->contents(), [ + $contents = array_merge($fieldset->contents(), [ 'title' => $request->title, - 'fields' => collect($request->fields)->map(function ($field) { + ]); + + if ($request->has('sections')) { + $contents['sections'] = collect($request->sections)->map(function ($section) { + return Arr::removeNullValues([ + 'display' => Arr::get($section, 'display'), + 'instructions' => Arr::get($section, 'instructions'), + 'collapsible' => ($collapsible = Arr::get($section, 'collapsible')) ?: null, + 'collapsed' => ($collapsible && Arr::get($section, 'collapsed')) ?: null, + 'fields' => collect(Arr::get($section, 'fields', []))->map(function ($field) { + return FieldTransformer::fromVue($field); + })->all(), + ]); + })->all(); + } else { + $contents['fields'] = collect($request->fields)->map(function ($field) { return FieldTransformer::fromVue($field); - })->all(), - ])); + })->all(); + } + + $fieldset->setContents($contents); $fieldset->validateRecursion(); @@ -217,4 +233,28 @@ private function groupKey(Fieldset $fieldset): string return __('My Fieldsets'); } + + private function sectionsToVue(Fieldset $fieldset): array + { + $sections = $fieldset->hasSections() + ? Arr::get($fieldset->contents(), 'sections', []) + : [[ + 'display' => __('Fields'), + 'fields' => Arr::get($fieldset->contents(), 'fields', []), + ]]; + + return collect($sections)->map(function ($section, $sectionIndex) { + return Arr::removeNullValues([ + '_id' => "section-{$sectionIndex}", + 'display' => Arr::get($section, 'display'), + 'instructions' => Arr::get($section, 'instructions'), + 'collapsible' => Arr::get($section, 'collapsible'), + 'collapsed' => Arr::get($section, 'collapsed'), + ]) + [ + 'fields' => collect(Arr::get($section, 'fields', []))->map(function ($field, $fieldIndex) use ($sectionIndex) { + return array_merge(FieldTransformer::toVue($field), ['_id' => "section-{$sectionIndex}-{$fieldIndex}"]); + })->all(), + ]; + })->all(); + } } diff --git a/tests/Feature/Fieldsets/EditFieldsetTest.php b/tests/Feature/Fieldsets/EditFieldsetTest.php index df82c1882d0..24d88b92f13 100644 --- a/tests/Feature/Fieldsets/EditFieldsetTest.php +++ b/tests/Feature/Fieldsets/EditFieldsetTest.php @@ -43,12 +43,46 @@ public function it_provides_the_fieldset() { $this->withoutExceptionHandling(); $user = Facades\User::make()->makeSuper()->save(); - $fieldset = (new Fieldset)->setHandle('test')->setContents(['title' => 'Test'])->save(); + $fieldset = (new Fieldset)->setHandle('test')->setContents([ + 'title' => 'Test', + 'fields' => [ + ['handle' => 'title', 'field' => ['type' => 'text']], + ], + ])->save(); + + $this + ->actingAs($user) + ->get($fieldset->editUrl()) + ->assertStatus(200) + ->assertInertia(fn ($page) => $page + ->where('initialFieldset.handle', $fieldset->handle()) + ->where('initialFieldset.sections.0.fields.0.handle', 'title') + ); + } + + #[Test] + public function it_provides_sectioned_fieldsets() + { + $user = Facades\User::make()->makeSuper()->save(); + $fieldset = (new Fieldset)->setHandle('test')->setContents([ + 'title' => 'Test', + 'sections' => [ + [ + 'display' => 'SEO', + 'fields' => [ + ['handle' => 'meta_title', 'field' => ['type' => 'text']], + ], + ], + ], + ])->save(); $this ->actingAs($user) ->get($fieldset->editUrl()) ->assertStatus(200) - ->assertInertia(fn ($page) => $page->where('initialFieldset.handle', $fieldset->handle())); + ->assertInertia(fn ($page) => $page + ->where('initialFieldset.sections.0.display', 'SEO') + ->where('initialFieldset.sections.0.fields.0.handle', 'meta_title') + ); } } diff --git a/tests/Feature/Fieldsets/UpdateFieldsetTest.php b/tests/Feature/Fieldsets/UpdateFieldsetTest.php index 619a75c10bf..71485262062 100644 --- a/tests/Feature/Fieldsets/UpdateFieldsetTest.php +++ b/tests/Feature/Fieldsets/UpdateFieldsetTest.php @@ -105,6 +105,88 @@ public function fieldset_gets_saved() ], Facades\Fieldset::find('test')->contents()); } + #[Test] + public function fieldset_gets_saved_with_sections() + { + $user = tap(Facades\User::make()->makeSuper())->save(); + (new Fieldset)->setHandle('seo_defaults')->setContents([ + 'title' => 'SEO Defaults', + 'fields' => [ + ['handle' => 'canonical_url', 'field' => ['type' => 'text']], + ], + ])->save(); + + $fieldset = (new Fieldset)->setHandle('test')->setContents([ + 'title' => 'Test', + 'foo' => 'bar', + 'fields' => [ + ['handle' => 'legacy', 'field' => ['type' => 'text']], + ], + ])->save(); + + $this + ->actingAs($user) + ->submit($fieldset, [ + 'title' => 'Updated title', + 'sections' => [ + [ + '_id' => 'section-1', + 'display' => 'SEO', + 'instructions' => 'SEO fields', + 'collapsible' => true, + 'collapsed' => true, + 'fields' => [ + [ + '_id' => 'section-1-field-1', + 'handle' => 'meta_title', + 'type' => 'inline', + 'config' => [ + 'type' => 'text', + 'display' => 'Meta title', + ], + ], + [ + '_id' => 'section-1-field-2', + 'type' => 'import', + 'fieldset' => 'seo_defaults', + 'prefix' => 'seo_', + ], + ], + ], + ], + ]) + ->assertStatus(204); + + $this->assertEquals([ + 'title' => 'Updated title', + 'foo' => 'bar', + 'fields' => [ + ['handle' => 'legacy', 'field' => ['type' => 'text']], + ], + 'sections' => [ + [ + 'display' => 'SEO', + 'instructions' => 'SEO fields', + 'collapsible' => true, + 'collapsed' => true, + 'fields' => [ + [ + 'handle' => 'meta_title', + 'field' => [ + 'type' => 'text', + 'display' => 'Meta title', + ], + ], + [ + 'import' => 'seo_defaults', + 'prefix' => 'seo_', + ], + ], + ], + ], + ], Facades\Fieldset::find('test')->contents()); + } + #[Test] public function title_is_required() { diff --git a/tests/Fields/FieldsetTest.php b/tests/Fields/FieldsetTest.php index ff8fb006d08..a17134c3aa4 100644 --- a/tests/Fields/FieldsetTest.php +++ b/tests/Fields/FieldsetTest.php @@ -117,6 +117,67 @@ public function it_gets_fields() $this->assertEquals(['text', 'textarea'], $fields->map->type()->values()->all()); } + #[Test] + public function it_gets_fields_from_sections() + { + $fieldset = new Fieldset; + + $fieldset->setContents([ + 'sections' => [ + [ + 'display' => 'First section', + 'fields' => [ + [ + 'handle' => 'one', + 'field' => ['type' => 'text'], + ], + ], + ], + [ + 'display' => 'Second section', + 'fields' => [ + [ + 'handle' => 'two', + 'field' => ['type' => 'textarea'], + ], + ], + ], + ], + ]); + + $fields = $fieldset->fields(); + + $this->assertTrue($fieldset->hasSections()); + $this->assertCount(2, $fieldset->sections()); + $this->assertInstanceOf(Fields::class, $fields); + $this->assertEveryItemIsInstanceOf(Field::class, $fields = $fields->all()); + $this->assertEquals(['one', 'two'], $fields->map->handle()->values()->all()); + $this->assertEquals(['text', 'textarea'], $fields->map->type()->values()->all()); + } + + #[Test] + public function it_normalizes_legacy_section_fields_syntax() + { + $fieldset = new Fieldset; + + $fieldset->setContents([ + 'sections' => [ + [ + 'display' => 'First section', + 'fields' => [ + 'one' => ['type' => 'text'], + 'two' => ['type' => 'textarea'], + ], + ], + ], + ]); + + $fields = $fieldset->fields()->all(); + + $this->assertEquals(['one', 'two'], $fields->map->handle()->values()->all()); + $this->assertEquals(['text', 'textarea'], $fields->map->type()->values()->all()); + } + #[Test] public function it_gets_fields_using_legacy_syntax() { @@ -445,6 +506,32 @@ public function gets_fieldsets_importing_single_field_from_fieldset() $this->assertEquals($fieldsetA->handle(), $importedBy['fieldsets']->first()->handle()); } + #[Test] + public function gets_fieldsets_importing_fieldset_inside_sections() + { + $fieldset = Fieldset::make('seo')->setContents(['fields' => [ + ['handle' => 'meta_title', 'field' => ['type' => 'text']], + ]])->save(); + + $fieldsetA = Fieldset::make('one') + ->setContents([ + 'sections' => [ + [ + 'display' => 'SEO', + 'fields' => [ + ['import' => 'seo'], + ], + ], + ], + ]) + ->save(); + + $importedBy = $fieldset->importedBy(); + + $this->assertCount(1, $importedBy['fieldsets']); + $this->assertEquals($fieldsetA->handle(), $importedBy['fieldsets']->first()->handle()); + } + #[Test] public function it_saves_through_the_repository() { diff --git a/tests/Fields/TabTest.php b/tests/Fields/TabTest.php index 69094f5abb1..621c929ee9e 100644 --- a/tests/Fields/TabTest.php +++ b/tests/Fields/TabTest.php @@ -4,8 +4,10 @@ use Facades\Statamic\Fields\FieldRepository; use PHPUnit\Framework\Attributes\Test; +use Statamic\Facades\Fieldset as FieldsetRepository; use Statamic\Fields\Field; use Statamic\Fields\Fields; +use Statamic\Fields\Fieldset; use Statamic\Fields\Tab; use Tests\TestCase; @@ -194,4 +196,85 @@ public function converts_to_array_suitable_for_rendering_fields_in_publish_compo ], ], $tab->toPublishArray()); } + + #[Test] + public function it_expands_sectioned_fieldset_imports_into_publish_sections() + { + FieldsetRepository::shouldReceive('find') + ->with('seo') + ->andReturn((new Fieldset)->setHandle('seo')->setContents([ + 'sections' => [ + [ + 'display' => 'SEO', + 'fields' => [ + ['handle' => 'meta_title', 'field' => ['type' => 'text']], + ], + ], + [ + 'display' => 'Social', + 'fields' => [ + ['handle' => 'og_title', 'field' => ['type' => 'text']], + ], + ], + ], + ])); + + $tab = (new Tab('main'))->setContents([ + 'sections' => [ + [ + 'display' => 'Main', + 'fields' => [ + ['handle' => 'title', 'field' => ['type' => 'text']], + ['import' => 'seo'], + ['handle' => 'summary', 'field' => ['type' => 'textarea']], + ], + ], + ], + ]); + + $publish = $tab->toPublishArray(); + + $this->assertCount(4, $publish['sections']); + $this->assertEquals('Main', $publish['sections'][0]['display']); + $this->assertEquals(['title'], collect($publish['sections'][0]['fields'])->pluck('handle')->all()); + $this->assertEquals('SEO', $publish['sections'][1]['display']); + $this->assertEquals(['meta_title'], collect($publish['sections'][1]['fields'])->pluck('handle')->all()); + $this->assertEquals('Social', $publish['sections'][2]['display']); + $this->assertEquals(['og_title'], collect($publish['sections'][2]['fields'])->pluck('handle')->all()); + $this->assertEquals('Main', $publish['sections'][3]['display']); + $this->assertEquals(['summary'], collect($publish['sections'][3]['fields'])->pluck('handle')->all()); + } + + #[Test] + public function it_applies_prefixes_to_fields_inside_imported_fieldset_sections() + { + FieldsetRepository::shouldReceive('find') + ->with('seo') + ->andReturn((new Fieldset)->setHandle('seo')->setContents([ + 'sections' => [ + [ + 'display' => 'SEO', + 'fields' => [ + ['handle' => 'meta_title', 'field' => ['type' => 'text']], + ], + ], + ], + ])); + + $tab = (new Tab('main'))->setContents([ + 'sections' => [ + [ + 'fields' => [ + ['import' => 'seo', 'prefix' => 'seo_'], + ], + ], + ], + ]); + + $publish = $tab->toPublishArray(); + $field = $publish['sections'][0]['fields'][0]; + + $this->assertEquals('seo_meta_title', $field['handle']); + $this->assertEquals('seo_', $field['prefix']); + } } From a657a4b2708d480a7fe00c054df6d6adc641e9dd Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Wed, 18 Mar 2026 22:12:26 -0400 Subject: [PATCH 2/6] Add section-aware fieldset imports with preserve/flatten controls --- .../js/components/blueprints/ImportField.vue | 29 ++++++ .../js/components/fields/ImportSettings.vue | 63 +++++++++++- src/Fields/FieldTransformer.php | 4 + src/Fields/Tab.php | 4 + .../CP/Fields/FieldsetController.php | 24 ++++- .../Controllers/CP/Fields/ManagesFields.php | 8 +- .../Feature/Fieldsets/UpdateFieldsetTest.php | 99 +++++++++++++++++++ tests/Fields/TabTest.php | 36 +++++++ 8 files changed, 263 insertions(+), 4 deletions(-) diff --git a/resources/js/components/blueprints/ImportField.vue b/resources/js/components/blueprints/ImportField.vue index babeb9da327..590fab51e3e 100644 --- a/resources/js/components/blueprints/ImportField.vue +++ b/resources/js/components/blueprints/ImportField.vue @@ -11,6 +11,7 @@ + + + + + + +