Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion resources/js/components/fieldtypes/DictionaryFieldtype.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
class="sortable-item cursor-grab active:cursor-grabbing"
>
<Badge size="lg" color="white">
<Icon v-if="option.icon" :name="option.icon" />
<div v-if="labelHtml" v-html="getOptionLabel(option)"></div>
<div v-else>{{ __(getOptionLabel(option)) }}</div>

Expand Down Expand Up @@ -65,14 +66,15 @@ 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],

components: {
Badge,
Combobox,
Icon,
SortableList,
},

Expand Down Expand Up @@ -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
};
});
Expand Down
1 change: 1 addition & 0 deletions resources/js/components/fieldtypes/HasInputOptions.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export default {
return {
value: option[valueKey],
label: `${__(option[labelKey]) || option[valueKey]}`,
...(option.icon ? { icon: option.icon } : {}),
};
}

Expand Down
6 changes: 4 additions & 2 deletions resources/js/components/ui/Combobox/Combobox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -414,8 +414,8 @@ defineExpose({
data-ui-combobox-selected-option
>
<slot v-if="selectedOption" name="selected-option" v-bind="{ option: selectedOption }">
<div v-if="icon" class="size-4">
<Icon :name="icon" class="text-gray-900 dark:text-white dark:opacity-50" />
<div v-if="selectedOption.icon || icon" class="size-4">
<Icon :name="selectedOption.icon ?? icon" class="text-gray-900 dark:text-white dark:opacity-50" />
</div>
<span v-if="labelHtml" v-html="getOptionLabel(selectedOption)" class="block truncate" />
<span v-else v-text="getOptionLabel(selectedOption)" class="block truncate" />
Expand Down Expand Up @@ -504,6 +504,7 @@ defineExpose({
>
<slot name="option" v-bind="option">
<img v-if="option.image" :src="option.image" class="size-5 rounded-full" :alt="getOptionLabel(option)">
<Icon v-else-if="option.icon" :name="option.icon" />
<span v-if="labelHtml" class="truncate" v-html="getOptionLabel(option)" />
<span class="truncate" v-else>{{ __(getOptionLabel(option)) }}</span>
</slot>
Expand Down Expand Up @@ -547,6 +548,7 @@ defineExpose({
class="sortable-item mt-2 cursor-grab active:cursor-grabbing"
>
<Badge pill size="lg" class="[&>*]:st-text-trim-ex-alphabetic">
<Icon v-if="option.icon" :name="option.icon" />
<div v-if="labelHtml" v-html="getOptionLabel(option)"></div>
<div v-else>{{ __(getOptionLabel(option)) }}</div>

Expand Down
24 changes: 24 additions & 0 deletions resources/js/tests/NormalizeInputOptions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
]);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
]);
});
});
2 changes: 1 addition & 1 deletion src/Console/Commands/stubs/dictionary.php.stub
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
// ...
Expand Down
4 changes: 4 additions & 0 deletions src/Dictionaries/BasicDictionary.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
7 changes: 6 additions & 1 deletion src/Dictionaries/Item.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/Fieldtypes/Dictionary.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
17 changes: 13 additions & 4 deletions src/Http/Controllers/DictionaryFieldtypeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
];
Expand Down
47 changes: 47 additions & 0 deletions tests/Dictionaries/BasicDictionaryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace Tests\Dictionaries;

use PHPUnit\Framework\Attributes\Test;
use Statamic\Dictionaries\BasicDictionary;
use Tests\TestCase;

class BasicDictionaryTest extends TestCase
{
#[Test]
public function search_does_not_match_against_icon_values()
{
$dictionary = new IconSearchDictionary;

$this->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('<svg>map-pin</svg>', $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' => '<svg>map-pin</svg>'],
['name' => 'Arizona', 'abbr' => 'AZ'],
];
}
}
27 changes: 27 additions & 0 deletions tests/Dictionaries/ItemTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
55 changes: 55 additions & 0 deletions tests/Fieldtypes/DictionaryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
{
Expand Down Expand Up @@ -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'],
];
}
}
Loading