From 3d5c1c47b4f2bfd2a9bfba0abbc4585e4525e937 Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Mon, 24 Aug 2026 16:03:07 +0200 Subject: [PATCH] Fix replicator field selection in templates --- src/Services/ReplicatorFieldService.php | 60 ++-- .../Services/ReplicatorFieldServiceTest.php | 290 +++++++++++------- 2 files changed, 193 insertions(+), 157 deletions(-) diff --git a/src/Services/ReplicatorFieldService.php b/src/Services/ReplicatorFieldService.php index 69503d9..dfdfc36 100644 --- a/src/Services/ReplicatorFieldService.php +++ b/src/Services/ReplicatorFieldService.php @@ -8,6 +8,7 @@ use Statamic\Entries\Collection; use Statamic\Entries\Entry; use Statamic\Fields\Blueprint; +use Statamic\Fields\Field; use Statamic\Fields\LabeledValue; use Statamic\Taxonomies\Taxonomy; @@ -74,13 +75,18 @@ public function getReplicatorFields(EntryContract|Collection|Taxonomy $dataTempl protected function extractFieldsFromBlueprints(SupportCollection $blueprints): array { return $blueprints->reduce(function (array $carry, Blueprint $blueprint): array { - $items = $blueprint->fields()->items(); - - if ($items instanceof SupportCollection) { - $items = $items->all(); + foreach ($blueprint->fields()->all() as $field) { + if (! $field instanceof Field) { + continue; + } + + $carry[] = [ + 'handle' => $field->handle(), + 'field' => $field->config(), + ]; } - return array_merge($carry, is_array($items) ? $items : []); + return $carry; }, []); } @@ -184,55 +190,28 @@ protected function parseSets(array $sets): array } /** @var array $setConfig */ - $setDisplay = is_string($setConfig['display'] ?? null) ? $setConfig['display'] : $setHandle; - $setFields = is_array($setConfig['fields'] ?? null) ? $setConfig['fields'] : []; $nestedSets = is_array($setConfig['sets'] ?? null) ? $setConfig['sets'] : []; - $fieldOptions = $this->parseSetFields($setFields); + if ($nestedSets !== []) { + /** @var array $nestedSets */ + $setOptions = array_merge($setOptions, $this->parseSets($nestedSets)); - if (! empty($nestedSets)) { - $nestedFields = $this->extractFieldsFromNestedSets($nestedSets); - $fieldOptions = array_merge($fieldOptions, $nestedFields); + continue; } + $setDisplay = is_string($setConfig['display'] ?? null) ? $setConfig['display'] : $setHandle; + $setFields = is_array($setConfig['fields'] ?? null) ? $setConfig['fields'] : []; + $setOptions[] = [ 'value' => $setHandle, 'label' => $setDisplay, - 'fields' => $fieldOptions, + 'fields' => $this->parseSetFields($setFields), ]; } return $setOptions; } - /** - * @param array $nestedSets - * @return array> - */ - protected function extractFieldsFromNestedSets(array $nestedSets): array - { - $allFields = []; - - foreach ($nestedSets as $nestedSetConfig) { - if (! is_array($nestedSetConfig)) { - continue; - } - - /** @var array $nestedSetConfig */ - $nestedSetFields = is_array($nestedSetConfig['fields'] ?? null) ? $nestedSetConfig['fields'] : []; - $fields = $this->parseSetFields($nestedSetFields); - $allFields = array_merge($allFields, $fields); - $deeperNestedSets = is_array($nestedSetConfig['sets'] ?? null) ? $nestedSetConfig['sets'] : []; - - if (! empty($deeperNestedSets)) { - $deeperFields = $this->extractFieldsFromNestedSets($deeperNestedSets); - $allFields = array_merge($allFields, $deeperFields); - } - } - - return $allFields; - } - /** * @param array $setFields * @return array> @@ -334,6 +313,7 @@ protected function isFieldTypeEligible(?string $fieldType): bool 'users', 'link', 'url', + 'select', ]; return in_array($fieldType, $eligibleFieldTypes, true); diff --git a/tests/Unit/Services/ReplicatorFieldServiceTest.php b/tests/Unit/Services/ReplicatorFieldServiceTest.php index 67a0c8f..6cd7baf 100644 --- a/tests/Unit/Services/ReplicatorFieldServiceTest.php +++ b/tests/Unit/Services/ReplicatorFieldServiceTest.php @@ -13,8 +13,10 @@ use Statamic\Entries\Entry; use Statamic\Facades\Blueprint as BlueprintFacade; use Statamic\Facades\Collection as CollectionFacade; +use Statamic\Facades\Fieldset as FieldsetFacade; use Statamic\Facades\Taxonomy as TaxonomyFacade; use Statamic\Fields\Blueprint; +use Statamic\Fields\Field; use Statamic\Fields\Fields; use Statamic\Fields\LabeledValue; use Statamic\Taxonomies\Taxonomy; @@ -166,56 +168,16 @@ public function get_replicator_fields_extracts_replicator_fields_from_blueprints } #[Test] - public function get_replicator_fields_handles_field_object_with_non_array_to_array(): void + public function get_replicator_fields_skips_non_field_values(): void { - $collection = CollectionFacade::make('blog'); - $collection->save(); - $templatesCollection = CollectionFacade::make('structured_data_templates'); - $templatesCollection->save(); - - $fieldObject = new class - { - public function toArray(): null - { - return null; - } - }; - $blueprint = $this->mock(Blueprint::class); - $fieldsMock = $this->mock(Fields::class, function (MockInterface $mock) use ($fieldObject): void { - $mock->shouldReceive('items')->andReturn([$fieldObject]); - }); - $blueprint->shouldReceive('fields')->andReturn($fieldsMock); - - $collectionMock = $this->mock(Collection::class, function ($mock) use ($blueprint): void { - $mock->shouldReceive('entryBlueprints')->andReturn(collect([$blueprint])); - $mock->shouldReceive('toArray')->andReturn([]); - }); - - $template = (new Entry) - ->collection($templatesCollection) - ->id('template-123'); - - $template->use_for_collection = $collectionMock; - - $service = new ReplicatorFieldService; - $result = $service->getReplicatorFields($template); - - $this->assertEmpty($result); - } - - #[Test] - public function extract_fields_from_blueprints_handles_collection_items(): void - { - $blueprint = $this->mock(Blueprint::class); - $fields = $this->mock(Fields::class, function (MockInterface $mock): void { - $mock->shouldReceive('items')->andReturn(collect([ - ['handle' => 'field1'], - ['handle' => 'field2'], + $fieldsMock = $this->mock(Fields::class, function (MockInterface $mock): void { + $mock->shouldReceive('all')->andReturn(collect([ + 'invalid' => 'not-a-field', + 'valid' => $this->mockField('valid', ['type' => 'text']), ])); }); - - $blueprint->shouldReceive('fields')->andReturn($fields); + $blueprint->shouldReceive('fields')->andReturn($fieldsMock); $service = new ReplicatorFieldService; $reflection = new \ReflectionClass($service); @@ -224,18 +186,20 @@ public function extract_fields_from_blueprints_handles_collection_items(): void $result = $method->invoke($service, collect([$blueprint])); $this->assertIsArray($result); - $this->assertCount(2, $result); + /** @var array> $result */ + $this->assertCount(1, $result); + $this->assertSame('valid', $result[0]['handle']); } #[Test] - public function extract_fields_from_blueprints_handles_array_items(): void + public function extract_fields_from_blueprints_handles_collection_items(): void { $blueprint = $this->mock(Blueprint::class); $fields = $this->mock(Fields::class, function (MockInterface $mock): void { - $mock->shouldReceive('items')->andReturn([ - ['handle' => 'field1'], - ['handle' => 'field2'], - ]); + $mock->shouldReceive('all')->andReturn(collect([ + 'field1' => $this->mockField('field1'), + 'field2' => $this->mockField('field2'), + ])); }); $blueprint->shouldReceive('fields')->andReturn($fields); @@ -248,26 +212,9 @@ public function extract_fields_from_blueprints_handles_array_items(): void $this->assertIsArray($result); $this->assertCount(2, $result); - } - - #[Test] - public function extract_fields_from_blueprints_handles_non_array_items(): void - { - $blueprint = $this->mock(Blueprint::class); - $fields = $this->mock(Fields::class, function (MockInterface $mock): void { - $mock->shouldReceive('items')->andReturn('not-an-array'); - }); - - $blueprint->shouldReceive('fields')->andReturn($fields); - - $service = new ReplicatorFieldService; - $reflection = new \ReflectionClass($service); - $method = $reflection->getMethod('extractFieldsFromBlueprints'); - $method->setAccessible(true); - $result = $method->invoke($service, collect([$blueprint])); - - $this->assertIsArray($result); - $this->assertEmpty($result); + /** @var array> $result */ + $this->assertSame('field1', $result[0]['handle']); + $this->assertSame('field2', $result[1]['handle']); } #[Test] @@ -275,7 +222,7 @@ public function extract_fields_from_blueprints_handles_empty_collection_items(): { $blueprint = $this->mock(Blueprint::class); $fields = $this->mock(Fields::class, function (MockInterface $mock): void { - $mock->shouldReceive('items')->andReturn(collect([])); + $mock->shouldReceive('all')->andReturn(collect([])); }); $blueprint->shouldReceive('fields')->andReturn($fields); @@ -488,7 +435,7 @@ public function parse_sets_parses_set_configurations(): void } #[Test] - public function parse_sets_handles_nested_sets(): void + public function parse_sets_uses_nested_set_handles_from_set_groups(): void { $service = new ReplicatorFieldService; $reflection = new \ReflectionClass($service); @@ -496,21 +443,19 @@ public function parse_sets_handles_nested_sets(): void $method->setAccessible(true); $sets = [ - 'parent' => [ - 'display' => 'Parent', - 'fields' => [ - 'parent_field' => ['type' => 'text', 'display' => 'Parent Field'], - ], + 'new_opening_time' => [ + 'display' => 'New opening time', 'sets' => [ - [ + 'opening_time' => [ + 'display' => 'Opening time', 'fields' => [ - 'child_field' => ['type' => 'text', 'display' => 'Child Field'], - ], - 'sets' => [ [ - 'fields' => [ - 'grandchild_field' => ['type' => 'text', 'display' => 'Grandchild Field'], - ], + 'handle' => 'day', + 'field' => ['type' => 'select', 'display' => 'Day'], + ], + [ + 'handle' => 'opening_time', + 'field' => ['type' => 'time', 'display' => 'Opens'], ], ], ], @@ -521,11 +466,18 @@ public function parse_sets_handles_nested_sets(): void $result = $method->invoke($service, $sets); $this->assertIsArray($result); - $this->assertNotEmpty($result); + $this->assertCount(1, $result); /** @var array> $result */ + /** @var array $firstResult */ + $firstResult = $result[0]; + $this->assertSame('opening_time', $firstResult['value']); + $this->assertSame('Opening time', $firstResult['label']); /** @var array> $fields */ - $fields = $result[0]['fields']; - $this->assertCount(3, $fields); + $fields = $firstResult['fields']; + $this->assertCount(2, $fields); + $this->assertSame('day', $fields[0]['value']); + $this->assertSame('select', $fields[0]['type']); + $this->assertSame('opening_time', $fields[1]['value']); } #[Test] @@ -720,30 +672,6 @@ public function extract_field_data_returns_null_for_invalid_input(): void $this->assertNull($result); } - #[Test] - public function extract_fields_from_nested_sets_skips_non_array_configs(): void - { - $service = new ReplicatorFieldService; - $reflection = new \ReflectionClass($service); - $method = $reflection->getMethod('extractFieldsFromNestedSets'); - $method->setAccessible(true); - - $nestedSets = [ - 'invalid', - [ - 'fields' => [ - 'field_one' => ['type' => 'text', 'display' => 'Field One'], - ], - ], - ]; - - /** @var array> $result */ - $result = $method->invoke($service, $nestedSets); - - $this->assertCount(1, $result); - $this->assertSame('field_one', $result[0]['value']); - } - #[Test] public function parse_set_fields_skips_fields_with_non_string_handle(): void { @@ -778,6 +706,7 @@ public function is_field_type_eligible_returns_true_for_eligible_types(): void $this->assertTrue($method->invoke($service, 'text')); $this->assertTrue($method->invoke($service, 'textarea')); $this->assertTrue($method->invoke($service, 'date')); + $this->assertTrue($method->invoke($service, 'select')); } #[Test] @@ -886,13 +815,13 @@ public function extract_fields_from_blueprints_handles_multiple_blueprints(): vo $blueprint1 = $this->mock(Blueprint::class); $blueprint2 = $this->mock(Blueprint::class); $fields1 = $this->mock(Fields::class, function (MockInterface $mock): void { - $mock->shouldReceive('items')->andReturn(collect([ - ['handle' => 'field1'], + $mock->shouldReceive('all')->andReturn(collect([ + 'field1' => $this->mockField('field1'), ])); }); $fields2 = $this->mock(Fields::class, function (MockInterface $mock): void { - $mock->shouldReceive('items')->andReturn(collect([ - ['handle' => 'field2'], + $mock->shouldReceive('all')->andReturn(collect([ + 'field2' => $this->mockField('field2'), ])); }); @@ -907,6 +836,8 @@ public function extract_fields_from_blueprints_handles_multiple_blueprints(): vo /** @var array> $result */ $this->assertCount(2, $result); + $this->assertSame('field1', $result[0]['handle']); + $this->assertSame('field2', $result[1]['handle']); } #[Test] @@ -1156,4 +1087,129 @@ public function get_replicator_fields_unwraps_labeled_value_runway_handle(): voi $this->assertNotEmpty($result); } + + #[Test] + public function get_replicator_fields_resolves_imported_fieldset_replicators(): void + { + FieldsetFacade::make('opening_times') + ->setContents([ + 'fields' => [ + [ + 'handle' => 'opening_times', + 'field' => [ + 'type' => 'replicator', + 'display' => 'Opening hours', + 'sets' => [ + 'new_opening_time' => [ + 'display' => 'New opening time', + 'sets' => [ + 'opening_time' => [ + 'display' => 'Opening time', + 'fields' => [ + [ + 'handle' => 'day', + 'field' => [ + 'type' => 'select', + 'display' => 'Day', + ], + ], + [ + 'handle' => 'opening_time', + 'field' => [ + 'type' => 'time', + 'display' => 'Opens', + ], + ], + ], + ], + ], + ], + ], + ], + ], + ], + ]) + ->save(); + + $collection = CollectionFacade::make('locations'); + $collection->save(); + + BlueprintFacade::make('location') + ->setNamespace('collections.locations') + ->setContents([ + 'fields' => [ + [ + 'handle' => 'sidebar_links', + 'field' => [ + 'type' => 'replicator', + 'display' => 'Sidebar links', + 'sets' => [ + 'link' => [ + 'display' => 'Link', + 'fields' => [ + [ + 'handle' => 'url', + 'field' => [ + 'type' => 'text', + 'display' => 'URL', + ], + ], + ], + ], + ], + ], + ], + [ + 'import' => 'opening_times', + ], + ], + ]) + ->save(); + + $templatesCollection = CollectionFacade::make('structured_data_templates'); + $templatesCollection->save(); + + $template = (new Entry) + ->collection($templatesCollection) + ->id('template-imported'); + + $template->use_for_collection = $collection; + + $service = new ReplicatorFieldService; + $result = $service->getReplicatorFields($template); + + $handles = array_column($result, 'handle'); + $this->assertContains('sidebar_links', $handles); + $this->assertContains('opening_times', $handles); + + /** @var array|null $openingTimes */ + $openingTimes = collect($result)->firstWhere('handle', 'opening_times'); + $this->assertIsArray($openingTimes); + $this->assertSame('Opening hours', $openingTimes['display']); + + /** @var array> $sets */ + $sets = $openingTimes['sets']; + $this->assertCount(1, $sets); + $this->assertSame('opening_time', $sets[0]['value']); + + /** @var array> $fields */ + $fields = $sets[0]['fields']; + $fieldHandles = array_column($fields, 'value'); + $this->assertContains('day', $fieldHandles); + $this->assertContains('opening_time', $fieldHandles); + } + + /** + * @param array $config + */ + private function mockField(string $handle, array $config = []): Field + { + /** @var Field&MockInterface $field */ + $field = $this->mock(Field::class, function (MockInterface $mock) use ($handle, $config): void { + $mock->shouldReceive('handle')->andReturn($handle); + $mock->shouldReceive('config')->andReturn($config); + }); + + return $field; + } }