diff --git a/resources/css/components/fieldtypes/dictionary-fields.css b/resources/css/components/fieldtypes/dictionary-fields.css
new file mode 100644
index 0000000000..f910a979a1
--- /dev/null
+++ b/resources/css/components/fieldtypes/dictionary-fields.css
@@ -0,0 +1,20 @@
+.dictionary_fields-fieldtype {
+ @apply p-0;
+
+ .publish-fields {
+ @apply w-full;
+ }
+
+ .config-field {
+ @apply md:flex flex-wrap border-b border-gray-400 dark:border-dark-900 w-full;
+ @apply p-3 @sm:p-4 m-0;
+
+ .field-inner {
+ @apply w-full md:w-1/2 rtl:md:pl-8 ltr:md:pr-8;
+ }
+
+ .field-inner + div {
+ @apply w-full md:w-1/2;
+ }
+ }
+}
diff --git a/resources/css/cp.css b/resources/css/cp.css
index 791941b281..0c9768b493 100644
--- a/resources/css/cp.css
+++ b/resources/css/cp.css
@@ -58,6 +58,7 @@
@import "components/fieldtypes/checkboxes";
@import "components/fieldtypes/code";
@import "components/fieldtypes/datetime";
+@import "components/fieldtypes/dictionary-fields";
@import "components/fieldtypes/environment";
@import "components/fieldtypes/grid";
@import "components/fieldtypes/hidden";
diff --git a/resources/css/vendors/vue-select.css b/resources/css/vendors/vue-select.css
index f4cfbbc050..4f2222c637 100644
--- a/resources/css/vendors/vue-select.css
+++ b/resources/css/vendors/vue-select.css
@@ -173,6 +173,12 @@
&.sortable-item { @apply !cursor-grab; }
+ &.invalid {
+ @apply border-red-300 dark:border-dark-red bg-red-100 dark:bg-red-400 text-red-500 dark:text-red-950;
+ background-image: none;
+
+ .vs__deselect { @apply text-red-500 dark:text-red-950 }
+ }
}
.vs__deselect {
diff --git a/resources/js/bootstrap/fieldtypes.js b/resources/js/bootstrap/fieldtypes.js
index 245cb53d69..fb18e8689c 100644
--- a/resources/js/bootstrap/fieldtypes.js
+++ b/resources/js/bootstrap/fieldtypes.js
@@ -24,6 +24,8 @@ import Routes from '../components/collections/Routes.vue';
import TitleFormats from '../components/collections/TitleFormats.vue';
import ColorFieldtype from '../components/fieldtypes/ColorFieldtype.vue';
import DateFieldtype from '../components/fieldtypes/DateFieldtype.vue';
+import DictionaryFieldtype from "../components/fieldtypes/DictionaryFieldtype.vue";
+import DictionaryFields from "../components/fieldtypes/DictionaryFields.vue";
import FieldDisplayFieldtype from '../components/fieldtypes/FieldDisplayFieldtype.vue';
import FieldsFieldtype from '../components/fieldtypes/grid/FieldsFieldtype.vue';
import FilesFieldtype from '../components/fieldtypes/FilesFieldtype.vue';
@@ -86,6 +88,8 @@ Vue.component('collection_routes-fieldtype', Routes);
Vue.component('collection_title_formats-fieldtype', TitleFormats);
Vue.component('color-fieldtype', ColorFieldtype);
Vue.component('date-fieldtype', DateFieldtype);
+Vue.component('dictionary-fieldtype', DictionaryFieldtype);
+Vue.component('dictionary_fields-fieldtype', DictionaryFields);
Vue.component('field_display-fieldtype', FieldDisplayFieldtype);
Vue.component('fields-fieldtype', FieldsFieldtype);
Vue.component('files-fieldtype', FilesFieldtype);
diff --git a/resources/js/components/fieldtypes/DictionaryFields.vue b/resources/js/components/fieldtypes/DictionaryFields.vue
new file mode 100644
index 0000000000..6c92a372d0
--- /dev/null
+++ b/resources/js/components/fieldtypes/DictionaryFields.vue
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
diff --git a/resources/js/components/fieldtypes/DictionaryFieldtype.vue b/resources/js/components/fieldtypes/DictionaryFieldtype.vue
new file mode 100644
index 0000000000..7cf2d83611
--- /dev/null
+++ b/resources/js/components/fieldtypes/DictionaryFieldtype.vue
@@ -0,0 +1,200 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /
+
+
+
+
+
+
+
diff --git a/resources/lang/en/fieldtypes.php b/resources/lang/en/fieldtypes.php
index 050eb22567..58f8b983b6 100644
--- a/resources/lang/en/fieldtypes.php
+++ b/resources/lang/en/fieldtypes.php
@@ -72,6 +72,10 @@
'date.config.time_enabled' => 'Enable the timepicker.',
'date.config.time_seconds_enabled' => 'Show seconds in the timepicker.',
'date.title' => 'Date',
+ 'dictionary.config.dictionary' => 'The dictionary you wish to pull options from.',
+ 'dictionary.file.config.filename' => 'The filename containing your options, relative to the `resources/dictionaries` directory.',
+ 'dictionary.file.config.label' => "The key containing the options' labels. By default it's `label`. Alternatively, you may use Antlers.",
+ 'dictionary.file.config.value' => "The key containing the options' values. By default it's `value`.",
'entries.config.create' => 'Allow creation of new entries.',
'entries.config.collections' => 'Choose which collections the user can select from.',
'entries.config.query_scopes' => 'Choose which query scopes should be applied when retrieving selectable entries.',
diff --git a/resources/lang/en/messages.php b/resources/lang/en/messages.php
index e218aa22c7..1ac3670ab5 100644
--- a/resources/lang/en/messages.php
+++ b/resources/lang/en/messages.php
@@ -69,6 +69,7 @@
'collections_sort_direction_instructions' => 'The default sort direction.',
'collections_preview_target_refresh_instructions' => 'Automatically refresh the preview while editing. Disabling this will use postMessage.',
'collections_taxonomies_instructions' => 'Connect entries in this collection to taxonomies. Fields will be automatically added to publish forms.',
+ 'dictionaries_countries_region_instructions' => 'Optionally filter the countries by region.',
'duplicate_action_warning_localization' => 'This entry is a localization. The origin entry will be duplicated.',
'duplicate_action_warning_localizations' => 'One or more selected entries are localizations. In those cases, the origin entry will be duplicated instead.',
'duplicate_action_localizations_confirmation' => 'Are you sure you want to run this action? Localizations will also be duplicated.',
diff --git a/resources/svg/icons/light/dictionary.svg b/resources/svg/icons/light/dictionary.svg
new file mode 100644
index 0000000000..4147382376
--- /dev/null
+++ b/resources/svg/icons/light/dictionary.svg
@@ -0,0 +1 @@
+
diff --git a/resources/views/extend/forms/fields/dictionary.antlers.html b/resources/views/extend/forms/fields/dictionary.antlers.html
new file mode 100644
index 0000000000..b093f6a026
--- /dev/null
+++ b/resources/views/extend/forms/fields/dictionary.antlers.html
@@ -0,0 +1,21 @@
+
diff --git a/resources/views/forms/automagic-email.antlers.html b/resources/views/forms/automagic-email.antlers.html
index f8fd90528b..cab5234e64 100644
--- a/resources/views/forms/automagic-email.antlers.html
+++ b/resources/views/forms/automagic-email.antlers.html
@@ -12,7 +12,7 @@
{{ elseif fieldtype == "radio" }}
{{ value:label ?? value }}
- {{ elseif fieldtype == "select" || fieldtype == "checkboxes" }}
+ {{ elseif fieldtype == "select" || fieldtype == "checkboxes" || fieldtype == "dictionary" }}
{{ value }}{{ label ?? value }}{{ if !last }}, {{ /if }}{{ /value }}
{{ elseif value|is_iterable }}
diff --git a/routes/cp.php b/routes/cp.php
index 959e0f63c1..75af4e9206 100644
--- a/routes/cp.php
+++ b/routes/cp.php
@@ -45,6 +45,7 @@
use Statamic\Http\Controllers\CP\Fields\FieldsetController;
use Statamic\Http\Controllers\CP\Fields\FieldtypesController;
use Statamic\Http\Controllers\CP\Fields\MetaController;
+use Statamic\Http\Controllers\CP\Fieldtypes\DictionaryFieldtypeController;
use Statamic\Http\Controllers\CP\Fieldtypes\FilesFieldtypeController;
use Statamic\Http\Controllers\CP\Fieldtypes\MarkdownFieldtypeController;
use Statamic\Http\Controllers\CP\Fieldtypes\RelationshipFieldtypeController;
@@ -309,6 +310,7 @@
Route::get('relationship/filters', [RelationshipFieldtypeController::class, 'filters'])->name('relationship.filters');
Route::post('markdown', [MarkdownFieldtypeController::class, 'preview'])->name('markdown.preview');
Route::post('files/upload', [FilesFieldtypeController::class, 'upload'])->name('files.upload');
+ Route::get('dictionaries/{dictionary}', DictionaryFieldtypeController::class)->name('dictionary-fieldtype');
});
Route::group(['prefix' => 'api', 'as' => 'api.'], function () {
diff --git a/src/Console/Commands/MakeDictionary.php b/src/Console/Commands/MakeDictionary.php
new file mode 100644
index 0000000000..f1a8618496
--- /dev/null
+++ b/src/Console/Commands/MakeDictionary.php
@@ -0,0 +1,74 @@
+argument('addon')) {
+ $this->updateServiceProvider();
+ }
+ }
+
+ /**
+ * Update the Service Provider to register dictionary components.
+ */
+ protected function updateServiceProvider()
+ {
+ $factory = new BuilderFactory();
+
+ $dictionaryClassValue = $factory->classConstFetch('Dictionaries\\'.$this->getNameInput(), 'class');
+
+ try {
+ PHPFile::load("addons/{$this->package}/src/ServiceProvider.php")
+ ->add()->protected()->property('dictionaries', $dictionaryClassValue)
+ ->save();
+ } catch (\Exception $e) {
+ $this->comment("Don't forget to register the Dictionary class in your addon's service provider.");
+ }
+ }
+}
diff --git a/src/Console/Commands/stubs/dictionary.php.stub b/src/Console/Commands/stubs/dictionary.php.stub
new file mode 100644
index 0000000000..05b2bc54ef
--- /dev/null
+++ b/src/Console/Commands/stubs/dictionary.php.stub
@@ -0,0 +1,21 @@
+ 'Alabama', 'abbr' => 'AL', 'capital' => 'Montgomery'],
+ ['name' => 'Alaska', 'abbr' => 'AK', 'capital' => 'Juneau'],
+ ['name' => 'Arizona', 'abbr' => 'AZ', 'capital' => 'Phoenix'],
+ // ...
+ ];
+ }
+}
diff --git a/src/Dictionaries/BasicDictionary.php b/src/Dictionaries/BasicDictionary.php
new file mode 100644
index 0000000000..45d6e5f322
--- /dev/null
+++ b/src/Dictionaries/BasicDictionary.php
@@ -0,0 +1,69 @@
+collectItems()->get($key);
+ }
+
+ public function options(?string $search = null): array
+ {
+ return $this
+ ->getFilteredItems()
+ ->when($search, fn ($collection) => $collection->filter(fn ($item) => $this->matchesSearchQuery($search, $item)))
+ ->mapWithKeys(fn (Item $item) => [$item->value() => $item->label()])
+ ->all();
+ }
+
+ protected function getFilteredItems(): Collection
+ {
+ return $this->collectItems();
+ }
+
+ protected function collectItems(): Collection
+ {
+ return collect($this->getItems())->mapWithKeys(function ($arr) {
+ $item = new Item($key = $this->getItemValue($arr), $this->getItemLabel($arr), $arr);
+
+ return [$key => $item];
+ });
+ }
+
+ protected function getItemValue(array $item): string
+ {
+ return $item[$this->valueKey];
+ }
+
+ protected function getItemLabel(array $item): string
+ {
+ return $item[$this->labelKey];
+ }
+
+ protected function matchesSearchQuery(string $query, Item $item): bool
+ {
+ $query = strtolower($query);
+
+ foreach ($item->data() as $key => $value) {
+ if (! empty($this->searchable) && ! in_array($key, $this->searchable)) {
+ continue;
+ }
+
+ if (str_contains(strtolower($value), $query)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ abstract protected function getItems(): array;
+}
diff --git a/src/Dictionaries/Countries.php b/src/Dictionaries/Countries.php
new file mode 100644
index 0000000000..669c20ec15
--- /dev/null
+++ b/src/Dictionaries/Countries.php
@@ -0,0 +1,299 @@
+ 'Africa',
+ 'americas' => 'Americas',
+ 'asia' => 'Asia',
+ 'europe' => 'Europe',
+ 'oceania' => 'Oceania',
+ 'polar' => 'Polar',
+ ];
+
+ protected function getItemLabel(array $item): string
+ {
+ return "{$item['emoji']} {$item['name']}";
+ }
+
+ protected function fieldItems()
+ {
+ return [
+ 'region' => [
+ 'display' => __('Region'),
+ 'instructions' => __('statamic::messages.dictionaries_countries_region_instructions'),
+ 'type' => 'select',
+ 'options' => $this->regions,
+ ],
+ ];
+ }
+
+ protected function getFilteredItems(): Collection
+ {
+ return $this
+ ->collectItems()
+ ->when($this->config['region'] ?? false, fn ($collection, $region) => $collection->where('region', $this->regions[$region]));
+ }
+
+ protected function getItems(): array
+ {
+ return [
+ ['name' => 'Afghanistan', 'iso3' => 'AFG', 'iso2' => 'AF', 'region' => 'Asia', 'subregion' => 'Southern Asia', 'emoji' => '๐ฆ๐ซ'],
+ ['name' => 'Aland Islands', 'iso3' => 'ALA', 'iso2' => 'AX', 'region' => 'Europe', 'subregion' => 'Northern Europe', 'emoji' => '๐ฆ๐ฝ'],
+ ['name' => 'Albania', 'iso3' => 'ALB', 'iso2' => 'AL', 'region' => 'Europe', 'subregion' => 'Southern Europe', 'emoji' => '๐ฆ๐ฑ'],
+ ['name' => 'Algeria', 'iso3' => 'DZA', 'iso2' => 'DZ', 'region' => 'Africa', 'subregion' => 'Northern Africa', 'emoji' => '๐ฉ๐ฟ'],
+ ['name' => 'American Samoa', 'iso3' => 'ASM', 'iso2' => 'AS', 'region' => 'Oceania', 'subregion' => 'Polynesia', 'emoji' => '๐ฆ๐ธ'],
+ ['name' => 'Andorra', 'iso3' => 'AND', 'iso2' => 'AD', 'region' => 'Europe', 'subregion' => 'Southern Europe', 'emoji' => '๐ฆ๐ฉ'],
+ ['name' => 'Angola', 'iso3' => 'AGO', 'iso2' => 'AO', 'region' => 'Africa', 'subregion' => 'Middle Africa', 'emoji' => '๐ฆ๐ด'],
+ ['name' => 'Anguilla', 'iso3' => 'AIA', 'iso2' => 'AI', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ฆ๐ฎ'],
+ ['name' => 'Antarctica', 'iso3' => 'ATA', 'iso2' => 'AQ', 'region' => 'Polar', 'subregion' => '', 'emoji' => '๐ฆ๐ถ'],
+ ['name' => 'Antigua And Barbuda', 'iso3' => 'ATG', 'iso2' => 'AG', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ฆ๐ฌ'],
+ ['name' => 'Argentina', 'iso3' => 'ARG', 'iso2' => 'AR', 'region' => 'Americas', 'subregion' => 'South America', 'emoji' => '๐ฆ๐ท'],
+ ['name' => 'Armenia', 'iso3' => 'ARM', 'iso2' => 'AM', 'region' => 'Asia', 'subregion' => 'Western Asia', 'emoji' => '๐ฆ๐ฒ'],
+ ['name' => 'Aruba', 'iso3' => 'ABW', 'iso2' => 'AW', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ฆ๐ผ'],
+ ['name' => 'Australia', 'iso3' => 'AUS', 'iso2' => 'AU', 'region' => 'Oceania', 'subregion' => 'Australia and New Zealand', 'emoji' => '๐ฆ๐บ'],
+ ['name' => 'Austria', 'iso3' => 'AUT', 'iso2' => 'AT', 'region' => 'Europe', 'subregion' => 'Western Europe', 'emoji' => '๐ฆ๐น'],
+ ['name' => 'Azerbaijan', 'iso3' => 'AZE', 'iso2' => 'AZ', 'region' => 'Asia', 'subregion' => 'Western Asia', 'emoji' => '๐ฆ๐ฟ'],
+ ['name' => 'Bahamas The', 'iso3' => 'BHS', 'iso2' => 'BS', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ง๐ธ'],
+ ['name' => 'Bahrain', 'iso3' => 'BHR', 'iso2' => 'BH', 'region' => 'Asia', 'subregion' => 'Western Asia', 'emoji' => '๐ง๐ญ'],
+ ['name' => 'Bangladesh', 'iso3' => 'BGD', 'iso2' => 'BD', 'region' => 'Asia', 'subregion' => 'Southern Asia', 'emoji' => '๐ง๐ฉ'],
+ ['name' => 'Barbados', 'iso3' => 'BRB', 'iso2' => 'BB', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ง๐ง'],
+ ['name' => 'Belarus', 'iso3' => 'BLR', 'iso2' => 'BY', 'region' => 'Europe', 'subregion' => 'Eastern Europe', 'emoji' => '๐ง๐พ'],
+ ['name' => 'Belgium', 'iso3' => 'BEL', 'iso2' => 'BE', 'region' => 'Europe', 'subregion' => 'Western Europe', 'emoji' => '๐ง๐ช'],
+ ['name' => 'Belize', 'iso3' => 'BLZ', 'iso2' => 'BZ', 'region' => 'Americas', 'subregion' => 'Central America', 'emoji' => '๐ง๐ฟ'],
+ ['name' => 'Benin', 'iso3' => 'BEN', 'iso2' => 'BJ', 'region' => 'Africa', 'subregion' => 'Western Africa', 'emoji' => '๐ง๐ฏ'],
+ ['name' => 'Bermuda', 'iso3' => 'BMU', 'iso2' => 'BM', 'region' => 'Americas', 'subregion' => 'Northern America', 'emoji' => '๐ง๐ฒ'],
+ ['name' => 'Bhutan', 'iso3' => 'BTN', 'iso2' => 'BT', 'region' => 'Asia', 'subregion' => 'Southern Asia', 'emoji' => '๐ง๐น'],
+ ['name' => 'Bolivia', 'iso3' => 'BOL', 'iso2' => 'BO', 'region' => 'Americas', 'subregion' => 'South America', 'emoji' => '๐ง๐ด'],
+ ['name' => 'Bonaire, Sint Eustatius and Saba', 'iso3' => 'BES', 'iso2' => 'BQ', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ง๐ถ'],
+ ['name' => 'Bosnia and Herzegovina', 'iso3' => 'BIH', 'iso2' => 'BA', 'region' => 'Europe', 'subregion' => 'Southern Europe', 'emoji' => '๐ง๐ฆ'],
+ ['name' => 'Botswana', 'iso3' => 'BWA', 'iso2' => 'BW', 'region' => 'Africa', 'subregion' => 'Southern Africa', 'emoji' => '๐ง๐ผ'],
+ ['name' => 'Bouvet Island', 'iso3' => 'BVT', 'iso2' => 'BV', 'region' => '', 'subregion' => '', 'emoji' => '๐ง๐ป'],
+ ['name' => 'Brazil', 'iso3' => 'BRA', 'iso2' => 'BR', 'region' => 'Americas', 'subregion' => 'South America', 'emoji' => '๐ง๐ท'],
+ ['name' => 'British Indian Ocean Territory', 'iso3' => 'IOT', 'iso2' => 'IO', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐ฎ๐ด'],
+ ['name' => 'Brunei', 'iso3' => 'BRN', 'iso2' => 'BN', 'region' => 'Asia', 'subregion' => 'South-Eastern Asia', 'emoji' => '๐ง๐ณ'],
+ ['name' => 'Bulgaria', 'iso3' => 'BGR', 'iso2' => 'BG', 'region' => 'Europe', 'subregion' => 'Eastern Europe', 'emoji' => '๐ง๐ฌ'],
+ ['name' => 'Burkina Faso', 'iso3' => 'BFA', 'iso2' => 'BF', 'region' => 'Africa', 'subregion' => 'Western Africa', 'emoji' => '๐ง๐ซ'],
+ ['name' => 'Burundi', 'iso3' => 'BDI', 'iso2' => 'BI', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐ง๐ฎ'],
+ ['name' => 'Cambodia', 'iso3' => 'KHM', 'iso2' => 'KH', 'region' => 'Asia', 'subregion' => 'South-Eastern Asia', 'emoji' => '๐ฐ๐ญ'],
+ ['name' => 'Cameroon', 'iso3' => 'CMR', 'iso2' => 'CM', 'region' => 'Africa', 'subregion' => 'Middle Africa', 'emoji' => '๐จ๐ฒ'],
+ ['name' => 'Canada', 'iso3' => 'CAN', 'iso2' => 'CA', 'region' => 'Americas', 'subregion' => 'Northern America', 'emoji' => '๐จ๐ฆ'],
+ ['name' => 'Cape Verde', 'iso3' => 'CPV', 'iso2' => 'CV', 'region' => 'Africa', 'subregion' => 'Western Africa', 'emoji' => '๐จ๐ป'],
+ ['name' => 'Cayman Islands', 'iso3' => 'CYM', 'iso2' => 'KY', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ฐ๐พ'],
+ ['name' => 'Central African Republic', 'iso3' => 'CAF', 'iso2' => 'CF', 'region' => 'Africa', 'subregion' => 'Middle Africa', 'emoji' => '๐จ๐ซ'],
+ ['name' => 'Chad', 'iso3' => 'TCD', 'iso2' => 'TD', 'region' => 'Africa', 'subregion' => 'Middle Africa', 'emoji' => '๐น๐ฉ'],
+ ['name' => 'Chile', 'iso3' => 'CHL', 'iso2' => 'CL', 'region' => 'Americas', 'subregion' => 'South America', 'emoji' => '๐จ๐ฑ'],
+ ['name' => 'China', 'iso3' => 'CHN', 'iso2' => 'CN', 'region' => 'Asia', 'subregion' => 'Eastern Asia', 'emoji' => '๐จ๐ณ'],
+ ['name' => 'Christmas Island', 'iso3' => 'CXR', 'iso2' => 'CX', 'region' => 'Oceania', 'subregion' => 'Australia and New Zealand', 'emoji' => '๐จ๐ฝ'],
+ ['name' => 'Cocos (Keeling) Islands', 'iso3' => 'CCK', 'iso2' => 'CC', 'region' => 'Oceania', 'subregion' => 'Australia and New Zealand', 'emoji' => '๐จ๐จ'],
+ ['name' => 'Colombia', 'iso3' => 'COL', 'iso2' => 'CO', 'region' => 'Americas', 'subregion' => 'South America', 'emoji' => '๐จ๐ด'],
+ ['name' => 'Comoros', 'iso3' => 'COM', 'iso2' => 'KM', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐ฐ๐ฒ'],
+ ['name' => 'Congo', 'iso3' => 'COG', 'iso2' => 'CG', 'region' => 'Africa', 'subregion' => 'Middle Africa', 'emoji' => '๐จ๐ฌ'],
+ ['name' => 'Cook Islands', 'iso3' => 'COK', 'iso2' => 'CK', 'region' => 'Oceania', 'subregion' => 'Polynesia', 'emoji' => '๐จ๐ฐ'],
+ ['name' => 'Costa Rica', 'iso3' => 'CRI', 'iso2' => 'CR', 'region' => 'Americas', 'subregion' => 'Central America', 'emoji' => '๐จ๐ท'],
+ ['name' => 'Cote D\'Ivoire (Ivory Coast)', 'iso3' => 'CIV', 'iso2' => 'CI', 'region' => 'Africa', 'subregion' => 'Western Africa', 'emoji' => '๐จ๐ฎ'],
+ ['name' => 'Croatia', 'iso3' => 'HRV', 'iso2' => 'HR', 'region' => 'Europe', 'subregion' => 'Southern Europe', 'emoji' => '๐ญ๐ท'],
+ ['name' => 'Cuba', 'iso3' => 'CUB', 'iso2' => 'CU', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐จ๐บ'],
+ ['name' => 'Cura\\u00e7ao', 'iso3' => 'CUW', 'iso2' => 'CW', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐จ๐ผ'],
+ ['name' => 'Cyprus', 'iso3' => 'CYP', 'iso2' => 'CY', 'region' => 'Europe', 'subregion' => 'Southern Europe', 'emoji' => '๐จ๐พ'],
+ ['name' => 'Czech Republic', 'iso3' => 'CZE', 'iso2' => 'CZ', 'region' => 'Europe', 'subregion' => 'Eastern Europe', 'emoji' => '๐จ๐ฟ'],
+ ['name' => 'Democratic Republic of the Congo', 'iso3' => 'COD', 'iso2' => 'CD', 'region' => 'Africa', 'subregion' => 'Middle Africa', 'emoji' => '๐จ๐ฉ'],
+ ['name' => 'Denmark', 'iso3' => 'DNK', 'iso2' => 'DK', 'region' => 'Europe', 'subregion' => 'Northern Europe', 'emoji' => '๐ฉ๐ฐ'],
+ ['name' => 'Djibouti', 'iso3' => 'DJI', 'iso2' => 'DJ', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐ฉ๐ฏ'],
+ ['name' => 'Dominica', 'iso3' => 'DMA', 'iso2' => 'DM', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ฉ๐ฒ'],
+ ['name' => 'Dominican Republic', 'iso3' => 'DOM', 'iso2' => 'DO', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ฉ๐ด'],
+ ['name' => 'East Timor', 'iso3' => 'TLS', 'iso2' => 'TL', 'region' => 'Asia', 'subregion' => 'South-Eastern Asia', 'emoji' => '๐น๐ฑ'],
+ ['name' => 'Ecuador', 'iso3' => 'ECU', 'iso2' => 'EC', 'region' => 'Americas', 'subregion' => 'South America', 'emoji' => '๐ช๐จ'],
+ ['name' => 'Egypt', 'iso3' => 'EGY', 'iso2' => 'EG', 'region' => 'Africa', 'subregion' => 'Northern Africa', 'emoji' => '๐ช๐ฌ'],
+ ['name' => 'El Salvador', 'iso3' => 'SLV', 'iso2' => 'SV', 'region' => 'Americas', 'subregion' => 'Central America', 'emoji' => '๐ธ๐ป'],
+ ['name' => 'Equatorial Guinea', 'iso3' => 'GNQ', 'iso2' => 'GQ', 'region' => 'Africa', 'subregion' => 'Middle Africa', 'emoji' => '๐ฌ๐ถ'],
+ ['name' => 'Eritrea', 'iso3' => 'ERI', 'iso2' => 'ER', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐ช๐ท'],
+ ['name' => 'Estonia', 'iso3' => 'EST', 'iso2' => 'EE', 'region' => 'Europe', 'subregion' => 'Northern Europe', 'emoji' => '๐ช๐ช'],
+ ['name' => 'Ethiopia', 'iso3' => 'ETH', 'iso2' => 'ET', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐ช๐น'],
+ ['name' => 'Falkland Islands', 'iso3' => 'FLK', 'iso2' => 'FK', 'region' => 'Americas', 'subregion' => 'South America', 'emoji' => '๐ซ๐ฐ'],
+ ['name' => 'Faroe Islands', 'iso3' => 'FRO', 'iso2' => 'FO', 'region' => 'Europe', 'subregion' => 'Northern Europe', 'emoji' => '๐ซ๐ด'],
+ ['name' => 'Fiji Islands', 'iso3' => 'FJI', 'iso2' => 'FJ', 'region' => 'Oceania', 'subregion' => 'Melanesia', 'emoji' => '๐ซ๐ฏ'],
+ ['name' => 'Finland', 'iso3' => 'FIN', 'iso2' => 'FI', 'region' => 'Europe', 'subregion' => 'Northern Europe', 'emoji' => '๐ซ๐ฎ'],
+ ['name' => 'France', 'iso3' => 'FRA', 'iso2' => 'FR', 'region' => 'Europe', 'subregion' => 'Western Europe', 'emoji' => '๐ซ๐ท'],
+ ['name' => 'French Guiana', 'iso3' => 'GUF', 'iso2' => 'GF', 'region' => 'Americas', 'subregion' => 'South America', 'emoji' => '๐ฌ๐ซ'],
+ ['name' => 'French Polynesia', 'iso3' => 'PYF', 'iso2' => 'PF', 'region' => 'Oceania', 'subregion' => 'Polynesia', 'emoji' => '๐ต๐ซ'],
+ ['name' => 'French Southern Territories', 'iso3' => 'ATF', 'iso2' => 'TF', 'region' => 'Africa', 'subregion' => 'Southern Africa', 'emoji' => '๐น๐ซ'],
+ ['name' => 'Gabon', 'iso3' => 'GAB', 'iso2' => 'GA', 'region' => 'Africa', 'subregion' => 'Middle Africa', 'emoji' => '๐ฌ๐ฆ'],
+ ['name' => 'Gambia The', 'iso3' => 'GMB', 'iso2' => 'GM', 'region' => 'Africa', 'subregion' => 'Western Africa', 'emoji' => '๐ฌ๐ฒ'],
+ ['name' => 'Georgia', 'iso3' => 'GEO', 'iso2' => 'GE', 'region' => 'Asia', 'subregion' => 'Western Asia', 'emoji' => '๐ฌ๐ช'],
+ ['name' => 'Germany', 'iso3' => 'DEU', 'iso2' => 'DE', 'region' => 'Europe', 'subregion' => 'Western Europe', 'emoji' => '๐ฉ๐ช'],
+ ['name' => 'Ghana', 'iso3' => 'GHA', 'iso2' => 'GH', 'region' => 'Africa', 'subregion' => 'Western Africa', 'emoji' => '๐ฌ๐ญ'],
+ ['name' => 'Gibraltar', 'iso3' => 'GIB', 'iso2' => 'GI', 'region' => 'Europe', 'subregion' => 'Southern Europe', 'emoji' => '๐ฌ๐ฎ'],
+ ['name' => 'Greece', 'iso3' => 'GRC', 'iso2' => 'GR', 'region' => 'Europe', 'subregion' => 'Southern Europe', 'emoji' => '๐ฌ๐ท'],
+ ['name' => 'Greenland', 'iso3' => 'GRL', 'iso2' => 'GL', 'region' => 'Americas', 'subregion' => 'Northern America', 'emoji' => '๐ฌ๐ฑ'],
+ ['name' => 'Grenada', 'iso3' => 'GRD', 'iso2' => 'GD', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ฌ๐ฉ'],
+ ['name' => 'Guadeloupe', 'iso3' => 'GLP', 'iso2' => 'GP', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ฌ๐ต'],
+ ['name' => 'Guam', 'iso3' => 'GUM', 'iso2' => 'GU', 'region' => 'Oceania', 'subregion' => 'Micronesia', 'emoji' => '๐ฌ๐บ'],
+ ['name' => 'Guatemala', 'iso3' => 'GTM', 'iso2' => 'GT', 'region' => 'Americas', 'subregion' => 'Central America', 'emoji' => '๐ฌ๐น'],
+ ['name' => 'Guernsey and Alderney', 'iso3' => 'GGY', 'iso2' => 'GG', 'region' => 'Europe', 'subregion' => 'Northern Europe', 'emoji' => '๐ฌ๐ฌ'],
+ ['name' => 'Guinea', 'iso3' => 'GIN', 'iso2' => 'GN', 'region' => 'Africa', 'subregion' => 'Western Africa', 'emoji' => '๐ฌ๐ณ'],
+ ['name' => 'Guinea-Bissau', 'iso3' => 'GNB', 'iso2' => 'GW', 'region' => 'Africa', 'subregion' => 'Western Africa', 'emoji' => '๐ฌ๐ผ'],
+ ['name' => 'Guyana', 'iso3' => 'GUY', 'iso2' => 'GY', 'region' => 'Americas', 'subregion' => 'South America', 'emoji' => '๐ฌ๐พ'],
+ ['name' => 'Haiti', 'iso3' => 'HTI', 'iso2' => 'HT', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ญ๐น'],
+ ['name' => 'Heard Island and McDonald Islands', 'iso3' => 'HMD', 'iso2' => 'HM', 'region' => '', 'subregion' => '', 'emoji' => '๐ญ๐ฒ'],
+ ['name' => 'Honduras', 'iso3' => 'HND', 'iso2' => 'HN', 'region' => 'Americas', 'subregion' => 'Central America', 'emoji' => '๐ญ๐ณ'],
+ ['name' => 'Hong Kong S.A.R.', 'iso3' => 'HKG', 'iso2' => 'HK', 'region' => 'Asia', 'subregion' => 'Eastern Asia', 'emoji' => '๐ญ๐ฐ'],
+ ['name' => 'Hungary', 'iso3' => 'HUN', 'iso2' => 'HU', 'region' => 'Europe', 'subregion' => 'Eastern Europe', 'emoji' => '๐ญ๐บ'],
+ ['name' => 'Iceland', 'iso3' => 'ISL', 'iso2' => 'IS', 'region' => 'Europe', 'subregion' => 'Northern Europe', 'emoji' => '๐ฎ๐ธ'],
+ ['name' => 'India', 'iso3' => 'IND', 'iso2' => 'IN', 'region' => 'Asia', 'subregion' => 'Southern Asia', 'emoji' => '๐ฎ๐ณ'],
+ ['name' => 'Indonesia', 'iso3' => 'IDN', 'iso2' => 'ID', 'region' => 'Asia', 'subregion' => 'South-Eastern Asia', 'emoji' => '๐ฎ๐ฉ'],
+ ['name' => 'Iran', 'iso3' => 'IRN', 'iso2' => 'IR', 'region' => 'Asia', 'subregion' => 'Southern Asia', 'emoji' => '๐ฎ๐ท'],
+ ['name' => 'Iraq', 'iso3' => 'IRQ', 'iso2' => 'IQ', 'region' => 'Asia', 'subregion' => 'Western Asia', 'emoji' => '๐ฎ๐ถ'],
+ ['name' => 'Ireland', 'iso3' => 'IRL', 'iso2' => 'IE', 'region' => 'Europe', 'subregion' => 'Northern Europe', 'emoji' => '๐ฎ๐ช'],
+ ['name' => 'Israel', 'iso3' => 'ISR', 'iso2' => 'IL', 'region' => 'Asia', 'subregion' => 'Western Asia', 'emoji' => '๐ฎ๐ฑ'],
+ ['name' => 'Italy', 'iso3' => 'ITA', 'iso2' => 'IT', 'region' => 'Europe', 'subregion' => 'Southern Europe', 'emoji' => '๐ฎ๐น'],
+ ['name' => 'Jamaica', 'iso3' => 'JAM', 'iso2' => 'JM', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ฏ๐ฒ'],
+ ['name' => 'Japan', 'iso3' => 'JPN', 'iso2' => 'JP', 'region' => 'Asia', 'subregion' => 'Eastern Asia', 'emoji' => '๐ฏ๐ต'],
+ ['name' => 'Jersey', 'iso3' => 'JEY', 'iso2' => 'JE', 'region' => 'Europe', 'subregion' => 'Northern Europe', 'emoji' => '๐ฏ๐ช'],
+ ['name' => 'Jordan', 'iso3' => 'JOR', 'iso2' => 'JO', 'region' => 'Asia', 'subregion' => 'Western Asia', 'emoji' => '๐ฏ๐ด'],
+ ['name' => 'Kazakhstan', 'iso3' => 'KAZ', 'iso2' => 'KZ', 'region' => 'Asia', 'subregion' => 'Central Asia', 'emoji' => '๐ฐ๐ฟ'],
+ ['name' => 'Kenya', 'iso3' => 'KEN', 'iso2' => 'KE', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐ฐ๐ช'],
+ ['name' => 'Kiribati', 'iso3' => 'KIR', 'iso2' => 'KI', 'region' => 'Oceania', 'subregion' => 'Micronesia', 'emoji' => '๐ฐ๐ฎ'],
+ ['name' => 'Kosovo', 'iso3' => 'XKX', 'iso2' => 'XK', 'region' => 'Europe', 'subregion' => 'Eastern Europe', 'emoji' => '๐ฝ๐ฐ'],
+ ['name' => 'Kuwait', 'iso3' => 'KWT', 'iso2' => 'KW', 'region' => 'Asia', 'subregion' => 'Western Asia', 'emoji' => '๐ฐ๐ผ'],
+ ['name' => 'Kyrgyzstan', 'iso3' => 'KGZ', 'iso2' => 'KG', 'region' => 'Asia', 'subregion' => 'Central Asia', 'emoji' => '๐ฐ๐ฌ'],
+ ['name' => 'Laos', 'iso3' => 'LAO', 'iso2' => 'LA', 'region' => 'Asia', 'subregion' => 'South-Eastern Asia', 'emoji' => '๐ฑ๐ฆ'],
+ ['name' => 'Latvia', 'iso3' => 'LVA', 'iso2' => 'LV', 'region' => 'Europe', 'subregion' => 'Northern Europe', 'emoji' => '๐ฑ๐ป'],
+ ['name' => 'Lebanon', 'iso3' => 'LBN', 'iso2' => 'LB', 'region' => 'Asia', 'subregion' => 'Western Asia', 'emoji' => '๐ฑ๐ง'],
+ ['name' => 'Lesotho', 'iso3' => 'LSO', 'iso2' => 'LS', 'region' => 'Africa', 'subregion' => 'Southern Africa', 'emoji' => '๐ฑ๐ธ'],
+ ['name' => 'Liberia', 'iso3' => 'LBR', 'iso2' => 'LR', 'region' => 'Africa', 'subregion' => 'Western Africa', 'emoji' => '๐ฑ๐ท'],
+ ['name' => 'Libya', 'iso3' => 'LBY', 'iso2' => 'LY', 'region' => 'Africa', 'subregion' => 'Northern Africa', 'emoji' => '๐ฑ๐พ'],
+ ['name' => 'Liechtenstein', 'iso3' => 'LIE', 'iso2' => 'LI', 'region' => 'Europe', 'subregion' => 'Western Europe', 'emoji' => '๐ฑ๐ฎ'],
+ ['name' => 'Lithuania', 'iso3' => 'LTU', 'iso2' => 'LT', 'region' => 'Europe', 'subregion' => 'Northern Europe', 'emoji' => '๐ฑ๐น'],
+ ['name' => 'Luxembourg', 'iso3' => 'LUX', 'iso2' => 'LU', 'region' => 'Europe', 'subregion' => 'Western Europe', 'emoji' => '๐ฑ๐บ'],
+ ['name' => 'Macau S.A.R.', 'iso3' => 'MAC', 'iso2' => 'MO', 'region' => 'Asia', 'subregion' => 'Eastern Asia', 'emoji' => '๐ฒ๐ด'],
+ ['name' => 'Macedonia', 'iso3' => 'MKD', 'iso2' => 'MK', 'region' => 'Europe', 'subregion' => 'Southern Europe', 'emoji' => '๐ฒ๐ฐ'],
+ ['name' => 'Madagascar', 'iso3' => 'MDG', 'iso2' => 'MG', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐ฒ๐ฌ'],
+ ['name' => 'Malawi', 'iso3' => 'MWI', 'iso2' => 'MW', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐ฒ๐ผ'],
+ ['name' => 'Malaysia', 'iso3' => 'MYS', 'iso2' => 'MY', 'region' => 'Asia', 'subregion' => 'South-Eastern Asia', 'emoji' => '๐ฒ๐พ'],
+ ['name' => 'Maldives', 'iso3' => 'MDV', 'iso2' => 'MV', 'region' => 'Asia', 'subregion' => 'Southern Asia', 'emoji' => '๐ฒ๐ป'],
+ ['name' => 'Mali', 'iso3' => 'MLI', 'iso2' => 'ML', 'region' => 'Africa', 'subregion' => 'Western Africa', 'emoji' => '๐ฒ๐ฑ'],
+ ['name' => 'Malta', 'iso3' => 'MLT', 'iso2' => 'MT', 'region' => 'Europe', 'subregion' => 'Southern Europe', 'emoji' => '๐ฒ๐น'],
+ ['name' => 'Man (Isle of)', 'iso3' => 'IMN', 'iso2' => 'IM', 'region' => 'Europe', 'subregion' => 'Northern Europe', 'emoji' => '๐ฎ๐ฒ'],
+ ['name' => 'Marshall Islands', 'iso3' => 'MHL', 'iso2' => 'MH', 'region' => 'Oceania', 'subregion' => 'Micronesia', 'emoji' => '๐ฒ๐ญ'],
+ ['name' => 'Martinique', 'iso3' => 'MTQ', 'iso2' => 'MQ', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ฒ๐ถ'],
+ ['name' => 'Mauritania', 'iso3' => 'MRT', 'iso2' => 'MR', 'region' => 'Africa', 'subregion' => 'Western Africa', 'emoji' => '๐ฒ๐ท'],
+ ['name' => 'Mauritius', 'iso3' => 'MUS', 'iso2' => 'MU', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐ฒ๐บ'],
+ ['name' => 'Mayotte', 'iso3' => 'MYT', 'iso2' => 'YT', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐พ๐น'],
+ ['name' => 'Mexico', 'iso3' => 'MEX', 'iso2' => 'MX', 'region' => 'Americas', 'subregion' => 'Central America', 'emoji' => '๐ฒ๐ฝ'],
+ ['name' => 'Micronesia', 'iso3' => 'FSM', 'iso2' => 'FM', 'region' => 'Oceania', 'subregion' => 'Micronesia', 'emoji' => '๐ซ๐ฒ'],
+ ['name' => 'Moldova', 'iso3' => 'MDA', 'iso2' => 'MD', 'region' => 'Europe', 'subregion' => 'Eastern Europe', 'emoji' => '๐ฒ๐ฉ'],
+ ['name' => 'Monaco', 'iso3' => 'MCO', 'iso2' => 'MC', 'region' => 'Europe', 'subregion' => 'Western Europe', 'emoji' => '๐ฒ๐จ'],
+ ['name' => 'Mongolia', 'iso3' => 'MNG', 'iso2' => 'MN', 'region' => 'Asia', 'subregion' => 'Eastern Asia', 'emoji' => '๐ฒ๐ณ'],
+ ['name' => 'Montenegro', 'iso3' => 'MNE', 'iso2' => 'ME', 'region' => 'Europe', 'subregion' => 'Southern Europe', 'emoji' => '๐ฒ๐ช'],
+ ['name' => 'Montserrat', 'iso3' => 'MSR', 'iso2' => 'MS', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ฒ๐ธ'],
+ ['name' => 'Morocco', 'iso3' => 'MAR', 'iso2' => 'MA', 'region' => 'Africa', 'subregion' => 'Northern Africa', 'emoji' => '๐ฒ๐ฆ'],
+ ['name' => 'Mozambique', 'iso3' => 'MOZ', 'iso2' => 'MZ', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐ฒ๐ฟ'],
+ ['name' => 'Myanmar', 'iso3' => 'MMR', 'iso2' => 'MM', 'region' => 'Asia', 'subregion' => 'South-Eastern Asia', 'emoji' => '๐ฒ๐ฒ'],
+ ['name' => 'Namibia', 'iso3' => 'NAM', 'iso2' => 'NA', 'region' => 'Africa', 'subregion' => 'Southern Africa', 'emoji' => '๐ณ๐ฆ'],
+ ['name' => 'Nauru', 'iso3' => 'NRU', 'iso2' => 'NR', 'region' => 'Oceania', 'subregion' => 'Micronesia', 'emoji' => '๐ณ๐ท'],
+ ['name' => 'Nepal', 'iso3' => 'NPL', 'iso2' => 'NP', 'region' => 'Asia', 'subregion' => 'Southern Asia', 'emoji' => '๐ณ๐ต'],
+ ['name' => 'Netherlands', 'iso3' => 'NLD', 'iso2' => 'NL', 'region' => 'Europe', 'subregion' => 'Western Europe', 'emoji' => '๐ณ๐ฑ'],
+ ['name' => 'New Caledonia', 'iso3' => 'NCL', 'iso2' => 'NC', 'region' => 'Oceania', 'subregion' => 'Melanesia', 'emoji' => '๐ณ๐จ'],
+ ['name' => 'New Zealand', 'iso3' => 'NZL', 'iso2' => 'NZ', 'region' => 'Oceania', 'subregion' => 'Australia and New Zealand', 'emoji' => '๐ณ๐ฟ'],
+ ['name' => 'Nicaragua', 'iso3' => 'NIC', 'iso2' => 'NI', 'region' => 'Americas', 'subregion' => 'Central America', 'emoji' => '๐ณ๐ฎ'],
+ ['name' => 'Niger', 'iso3' => 'NER', 'iso2' => 'NE', 'region' => 'Africa', 'subregion' => 'Western Africa', 'emoji' => '๐ณ๐ช'],
+ ['name' => 'Nigeria', 'iso3' => 'NGA', 'iso2' => 'NG', 'region' => 'Africa', 'subregion' => 'Western Africa', 'emoji' => '๐ณ๐ฌ'],
+ ['name' => 'Niue', 'iso3' => 'NIU', 'iso2' => 'NU', 'region' => 'Oceania', 'subregion' => 'Polynesia', 'emoji' => '๐ณ๐บ'],
+ ['name' => 'Norfolk Island', 'iso3' => 'NFK', 'iso2' => 'NF', 'region' => 'Oceania', 'subregion' => 'Australia and New Zealand', 'emoji' => '๐ณ๐ซ'],
+ ['name' => 'North Korea', 'iso3' => 'PRK', 'iso2' => 'KP', 'region' => 'Asia', 'subregion' => 'Eastern Asia', 'emoji' => '๐ฐ๐ต'],
+ ['name' => 'Northern Mariana Islands', 'iso3' => 'MNP', 'iso2' => 'MP', 'region' => 'Oceania', 'subregion' => 'Micronesia', 'emoji' => '๐ฒ๐ต'],
+ ['name' => 'Norway', 'iso3' => 'NOR', 'iso2' => 'NO', 'region' => 'Europe', 'subregion' => 'Northern Europe', 'emoji' => '๐ณ๐ด'],
+ ['name' => 'Oman', 'iso3' => 'OMN', 'iso2' => 'OM', 'region' => 'Asia', 'subregion' => 'Western Asia', 'emoji' => '๐ด๐ฒ'],
+ ['name' => 'Pakistan', 'iso3' => 'PAK', 'iso2' => 'PK', 'region' => 'Asia', 'subregion' => 'Southern Asia', 'emoji' => '๐ต๐ฐ'],
+ ['name' => 'Palau', 'iso3' => 'PLW', 'iso2' => 'PW', 'region' => 'Oceania', 'subregion' => 'Micronesia', 'emoji' => '๐ต๐ผ'],
+ ['name' => 'Palestinian Territory Occupied', 'iso3' => 'PSE', 'iso2' => 'PS', 'region' => 'Asia', 'subregion' => 'Western Asia', 'emoji' => '๐ต๐ธ'],
+ ['name' => 'Panama', 'iso3' => 'PAN', 'iso2' => 'PA', 'region' => 'Americas', 'subregion' => 'Central America', 'emoji' => '๐ต๐ฆ'],
+ ['name' => 'Papua new Guinea', 'iso3' => 'PNG', 'iso2' => 'PG', 'region' => 'Oceania', 'subregion' => 'Melanesia', 'emoji' => '๐ต๐ฌ'],
+ ['name' => 'Paraguay', 'iso3' => 'PRY', 'iso2' => 'PY', 'region' => 'Americas', 'subregion' => 'South America', 'emoji' => '๐ต๐พ'],
+ ['name' => 'Peru', 'iso3' => 'PER', 'iso2' => 'PE', 'region' => 'Americas', 'subregion' => 'South America', 'emoji' => '๐ต๐ช'],
+ ['name' => 'Philippines', 'iso3' => 'PHL', 'iso2' => 'PH', 'region' => 'Asia', 'subregion' => 'South-Eastern Asia', 'emoji' => '๐ต๐ญ'],
+ ['name' => 'Pitcairn Island', 'iso3' => 'PCN', 'iso2' => 'PN', 'region' => 'Oceania', 'subregion' => 'Polynesia', 'emoji' => '๐ต๐ณ'],
+ ['name' => 'Poland', 'iso3' => 'POL', 'iso2' => 'PL', 'region' => 'Europe', 'subregion' => 'Eastern Europe', 'emoji' => '๐ต๐ฑ'],
+ ['name' => 'Portugal', 'iso3' => 'PRT', 'iso2' => 'PT', 'region' => 'Europe', 'subregion' => 'Southern Europe', 'emoji' => '๐ต๐น'],
+ ['name' => 'Puerto Rico', 'iso3' => 'PRI', 'iso2' => 'PR', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ต๐ท'],
+ ['name' => 'Qatar', 'iso3' => 'QAT', 'iso2' => 'QA', 'region' => 'Asia', 'subregion' => 'Western Asia', 'emoji' => '๐ถ๐ฆ'],
+ ['name' => 'Reunion', 'iso3' => 'REU', 'iso2' => 'RE', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐ท๐ช'],
+ ['name' => 'Romania', 'iso3' => 'ROU', 'iso2' => 'RO', 'region' => 'Europe', 'subregion' => 'Eastern Europe', 'emoji' => '๐ท๐ด'],
+ ['name' => 'Russia', 'iso3' => 'RUS', 'iso2' => 'RU', 'region' => 'Europe', 'subregion' => 'Eastern Europe', 'emoji' => '๐ท๐บ'],
+ ['name' => 'Rwanda', 'iso3' => 'RWA', 'iso2' => 'RW', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐ท๐ผ'],
+ ['name' => 'Saint Helena', 'iso3' => 'SHN', 'iso2' => 'SH', 'region' => 'Africa', 'subregion' => 'Western Africa', 'emoji' => '๐ธ๐ญ'],
+ ['name' => 'Saint Kitts And Nevis', 'iso3' => 'KNA', 'iso2' => 'KN', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ฐ๐ณ'],
+ ['name' => 'Saint Lucia', 'iso3' => 'LCA', 'iso2' => 'LC', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ฑ๐จ'],
+ ['name' => 'Saint Pierre and Miquelon', 'iso3' => 'SPM', 'iso2' => 'PM', 'region' => 'Americas', 'subregion' => 'Northern America', 'emoji' => '๐ต๐ฒ'],
+ ['name' => 'Saint Vincent And The Grenadines', 'iso3' => 'VCT', 'iso2' => 'VC', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ป๐จ'],
+ ['name' => 'Saint-Barthelemy', 'iso3' => 'BLM', 'iso2' => 'BL', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ง๐ฑ'],
+ ['name' => 'Saint-Martin (French part)', 'iso3' => 'MAF', 'iso2' => 'MF', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ฒ๐ซ'],
+ ['name' => 'Samoa', 'iso3' => 'WSM', 'iso2' => 'WS', 'region' => 'Oceania', 'subregion' => 'Polynesia', 'emoji' => '๐ผ๐ธ'],
+ ['name' => 'San Marino', 'iso3' => 'SMR', 'iso2' => 'SM', 'region' => 'Europe', 'subregion' => 'Southern Europe', 'emoji' => '๐ธ๐ฒ'],
+ ['name' => 'Sao Tome and Principe', 'iso3' => 'STP', 'iso2' => 'ST', 'region' => 'Africa', 'subregion' => 'Middle Africa', 'emoji' => '๐ธ๐น'],
+ ['name' => 'Saudi Arabia', 'iso3' => 'SAU', 'iso2' => 'SA', 'region' => 'Asia', 'subregion' => 'Western Asia', 'emoji' => '๐ธ๐ฆ'],
+ ['name' => 'Senegal', 'iso3' => 'SEN', 'iso2' => 'SN', 'region' => 'Africa', 'subregion' => 'Western Africa', 'emoji' => '๐ธ๐ณ'],
+ ['name' => 'Serbia', 'iso3' => 'SRB', 'iso2' => 'RS', 'region' => 'Europe', 'subregion' => 'Southern Europe', 'emoji' => '๐ท๐ธ'],
+ ['name' => 'Seychelles', 'iso3' => 'SYC', 'iso2' => 'SC', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐ธ๐จ'],
+ ['name' => 'Sierra Leone', 'iso3' => 'SLE', 'iso2' => 'SL', 'region' => 'Africa', 'subregion' => 'Western Africa', 'emoji' => '๐ธ๐ฑ'],
+ ['name' => 'Singapore', 'iso3' => 'SGP', 'iso2' => 'SG', 'region' => 'Asia', 'subregion' => 'South-Eastern Asia', 'emoji' => '๐ธ๐ฌ'],
+ ['name' => 'Sint Maarten (Dutch part)', 'iso3' => 'SXM', 'iso2' => 'SX', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ธ๐ฝ'],
+ ['name' => 'Slovakia', 'iso3' => 'SVK', 'iso2' => 'SK', 'region' => 'Europe', 'subregion' => 'Eastern Europe', 'emoji' => '๐ธ๐ฐ'],
+ ['name' => 'Slovenia', 'iso3' => 'SVN', 'iso2' => 'SI', 'region' => 'Europe', 'subregion' => 'Southern Europe', 'emoji' => '๐ธ๐ฎ'],
+ ['name' => 'Solomon Islands', 'iso3' => 'SLB', 'iso2' => 'SB', 'region' => 'Oceania', 'subregion' => 'Melanesia', 'emoji' => '๐ธ๐ง'],
+ ['name' => 'Somalia', 'iso3' => 'SOM', 'iso2' => 'SO', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐ธ๐ด'],
+ ['name' => 'South Africa', 'iso3' => 'ZAF', 'iso2' => 'ZA', 'region' => 'Africa', 'subregion' => 'Southern Africa', 'emoji' => '๐ฟ๐ฆ'],
+ ['name' => 'South Georgia', 'iso3' => 'SGS', 'iso2' => 'GS', 'region' => 'Americas', 'subregion' => 'South America', 'emoji' => '๐ฌ๐ธ'],
+ ['name' => 'South Korea', 'iso3' => 'KOR', 'iso2' => 'KR', 'region' => 'Asia', 'subregion' => 'Eastern Asia', 'emoji' => '๐ฐ๐ท'],
+ ['name' => 'South Sudan', 'iso3' => 'SSD', 'iso2' => 'SS', 'region' => 'Africa', 'subregion' => 'Middle Africa', 'emoji' => '๐ธ๐ธ'],
+ ['name' => 'Spain', 'iso3' => 'ESP', 'iso2' => 'ES', 'region' => 'Europe', 'subregion' => 'Southern Europe', 'emoji' => '๐ช๐ธ'],
+ ['name' => 'Sri Lanka', 'iso3' => 'LKA', 'iso2' => 'LK', 'region' => 'Asia', 'subregion' => 'Southern Asia', 'emoji' => '๐ฑ๐ฐ'],
+ ['name' => 'Sudan', 'iso3' => 'SDN', 'iso2' => 'SD', 'region' => 'Africa', 'subregion' => 'Northern Africa', 'emoji' => '๐ธ๐ฉ'],
+ ['name' => 'Suriname', 'iso3' => 'SUR', 'iso2' => 'SR', 'region' => 'Americas', 'subregion' => 'South America', 'emoji' => '๐ธ๐ท'],
+ ['name' => 'Svalbard And Jan Mayen Islands', 'iso3' => 'SJM', 'iso2' => 'SJ', 'region' => 'Europe', 'subregion' => 'Northern Europe', 'emoji' => '๐ธ๐ฏ'],
+ ['name' => 'Swaziland', 'iso3' => 'SWZ', 'iso2' => 'SZ', 'region' => 'Africa', 'subregion' => 'Southern Africa', 'emoji' => '๐ธ๐ฟ'],
+ ['name' => 'Sweden', 'iso3' => 'SWE', 'iso2' => 'SE', 'region' => 'Europe', 'subregion' => 'Northern Europe', 'emoji' => '๐ธ๐ช'],
+ ['name' => 'Switzerland', 'iso3' => 'CHE', 'iso2' => 'CH', 'region' => 'Europe', 'subregion' => 'Western Europe', 'emoji' => '๐จ๐ญ'],
+ ['name' => 'Syria', 'iso3' => 'SYR', 'iso2' => 'SY', 'region' => 'Asia', 'subregion' => 'Western Asia', 'emoji' => '๐ธ๐พ'],
+ ['name' => 'Taiwan', 'iso3' => 'TWN', 'iso2' => 'TW', 'region' => 'Asia', 'subregion' => 'Eastern Asia', 'emoji' => '๐น๐ผ'],
+ ['name' => 'Tajikistan', 'iso3' => 'TJK', 'iso2' => 'TJ', 'region' => 'Asia', 'subregion' => 'Central Asia', 'emoji' => '๐น๐ฏ'],
+ ['name' => 'Tanzania', 'iso3' => 'TZA', 'iso2' => 'TZ', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐น๐ฟ'],
+ ['name' => 'Thailand', 'iso3' => 'THA', 'iso2' => 'TH', 'region' => 'Asia', 'subregion' => 'South-Eastern Asia', 'emoji' => '๐น๐ญ'],
+ ['name' => 'Togo', 'iso3' => 'TGO', 'iso2' => 'TG', 'region' => 'Africa', 'subregion' => 'Western Africa', 'emoji' => '๐น๐ฌ'],
+ ['name' => 'Tokelau', 'iso3' => 'TKL', 'iso2' => 'TK', 'region' => 'Oceania', 'subregion' => 'Polynesia', 'emoji' => '๐น๐ฐ'],
+ ['name' => 'Tonga', 'iso3' => 'TON', 'iso2' => 'TO', 'region' => 'Oceania', 'subregion' => 'Polynesia', 'emoji' => '๐น๐ด'],
+ ['name' => 'Trinidad And Tobago', 'iso3' => 'TTO', 'iso2' => 'TT', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐น๐น'],
+ ['name' => 'Tunisia', 'iso3' => 'TUN', 'iso2' => 'TN', 'region' => 'Africa', 'subregion' => 'Northern Africa', 'emoji' => '๐น๐ณ'],
+ ['name' => 'Turkey', 'iso3' => 'TUR', 'iso2' => 'TR', 'region' => 'Asia', 'subregion' => 'Western Asia', 'emoji' => '๐น๐ท'],
+ ['name' => 'Turkmenistan', 'iso3' => 'TKM', 'iso2' => 'TM', 'region' => 'Asia', 'subregion' => 'Central Asia', 'emoji' => '๐น๐ฒ'],
+ ['name' => 'Turks And Caicos Islands', 'iso3' => 'TCA', 'iso2' => 'TC', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐น๐จ'],
+ ['name' => 'Tuvalu', 'iso3' => 'TUV', 'iso2' => 'TV', 'region' => 'Oceania', 'subregion' => 'Polynesia', 'emoji' => '๐น๐ป'],
+ ['name' => 'Uganda', 'iso3' => 'UGA', 'iso2' => 'UG', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐บ๐ฌ'],
+ ['name' => 'Ukraine', 'iso3' => 'UKR', 'iso2' => 'UA', 'region' => 'Europe', 'subregion' => 'Eastern Europe', 'emoji' => '๐บ๐ฆ'],
+ ['name' => 'United Arab Emirates', 'iso3' => 'ARE', 'iso2' => 'AE', 'region' => 'Asia', 'subregion' => 'Western Asia', 'emoji' => '๐ฆ๐ช'],
+ ['name' => 'United Kingdom', 'iso3' => 'GBR', 'iso2' => 'GB', 'region' => 'Europe', 'subregion' => 'Northern Europe', 'emoji' => '๐ฌ๐ง'],
+ ['name' => 'United States', 'iso3' => 'USA', 'iso2' => 'US', 'region' => 'Americas', 'subregion' => 'Northern America', 'emoji' => '๐บ๐ธ'],
+ ['name' => 'United States Minor Outlying Islands', 'iso3' => 'UMI', 'iso2' => 'UM', 'region' => 'Americas', 'subregion' => 'Northern America', 'emoji' => '๐บ๐ฒ'],
+ ['name' => 'Uruguay', 'iso3' => 'URY', 'iso2' => 'UY', 'region' => 'Americas', 'subregion' => 'South America', 'emoji' => '๐บ๐พ'],
+ ['name' => 'Uzbekistan', 'iso3' => 'UZB', 'iso2' => 'UZ', 'region' => 'Asia', 'subregion' => 'Central Asia', 'emoji' => '๐บ๐ฟ'],
+ ['name' => 'Vanuatu', 'iso3' => 'VUT', 'iso2' => 'VU', 'region' => 'Oceania', 'subregion' => 'Melanesia', 'emoji' => '๐ป๐บ'],
+ ['name' => 'Vatican City State (Holy See)', 'iso3' => 'VAT', 'iso2' => 'VA', 'region' => 'Europe', 'subregion' => 'Southern Europe', 'emoji' => '๐ป๐ฆ'],
+ ['name' => 'Venezuela', 'iso3' => 'VEN', 'iso2' => 'VE', 'region' => 'Americas', 'subregion' => 'South America', 'emoji' => '๐ป๐ช'],
+ ['name' => 'Vietnam', 'iso3' => 'VNM', 'iso2' => 'VN', 'region' => 'Asia', 'subregion' => 'South-Eastern Asia', 'emoji' => '๐ป๐ณ'],
+ ['name' => 'Virgin Islands (British)', 'iso3' => 'VGB', 'iso2' => 'VG', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ป๐ฌ'],
+ ['name' => 'Virgin Islands (US)', 'iso3' => 'VIR', 'iso2' => 'VI', 'region' => 'Americas', 'subregion' => 'Caribbean', 'emoji' => '๐ป๐ฎ'],
+ ['name' => 'Wallis And Futuna Islands', 'iso3' => 'WLF', 'iso2' => 'WF', 'region' => 'Oceania', 'subregion' => 'Polynesia', 'emoji' => '๐ผ๐ซ'],
+ ['name' => 'Western Sahara', 'iso3' => 'ESH', 'iso2' => 'EH', 'region' => 'Africa', 'subregion' => 'Northern Africa', 'emoji' => '๐ช๐ญ'],
+ ['name' => 'Yemen', 'iso3' => 'YEM', 'iso2' => 'YE', 'region' => 'Asia', 'subregion' => 'Western Asia', 'emoji' => '๐พ๐ช'],
+ ['name' => 'Zambia', 'iso3' => 'ZMB', 'iso2' => 'ZM', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐ฟ๐ฒ'],
+ ['name' => 'Zimbabwe', 'iso3' => 'ZWE', 'iso2' => 'ZW', 'region' => 'Africa', 'subregion' => 'Eastern Africa', 'emoji' => '๐ฟ๐ผ'],
+ ];
+ }
+}
diff --git a/src/Dictionaries/Currencies.php b/src/Dictionaries/Currencies.php
new file mode 100644
index 0000000000..dce2b0d7bb
--- /dev/null
+++ b/src/Dictionaries/Currencies.php
@@ -0,0 +1,138 @@
+ 'AED', 'name' => 'United Arab Emirates Dirham', 'symbol' => 'ุฏ.ุฅ.โ', 'decimals' => 2],
+ ['code' => 'AFN', 'name' => 'Afghan Afghani', 'symbol' => 'ุ', 'decimals' => 0],
+ ['code' => 'ALL', 'name' => 'Albanian Lek', 'symbol' => 'Lek', 'decimals' => 0],
+ ['code' => 'AMD', 'name' => 'Armenian Dram', 'symbol' => 'ีคึ.', 'decimals' => 0],
+ ['code' => 'ARS', 'name' => 'Argentine Peso', 'symbol' => '$', 'decimals' => 2],
+ ['code' => 'AUD', 'name' => 'Australian Dollar', 'symbol' => '$', 'decimals' => 2],
+ ['code' => 'AZN', 'name' => 'Azerbaijani Manat', 'symbol' => 'ะผะฐะฝ.', 'decimals' => 2],
+ ['code' => 'BAM', 'name' => 'Bosnia-Herzegovina Convertible Mark', 'symbol' => 'KM', 'decimals' => 2],
+ ['code' => 'BDT', 'name' => 'Bangladeshi Taka', 'symbol' => 'เงณ', 'decimals' => 2],
+ ['code' => 'BGN', 'name' => 'Bulgarian Lev', 'symbol' => 'ะปะฒ.', 'decimals' => 2],
+ ['code' => 'BHD', 'name' => 'Bahraini Dinar', 'symbol' => 'ุฏ.ุจ.โ', 'decimals' => 3],
+ ['code' => 'BIF', 'name' => 'Burundian Franc', 'symbol' => 'FBu', 'decimals' => 0],
+ ['code' => 'BND', 'name' => 'Brunei Dollar', 'symbol' => '$', 'decimals' => 2],
+ ['code' => 'BOB', 'name' => 'Bolivian Boliviano', 'symbol' => 'Bs', 'decimals' => 2],
+ ['code' => 'BRL', 'name' => 'Brazilian Real', 'symbol' => 'R$', 'decimals' => 2],
+ ['code' => 'BWP', 'name' => 'Botswanan Pula', 'symbol' => 'P', 'decimals' => 2],
+ ['code' => 'BYN', 'name' => 'Belarusian Ruble', 'symbol' => 'ััะฑ.', 'decimals' => 2],
+ ['code' => 'BZD', 'name' => 'Belize Dollar', 'symbol' => '$', 'decimals' => 2],
+ ['code' => 'CAD', 'name' => 'Canadian Dollar', 'symbol' => '$', 'decimals' => 2],
+ ['code' => 'CDF', 'name' => 'Congolese Franc', 'symbol' => 'FrCD', 'decimals' => 2],
+ ['code' => 'CHF', 'name' => 'Swiss Franc', 'symbol' => 'CHF', 'decimals' => 2],
+ ['code' => 'CLP', 'name' => 'Chilean Peso', 'symbol' => '$', 'decimals' => 0],
+ ['code' => 'CNY', 'name' => 'Chinese Yuan', 'symbol' => 'CNยฅ', 'decimals' => 2],
+ ['code' => 'COP', 'name' => 'Colombian Peso', 'symbol' => '$', 'decimals' => 0],
+ ['code' => 'CRC', 'name' => "Costa Rican Col\u00f3n", 'symbol' => 'โก', 'decimals' => 0],
+ ['code' => 'CVE', 'name' => 'Cape Verdean Escudo', 'symbol' => 'CV$', 'decimals' => 2],
+ ['code' => 'CZK', 'name' => 'Czech Republic Koruna', 'symbol' => 'Kฤ', 'decimals' => 2],
+ ['code' => 'DJF', 'name' => 'Djiboutian Franc', 'symbol' => 'Fdj', 'decimals' => 0],
+ ['code' => 'DKK', 'name' => 'Danish Krone', 'symbol' => 'kr', 'decimals' => 2],
+ ['code' => 'DOP', 'name' => 'Dominican Peso', 'symbol' => 'RD$', 'decimals' => 2],
+ ['code' => 'DZD', 'name' => 'Algerian Dinar', 'symbol' => 'ุฏ.ุฌ.โ', 'decimals' => 2],
+ ['code' => 'EEK', 'name' => 'Estonian Kroon', 'symbol' => 'kr', 'decimals' => 2],
+ ['code' => 'EGP', 'name' => 'Egyptian Pound', 'symbol' => 'ุฌ.ู
.โ', 'decimals' => 2],
+ ['code' => 'ERN', 'name' => 'Eritrean Nakfa', 'symbol' => 'Nfk', 'decimals' => 2],
+ ['code' => 'ETB', 'name' => 'Ethiopian Birr', 'symbol' => 'Br', 'decimals' => 2],
+ ['code' => 'EUR', 'name' => 'Euro', 'symbol' => 'โฌ', 'decimals' => 2],
+ ['code' => 'GBP', 'name' => 'British Pound Sterling', 'symbol' => 'ยฃ', 'decimals' => 2],
+ ['code' => 'GEL', 'name' => 'Georgian Lari', 'symbol' => 'GEL', 'decimals' => 2],
+ ['code' => 'GHS', 'name' => 'Ghanaian Cedi', 'symbol' => 'GHโต', 'decimals' => 2],
+ ['code' => 'GNF', 'name' => 'Guinean Franc', 'symbol' => 'FG', 'decimals' => 0],
+ ['code' => 'GTQ', 'name' => 'Guatemalan Quetzal', 'symbol' => 'Q', 'decimals' => 2],
+ ['code' => 'HKD', 'name' => 'Hong Kong Dollar', 'symbol' => '$', 'decimals' => 2],
+ ['code' => 'HNL', 'name' => 'Honduran Lempira', 'symbol' => 'L', 'decimals' => 2],
+ ['code' => 'HRK', 'name' => 'Croatian Kuna', 'symbol' => 'kn', 'decimals' => 2],
+ ['code' => 'HUF', 'name' => 'Hungarian Forint', 'symbol' => 'Ft', 'decimals' => 0],
+ ['code' => 'IDR', 'name' => 'Indonesian Rupiah', 'symbol' => 'Rp', 'decimals' => 0],
+ ['code' => 'ILS', 'name' => 'Israeli New Sheqel', 'symbol' => 'โช', 'decimals' => 2],
+ ['code' => 'INR', 'name' => 'Indian Rupee', 'symbol' => 'เฆเฆเฆพ', 'decimals' => 2],
+ ['code' => 'IQD', 'name' => 'Iraqi Dinar', 'symbol' => 'ุฏ.ุน.โ', 'decimals' => 0],
+ ['code' => 'IRR', 'name' => 'Iranian Rial', 'symbol' => '๏ทผ', 'decimals' => 0],
+ ['code' => 'ISK', 'name' => "Icelandic Kr\u00f3na", 'symbol' => 'kr', 'decimals' => 0],
+ ['code' => 'JMD', 'name' => 'Jamaican Dollar', 'symbol' => '$', 'decimals' => 2],
+ ['code' => 'JOD', 'name' => 'Jordanian Dinar', 'symbol' => 'ุฏ.ุฃ.โ', 'decimals' => 3],
+ ['code' => 'JPY', 'name' => 'Japanese Yen', 'symbol' => '๏ฟฅ', 'decimals' => 0],
+ ['code' => 'KES', 'name' => 'Kenyan Shilling', 'symbol' => 'Ksh', 'decimals' => 2],
+ ['code' => 'KHR', 'name' => 'Cambodian Riel', 'symbol' => 'แ', 'decimals' => 2],
+ ['code' => 'KMF', 'name' => 'Comorian Franc', 'symbol' => 'FC', 'decimals' => 0],
+ ['code' => 'KRW', 'name' => 'South Korean Won', 'symbol' => 'โฉ', 'decimals' => 0],
+ ['code' => 'KWD', 'name' => 'Kuwaiti Dinar', 'symbol' => 'ุฏ.ู.โ', 'decimals' => 3],
+ ['code' => 'KZT', 'name' => 'Kazakhstani Tenge', 'symbol' => 'ัาฃะณ.', 'decimals' => 2],
+ ['code' => 'LBP', 'name' => 'Lebanese Pound', 'symbol' => 'ู.ู.โ', 'decimals' => 0],
+ ['code' => 'LKR', 'name' => 'Sri Lankan Rupee', 'symbol' => 'SL Re', 'decimals' => 2],
+ ['code' => 'LTL', 'name' => 'Lithuanian Litas', 'symbol' => 'Lt', 'decimals' => 2],
+ ['code' => 'LVL', 'name' => 'Latvian Lats', 'symbol' => 'Ls', 'decimals' => 2],
+ ['code' => 'LYD', 'name' => 'Libyan Dinar', 'symbol' => 'ุฏ.ู.โ', 'decimals' => 3],
+ ['code' => 'MAD', 'name' => 'Moroccan Dirham', 'symbol' => 'ุฏ.ู
.โ', 'decimals' => 2],
+ ['code' => 'MDL', 'name' => 'Moldovan Leu', 'symbol' => 'MDL', 'decimals' => 2],
+ ['code' => 'MGA', 'name' => 'Malagasy Ariary', 'symbol' => 'MGA', 'decimals' => 0],
+ ['code' => 'MKD', 'name' => 'Macedonian Denar', 'symbol' => 'MKD', 'decimals' => 2],
+ ['code' => 'MMK', 'name' => 'Myanma Kyat', 'symbol' => 'K', 'decimals' => 0],
+ ['code' => 'MOP', 'name' => 'Macanese Pataca', 'symbol' => 'MOP$', 'decimals' => 2],
+ ['code' => 'MUR', 'name' => 'Mauritian Rupee', 'symbol' => 'MURs', 'decimals' => 0],
+ ['code' => 'MXN', 'name' => 'Mexican Peso', 'symbol' => '$', 'decimals' => 2],
+ ['code' => 'MYR', 'name' => 'Malaysian Ringgit', 'symbol' => 'RM', 'decimals' => 2],
+ ['code' => 'MZN', 'name' => 'Mozambican Metical', 'symbol' => 'MTn', 'decimals' => 2],
+ ['code' => 'NAD', 'name' => 'Namibian Dollar', 'symbol' => 'N$', 'decimals' => 2],
+ ['code' => 'NGN', 'name' => 'Nigerian Naira', 'symbol' => 'โฆ', 'decimals' => 2],
+ ['code' => 'NIO', 'name' => "Nicaraguan C\u00f3rdoba", 'symbol' => 'C$', 'decimals' => 2],
+ ['code' => 'NOK', 'name' => 'Norwegian Krone', 'symbol' => 'kr', 'decimals' => 2],
+ ['code' => 'NPR', 'name' => 'Nepalese Rupee', 'symbol' => 'เคจเฅเคฐเฅ', 'decimals' => 2],
+ ['code' => 'NZD', 'name' => 'New Zealand Dollar', 'symbol' => '$', 'decimals' => 2],
+ ['code' => 'OMR', 'name' => 'Omani Rial', 'symbol' => 'ุฑ.ุน.โ', 'decimals' => 3],
+ ['code' => 'PAB', 'name' => 'Panamanian Balboa', 'symbol' => 'B/.', 'decimals' => 2],
+ ['code' => 'PEN', 'name' => 'Peruvian Nuevo Sol', 'symbol' => 'S/.', 'decimals' => 2],
+ ['code' => 'PHP', 'name' => 'Philippine Peso', 'symbol' => 'โฑ', 'decimals' => 2],
+ ['code' => 'PKR', 'name' => 'Pakistani Rupee', 'symbol' => 'โจ', 'decimals' => 0],
+ ['code' => 'PLN', 'name' => 'Polish Zloty', 'symbol' => 'zล', 'decimals' => 2],
+ ['code' => 'PYG', 'name' => 'Paraguayan Guarani', 'symbol' => 'โฒ', 'decimals' => 0],
+ ['code' => 'QAR', 'name' => 'Qatari Rial', 'symbol' => 'ุฑ.ู.โ', 'decimals' => 2],
+ ['code' => 'RON', 'name' => 'Romanian Leu', 'symbol' => 'RON', 'decimals' => 2],
+ ['code' => 'RSD', 'name' => 'Serbian Dinar', 'symbol' => 'ะดะธะฝ.', 'decimals' => 0],
+ ['code' => 'RUB', 'name' => 'Russian Ruble', 'symbol' => 'โฝ.', 'decimals' => 2],
+ ['code' => 'RWF', 'name' => 'Rwandan Franc', 'symbol' => 'FR', 'decimals' => 0],
+ ['code' => 'SAR', 'name' => 'Saudi Riyal', 'symbol' => 'ุฑ.ุณ.โ', 'decimals' => 2],
+ ['code' => 'SDG', 'name' => 'Sudanese Pound', 'symbol' => 'SDG', 'decimals' => 2],
+ ['code' => 'SEK', 'name' => 'Swedish Krona', 'symbol' => 'kr', 'decimals' => 2],
+ ['code' => 'SGD', 'name' => 'Singapore Dollar', 'symbol' => '$', 'decimals' => 2],
+ ['code' => 'SOS', 'name' => 'Somali Shilling', 'symbol' => 'Ssh', 'decimals' => 0],
+ ['code' => 'SYP', 'name' => 'Syrian Pound', 'symbol' => 'ู.ุณ.โ', 'decimals' => 0],
+ ['code' => 'THB', 'name' => 'Thai Baht', 'symbol' => 'เธฟ', 'decimals' => 2],
+ ['code' => 'TND', 'name' => 'Tunisian Dinar', 'symbol' => 'ุฏ.ุช.โ', 'decimals' => 3],
+ ['code' => 'TOP', 'name' => "Tongan Pa\u02bbanga", 'symbol' => 'T$', 'decimals' => 2],
+ ['code' => 'TRY', 'name' => 'Turkish Lira', 'symbol' => 'TL', 'decimals' => 2],
+ ['code' => 'TTD', 'name' => 'Trinidad and Tobago Dollar', 'symbol' => '$', 'decimals' => 2],
+ ['code' => 'TWD', 'name' => 'New Taiwan Dollar', 'symbol' => 'NT$', 'decimals' => 2],
+ ['code' => 'TZS', 'name' => 'Tanzanian Shilling', 'symbol' => 'TSh', 'decimals' => 0],
+ ['code' => 'UAH', 'name' => 'Ukrainian Hryvnia', 'symbol' => 'โด', 'decimals' => 2],
+ ['code' => 'UGX', 'name' => 'Ugandan Shilling', 'symbol' => 'USh', 'decimals' => 0],
+ ['code' => 'USD', 'name' => 'US Dollar', 'symbol' => '$', 'decimals' => 2],
+ ['code' => 'UYU', 'name' => 'Uruguayan Peso', 'symbol' => '$', 'decimals' => 2],
+ ['code' => 'UZS', 'name' => 'Uzbekistan Som', 'symbol' => 'UZS', 'decimals' => 0],
+ ['code' => 'VEF', 'name' => "Venezuelan Bol\u00edvar", 'symbol' => 'Bs.F.', 'decimals' => 2],
+ ['code' => 'VND', 'name' => 'Vietnamese Dong', 'symbol' => 'โซ', 'decimals' => 0],
+ ['code' => 'XAF', 'name' => 'CFA Franc BEAC', 'symbol' => 'FCFA', 'decimals' => 0],
+ ['code' => 'XOF', 'name' => 'CFA Franc BCEAO', 'symbol' => 'CFA', 'decimals' => 0],
+ ['code' => 'YER', 'name' => 'Yemeni Rial', 'symbol' => 'ุฑ.ู.โ', 'decimals' => 0],
+ ['code' => 'ZAR', 'name' => 'South African Rand', 'symbol' => 'R', 'decimals' => 2],
+ ['code' => 'ZMK', 'name' => 'Zambian Kwacha', 'symbol' => 'ZK', 'decimals' => 0],
+ ['code' => 'ZWL', 'name' => 'Zimbabwean Dollar', 'symbol' => 'ZWL$', 'decimals' => 0],
+ ];
+ }
+}
diff --git a/src/Dictionaries/Dictionary.php b/src/Dictionaries/Dictionary.php
new file mode 100644
index 0000000000..c94771a254
--- /dev/null
+++ b/src/Dictionaries/Dictionary.php
@@ -0,0 +1,71 @@
+config = $config;
+
+ return $this;
+ }
+
+ public function config(): array
+ {
+ return $this->config;
+ }
+
+ protected function fieldItems()
+ {
+ return $this->fields;
+ }
+
+ public function getGqlType()
+ {
+ $name = str(class_basename($this))->singular()->value();
+
+ return new DictionaryType($name, $this->getGqlFields());
+ }
+
+ protected function getGqlFields(): array
+ {
+ // By default, we will make non-nullable strings out of all the keys
+ // of the first option. This is an easy way for it to "just work",
+ // and of course it may be easily overridden per dictionary.
+ $firstOption = collect($this->options())->keys()->first();
+
+ return collect($this->get($firstOption))
+ ->map(fn ($value) => ['type' => GraphQL::nonNull($this->getInferredGqlType($value))])
+ ->all();
+ }
+
+ private function getInferredGqlType($value)
+ {
+ if (is_int($value)) {
+ return GraphQL::int();
+ }
+
+ if (is_bool($value)) {
+ return GraphQL::boolean();
+ }
+
+ return GraphQL::string();
+ }
+}
diff --git a/src/Dictionaries/DictionaryRepository.php b/src/Dictionaries/DictionaryRepository.php
new file mode 100644
index 0000000000..55e43df95b
--- /dev/null
+++ b/src/Dictionaries/DictionaryRepository.php
@@ -0,0 +1,30 @@
+map(fn ($class) => app($class))
+ ->filter()
+ ->values();
+ }
+
+ public function find(string $handle, array $context = []): ?Dictionary
+ {
+ if (! $dictionary = app('statamic.dictionaries')->get($handle)) {
+ return null;
+ }
+
+ /** @var $dictionary Dictionary */
+ if (! $dictionary = app($dictionary)) {
+ return null;
+ }
+
+ return $dictionary->setConfig($context);
+ }
+}
diff --git a/src/Dictionaries/File.php b/src/Dictionaries/File.php
new file mode 100644
index 0000000000..57bdc173b6
--- /dev/null
+++ b/src/Dictionaries/File.php
@@ -0,0 +1,85 @@
+ [
+ 'type' => 'slug',
+ 'display' => __('Filename'),
+ 'instructions' => __('statamic::fieldtypes.dictionary.file.config.filename'),
+ 'validate' => ['required'],
+ ],
+ 'label' => [
+ 'type' => 'text',
+ 'display' => __('Label'),
+ 'instructions' => __('statamic::fieldtypes.dictionary.file.config.label'),
+ ],
+ 'value' => [
+ 'type' => 'text',
+ 'display' => __('Value'),
+ 'instructions' => __('statamic::fieldtypes.dictionary.file.config.value'),
+ ],
+ ];
+ }
+
+ public function setConfig(array $config): Dictionary
+ {
+ if ($value = $config['value'] ?? null) {
+ $this->valueKey = $value;
+ }
+
+ if ($label = $config['label'] ?? null) {
+ $this->labelKey = $label;
+ }
+
+ return parent::setConfig($config);
+ }
+
+ protected function getItemLabel(array $item): string
+ {
+ if (str_contains($this->labelKey, '{{')) {
+ return (string) Antlers::parse($this->labelKey, $item);
+ }
+
+ return parent::getItemLabel($item);
+ }
+
+ protected function getItems(): array
+ {
+ $path = resource_path('dictionaries').'/'.$this->config['filename'];
+
+ if (! file_exists($path)) {
+ throw new \Exception('Dictionary file ['.$path.'] does not exist.');
+ }
+
+ $extension = pathinfo($path, PATHINFO_EXTENSION);
+
+ return match ($extension) {
+ 'json' => json_decode(file_get_contents($path), true),
+ 'yaml' => YAML::file($path)->parse(),
+ 'csv' => $this->fromCsv($path),
+ };
+ }
+
+ private function fromCsv(string $path): array
+ {
+ $rows = [];
+
+ if (($handle = fopen($path, 'r')) !== false) {
+ $headers = fgetcsv($handle, 1000, ',');
+ while (($data = fgetcsv($handle, 1000, ',')) !== false) {
+ $rows[] = array_combine($headers, $data);
+ }
+ fclose($handle);
+ }
+
+ return $rows;
+ }
+}
diff --git a/src/Dictionaries/Item.php b/src/Dictionaries/Item.php
new file mode 100644
index 0000000000..5ee458f75a
--- /dev/null
+++ b/src/Dictionaries/Item.php
@@ -0,0 +1,43 @@
+extra = array_merge(
+ $extra,
+ ['label' => $label]
+ );
+ }
+
+ public function data(): array
+ {
+ return Arr::except($this->extra, ['label']);
+ }
+
+ public function offsetExists(mixed $offset): bool
+ {
+ return true;
+ }
+
+ public function offsetGet(mixed $offset): mixed
+ {
+ return $this->data()[$offset];
+ }
+
+ public function offsetSet(mixed $offset, mixed $value): void
+ {
+ $this->extra[$offset] = $value;
+ }
+
+ public function offsetUnset(mixed $offset): void
+ {
+ unset($this->extra[$offset]);
+ }
+}
diff --git a/src/Dictionaries/Timezones.php b/src/Dictionaries/Timezones.php
new file mode 100644
index 0000000000..8a4e9420d0
--- /dev/null
+++ b/src/Dictionaries/Timezones.php
@@ -0,0 +1,33 @@
+map(fn ($tz) => ['name' => $tz, 'offset' => $this->getOffset($tz)])
+ ->all();
+ }
+
+ private function getOffset(string $tz): string
+ {
+ $tz = new DateTimeZone($tz);
+ $utcTime = Carbon::now('UTC');
+ $offsetInSecs = $tz->getOffset($utcTime);
+ $hoursAndSec = gmdate('H:i', abs($offsetInSecs));
+
+ return stripos($offsetInSecs, '-') === false ? "+{$hoursAndSec}" : "-{$hoursAndSec}";
+ }
+}
diff --git a/src/Exceptions/DictionaryNotFoundException.php b/src/Exceptions/DictionaryNotFoundException.php
new file mode 100644
index 0000000000..dd672f1985
--- /dev/null
+++ b/src/Exceptions/DictionaryNotFoundException.php
@@ -0,0 +1,11 @@
+dictionaryHandle}] not found");
+ }
+}
diff --git a/src/Exceptions/UndefinedDictionaryException.php b/src/Exceptions/UndefinedDictionaryException.php
new file mode 100644
index 0000000000..a9c1f0f56d
--- /dev/null
+++ b/src/Exceptions/UndefinedDictionaryException.php
@@ -0,0 +1,13 @@
+ __('Options'),
+ 'fields' => [
+ 'dictionary' => [
+ 'type' => 'dictionary_fields',
+ 'hide_display' => true,
+ 'full_width_setting' => true,
+ ],
+ ],
+ ],
+ [
+ 'display' => __('Selection'),
+ 'fields' => [
+ 'placeholder' => [
+ 'display' => __('Placeholder'),
+ 'instructions' => __('statamic::fieldtypes.select.config.placeholder'),
+ 'type' => 'text',
+ 'default' => '',
+ ],
+ 'max_items' => [
+ 'display' => __('Max Items'),
+ 'instructions' => __('statamic::messages.max_items_instructions'),
+ 'min' => 1,
+ 'type' => 'integer',
+ ],
+ ],
+ ],
+ [
+ 'display' => __('Data'),
+ 'fields' => [
+ 'default' => [
+ 'display' => __('Default Value'),
+ 'instructions' => __('statamic::messages.fields_default_instructions'),
+ 'type' => 'text',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ public function preload(): array
+ {
+ return [
+ 'url' => cp_route('dictionary-fieldtype', $this->dictionary()->handle()),
+ 'selectedOptions' => $this->getItemData($this->field->value()),
+ ];
+ }
+
+ private function getItemData($values)
+ {
+ return collect($values)->map(function ($key) {
+ $item = $this->dictionary()->get($key);
+
+ return [
+ 'value' => $item?->value() ?? $key,
+ 'label' => $item?->label() ?? $key,
+ 'invalid' => ! $item,
+ ];
+ })->values()->all();
+ }
+
+ public function augment($value)
+ {
+ if ($this->multiple() && is_null($value)) {
+ return [];
+ }
+
+ $dictionary = $this->dictionary();
+
+ if ($this->multiple()) {
+ return collect($value)->map(function ($value) use ($dictionary) {
+ return $dictionary->get($value);
+ })->filter()->all();
+ }
+
+ $item = $value ? $dictionary->get($value) : null;
+
+ return $item ?? new Item(null, null, []);
+ }
+
+ public function extraRenderableFieldData(): array
+ {
+ return [
+ 'multiple' => $this->multiple(),
+ 'options' => $this->dictionary()->options(),
+ ];
+ }
+
+ protected function multiple(): bool
+ {
+ return $this->config('max_items') !== 1;
+ }
+
+ public function dictionary(): DictionaryInstance
+ {
+ $config = is_array($config = $this->config('dictionary')) ? $config : ['type' => $config];
+
+ if (! $handle = Arr::pull($config, 'type')) {
+ throw new UndefinedDictionaryException;
+ }
+
+ if ($dictionary = Dictionaries::find($handle, $config)) {
+ return $dictionary;
+ }
+
+ throw new DictionaryNotFoundException($handle);
+ }
+
+ public function toGqlType()
+ {
+ $type = GraphQL::type($this->dictionary()->getGqlType()->name);
+
+ return $this->multiple()
+ ? $this->multiSelectGqlType($type)
+ : $this->singleSelectGqlType($type);
+ }
+
+ private function singleSelectGqlType($type)
+ {
+ return [
+ 'type' => $type,
+ 'resolve' => function ($item, $args, $context, $info) {
+ $resolved = $item->resolveGqlValue($info->fieldName);
+
+ return is_null($resolved->value()) ? null : $resolved;
+ },
+ ];
+ }
+
+ private function multiSelectGqlType($type)
+ {
+ return [
+ 'type' => GraphQL::listOf($type),
+ 'resolve' => function ($item, $args, $context, $info) {
+ $resolved = $item->resolveGqlValue($info->fieldName);
+
+ return empty($resolved) ? null : $resolved;
+ },
+ ];
+ }
+
+ public function addGqlTypes()
+ {
+ GraphQL::addType($this->dictionary()->getGqlType());
+ }
+}
diff --git a/src/Fieldtypes/DictionaryFields.php b/src/Fieldtypes/DictionaryFields.php
new file mode 100644
index 0000000000..35cac097bc
--- /dev/null
+++ b/src/Fieldtypes/DictionaryFields.php
@@ -0,0 +1,100 @@
+ 'type',
+ 'field' => [
+ 'display' => __('Dictionary'),
+ 'instructions' => __('statamic::fieldtypes.dictionary.config.dictionary'),
+ 'type' => 'select',
+ 'options' => Dictionary::all()
+ ->mapWithKeys(fn ($dictionary) => [$dictionary->handle() => $dictionary->title()])
+ ->all(),
+ 'max_items' => 1,
+ 'validate' => 'required',
+ ],
+ ]]);
+
+ return [
+ 'type' => [
+ 'fields' => $typeField->toPublishArray(),
+ 'meta' => $typeField->meta(),
+ ],
+ 'dictionaries' => Dictionary::all()->mapWithKeys(function (\Statamic\Dictionaries\Dictionary $dictionary) {
+ return [$dictionary->handle() => [
+ 'fields' => $dictionary->fields()->toPublishArray(),
+ 'meta' => $dictionary->fields()->meta(),
+ 'defaults' => $dictionary->fields()->all()->map(function ($field) {
+ return $field->fieldtype()->preProcess($field->defaultValue());
+ }),
+ ]];
+ }),
+ ];
+ }
+
+ public function preProcess($data): array
+ {
+ if (is_null($data)) {
+ return ['type' => null];
+ }
+
+ if (is_string($data)) {
+ return ['type' => $data];
+ }
+
+ $dictionary = Dictionary::find($data['type']);
+
+ return array_merge(
+ ['type' => $data['type']],
+ $dictionary->fields()->addValues($data)->preProcess()->values()->all()
+ );
+ }
+
+ public function process($data): string|array
+ {
+ $dictionary = Dictionary::find($data['type']);
+ $values = $dictionary->fields()->addValues($data)->process()->values();
+
+ if ($values->filter()->isEmpty()) {
+ return $dictionary->handle();
+ }
+
+ return array_merge(['type' => $dictionary->handle()], $values->all());
+ }
+
+ public function extraRules(): array
+ {
+ if (! $dictionary = Arr::get($this->field->value(), 'type')) {
+ return [
+ $this->field->handle().'.type' => ['required'],
+ ];
+ }
+
+ $dictionary = Dictionary::find($dictionary);
+
+ $rules = $dictionary
+ ->fields()
+ ->addValues((array) $this->field->value())
+ ->validator()
+ ->withContext([
+ 'prefix' => $this->field->handle().'.',
+ ])
+ ->rules();
+
+ return collect($rules)->mapWithKeys(function ($rules, $handle) {
+ return [$this->field->handle().'.'.$handle => $rules];
+ })->all();
+ }
+}
diff --git a/src/GraphQL/Types/DictionaryType.php b/src/GraphQL/Types/DictionaryType.php
new file mode 100644
index 0000000000..97524e08ef
--- /dev/null
+++ b/src/GraphQL/Types/DictionaryType.php
@@ -0,0 +1,19 @@
+attributes['name'] = 'Dictionary_'.$name;
+ $this->fields = $fields;
+ }
+
+ public function fields(): array
+ {
+ return $this->fields;
+ }
+}
diff --git a/src/Http/Controllers/CP/Fieldtypes/DictionaryFieldtypeController.php b/src/Http/Controllers/CP/Fieldtypes/DictionaryFieldtypeController.php
new file mode 100644
index 0000000000..5d442c916f
--- /dev/null
+++ b/src/Http/Controllers/CP/Fieldtypes/DictionaryFieldtypeController.php
@@ -0,0 +1,46 @@
+fieldtype($request);
+
+ return [
+ 'data' => $fieldtype->dictionary()->options($request->search),
+ ];
+ }
+
+ protected function fieldtype($request)
+ {
+ $config = $this->getConfig($request);
+
+ return Fieldtype::find($config['type'])->setField(
+ new Field('relationship', $config)
+ );
+ }
+
+ private function getConfig($request)
+ {
+ // The fieldtype base64-encodes the config.
+ $json = base64_decode($request->config);
+
+ // The json may include unicode characters, so we'll try to convert it to UTF-8.
+ // See https://github.com/statamic/cms/issues/566
+ $utf8 = mb_convert_encoding($json, 'UTF-8', mb_list_encodings());
+
+ // In PHP 8.1 there's a bug where encoding will return null. It's fixed in 8.1.2.
+ // In this case, we'll fall back to the original JSON, but without the encoding.
+ // Issue #566 may still occur, but it's better than failing completely.
+ $json = empty($utf8) ? $json : $utf8;
+
+ return json_decode($json, true);
+ }
+}
diff --git a/src/Providers/AddonServiceProvider.php b/src/Providers/AddonServiceProvider.php
index 307bd9e76c..44d463ed76 100644
--- a/src/Providers/AddonServiceProvider.php
+++ b/src/Providers/AddonServiceProvider.php
@@ -10,6 +10,7 @@
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Statamic\Actions\Action;
+use Statamic\Dictionaries\Dictionary;
use Statamic\Exceptions\NotBootedException;
use Statamic\Extend\Manifest;
use Statamic\Facades\Addon;
@@ -55,6 +56,11 @@ abstract class AddonServiceProvider extends ServiceProvider
*/
protected $actions = [];
+ /**
+ * @var list>
+ */
+ protected $dictionaries = [];
+
/**
* @var list>
*/
@@ -187,6 +193,7 @@ public function boot()
->bootTags()
->bootScopes()
->bootActions()
+ ->bootDictionaries()
->bootFieldtypes()
->bootModifiers()
->bootWidgets()
@@ -258,6 +265,15 @@ protected function bootActions()
return $this;
}
+ protected function bootDictionaries()
+ {
+ foreach ($this->dictionaries as $class) {
+ $class::register();
+ }
+
+ return $this;
+ }
+
protected function bootFieldtypes()
{
foreach ($this->fieldtypes as $class) {
diff --git a/src/Providers/ConsoleServiceProvider.php b/src/Providers/ConsoleServiceProvider.php
index 79f5bb4517..618a82358b 100644
--- a/src/Providers/ConsoleServiceProvider.php
+++ b/src/Providers/ConsoleServiceProvider.php
@@ -22,6 +22,7 @@ class ConsoleServiceProvider extends ServiceProvider
Commands\LicenseSet::class,
Commands\MakeAction::class,
Commands\MakeAddon::class,
+ Commands\MakeDictionary::class,
Commands\MakeFieldtype::class,
Commands\MakeModifier::class,
Commands\MakeScope::class,
diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php
index 10a0da94a3..e499359dc5 100644
--- a/src/Providers/ExtensionServiceProvider.php
+++ b/src/Providers/ExtensionServiceProvider.php
@@ -6,6 +6,8 @@
use Illuminate\Support\ServiceProvider;
use Statamic\Actions;
use Statamic\Actions\Action;
+use Statamic\Dictionaries;
+use Statamic\Dictionaries\Dictionary;
use Statamic\Extend\Manifest;
use Statamic\Fields\Fieldtype;
use Statamic\Fieldtypes;
@@ -48,6 +50,13 @@ class ExtensionServiceProvider extends ServiceProvider
Actions\Impersonate::class,
];
+ protected $dictionaries = [
+ Dictionaries\Countries::class,
+ Dictionaries\Currencies::class,
+ Dictionaries\File::class,
+ Dictionaries\Timezones::class,
+ ];
+
protected $fieldtypes = [
Fieldtypes\Arr::class,
Fieldtypes\AssetContainer::class,
@@ -63,6 +72,8 @@ class ExtensionServiceProvider extends ServiceProvider
Fieldtypes\Collections::class,
Fieldtypes\Color::class,
Fieldtypes\Date::class,
+ Fieldtypes\Dictionary::class,
+ Fieldtypes\DictionaryFields::class,
Fieldtypes\Entries::class,
Fieldtypes\FieldDisplay::class,
Fieldtypes\Files::class,
@@ -263,6 +274,11 @@ protected function registerExtensions()
'directory' => 'Actions',
'extensions' => $this->actions,
],
+ 'dictionaries' => [
+ 'class' => Dictionary::class,
+ 'directory' => 'Dictionaries',
+ 'extensions' => $this->dictionaries,
+ ],
'fieldtypes' => [
'class' => Fieldtype::class,
'directory' => 'Fieldtypes',
diff --git a/tests/Console/Commands/Concerns/CleansUpGeneratedPaths.php b/tests/Console/Commands/Concerns/CleansUpGeneratedPaths.php
index c72fb1dcc3..45d2a63ed4 100644
--- a/tests/Console/Commands/Concerns/CleansUpGeneratedPaths.php
+++ b/tests/Console/Commands/Concerns/CleansUpGeneratedPaths.php
@@ -11,6 +11,7 @@ protected function cleanupPaths()
$dirs = [
base_path('addons'),
base_path('app/Actions'),
+ base_path('app/Dictionaries'),
base_path('app/Fieldtypes'),
base_path('app/Modifiers'),
base_path('app/Scopes'),
diff --git a/tests/Console/Commands/MakeDictionaryTest.php b/tests/Console/Commands/MakeDictionaryTest.php
new file mode 100644
index 0000000000..48c9a61832
--- /dev/null
+++ b/tests/Console/Commands/MakeDictionaryTest.php
@@ -0,0 +1,92 @@
+files = app(Filesystem::class);
+ $this->fakeSuccessfulComposerRequire();
+ }
+
+ public function tearDown(): void
+ {
+ $this->cleanupPaths();
+
+ parent::tearDown();
+ }
+
+ #[Test]
+ public function it_can_make_a_dictionary()
+ {
+ $path = base_path('app/Dictionaries/Provinces.php');
+
+ $this->assertFileDoesNotExist($path);
+
+ $this->artisan('statamic:make:dictionary', ['name' => 'Provinces']);
+
+ $this->assertFileExists($path);
+ $this->assertStringContainsString('namespace App\Dictionaries;', $this->files->get($path));
+ }
+
+ #[Test]
+ public function it_will_not_overwrite_an_existing_dictionary()
+ {
+ $path = base_path('app/Dictionaries/Provinces.php');
+
+ $this->artisan('statamic:make:dictionary', ['name' => 'Provinces']);
+ $this->files->put($path, 'overwritten action');
+
+ $this->assertStringContainsString('overwritten action', $this->files->get($path));
+
+ $this->artisan('statamic:make:dictionary', ['name' => 'Provinces']);
+
+ $this->assertStringContainsString('overwritten action', $this->files->get($path));
+ }
+
+ #[Test]
+ public function using_force_option_will_overwrite_original_dictionary()
+ {
+ $path = base_path('app/Dictionaries/Provinces.php');
+
+ $this->artisan('statamic:make:dictionary', ['name' => 'Provinces']);
+ $this->files->put($path, 'overwritten action');
+
+ $this->assertStringContainsString('overwritten action', $this->files->get($path));
+
+ $this->artisan('statamic:make:dictionary', ['name' => 'Provinces', '--force' => true]);
+
+ $this->assertStringNotContainsString('overwritten action', $this->files->get($path));
+ }
+
+ #[Test]
+ public function it_can_make_a_dictionary_into_an_addon()
+ {
+ $path = base_path('addons/yoda/bag-odah');
+
+ $this->artisan('statamic:make:addon', ['addon' => 'yoda/bag-odah']);
+
+ Composer::shouldReceive('installedPath')->andReturn($path);
+
+ $this->assertFileDoesNotExist($action = "$path/src/Dictionaries/Provinces.php");
+
+ $this->artisan('statamic:make:dictionary', ['name' => 'Provinces', 'addon' => 'yoda/bag-odah']);
+
+ $this->assertFileExists($action);
+ $this->assertStringContainsString('namespace Yoda\BagOdah\Dictionaries;', $this->files->get($action));
+ }
+}
diff --git a/tests/Dictionaries/CountriesTest.php b/tests/Dictionaries/CountriesTest.php
new file mode 100644
index 0000000000..ffcb6fdadc
--- /dev/null
+++ b/tests/Dictionaries/CountriesTest.php
@@ -0,0 +1,99 @@
+options();
+
+ $this->assertCount(250, $options);
+ $this->assertEquals([
+ 'AFG' => '๐ฆ๐ซ Afghanistan',
+ 'ALA' => '๐ฆ๐ฝ Aland Islands',
+ 'ALB' => '๐ฆ๐ฑ Albania',
+ 'DZA' => '๐ฉ๐ฟ Algeria',
+ 'ASM' => '๐ฆ๐ธ American Samoa',
+ ], array_slice($options, 0, 5));
+ }
+
+ #[Test]
+ public function it_filters_options_by_region()
+ {
+ $options = (new Countries)->setConfig(['region' => 'oceania'])->options();
+
+ $this->assertCount(27, $options);
+ $this->assertEquals([
+ 'ASM' => '๐ฆ๐ธ American Samoa',
+ 'AUS' => '๐ฆ๐บ Australia',
+ 'CXR' => '๐จ๐ฝ Christmas Island',
+ 'CCK' => '๐จ๐จ Cocos (Keeling) Islands',
+ 'COK' => '๐จ๐ฐ Cook Islands',
+ ], array_slice($options, 0, 5));
+ }
+
+ #[Test]
+ #[DataProvider('searchProvider')]
+ public function it_searches_options($query, $expected)
+ {
+ $this->assertEquals($expected, (new Countries)->options($query));
+ }
+
+ public static function searchProvider()
+ {
+ return [
+ 'au' => [
+ 'au',
+ [
+ 'AUS' => '๐ฆ๐บ Australia',
+ 'AUT' => '๐ฆ๐น Austria',
+ 'GNB' => '๐ฌ๐ผ Guinea-Bissau',
+ 'MAC' => '๐ฒ๐ด Macau S.A.R.',
+ 'MRT' => '๐ฒ๐ท Mauritania',
+ 'MUS' => '๐ฒ๐บ Mauritius',
+ 'NRU' => '๐ณ๐ท Nauru',
+ 'PLW' => '๐ต๐ผ Palau',
+ 'SAU' => '๐ธ๐ฆ Saudi Arabia',
+ 'TKL' => '๐น๐ฐ Tokelau',
+ ],
+ ],
+ 'us' => [
+ 'us',
+ [
+ 'AUS' => '๐ฆ๐บ Australia',
+ 'AUT' => '๐ฆ๐น Austria',
+ 'BLR' => '๐ง๐พ Belarus',
+ 'BES' => '๐ง๐ถ Bonaire, Sint Eustatius and Saba',
+ 'CYP' => '๐จ๐พ Cyprus',
+ 'MUS' => '๐ฒ๐บ Mauritius',
+ 'RUS' => '๐ท๐บ Russia',
+ 'USA' => '๐บ๐ธ United States',
+ 'VIR' => '๐ป๐ฎ Virgin Islands (US)',
+ ],
+ ],
+ ];
+ }
+
+ #[Test]
+ public function it_gets_array_from_value()
+ {
+ $item = (new Countries)->get('AUS');
+ $this->assertInstanceOf(Item::class, $item);
+ $this->assertEquals([
+ 'name' => 'Australia',
+ 'iso3' => 'AUS',
+ 'iso2' => 'AU',
+ 'region' => 'Oceania',
+ 'subregion' => 'Australia and New Zealand',
+ 'emoji' => '๐ฆ๐บ',
+ ], $item->data());
+ }
+}
diff --git a/tests/Dictionaries/CurrenciesTest.php b/tests/Dictionaries/CurrenciesTest.php
new file mode 100644
index 0000000000..0328dce849
--- /dev/null
+++ b/tests/Dictionaries/CurrenciesTest.php
@@ -0,0 +1,107 @@
+options();
+
+ $this->assertCount(119, $options);
+ $option = $options['USD'];
+ $this->assertEquals('US Dollar (USD)', $option);
+ }
+
+ #[Test]
+ #[DataProvider('searchProvider')]
+ public function it_searches_options($query, $expected)
+ {
+ $this->assertEquals($expected, (new Currencies)->options($query));
+ }
+
+ public static function searchProvider()
+ {
+ return [
+ 'euro' => [
+ 'euro',
+ [
+ 'EUR' => 'Euro (EUR)',
+ ],
+ ],
+ 'dollar' => [
+ 'dollar',
+ [
+ 'AUD' => 'Australian Dollar (AUD)',
+ 'BZD' => 'Belize Dollar (BZD)',
+ 'CAD' => 'Canadian Dollar (CAD)',
+ 'HKD' => 'Hong Kong Dollar (HKD)',
+ 'JMD' => 'Jamaican Dollar (JMD)',
+ 'NAD' => 'Namibian Dollar (NAD)',
+ 'NZD' => 'New Zealand Dollar (NZD)',
+ 'SGD' => 'Singapore Dollar (SGD)',
+ 'TTD' => 'Trinidad and Tobago Dollar (TTD)',
+ 'USD' => 'US Dollar (USD)',
+ 'BND' => 'Brunei Dollar (BND)',
+ 'TWD' => 'New Taiwan Dollar (TWD)',
+ 'ZWL' => 'Zimbabwean Dollar (ZWL)',
+ ],
+ ],
+ 'dollar symbol' => [
+ '$',
+ [
+ 'ARS' => 'Argentine Peso (ARS)',
+ 'AUD' => 'Australian Dollar (AUD)',
+ 'BND' => 'Brunei Dollar (BND)',
+ 'BRL' => 'Brazilian Real (BRL)',
+ 'BZD' => 'Belize Dollar (BZD)',
+ 'CAD' => 'Canadian Dollar (CAD)',
+ 'CLP' => 'Chilean Peso (CLP)',
+ 'COP' => 'Colombian Peso (COP)',
+ 'CVE' => 'Cape Verdean Escudo (CVE)',
+ 'DOP' => 'Dominican Peso (DOP)',
+ 'HKD' => 'Hong Kong Dollar (HKD)',
+ 'JMD' => 'Jamaican Dollar (JMD)',
+ 'MOP' => 'Macanese Pataca (MOP)',
+ 'MXN' => 'Mexican Peso (MXN)',
+ 'NAD' => 'Namibian Dollar (NAD)',
+ 'NIO' => "Nicaraguan C\u00f3rdoba (NIO)",
+ 'NZD' => 'New Zealand Dollar (NZD)',
+ 'SGD' => 'Singapore Dollar (SGD)',
+ 'TOP' => "Tongan Pa\u02bbanga (TOP)",
+ 'TTD' => 'Trinidad and Tobago Dollar (TTD)',
+ 'TWD' => 'New Taiwan Dollar (TWD)',
+ 'USD' => 'US Dollar (USD)',
+ 'UYU' => 'Uruguayan Peso (UYU)',
+ 'ZWL' => 'Zimbabwean Dollar (ZWL)',
+ ],
+ ],
+ 'pound symbol' => [
+ 'ยฃ',
+ [
+ 'GBP' => 'British Pound Sterling (GBP)',
+ ],
+ ],
+ ];
+ }
+
+ #[Test]
+ public function it_gets_array_from_value()
+ {
+ $item = (new Currencies)->get('USD');
+ $this->assertInstanceOf(Item::class, $item);
+ $this->assertEquals([
+ 'name' => 'US Dollar',
+ 'code' => 'USD',
+ 'symbol' => '$',
+ 'decimals' => 2,
+ ], $item->data());
+ }
+}
diff --git a/tests/Dictionaries/DictionaryRepositoryTest.php b/tests/Dictionaries/DictionaryRepositoryTest.php
new file mode 100644
index 0000000000..62df5a27ab
--- /dev/null
+++ b/tests/Dictionaries/DictionaryRepositoryTest.php
@@ -0,0 +1,97 @@
+repo = new DictionaryRepository;
+
+ FakeDictionary::register();
+ }
+
+ #[Test]
+ public function can_get_all_dictionaries()
+ {
+ $all = $this->repo->all();
+
+ $this->assertCount(5, $all); // The built-in dictionaries + our fake one
+ $this->assertEveryItem($all, fn ($item) => $item instanceof Dictionary);
+ }
+
+ #[Test]
+ public function can_get_a_dictionary()
+ {
+ $find = $this->repo->find('fake_dictionary');
+
+ $this->assertInstanceOf(Dictionary::class, $find);
+ $this->assertSame('fake_dictionary', $find->handle());
+ }
+
+ #[Test]
+ public function ensure_context_is_passed_to_dictionary()
+ {
+ $dictionary = $this->repo->find('fake_dictionary', [
+ 'sort_in_alphabetical_order' => true,
+ ]);
+
+ // When the sort_in_alphabetical_order context is passed,
+ // the options should be returned in alphabetical order.
+ $this->assertEquals([
+ 'bar' => 'Bar',
+ 'baz' => 'Baz',
+ 'foo' => 'Foo',
+ 'qux' => 'Qux',
+ ], $dictionary->options());
+ }
+}
+
+class FakeDictionary extends Dictionary
+{
+ public function options(?string $search = null): array
+ {
+ return $this->data()
+ ->when($search ?? false, function ($collection) use ($search) {
+ return $collection->filter(fn ($item) => str_contains($item['id'], $search));
+ })
+ ->mapWithKeys(fn ($item) => [$item['id'] => $item['name']])
+ ->when($this->config['sort_in_alphabetical_order'] ?? false, function ($collection) {
+ return $collection->sortBy('id');
+ })
+ ->all();
+ }
+
+ public function get(string $key): ?Item
+ {
+ return $this->data()->firstWhere('id', $key);
+ }
+
+ protected function data()
+ {
+ return collect([
+ ['name' => 'Foo', 'id' => 'foo'],
+ ['name' => 'Bar', 'id' => 'bar'],
+ ['name' => 'Baz', 'id' => 'baz'],
+ ['name' => 'Qux', 'id' => 'qux'],
+ ]);
+ }
+
+ protected function fieldItems()
+ {
+ return [
+ 'sort_in_alphabetical_order' => [
+ 'display' => 'Sort in alphabetical order?',
+ 'type' => 'toggle',
+ ],
+ ];
+ }
+}
diff --git a/tests/Dictionaries/FileTest.php b/tests/Dictionaries/FileTest.php
new file mode 100644
index 0000000000..cf5f45b8a4
--- /dev/null
+++ b/tests/Dictionaries/FileTest.php
@@ -0,0 +1,183 @@
+ 'apple', 'label' => 'Apple', 'emoji' => '๐'],
+ ['value' => 'banana', 'label' => 'Banana', 'emoji' => '๐'],
+ ['value' => 'cherry', 'label' => 'Cherry', 'emoji' => '๐'],
+ ];
+
+ Filesystem::put(
+ resource_path('dictionaries').'/items.'.$extension,
+ $fileDumpCallback($arr, 'value', 'label')
+ );
+
+ $options = (new File)
+ ->setConfig(['filename' => 'items.'.$extension])
+ ->options();
+
+ $this->assertCount(3, $options);
+ $this->assertEquals([
+ 'apple' => 'Apple',
+ 'banana' => 'Banana',
+ 'cherry' => 'Cherry',
+ ], $options);
+ }
+
+ #[Test]
+ #[DataProvider('optionProvider')]
+ public function it_gets_options_with_custom_value_and_label_keys(
+ $extension,
+ $fileDumpCallback
+ ) {
+ $arr = [
+ ['id' => 'apple', 'name' => 'Apple', 'emoji' => '๐'],
+ ['id' => 'banana', 'name' => 'Banana', 'emoji' => '๐'],
+ ['id' => 'cherry', 'name' => 'Cherry', 'emoji' => '๐'],
+ ];
+
+ Filesystem::put(
+ resource_path('dictionaries').'/items.'.$extension,
+ $fileDumpCallback($arr, 'id', 'name')
+ );
+
+ $options = (new File)
+ ->setConfig([
+ 'filename' => 'items.'.$extension,
+ 'value' => 'id',
+ 'label' => 'name',
+ ])
+ ->options();
+
+ $this->assertCount(3, $options);
+ $this->assertEquals([
+ 'apple' => 'Apple',
+ 'banana' => 'Banana',
+ 'cherry' => 'Cherry',
+ ], $options);
+ }
+
+ #[Test]
+ #[DataProvider('optionProvider')]
+ public function it_gets_options_with_antlers_label(
+ $extension,
+ $fileDumpCallback
+ ) {
+ $arr = [
+ ['value' => 'apple', 'name' => 'Apple', 'emoji' => '๐'],
+ ['value' => 'banana', 'name' => 'Banana', 'emoji' => '๐'],
+ ['value' => 'cherry', 'name' => 'Cherry', 'emoji' => '๐'],
+ ];
+
+ Filesystem::put(
+ resource_path('dictionaries').'/items.'.$extension,
+ $fileDumpCallback($arr, 'value', 'name')
+ );
+
+ $options = (new File)
+ ->setConfig([
+ 'filename' => 'items.'.$extension,
+ 'label' => '{{ emoji }} {{ name }}!',
+ ])
+ ->options();
+
+ $this->assertCount(3, $options);
+ $this->assertEquals([
+ 'apple' => '๐ Apple!',
+ 'banana' => '๐ Banana!',
+ 'cherry' => '๐ Cherry!',
+ ], $options);
+ }
+
+ public static function optionProvider()
+ {
+ return [
+ 'yaml' => ['yaml', fn ($arr, $value, $label) => YAML::dump($arr)],
+ 'json' => ['json', fn ($arr, $value, $label) => json_encode($arr)],
+ 'csv' => ['csv', fn ($arr, $value, $label) => "{$value},{$label},emoji".PHP_EOL.implode(PHP_EOL, array_map(fn ($item) => implode(',', $item), $arr))],
+ ];
+ }
+
+ #[Test]
+ #[DataProvider('searchProvider')]
+ public function it_searches_options($query, $expected)
+ {
+ $arr = [
+ ['value' => 'apple', 'label' => 'Apple', 'emoji' => '๐'],
+ ['value' => 'banana', 'label' => 'Banana', 'emoji' => '๐'],
+ ['value' => 'cherry', 'label' => 'Cherry', 'emoji' => '๐'],
+ ];
+
+ Filesystem::put(
+ resource_path('dictionaries').'/items.yaml',
+ YAML::dump($arr)
+ );
+
+ $dictionary = (new File)->setConfig(['filename' => 'items.yaml']);
+
+ $this->assertEquals($expected, $dictionary->options($query));
+ }
+
+ public static function searchProvider()
+ {
+ return [
+ 'e' => [
+ 'e',
+ [
+ 'apple' => 'Apple',
+ 'cherry' => 'Cherry',
+ ],
+ ],
+ 'n' => [
+ 'n',
+ [
+ 'banana' => 'Banana',
+ ],
+ ],
+ ];
+ }
+
+ #[Test]
+ public function it_gets_array_from_value()
+ {
+ $arr = [
+ ['value' => 'apple', 'label' => 'Apple', 'emoji' => '๐'],
+ ['value' => 'banana', 'label' => 'Banana', 'emoji' => '๐'],
+ ['value' => 'cherry', 'label' => 'Cherry', 'emoji' => '๐'],
+ ];
+
+ Filesystem::put(
+ resource_path('dictionaries').'/items.yaml',
+ YAML::dump($arr)
+ );
+
+ $item = (new File)
+ ->setConfig(['filename' => 'items.yaml'])
+ ->get('banana');
+
+ $this->assertInstanceOf(Item::class, $item);
+ $this->assertEquals('Banana', $item->label());
+ $this->assertEquals('banana', $item->value());
+ $this->assertEquals([
+ 'value' => 'banana',
+ 'emoji' => '๐',
+ ], $item->data());
+ }
+}
diff --git a/tests/Dictionaries/ItemTest.php b/tests/Dictionaries/ItemTest.php
new file mode 100644
index 0000000000..6dc623060b
--- /dev/null
+++ b/tests/Dictionaries/ItemTest.php
@@ -0,0 +1,31 @@
+ 'Apple', // Ensures the label argument takes precedence.
+ 'color' => 'red',
+ 'emoji' => '๐',
+ ]);
+
+ $this->assertEquals('apple', $item->value());
+ $this->assertEquals('๐ Apple', $item->label());
+ $this->assertEquals(['color' => 'red', 'emoji' => '๐'], $item->data());
+ $this->assertEquals([
+ 'key' => 'apple',
+ 'value' => 'apple',
+ 'color' => 'red',
+ 'emoji' => '๐',
+ 'label' => '๐ Apple',
+ ], $item->toArray());
+ }
+}
diff --git a/tests/Dictionaries/TimezonesTest.php b/tests/Dictionaries/TimezonesTest.php
new file mode 100644
index 0000000000..b426a4ae51
--- /dev/null
+++ b/tests/Dictionaries/TimezonesTest.php
@@ -0,0 +1,115 @@
+options();
+
+ $this->assertCount(419, $options);
+ $this->assertEquals([
+ 'Africa/Abidjan' => 'Africa/Abidjan (+00:00)',
+ 'Africa/Accra' => 'Africa/Accra (+00:00)',
+ 'Africa/Addis_Ababa' => 'Africa/Addis_Ababa (+03:00)',
+ 'Pacific/Wake' => 'Pacific/Wake (+12:00)',
+ 'Pacific/Wallis' => 'Pacific/Wallis (+12:00)',
+ 'UTC' => 'UTC (+00:00)',
+ ], [...array_slice($options, 0, 3), ...array_slice($options, -3, 3)]);
+ }
+
+ #[Test]
+ #[DataProvider('searchProvider')]
+ public function it_searches_options($query, $expected)
+ {
+ // UTC offsets can change during daylight saving time, so we need to freeze time.
+ Carbon::setTestNow('2024-07-23');
+
+ $this->assertEquals($expected, (new Timezones)->options($query));
+ }
+
+ public static function searchProvider()
+ {
+ return [
+ 'new' => [
+ 'new',
+ [
+ 'America/New_York' => 'America/New_York (-04:00)',
+ 'America/North_Dakota/New_Salem' => 'America/North_Dakota/New_Salem (-05:00)',
+ ],
+ ],
+ 'ten' => [
+ '10',
+ [
+ 'Antarctica/DumontDUrville' => 'Antarctica/DumontDUrville (+10:00)',
+ 'Antarctica/Macquarie' => 'Antarctica/Macquarie (+10:00)',
+ 'Asia/Ust-Nera' => 'Asia/Ust-Nera (+10:00)',
+ 'Asia/Vladivostok' => 'Asia/Vladivostok (+10:00)',
+ 'Australia/Brisbane' => 'Australia/Brisbane (+10:00)',
+ 'Australia/Hobart' => 'Australia/Hobart (+10:00)',
+ 'Australia/Lindeman' => 'Australia/Lindeman (+10:00)',
+ 'Australia/Lord_Howe' => 'Australia/Lord_Howe (+10:30)',
+ 'Australia/Melbourne' => 'Australia/Melbourne (+10:00)',
+ 'Australia/Sydney' => 'Australia/Sydney (+10:00)',
+ 'Pacific/Chuuk' => 'Pacific/Chuuk (+10:00)',
+ 'Pacific/Guam' => 'Pacific/Guam (+10:00)',
+ 'Pacific/Honolulu' => 'Pacific/Honolulu (-10:00)',
+ 'Pacific/Port_Moresby' => 'Pacific/Port_Moresby (+10:00)',
+ 'Pacific/Rarotonga' => 'Pacific/Rarotonga (-10:00)',
+ 'Pacific/Saipan' => 'Pacific/Saipan (+10:00)',
+ 'Pacific/Tahiti' => 'Pacific/Tahiti (-10:00)',
+ ],
+ ],
+ 'plus ten' => [
+ '+10',
+ [
+ 'Antarctica/DumontDUrville' => 'Antarctica/DumontDUrville (+10:00)',
+ 'Antarctica/Macquarie' => 'Antarctica/Macquarie (+10:00)',
+ 'Asia/Ust-Nera' => 'Asia/Ust-Nera (+10:00)',
+ 'Asia/Vladivostok' => 'Asia/Vladivostok (+10:00)',
+ 'Australia/Brisbane' => 'Australia/Brisbane (+10:00)',
+ 'Australia/Hobart' => 'Australia/Hobart (+10:00)',
+ 'Australia/Lindeman' => 'Australia/Lindeman (+10:00)',
+ 'Australia/Lord_Howe' => 'Australia/Lord_Howe (+10:30)',
+ 'Australia/Melbourne' => 'Australia/Melbourne (+10:00)',
+ 'Australia/Sydney' => 'Australia/Sydney (+10:00)',
+ 'Pacific/Chuuk' => 'Pacific/Chuuk (+10:00)',
+ 'Pacific/Guam' => 'Pacific/Guam (+10:00)',
+ 'Pacific/Port_Moresby' => 'Pacific/Port_Moresby (+10:00)',
+ 'Pacific/Saipan' => 'Pacific/Saipan (+10:00)',
+ ],
+ ],
+ 'minus ten' => [
+ '-10',
+ [
+ 'Pacific/Honolulu' => 'Pacific/Honolulu (-10:00)',
+ 'Pacific/Rarotonga' => 'Pacific/Rarotonga (-10:00)',
+ 'Pacific/Tahiti' => 'Pacific/Tahiti (-10:00)',
+ ],
+ ],
+ ];
+ }
+
+ #[Test]
+ public function it_gets_array_from_value()
+ {
+ // UTC offsets can change during daylight saving time, so we need to freeze time.
+ Carbon::setTestNow('2024-07-23');
+
+ $item = (new Timezones)->get('America/New_York');
+ $this->assertInstanceOf(Item::class, $item);
+ $this->assertEquals([
+ 'name' => 'America/New_York',
+ 'offset' => '-04:00',
+ ], $item->data());
+ }
+}
diff --git a/tests/Feature/GraphQL/Fieldtypes/DictionaryFieldtypeTest.php b/tests/Feature/GraphQL/Fieldtypes/DictionaryFieldtypeTest.php
new file mode 100644
index 0000000000..6ec56a0fa7
--- /dev/null
+++ b/tests/Feature/GraphQL/Fieldtypes/DictionaryFieldtypeTest.php
@@ -0,0 +1,91 @@
+createEntryWithFields([
+ 'undefined' => [
+ 'value' => null,
+ 'field' => ['type' => 'dictionary', 'dictionary' => ['type' => 'countries']],
+ ],
+ 'country' => [
+ 'value' => 'USA',
+ 'field' => ['type' => 'dictionary', 'dictionary' => ['type' => 'countries'], 'max_items' => 1],
+ ],
+ 'countries' => [
+ 'value' => ['AUS', 'USA'],
+ 'field' => ['type' => 'dictionary', 'dictionary' => ['type' => 'countries']],
+ ],
+ 'timezone' => [
+ 'value' => 'America/New_York',
+ 'field' => ['type' => 'dictionary', 'dictionary' => ['type' => 'timezones'], 'max_items' => 1],
+ ],
+ 'timezones' => [
+ 'value' => ['Australia/Sydney', 'America/New_York'],
+ 'field' => ['type' => 'dictionary', 'dictionary' => ['type' => 'timezones']],
+ ],
+ 'currency' => [
+ 'value' => 'USD',
+ 'field' => ['type' => 'dictionary', 'dictionary' => ['type' => 'currencies'], 'max_items' => 1],
+ ],
+ 'currencies' => [
+ 'value' => ['GBP', 'USD'],
+ 'field' => ['type' => 'dictionary', 'dictionary' => ['type' => 'currencies']],
+ ],
+ ]);
+
+ $this->assertGqlEntryHas('
+ undefined { name, iso2 }
+ country { name, iso2 }
+ countries { name, iso2 }
+ timezone { name, offset }
+ timezones { name, offset }
+ currency { name, code, symbol }
+ currencies { name, code, symbol }
+ ', [
+ 'undefined' => null,
+ 'country' => ['name' => 'United States', 'iso2' => 'US'],
+ 'countries' => [['name' => 'Australia', 'iso2' => 'AU'], ['name' => 'United States', 'iso2' => 'US']],
+ 'timezone' => ['name' => 'America/New_York', 'offset' => '-04:00'],
+ 'timezones' => [['name' => 'Australia/Sydney', 'offset' => '+10:00'], ['name' => 'America/New_York', 'offset' => '-04:00']],
+ 'currency' => ['name' => 'US Dollar', 'code' => 'USD', 'symbol' => '$'],
+ 'currencies' => [['name' => 'British Pound Sterling', 'code' => 'GBP', 'symbol' => 'ยฃ'], ['name' => 'US Dollar', 'code' => 'USD', 'symbol' => '$']],
+ ]);
+ }
+
+ #[Test]
+ public function it_filters_out_invalid_values()
+ {
+ $this->createEntryWithFields([
+ 'timezone' => [
+ 'value' => 'Somewhere/Nowhere',
+ 'field' => ['type' => 'dictionary', 'dictionary' => ['type' => 'timezones'], 'max_items' => 1],
+ ],
+ 'timezones' => [
+ 'value' => ['Somewhere/Nowhere', 'America/New_York'],
+ 'field' => ['type' => 'dictionary', 'dictionary' => ['type' => 'timezones']],
+ ],
+ ]);
+
+ $this->assertGqlEntryHas('
+ timezone { name }
+ timezones { name }
+ ', [
+ 'timezone' => null,
+ 'timezones' => [['name' => 'America/New_York']],
+ ]);
+ }
+}
diff --git a/tests/Fieldtypes/DictionaryFieldsTest.php b/tests/Fieldtypes/DictionaryFieldsTest.php
new file mode 100644
index 0000000000..ade9f3e1c5
--- /dev/null
+++ b/tests/Fieldtypes/DictionaryFieldsTest.php
@@ -0,0 +1,156 @@
+preload();
+
+ $this->assertArraySubset([
+ 'type' => [
+ 'fields' => [
+ ['handle' => 'type', 'type' => 'select'],
+ ],
+ 'meta' => collect(['type' => null]),
+ ],
+ ], $preload);
+
+ $this->assertArraySubset([
+ 'fake_dictionary' => [
+ 'fields' => [
+ ['handle' => 'category', 'type' => 'select'],
+ ],
+ 'meta' => collect(['category' => null]),
+ 'defaults' => collect(['category' => null]),
+ ],
+ ], $preload['dictionaries']->all());
+ }
+
+ #[Test]
+ public function it_pre_processes_dictionary_fields()
+ {
+ $fieldtype = FieldtypeRepository::find('dictionary_fields');
+
+ $preProcess = $fieldtype->preProcess([
+ 'type' => 'fake_dictionary',
+ 'category' => 'foo',
+ ]);
+
+ $this->assertEquals([
+ 'type' => 'fake_dictionary',
+ 'category' => 'foo',
+ ], $preProcess);
+ }
+
+ #[Test]
+ public function it_pre_processes_dictionary_fields_saved_as_a_string()
+ {
+ $fieldtype = FieldtypeRepository::find('dictionary_fields');
+
+ $preProcess = $fieldtype->preProcess('fake_dictionary');
+
+ $this->assertEquals([
+ 'type' => 'fake_dictionary',
+ ], $preProcess);
+ }
+
+ #[Test]
+ public function it_processes_dictionary_fields()
+ {
+ $fieldtype = FieldtypeRepository::find('dictionary_fields');
+
+ $process = $fieldtype->process([
+ 'type' => 'fake_dictionary',
+ 'category' => 'foo',
+ ]);
+
+ $this->assertEquals([
+ 'type' => 'fake_dictionary',
+ 'category' => 'foo',
+ ], $process);
+ }
+
+ #[Test]
+ public function it_processes_dictionary_fields_into_a_string_when_dictionary_has_no_config_values()
+ {
+ $fieldtype = FieldtypeRepository::find('dictionary_fields');
+
+ $process = $fieldtype->process([
+ 'type' => 'fake_dictionary',
+ ]);
+
+ $this->assertEquals('fake_dictionary', $process);
+ }
+
+ #[Test]
+ public function it_returns_validation_rules()
+ {
+ $field = (new Field('test', ['type' => 'dictionary_fields']))->setValue(['type' => 'fake_dictionary']);
+
+ $fieldtype = FieldtypeRepository::find('dictionary_fields');
+ $fieldtype->setField($field);
+
+ $extraRules = $fieldtype->extraRules();
+
+ $this->assertEquals([
+ 'test.category' => ['required'],
+ ], $extraRules);
+ }
+
+ #[Test]
+ public function it_returns_validation_rules_when_no_dictionary_is_selected()
+ {
+ $field = (new Field('test', ['type' => 'dictionary_fields']));
+
+ $fieldtype = FieldtypeRepository::find('dictionary_fields');
+ $fieldtype->setField($field);
+
+ $extraRules = $fieldtype->extraRules();
+
+ $this->assertEquals([
+ 'test.type' => ['required'],
+ ], $extraRules);
+ }
+}
+
+class FakeDictionary extends Dictionary
+{
+ public function options(?string $search = null): array
+ {
+ return [];
+ }
+
+ public function get(string $key): ?Item
+ {
+ return [];
+ }
+
+ protected function fieldItems()
+ {
+ return [
+ 'category' => [
+ 'type' => 'select',
+ 'validate' => 'required',
+ ],
+ ];
+ }
+}
diff --git a/tests/Fieldtypes/DictionaryTest.php b/tests/Fieldtypes/DictionaryTest.php
new file mode 100644
index 0000000000..eac8639263
--- /dev/null
+++ b/tests/Fieldtypes/DictionaryTest.php
@@ -0,0 +1,245 @@
+ 'dictionary', 'dictionary' => $dictionaryConfig]));
+ $fieldtype = FieldtypeRepository::find('dictionary');
+ $fieldtype->setField($field);
+
+ $dictionary = $fieldtype->dictionary();
+ $this->assertInstanceOf(Countries::class, $dictionary);
+ $this->assertEquals($expectedConfig, $dictionary->config());
+ }
+
+ public static function dictionaryConfigProvider()
+ {
+ return [
+ 'string' => [
+ 'countries',
+ [],
+ ],
+ 'array' => [
+ ['type' => 'countries', 'foo' => 'bar', 'baz' => 'qux'],
+ ['foo' => 'bar', 'baz' => 'qux'],
+ ],
+ ];
+ }
+
+ #[Test]
+ #[DataProvider('undefinedDictionaryConfigProvider')]
+ public function it_throw_exception_when_dictionary_is_undefined($dictionaryConfig)
+ {
+ $this->expectException(UndefinedDictionaryException::class);
+ $field = (new Field('test', ['type' => 'dictionary', 'dictionary' => $dictionaryConfig]));
+ $fieldtype = FieldtypeRepository::find('dictionary');
+ $fieldtype->setField($field);
+ $fieldtype->dictionary();
+ }
+
+ public static function undefinedDictionaryConfigProvider()
+ {
+ return [
+ 'string' => [null],
+ 'array' => [['foo' => 'bar']],
+ ];
+ }
+
+ #[Test]
+ #[DataProvider('invalidDictionaryConfigProvider')]
+ public function it_throws_exception_when_invalid_dictionary_is_defined($dictionaryConfig)
+ {
+ $this->expectException(DictionaryNotFoundException::class);
+ $field = (new Field('test', ['type' => 'dictionary', 'dictionary' => $dictionaryConfig]));
+ $fieldtype = FieldtypeRepository::find('dictionary');
+ $fieldtype->setField($field);
+ $fieldtype->dictionary();
+ }
+
+ public static function invalidDictionaryConfigProvider()
+ {
+ return [
+ 'string' => ['invalid'],
+ 'array' => [['type' => 'invalid']],
+ ];
+ }
+
+ #[Test]
+ public function it_returns_preload_data()
+ {
+ $field = (new Field('test', ['type' => 'dictionary', 'dictionary' => 'countries']));
+ $field->setValue(['USA', 'AUS', 'CAN', 'BLA', 'DEU', 'GBR']);
+
+ $fieldtype = FieldtypeRepository::find('dictionary');
+ $fieldtype->setField($field);
+
+ $preload = $fieldtype->preload();
+
+ $this->assertArraySubset([
+ 'url' => 'http://localhost/cp/fieldtypes/dictionaries/countries',
+ 'selectedOptions' => [
+ ['value' => 'USA', 'label' => '๐บ๐ธ United States', 'invalid' => false],
+ ['value' => 'AUS', 'label' => '๐ฆ๐บ Australia', 'invalid' => false],
+ ['value' => 'CAN', 'label' => '๐จ๐ฆ Canada', 'invalid' => false],
+ ['value' => 'BLA', 'label' => 'BLA', 'invalid' => true],
+ ['value' => 'DEU', 'label' => '๐ฉ๐ช Germany', 'invalid' => false],
+ ['value' => 'GBR', 'label' => '๐ฌ๐ง United Kingdom', 'invalid' => false],
+ ],
+ ], $preload);
+ }
+
+ #[Test]
+ public function it_augments_a_single_option()
+ {
+ $field = (new Field('test', ['type' => 'dictionary', 'dictionary' => 'countries', 'max_items' => 1]));
+
+ $fieldtype = FieldtypeRepository::find('dictionary');
+ $fieldtype->setField($field);
+
+ $augmented = $fieldtype->augment('USA');
+ $this->assertInstanceOf(Item::class, $augmented);
+ $this->assertEquals('USA', $augmented->value());
+ $this->assertEquals('๐บ๐ธ United States', $augmented->label());
+ $this->assertEquals([
+ 'key' => 'USA',
+ 'name' => 'United States',
+ 'iso3' => 'USA',
+ 'iso2' => 'US',
+ 'region' => 'Americas',
+ 'subregion' => 'Northern America',
+ 'emoji' => '๐บ๐ธ',
+ 'value' => 'USA',
+ 'label' => '๐บ๐ธ United States',
+ ], $augmented->toArray());
+
+ $augmented = $fieldtype->augment(null);
+ $this->assertInstanceOf(Item::class, $augmented);
+ $this->assertNull($augmented->value());
+ $this->assertNull($augmented->label());
+ }
+
+ #[Test]
+ public function it_augments_multiple_options()
+ {
+ $field = (new Field('test', ['type' => 'dictionary', 'dictionary' => 'countries']));
+
+ $fieldtype = FieldtypeRepository::find('dictionary');
+ $fieldtype->setField($field);
+
+ $augment = $fieldtype->augment(['USA', 'GBR']);
+
+ $this->assertEveryItemIsInstanceOf(Item::class, $augment);
+
+ $this->assertEquals([
+ [
+ 'name' => 'United States',
+ 'key' => 'USA',
+ 'iso3' => 'USA',
+ 'iso2' => 'US',
+ 'region' => 'Americas',
+ 'subregion' => 'Northern America',
+ 'emoji' => '๐บ๐ธ',
+ 'value' => 'USA',
+ 'label' => '๐บ๐ธ United States',
+ ],
+ [
+ 'name' => 'United Kingdom',
+ 'key' => 'GBR',
+ 'iso3' => 'GBR',
+ 'iso2' => 'GB',
+ 'region' => 'Europe',
+ 'subregion' => 'Northern Europe',
+ 'emoji' => '๐ฌ๐ง',
+ 'value' => 'GBR',
+ 'label' => '๐ฌ๐ง United Kingdom',
+ ],
+ ], collect($augment)->toArray());
+ }
+
+ #[Test]
+ public function it_augments_to_empty_array_when_null_and_configured_for_multiple()
+ {
+ $field = (new Field('test', ['type' => 'dictionary', 'dictionary' => 'countries']));
+ $fieldtype = FieldtypeRepository::find('dictionary');
+ $fieldtype->setField($field);
+
+ $this->assertEquals([], $fieldtype->augment(null));
+ }
+
+ #[Test]
+ public function invalid_value_augments_to_null()
+ {
+ $field = (new Field('test', ['type' => 'dictionary', 'dictionary' => 'countries', 'max_items' => 1]));
+ $fieldtype = FieldtypeRepository::find('dictionary');
+ $fieldtype->setField($field);
+
+ $augmented = $fieldtype->augment('invalid');
+ $this->assertInstanceOf(Item::class, $augmented);
+ $this->assertNull($augmented->value());
+ $this->assertNull($augmented->label());
+ }
+
+ #[Test]
+ public function it_filters_out_invalid_values_when_augmenting_multiple()
+ {
+ $field = (new Field('test', ['type' => 'dictionary', 'dictionary' => 'countries']));
+
+ $fieldtype = FieldtypeRepository::find('dictionary');
+ $fieldtype->setField($field);
+
+ $augment = $fieldtype->augment(['USA', 'Invalid']);
+
+ $this->assertCount(1, $augment);
+ $this->assertEveryItemIsInstanceOf(Item::class, $augment);
+ $this->assertEquals([
+ [
+ 'name' => 'United States',
+ 'key' => 'USA',
+ 'iso3' => 'USA',
+ 'iso2' => 'US',
+ 'region' => 'Americas',
+ 'subregion' => 'Northern America',
+ 'emoji' => '๐บ๐ธ',
+ 'value' => 'USA',
+ 'label' => '๐บ๐ธ United States',
+ ],
+ ], collect($augment)->toArray());
+ }
+
+ #[Test]
+ public function it_returns_extra_renderable_field_data()
+ {
+ $field = (new Field('test', ['type' => 'dictionary', 'dictionary' => 'countries']));
+ $field->setValue(['USA', 'AUS', 'CAN', 'DEU', 'GBR']);
+
+ $fieldtype = FieldtypeRepository::find('dictionary');
+ $fieldtype->setField($field);
+
+ $extraRenderableFieldData = $fieldtype->extraRenderableFieldData();
+
+ $this->assertArraySubset([
+ 'options' => [
+ 'AUS' => '๐ฆ๐บ Australia',
+ 'CAN' => '๐จ๐ฆ Canada',
+ 'DEU' => '๐ฉ๐ช Germany',
+ 'GBR' => '๐ฌ๐ง United Kingdom',
+ 'USA' => '๐บ๐ธ United States',
+ ],
+ ], $extraRenderableFieldData);
+ }
+}