diff --git a/resources/js/components/fieldtypes/DictionaryFieldtype.vue b/resources/js/components/fieldtypes/DictionaryFieldtype.vue index 3a695d07daf..12042303aa7 100644 --- a/resources/js/components/fieldtypes/DictionaryFieldtype.vue +++ b/resources/js/components/fieldtypes/DictionaryFieldtype.vue @@ -36,6 +36,7 @@ class="sortable-item cursor-grab active:cursor-grabbing" > +
{{ __(getOptionLabel(option)) }}
@@ -65,7 +66,7 @@ import Fieldtype from './Fieldtype.vue'; import HasInputOptions from './HasInputOptions.js'; import { SortableList } from '../sortable/Sortable'; import debounce from '@/util/debounce.js'; -import { Badge, Combobox } from '@/components/ui'; +import { Badge, Combobox, Icon } from '@/components/ui'; export default { mixins: [Fieldtype, HasInputOptions], @@ -73,6 +74,7 @@ export default { components: { Badge, Combobox, + Icon, SortableList, }, @@ -119,6 +121,7 @@ export default { label: DOMPurify.sanitize(option.label, { USE_PROFILES: { html: true, svg: true }, }), + ...(option.icon ? { icon: option.icon } : {}), invalid: option.invalid }; }); diff --git a/resources/js/components/fieldtypes/HasInputOptions.js b/resources/js/components/fieldtypes/HasInputOptions.js index 600d83a1e94..633aef81adf 100644 --- a/resources/js/components/fieldtypes/HasInputOptions.js +++ b/resources/js/components/fieldtypes/HasInputOptions.js @@ -26,6 +26,7 @@ export default { return { value: option[valueKey], label: `${__(option[labelKey]) || option[valueKey]}`, + ...(option.icon ? { icon: option.icon } : {}), }; } diff --git a/resources/js/components/ui/Combobox/Combobox.vue b/resources/js/components/ui/Combobox/Combobox.vue index 9aaf4d37171..ae372b4d0c3 100644 --- a/resources/js/components/ui/Combobox/Combobox.vue +++ b/resources/js/components/ui/Combobox/Combobox.vue @@ -414,8 +414,8 @@ defineExpose({ data-ui-combobox-selected-option > -
- +
+
@@ -504,6 +504,7 @@ defineExpose({ > + {{ __(getOptionLabel(option)) }} @@ -547,6 +548,7 @@ defineExpose({ class="sortable-item mt-2 cursor-grab active:cursor-grabbing" > +
{{ __(getOptionLabel(option)) }}
diff --git a/resources/js/tests/NormalizeInputOptions.test.js b/resources/js/tests/NormalizeInputOptions.test.js index 8997a8b58ef..5855fe77182 100644 --- a/resources/js/tests/NormalizeInputOptions.test.js +++ b/resources/js/tests/NormalizeInputOptions.test.js @@ -54,3 +54,27 @@ it('normalizes input options with array of objects with key value keys', () => { { value: 'two', label: 'Two' }, ]); }); + +it('preserves icon when normalizing object options with value label keys', () => { + expect( + normalizeInputOptions([ + { value: 'one', label: 'One', icon: 'globe' }, + { value: 'two', label: 'Two' }, + ]), + ).toEqual([ + { value: 'one', label: 'Uno', icon: 'globe' }, + { value: 'two', label: 'Two' }, + ]); +}); + +it('preserves icon when normalizing object options with key value keys', () => { + expect( + normalizeInputOptions([ + { key: 'one', value: 'One', icon: 'globe' }, + { key: 'two', value: 'Two' }, + ]), + ).toEqual([ + { value: 'one', label: 'Uno', icon: 'globe' }, + { value: 'two', label: 'Two' }, + ]); +}); diff --git a/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js b/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js index a303c9bbaac..c6d95d99c35 100644 --- a/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js +++ b/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js @@ -68,4 +68,23 @@ describe('DictionaryFieldtype options', () => { expect(fieldtype.vm.normalizedOptions).toEqual([{ value: 'ca', label: 'Canada' }]); }); + + test('selected options carry their icon when present', async () => { + const fieldtype = mountFieldtype({ + value: ['de', 'fr'], + maxItems: null, + selectedOptions: [ + { value: 'de', label: 'Germany', icon: 'globe', invalid: false }, + { value: 'fr', label: 'France', invalid: false }, + ], + fetchedOptions: [], + shallow: true, + }); + await flushPromises(); + + expect(fieldtype.vm.selectedOptions).toEqual([ + { value: 'de', label: 'Germany', icon: 'globe', invalid: false }, + { value: 'fr', label: 'France', invalid: false }, + ]); + }); }); diff --git a/src/Console/Commands/stubs/dictionary.php.stub b/src/Console/Commands/stubs/dictionary.php.stub index 05b2bc54ef9..7979da30547 100644 --- a/src/Console/Commands/stubs/dictionary.php.stub +++ b/src/Console/Commands/stubs/dictionary.php.stub @@ -12,7 +12,7 @@ class DummyClass extends BasicDictionary protected function getItems(): array { return [ - ['name' => 'Alabama', 'abbr' => 'AL', 'capital' => 'Montgomery'], + ['name' => 'Alabama', 'abbr' => 'AL', 'capital' => 'Montgomery', 'icon' => 'map-pin'], ['name' => 'Alaska', 'abbr' => 'AK', 'capital' => 'Juneau'], ['name' => 'Arizona', 'abbr' => 'AZ', 'capital' => 'Phoenix'], // ... diff --git a/src/Dictionaries/BasicDictionary.php b/src/Dictionaries/BasicDictionary.php index 77cb0d672a5..ded276c343a 100644 --- a/src/Dictionaries/BasicDictionary.php +++ b/src/Dictionaries/BasicDictionary.php @@ -62,6 +62,10 @@ protected function matchesSearchQuery(string $query, Item $item): bool $searchableLookup = empty($this->searchable) ? null : array_flip($this->searchable); foreach ($item->extra() as $key => $value) { + if ($key === 'icon') { + continue; + } + if ($searchableLookup !== null && ! isset($searchableLookup[$key])) { continue; } diff --git a/src/Dictionaries/Item.php b/src/Dictionaries/Item.php index 5ee458f75a9..3247c072742 100644 --- a/src/Dictionaries/Item.php +++ b/src/Dictionaries/Item.php @@ -16,9 +16,14 @@ public function __construct($value, $label, array $extra) ); } + public function icon(): ?string + { + return $this->extra['icon'] ?? null; + } + public function data(): array { - return Arr::except($this->extra, ['label']); + return Arr::except($this->extra, ['label', 'icon']); } public function offsetExists(mixed $offset): bool diff --git a/src/Fieldtypes/Dictionary.php b/src/Fieldtypes/Dictionary.php index fd1358d015f..128e33fc090 100644 --- a/src/Fieldtypes/Dictionary.php +++ b/src/Fieldtypes/Dictionary.php @@ -86,11 +86,12 @@ private function getItemData($values) return collect($values)->map(function ($key) { $item = $this->dictionary()->get($key); - return [ + return array_filter([ 'value' => $item?->value() ?? $key, 'label' => $item?->label() ?? $key, + 'icon' => $item?->icon(), 'invalid' => ! $item, - ]; + ], fn ($v, $k) => $k !== 'icon' || $v !== null, ARRAY_FILTER_USE_BOTH); })->values()->all(); } diff --git a/src/Http/Controllers/DictionaryFieldtypeController.php b/src/Http/Controllers/DictionaryFieldtypeController.php index a54ee4ade84..8bfd033f35a 100644 --- a/src/Http/Controllers/DictionaryFieldtypeController.php +++ b/src/Http/Controllers/DictionaryFieldtypeController.php @@ -25,14 +25,23 @@ public function __invoke(Request $request, string $dictionary) throw new ForbiddenHttpException; } - $options = $dictionary->options($request->search); - // Return an ordered list of key/value pairs rather than a value-keyed object. // When the values are integers, the browser would re-sort the object's keys ascending, // discarding the dictionary's own order. return [ - 'data' => collect($options) - ->map(fn ($label, $key) => ['key' => (string) $key, 'value' => $label]) + 'data' => collect($dictionary->optionItems($request->search)) + ->map(function ($item) { + $option = [ + 'key' => (string) $item->value(), + 'value' => $item->label(), + ]; + + if ($icon = $item->icon()) { + $option['icon'] = $icon; + } + + return $option; + }) ->values() ->all(), ]; diff --git a/tests/Dictionaries/BasicDictionaryTest.php b/tests/Dictionaries/BasicDictionaryTest.php new file mode 100644 index 00000000000..7af68c58b8c --- /dev/null +++ b/tests/Dictionaries/BasicDictionaryTest.php @@ -0,0 +1,47 @@ +assertCount(0, $dictionary->optionItems('svg')); + $this->assertCount(0, $dictionary->optionItems('map-pin')); + $this->assertCount(1, $dictionary->optionItems('Alabama')); + } + + #[Test] + public function option_items_expose_the_icon() + { + $dictionary = new IconSearchDictionary; + + $items = collect($dictionary->optionItems()); + + $this->assertEquals('map-pin', $items->get('AL')->icon()); + $this->assertEquals('map-pin', $items->get('AK')->icon()); + $this->assertNull($items->get('AZ')->icon()); + } +} + +class IconSearchDictionary extends BasicDictionary +{ + protected string $valueKey = 'abbr'; + protected string $labelKey = 'name'; + + protected function getItems(): array + { + return [ + ['name' => 'Alabama', 'abbr' => 'AL', 'icon' => 'map-pin'], + ['name' => 'Alaska', 'abbr' => 'AK', 'icon' => 'map-pin'], + ['name' => 'Arizona', 'abbr' => 'AZ'], + ]; + } +} diff --git a/tests/Dictionaries/ItemTest.php b/tests/Dictionaries/ItemTest.php index 6dc623060ba..552cdca125c 100644 --- a/tests/Dictionaries/ItemTest.php +++ b/tests/Dictionaries/ItemTest.php @@ -28,4 +28,31 @@ public function it_gets_value_label_and_data() 'label' => '🍎 Apple', ], $item->toArray()); } + + #[Test] + public function it_gets_the_icon() + { + $item = new Item('apple', 'Apple', [ + 'icon' => 'apple', + 'color' => 'red', + ]); + + $this->assertEquals('apple', $item->icon()); + $this->assertEquals(['color' => 'red'], $item->data()); + $this->assertEquals([ + 'key' => 'apple', + 'value' => 'apple', + 'icon' => 'apple', + 'color' => 'red', + 'label' => 'Apple', + ], $item->toArray()); + } + + #[Test] + public function icon_is_null_when_not_set() + { + $item = new Item('apple', 'Apple', ['color' => 'red']); + + $this->assertNull($item->icon()); + } } diff --git a/tests/Fieldtypes/DictionaryTest.php b/tests/Fieldtypes/DictionaryTest.php index ba8351fdcc4..65b7f11de31 100644 --- a/tests/Fieldtypes/DictionaryTest.php +++ b/tests/Fieldtypes/DictionaryTest.php @@ -5,6 +5,7 @@ use Facades\Statamic\Fields\FieldtypeRepository; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Statamic\Dictionaries\BasicDictionary; use Statamic\Dictionaries\Countries; use Statamic\Dictionaries\Dictionary; use Statamic\Dictionaries\Item; @@ -321,6 +322,44 @@ public function it_filters_out_invalid_values_when_augmenting_multiple() ], collect($augment)->toArray()); } + #[Test] + public function it_includes_icons_in_preload_data_when_present() + { + IconDictionary::register(); + + $field = (new Field('test', ['type' => 'dictionary', 'dictionary' => 'icon'])); + $field->setValue(['AL', 'AK']); + + $fieldtype = FieldtypeRepository::find('dictionary'); + $fieldtype->setField($field); + + $preload = $fieldtype->preload(); + + $this->assertEquals([ + ['value' => 'AL', 'label' => 'Alabama', 'icon' => 'map-pin', 'invalid' => false], + ['value' => 'AK', 'label' => 'Alaska', 'invalid' => false], + ], $preload['selectedOptions']); + } + + #[Test] + public function the_options_api_returns_icons_when_present() + { + IconDictionary::register(); + + $config = base64_encode(json_encode(['type' => 'dictionary', 'dictionary' => 'icon'])); + + $this + ->actingAs(User::make()->makeSuper()) + ->getJson(route('statamic.dictionary-fieldtype', 'icon').'?config='.$config) + ->assertOk() + ->assertExactJson([ + 'data' => [ + ['key' => 'AL', 'value' => 'Alabama', 'icon' => 'map-pin'], + ['key' => 'AK', 'value' => 'Alaska'], + ], + ]); + } + #[Test] public function it_returns_extra_renderable_field_data() { @@ -358,3 +397,19 @@ public function get(string $key): ?Item return new Item($key, $this->options()[$key], []); } } + +class IconDictionary extends BasicDictionary +{ + protected static $handle = 'icon'; + + protected string $valueKey = 'abbr'; + protected string $labelKey = 'name'; + + protected function getItems(): array + { + return [ + ['name' => 'Alabama', 'abbr' => 'AL', 'icon' => 'map-pin'], + ['name' => 'Alaska', 'abbr' => 'AK'], + ]; + } +}