From 08940fec528180a47202bcb7e59b11a03c20d9c9 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 10 Sep 2026 16:40:50 -0400 Subject: [PATCH] Allow fieldtypes to have an "icon" config field The fieldtype selector built a single flat object containing both the fieldtype's CP icon and the defaults for each of its config fields. A fieldtype declaring a config field handled "icon" would have its default overwritten by the CP icon, and that value then followed the field into its config, through the settings form, and on to the save request. FieldTransformer worked around this by unconditionally stripping "icon" from every field config, which made an "icon" config field impossible to save at all. The icon is now kept alongside the config rather than inside it, so it can no longer collide, and the strip is no longer needed. Co-Authored-By: Claude Opus 5 (1M context) --- resources/js/components/blueprints/Fields.vue | 8 +- .../components/fields/FieldtypeSelector.vue | 42 ++++--- .../components/FieldtypeSelector.test.js | 106 ++++++++++++++++++ src/Fields/FieldTransformer.php | 2 +- tests/Fields/FieldTransformerTest.php | 29 +++++ 5 files changed, 164 insertions(+), 23 deletions(-) create mode 100644 resources/js/tests/components/FieldtypeSelector.test.js diff --git a/resources/js/components/blueprints/Fields.vue b/resources/js/components/blueprints/Fields.vue index 6d48bf3759a..5e0606befb3 100644 --- a/resources/js/components/blueprints/Fields.vue +++ b/resources/js/components/blueprints/Fields.vue @@ -128,16 +128,16 @@ export default { return field.type === 'import' ? 'ImportField' : 'RegularField'; }, - fieldtypeSelected(field) { + fieldtypeSelected({ config, icon }) { this.isSelectingNewFieldtype = false; const pending = { _id: uniqid(), type: 'inline', - fieldtype: field.type, - icon: field.icon, + fieldtype: config.type, + icon, config: { - ...field, + ...config, isNew: true, }, }; diff --git a/resources/js/components/fields/FieldtypeSelector.vue b/resources/js/components/fields/FieldtypeSelector.vue index ae835d7ad8e..00c510cb289 100644 --- a/resources/js/components/fields/FieldtypeSelector.vue +++ b/resources/js/components/fields/FieldtypeSelector.vue @@ -252,9 +252,7 @@ export default { return this.selectMeta(selection); } - const field = this.createField(selection.value); - - this.$emit('selected', field); + this.$emit('selected', this.createField(selection.value)); this.close(); }, @@ -265,19 +263,21 @@ export default { fieldtype = 'text'; } - let field = this.createField(fieldtype); - - field = Object.assign( - { - display: __(`cp.${selection.value}`), - handle: selection.value, - type: fieldtype, - isMeta: true, - }, - field, - ); + const { config, icon } = this.createField(fieldtype); + + this.$emit('selected', { + icon, + config: Object.assign( + { + display: __(`cp.${selection.value}`), + handle: selection.value, + type: fieldtype, + isMeta: true, + }, + config, + ), + }); - this.$emit('selected', field); this.close(); }, @@ -291,7 +291,6 @@ export default { type: fieldtype.handle, display: __(':title Field', { title: fieldtype.title }), handle: null, // The handle will be generated from the display by the "slug" fieldtype. - icon: fieldtype.icon, instructions: null, localizable: false, width: 100, @@ -307,8 +306,15 @@ export default { defaults[configField.handle] = configField.default || null; }); - // Smoosh the field together with the defaults. - return Object.assign(defaults, field); + // The icon is kept alongside the config rather than inside it. It belongs to the + // fieldtype, not the field, and would otherwise clobber the default value of a + // config field that happens to be handled "icon". + return { + icon: fieldtype.icon, + + // Smoosh the field together with the defaults. + config: Object.assign(defaults, field), + }; }, close() { diff --git a/resources/js/tests/components/FieldtypeSelector.test.js b/resources/js/tests/components/FieldtypeSelector.test.js new file mode 100644 index 00000000000..2e4f0cd2d92 --- /dev/null +++ b/resources/js/tests/components/FieldtypeSelector.test.js @@ -0,0 +1,106 @@ +import { flushPromises, mount, shallowMount } from '@vue/test-utils'; +import { expect, test } from 'vitest'; +import * as Globals from '@/bootstrap/globals'; +import FieldtypeSelector from '@/components/fields/FieldtypeSelector.vue'; +import Fields from '@/components/blueprints/Fields.vue'; + +Object.keys(Globals).forEach((fn) => (window[fn] = Globals[fn])); +window.__ = (key) => key; +window.cp_url = (url) => url; + +window.Statamic = { + $config: { get: (key) => (key === 'sites' ? [{ handle: 'default' }] : undefined) }, + $commandPalette: { add: () => {}, category: { Actions: 'actions' } }, + $toast: { success: () => {} }, +}; + +const fieldtypes = [ + { + handle: 'test', + title: 'Test', + icon: 'test-fieldtype-icon', + categories: ['special'], + keywords: [], + config: [ + { handle: 'icon', default: 'default-icon' }, + { handle: 'foo', default: 'bar' }, + ], + }, + { + handle: 'text', + title: 'Text', + icon: 'text-fieldtype-icon', + categories: ['text'], + keywords: [], + config: [], + }, +]; + +async function mountSelector() { + const wrapper = mount(FieldtypeSelector, { + props: { allowTitle: true }, + global: { + mocks: { + $axios: { get: () => Promise.resolve({ data: fieldtypes }) }, + $config: { get: () => undefined }, + $toast: { error: () => {} }, + }, + stubs: { + 'ui-input': true, + 'ui-panel': true, + 'ui-panel-header': true, + 'ui-description': true, + 'ui-icon': true, + }, + }, + }); + + await flushPromises(); + + return wrapper; +} + +test('the fieldtype icon is emitted alongside the config, leaving an "icon" config field intact', async () => { + const wrapper = await mountSelector(); + + wrapper.vm.select({ value: 'test' }); + + const [{ icon, config }] = wrapper.emitted('selected')[0]; + + expect(icon).toBe('test-fieldtype-icon'); + expect(config.type).toBe('test'); + expect(config.icon).toBe('default-icon'); + expect(config.foo).toBe('bar'); +}); + +test('meta fields also emit the icon alongside the config', async () => { + const wrapper = await mountSelector(); + + wrapper.vm.select({ value: 'title', isMeta: true }); + + const [{ icon, config }] = wrapper.emitted('selected')[0]; + + expect(icon).toBe('text-fieldtype-icon'); + expect(config.isMeta).toBe(true); + expect(config.type).toBe('text'); +}); + +test('the fieldtype icon does not leak into the created field config', () => { + const wrapper = shallowMount(Fields, { + props: { fields: [] }, + global: { + mocks: { $toast: { success: () => {} } }, + }, + }); + + wrapper.vm.fieldtypeSelected({ + icon: 'test-fieldtype-icon', + config: { type: 'test', icon: 'default-icon' }, + }); + + const pending = wrapper.vm.pendingCreatedField; + + expect(pending.icon).toBe('test-fieldtype-icon'); + expect(pending.fieldtype).toBe('test'); + expect(pending.config.icon).toBe('default-icon'); +}); diff --git a/src/Fields/FieldTransformer.php b/src/Fields/FieldTransformer.php index ab992143676..36d04352102 100644 --- a/src/Fields/FieldTransformer.php +++ b/src/Fields/FieldTransformer.php @@ -36,7 +36,7 @@ private static function inlineTabField(array $submitted) $field = collect($submitted['config']) ->reject(function ($value, $key) use ($fields) { - if (in_array($key, ['isNew', 'icon'])) { + if ($key === 'isNew') { return true; } diff --git a/tests/Fields/FieldTransformerTest.php b/tests/Fields/FieldTransformerTest.php index c594161bf70..442ffffc296 100644 --- a/tests/Fields/FieldTransformerTest.php +++ b/tests/Fields/FieldTransformerTest.php @@ -115,6 +115,35 @@ public function configFieldItems(): array ], $fromVue['field']); } + #[Test] + public function a_fieldtype_can_have_an_icon_config_field() + { + $fieldtype = new class extends Fieldtype + { + protected static $handle = 'test'; + + public function configFieldItems(): array + { + return [ + 'icon' => ['type' => 'text', 'default' => 'default-icon'], + ]; + } + }; + $fieldtype::register(); + + $fromVue = FieldTransformer::fromVue([ + 'fieldtype' => 'test', + 'handle' => 'test', + 'type' => 'inline', + 'config' => [ + 'icon' => 'chosen-icon', + 'foo' => 'bar', + ], + ]); + + $this->assertEquals(['icon' => 'chosen-icon', 'foo' => 'bar'], $fromVue['field']); + } + #[Test] public function it_removes_full_width_from_field_config() {