diff --git a/resources/js/components/blueprints/Builder.vue b/resources/js/components/blueprints/Builder.vue
index 7a1d7b0598b..d022356da4e 100644
--- a/resources/js/components/blueprints/Builder.vue
+++ b/resources/js/components/blueprints/Builder.vue
@@ -43,6 +43,7 @@
import SuggestsConditionalFields from './SuggestsConditionalFields';
import Tabs from './Tabs.vue';
import CanDefineLocalizable from '../fields/CanDefineLocalizable';
+import { computed } from 'vue';
export default {
mixins: [SuggestsConditionalFields, CanDefineLocalizable],
@@ -59,6 +60,17 @@ export default {
isFormBlueprint: { type: Boolean, default: false },
},
+ provide() {
+ return {
+ fieldtypeContext: computed(() => ({
+ view: 'blueprint',
+ blueprint: this.initialBlueprint.fullyQualifiedHandle,
+ field_path: [],
+ nested: false,
+ })),
+ };
+ },
+
data() {
return {
blueprint: this.initializeBlueprint(),
diff --git a/resources/js/components/blueprints/Section.vue b/resources/js/components/blueprints/Section.vue
index 90ccc4a84e8..8ce3195d5b1 100644
--- a/resources/js/components/blueprints/Section.vue
+++ b/resources/js/components/blueprints/Section.vue
@@ -138,12 +138,25 @@
import Fields from './Fields.vue';
import CanDefineLocalizable from '../fields/CanDefineLocalizable';
import { Switch, Heading } from '@/components/ui';
+import { computed } from 'vue';
export default {
mixins: [CanDefineLocalizable],
inject: {
suggestableConditionFieldsProvider: { default: null },
+ parentFieldtypeContext: { from: 'fieldtypeContext', default: () => ({}) },
+ isInsideSet: { default: false },
+ },
+
+ provide() {
+ return {
+ fieldtypeContext: computed(() => this.isInsideSet ? {
+ ...this.parentFieldtypeContext,
+ field_path: [...(this.parentFieldtypeContext.field_path ?? []), this.section.handle],
+ nested: true,
+ } : this.parentFieldtypeContext),
+ };
},
components: {
diff --git a/resources/js/components/fields/FieldtypeSelector.vue b/resources/js/components/fields/FieldtypeSelector.vue
index 00c510cb289..867444d22e1 100644
--- a/resources/js/components/fields/FieldtypeSelector.vue
+++ b/resources/js/components/fields/FieldtypeSelector.vue
@@ -52,8 +52,9 @@ import fuzzysort from 'fuzzysort';
import { ref } from 'vue';
import { mapValues } from 'lodash-es';
import { Icon } from '@/components/ui';
+import qs from 'qs';
-const loadedFieldtypes = ref(null);
+const loadedFieldtypes = ref({});
export default {
components: {
@@ -72,6 +73,10 @@ export default {
},
},
+ inject: {
+ fieldtypeContext: { default: () => ({}) },
+ },
+
data: function () {
return {
categories: {
@@ -112,18 +117,19 @@ export default {
fieldtypes() {
if (!this.fieldtypesLoaded) return;
- return loadedFieldtypes.value.data;
+ return loadedFieldtypes.value[this.cacheKey];
},
isFormBlueprint() {
return !!this.$config.get('isFormBlueprint');
},
+ cacheKey() {
+ return JSON.stringify({ forms: this.isFormBlueprint, context: this.fieldtypeContext });
+ },
+
fieldtypesLoaded() {
- // The cache is shared across every picker instance, but form and regular blueprints
- // request different lists. Only treat it as loaded when the cached list matches the
- // current blueprint mode, otherwise we'll refetch the correct one.
- return Array.isArray(loadedFieldtypes.value?.data) && loadedFieldtypes.value.forms === this.isFormBlueprint;
+ return Array.isArray(loadedFieldtypes.value[this.cacheKey]);
},
allFieldtypes() {
@@ -217,6 +223,13 @@ export default {
},
watch: {
+ cacheKey: {
+ immediate: true,
+ handler() {
+ this.loadFieldtypes();
+ },
+ },
+
fieldtypesLoaded: {
immediate: true,
handler() {
@@ -229,24 +242,26 @@ export default {
},
},
- created() {
- if (this.fieldtypesLoaded) return;
-
- const forms = this.isFormBlueprint;
-
- let url = cp_url('fields/fieldtypes?selectable=true');
-
- if (forms) url += '&forms=true';
-
- this.$axios.get(url)
- .then((response) => (loadedFieldtypes.value = { forms, data: response.data }))
- .catch((e) => {
- this.$toast.error(e.response?.data?.message || __('Something went wrong'));
- this.close();
+ methods: {
+ loadFieldtypes() {
+ if (this.fieldtypesLoaded) return;
+
+ const key = this.cacheKey;
+ const query = qs.stringify({
+ selectable: true,
+ forms: this.isFormBlueprint || undefined,
+ context: this.fieldtypeContext,
});
- },
- methods: {
+ this.$axios.get(cp_url(`fields/fieldtypes?${query}`))
+ .then((response) => (loadedFieldtypes.value[key] = response.data))
+ .catch((e) => {
+ if (this.cacheKey !== key) return;
+ this.$toast.error(e.response?.data?.message || __('Something went wrong'));
+ this.close();
+ });
+ },
+
select(selection) {
if (selection.isMeta) {
return this.selectMeta(selection);
diff --git a/resources/js/components/fields/Settings.vue b/resources/js/components/fields/Settings.vue
index 50257ee9c34..b2dd055a843 100644
--- a/resources/js/components/fields/Settings.vue
+++ b/resources/js/components/fields/Settings.vue
@@ -64,6 +64,7 @@ import { FieldConditionsBuilder, FIELD_CONDITIONS_KEYS } from '../field-conditio
import FieldValidationBuilder from '../field-validation/Builder.vue';
import { Heading, Button, Tabs, TabList, TabTrigger, TabContent, CardPanel, Icon, StackHeader, StackContent } from '@/components/ui';
import shortcutLabel from '@/util/shortcutLabel.js';
+import { computed } from 'vue';
export default {
emits: ['committed', 'closed'],
@@ -104,10 +105,12 @@ export default {
updateFieldSettingsValue: this.updateField,
getFieldSettingsValue: this.getFieldValue,
commitParentField: this.commit,
+ fieldtypeContext: computed(() => this.fieldtypeContext),
};
},
inject: {
+ parentFieldtypeContext: { from: 'fieldtypeContext', default: () => ({}) },
isInsideConfigFields: {
default: false
},
@@ -139,6 +142,13 @@ export default {
},
computed: {
+ fieldtypeContext() {
+ return {
+ ...this.parentFieldtypeContext,
+ field_path: [...(this.parentFieldtypeContext.field_path ?? []), this.values?.handle ?? this.config.handle ?? null],
+ };
+ },
+
adjustedBlueprint() {
let blueprint = this.blueprint;
@@ -283,6 +293,7 @@ export default {
values: this.values,
fields: this.fields,
isInsideSet: this.isInsideSet,
+ context: this.fieldtypeContext,
})
.then((response) => {
this.$refs.container?.clearDirtyState();
@@ -389,6 +400,7 @@ export default {
type: this.type,
reference: field?.type === 'reference' ? field.field_reference : false,
values: this.config,
+ context: this.fieldtypeContext,
})
.then((response) => {
this.loading = false;
diff --git a/resources/js/components/fieldtypes/grid/FieldsFieldtype.vue b/resources/js/components/fieldtypes/grid/FieldsFieldtype.vue
index c04e5506545..a889480f1ef 100644
--- a/resources/js/components/fieldtypes/grid/FieldsFieldtype.vue
+++ b/resources/js/components/fieldtypes/grid/FieldsFieldtype.vue
@@ -18,12 +18,20 @@ import Fieldtype from '../Fieldtype.vue';
import Fields from '../../blueprints/Fields.vue';
import { Sortable, Plugins } from '@shopify/draggable';
import SuggestsConditionalFields from '../../blueprints/SuggestsConditionalFields';
+import { computed } from 'vue';
export default {
mixins: [Fieldtype, SuggestsConditionalFields],
inject: {
suggestableConditionFieldsProvider: { default: null },
+ parentFieldtypeContext: { from: 'fieldtypeContext', default: () => ({}) },
+ },
+
+ provide() {
+ return {
+ fieldtypeContext: computed(() => ({ ...this.parentFieldtypeContext, nested: true })),
+ };
},
components: {
diff --git a/resources/js/pages/fieldsets/Edit.vue b/resources/js/pages/fieldsets/Edit.vue
index d00168a2260..5d7937854af 100644
--- a/resources/js/pages/fieldsets/Edit.vue
+++ b/resources/js/pages/fieldsets/Edit.vue
@@ -54,8 +54,16 @@ export default {
props: ['action', 'initialFieldset'],
- provide: {
- isInsideFieldset: true,
+ provide() {
+ return {
+ isInsideFieldset: true,
+ fieldtypeContext: {
+ view: 'fieldset',
+ fieldset: this.initialFieldset.handle,
+ field_path: [],
+ nested: false,
+ },
+ };
},
data() {
diff --git a/resources/js/tests/components/FieldtypeContext.test.js b/resources/js/tests/components/FieldtypeContext.test.js
new file mode 100644
index 00000000000..6cb7b45e186
--- /dev/null
+++ b/resources/js/tests/components/FieldtypeContext.test.js
@@ -0,0 +1,104 @@
+import { flushPromises, mount } from '@vue/test-utils';
+import { expect, test, vi } from 'vitest';
+import { h, inject } from 'vue';
+import * as Globals from '@/bootstrap/globals';
+import Settings from '@/components/fields/Settings.vue';
+import Section from '@/components/blueprints/Section.vue';
+import GridFields from '@/components/fieldtypes/grid/FieldsFieldtype.vue';
+import { publishContextKey } from '@/components/ui';
+
+Object.assign(window, Globals);
+window.__ = key => key;
+window.cp_url = url => url;
+window.Statamic = { $config: { get: key => key === 'sites' ? [] : undefined }, $fieldActions: { get: () => [] } };
+
+const Probe = {
+ methods: {
+ clearDirtyState() {},
+ },
+ setup() {
+ const context = inject('fieldtypeContext');
+ return () => h('div', [h('output', { class: 'blueprint-section-draggable-zone' }, JSON.stringify(context.value ?? context))]);
+ },
+};
+const PassThrough = { template: '
' };
+
+test.each([
+ [{ view: 'blueprint', blueprint: 'runway::post', field_path: [], nested: false }, 'author'],
+ [{ view: 'fieldset', fieldset: 'common', field_path: [], nested: false }, null],
+])('settings send and inject owner context independently of configuration values', async (context, handle) => {
+ const post = vi.fn((url, payload) => Promise.resolve({ data: url === 'fields/edit' ? {
+ fieldtype: { icon: 'text' },
+ blueprint: { tabs: [{ sections: [] }] },
+ values: payload.values,
+ meta: {},
+ } : payload.values }));
+ const wrapper = mount(Settings, {
+ props: { config: { handle, type: 'text' }, fields: [], type: 'text' },
+ global: {
+ provide: { fieldtypeContext: context },
+ mocks: {
+ $axios: { post },
+ $keys: { bindGlobal: () => ({ destroy() {} }) },
+ $config: { get: () => undefined },
+ },
+ stubs: {
+ StackHeader: PassThrough, StackContent: PassThrough,
+ Tabs: PassThrough, TabContent: PassThrough, TabList: true, Button: true,
+ CardPanel: true, FieldConditionsBuilder: true, FieldValidationBuilder: true,
+ },
+ components: { 'ui-publish-container': Probe },
+ },
+ });
+ await flushPromises();
+
+ expect(JSON.parse(wrapper.find('output').text())).toEqual({ ...context, field_path: [handle] });
+ expect(post.mock.calls[0][1].context).toEqual({ ...context, field_path: [handle] });
+ wrapper.vm.values.handle = 'renamed';
+ await flushPromises();
+ expect(JSON.parse(wrapper.find('output').text()).field_path).toEqual(['renamed']);
+
+ await wrapper.findAllComponents({ name: 'Button' })[0].trigger('click');
+ await flushPromises();
+ const payload = post.mock.calls.at(-1)[1];
+ expect(payload.context.field_path).toEqual(['renamed']);
+ expect(payload.values).not.toHaveProperty('context');
+ wrapper.unmount();
+});
+
+test.each([false, true])('sections add set handles only inside sets (inside set: %s)', insideSet => {
+ const wrapper = mount(Section, {
+ props: { section: { handle: 'quote', fields: [] } },
+ global: {
+ provide: {
+ fieldtypeContext: { blueprint: 'runway::post', field_path: ['blocks'], nested: false },
+ isInsideSet: insideSet,
+ },
+ mocks: { $config: { get: () => undefined } },
+ stubs: { Fields: Probe, 'ui-panel': PassThrough, 'ui-panel-header': true, 'ui-stack': true },
+ },
+ });
+ expect(JSON.parse(wrapper.find('output').text())).toEqual({
+ blueprint: 'runway::post',
+ field_path: insideSet ? ['blocks', 'quote'] : ['blocks'],
+ nested: insideSet,
+ });
+ wrapper.unmount();
+});
+
+test('grid field configuration marks its children nested without adding a config-field handle', () => {
+ const wrapper = mount(GridFields, {
+ props: { handle: 'fields', value: [], config: {} },
+ global: {
+ provide: {
+ [publishContextKey]: {},
+ fieldtypeContext: { blueprint: 'runway::post', field_path: ['rows'], nested: false },
+ },
+ stubs: { Fields: Probe },
+ },
+ });
+ expect(JSON.parse(wrapper.find('output').text())).toEqual({
+ blueprint: 'runway::post', field_path: ['rows'], nested: true,
+ });
+ wrapper.unmount();
+});
diff --git a/resources/js/tests/components/FieldtypeSelector.test.js b/resources/js/tests/components/FieldtypeSelector.test.js
index 2e4f0cd2d92..4b4bbc9fd58 100644
--- a/resources/js/tests/components/FieldtypeSelector.test.js
+++ b/resources/js/tests/components/FieldtypeSelector.test.js
@@ -1,5 +1,7 @@
import { flushPromises, mount, shallowMount } from '@vue/test-utils';
-import { expect, test } from 'vitest';
+import { expect, test, vi } from 'vitest';
+import { ref } from 'vue';
+import qs from 'qs';
import * as Globals from '@/bootstrap/globals';
import FieldtypeSelector from '@/components/fields/FieldtypeSelector.vue';
import Fields from '@/components/blueprints/Fields.vue';
@@ -36,13 +38,14 @@ const fieldtypes = [
},
];
-async function mountSelector() {
+async function mountSelector({ context = {}, get = () => Promise.resolve({ data: fieldtypes }), forms = false } = {}) {
const wrapper = mount(FieldtypeSelector, {
props: { allowTitle: true },
global: {
+ provide: { fieldtypeContext: context },
mocks: {
- $axios: { get: () => Promise.resolve({ data: fieldtypes }) },
- $config: { get: () => undefined },
+ $axios: { get },
+ $config: { get: () => forms },
$toast: { error: () => {} },
},
stubs: {
@@ -104,3 +107,36 @@ test('the fieldtype icon does not leak into the created field config', () => {
expect(pending.fieldtype).toBe('test');
expect(pending.config.icon).toBe('default-icon');
});
+
+test('picker caches isolate owners, nested paths and forms, including late responses', async () => {
+ const context = ref({ blueprint: 'runway::post', field_path: [], nested: false });
+ let resolveFirst;
+ const get = vi.fn()
+ .mockImplementationOnce(() => new Promise(resolve => resolveFirst = resolve))
+ .mockResolvedValueOnce({ data: [fieldtypes[1]] });
+ const root = await mountSelector({ context, get });
+
+ context.value = { blueprint: 'runway::video', field_path: ['blocks', 'quote'], nested: true };
+ await flushPromises();
+ resolveFirst({ data: [fieldtypes[0]] });
+ await flushPromises();
+
+ expect(root.vm.allFieldtypes.map(field => field.value)).toEqual(['title', 'text']);
+ expect(qs.parse(get.mock.calls[1][0].split('?')[1]).context).toEqual({
+ blueprint: 'runway::video', field_path: ['blocks', 'quote'], nested: 'true',
+ });
+
+ const cachedGet = vi.fn();
+ const cached = await mountSelector({ context: { blueprint: 'runway::post', field_path: [], nested: false }, get: cachedGet });
+ expect(cached.vm.allFieldtypes.map(field => field.value)).toEqual(['title', 'test']);
+ expect(cachedGet).not.toHaveBeenCalled();
+
+ const formGet = vi.fn().mockResolvedValue({ data: [] });
+ const form = await mountSelector({ context, get: formGet, forms: true });
+ expect(form.vm.allFieldtypes.map(field => field.value)).toEqual(['title']);
+ expect(formGet).toHaveBeenCalledOnce();
+
+ root.unmount();
+ cached.unmount();
+ form.unmount();
+});
diff --git a/src/Fields/Fieldtype.php b/src/Fields/Fieldtype.php
index 2195d284c73..61f90f65ff9 100644
--- a/src/Fields/Fieldtype.php
+++ b/src/Fields/Fieldtype.php
@@ -25,6 +25,7 @@ abstract class Fieldtype implements Arrayable
protected static $preloadable = null;
protected $field;
+ protected $context = [];
protected $localizable = true;
protected $validatable = true;
protected $defaultable = true;
@@ -102,6 +103,18 @@ public function selectable(): bool
return $this->selectable;
}
+ public function context(array $context): self
+ {
+ $this->context = $context;
+
+ return $this;
+ }
+
+ public function isVisible(): bool
+ {
+ return true;
+ }
+
public function selectableInForms(): bool
{
if (FieldtypeRepository::selectableInFormIsOverriden($this->handle())) {
@@ -274,7 +287,7 @@ private function configFieldsUseSections()
public function configFields(): Fields
{
- if ($cached = Blink::get($blink = 'config-fields-'.$this->handle())) {
+ if ($cached = Blink::get($blink = 'config-fields-'.$this->handle().'-'.md5(serialize($this->context)))) {
return $cached;
}
diff --git a/src/Http/Controllers/CP/Fields/FieldsController.php b/src/Http/Controllers/CP/Fields/FieldsController.php
index 77f734d2be3..4d7d1cb648d 100644
--- a/src/Http/Controllers/CP/Fields/FieldsController.php
+++ b/src/Http/Controllers/CP/Fields/FieldsController.php
@@ -32,9 +32,10 @@ public function edit(Request $request)
$request->validate([
'type' => 'required',
'values' => 'array',
+ 'context' => 'sometimes|array',
]);
- $fieldtype = FieldtypeRepository::find($request->type);
+ $fieldtype = FieldtypeRepository::find($request->type)->context($request->input('context', []));
$blueprint = $this->blueprint($fieldtype->configBlueprint());
@@ -73,9 +74,10 @@ public function update(Request $request)
'values' => 'required|array',
'fields' => 'sometimes|array',
'isInsideSet' => 'sometimes|boolean',
+ 'context' => 'sometimes|array',
]);
- $fieldtype = FieldtypeRepository::find($request->type);
+ $fieldtype = FieldtypeRepository::find($request->type)->context($request->input('context', []));
$blueprint = $this->blueprint($fieldtype->configBlueprint());
diff --git a/src/Http/Controllers/CP/Fields/FieldtypesController.php b/src/Http/Controllers/CP/Fields/FieldtypesController.php
index 892cccbd9f0..0291cacde4f 100644
--- a/src/Http/Controllers/CP/Fields/FieldtypesController.php
+++ b/src/Http/Controllers/CP/Fields/FieldtypesController.php
@@ -9,15 +9,27 @@ class FieldtypesController extends CpController
{
public function index(Request $request)
{
+ $request->validate(['context' => 'sometimes|array']);
+
+ $context = $request->input('context', []);
+
+ if ($context) {
+ $context['field_path'] ??= [];
+ }
+
+ if (array_key_exists('nested', $context)) {
+ $context['nested'] = $request->boolean('context.nested');
+ }
+
$fieldtypes = app('statamic.fieldtypes')
->unique() // Remove any dupes in the case of aliases. Aliases are defined later so they will win.
- ->map(function ($class) {
- return app($class);
+ ->map(function ($class) use ($context) {
+ return app($class)->context($context);
});
if ($request->selectable) {
$selectableMethod = $request->forms ? 'selectableInForms' : 'selectable';
- $fieldtypes = $fieldtypes->filter->$selectableMethod();
+ $fieldtypes = $fieldtypes->filter->$selectableMethod()->filter->isVisible();
}
return $fieldtypes->sortBy->handle()->values();
diff --git a/src/Http/Controllers/CP/Fields/ManagesBlueprints.php b/src/Http/Controllers/CP/Fields/ManagesBlueprints.php
index 0e718e49a01..84330398c05 100644
--- a/src/Http/Controllers/CP/Fields/ManagesBlueprints.php
+++ b/src/Http/Controllers/CP/Fields/ManagesBlueprints.php
@@ -121,6 +121,7 @@ private function toVueObject(Blueprint $blueprint): array
return [
'title' => $blueprint->title(),
'handle' => $blueprint->handle(),
+ 'fullyQualifiedHandle' => $blueprint->fullyQualifiedHandle(),
'hidden' => $blueprint->hidden(),
'tabs' => $blueprint->tabs()->map(function ($tab, $i) {
return array_merge($this->tabToVue($tab), ['_id' => $i]);
diff --git a/tests/Feature/Blueprints/EditCustomBlueprintTest.php b/tests/Feature/Blueprints/EditCustomBlueprintTest.php
index 4db9677f39f..756d534ea74 100644
--- a/tests/Feature/Blueprints/EditCustomBlueprintTest.php
+++ b/tests/Feature/Blueprints/EditCustomBlueprintTest.php
@@ -53,7 +53,9 @@ public function it_lets_you_edit_a_custom_namespace_blueprint()
->actingAs($user)
->get(cp_route('blueprints.additional.edit', [$namespace, $handle]))
->assertOk()
- ->assertInertia(fn ($page) => $page->component('blueprints/Edit'));
+ ->assertInertia(fn ($page) => $page
+ ->component('blueprints/Edit')
+ ->where('blueprint.fullyQualifiedHandle', 'foo::bar'));
}
private function createBlueprint($namespace, $handle)
diff --git a/tests/Feature/Fields/FieldtypeContextTest.php b/tests/Feature/Fields/FieldtypeContextTest.php
new file mode 100644
index 00000000000..13614d2490b
--- /dev/null
+++ b/tests/Feature/Fields/FieldtypeContextTest.php
@@ -0,0 +1,122 @@
+actingAs(User::make()->makeSuper()->save());
+ }
+
+ #[Test]
+ #[DataProvider('pickerContexts')]
+ public function picker_visibility_depends_on_context_without_changing_selectability(array $context, bool $forms, bool $visible): void
+ {
+ $response = $this->getJson('/cp/fields/fieldtypes?'.http_build_query([
+ 'selectable' => true,
+ 'forms' => $forms,
+ 'context' => $context,
+ ]))->assertOk();
+
+ $handles = collect($response->json())->pluck('handle');
+
+ $this->assertSame($visible, $handles->contains('contextual'));
+ $this->assertNotContains('unselectable_contextual', $handles);
+ }
+
+ public static function pickerContexts(): array
+ {
+ return [
+ 'top level' => [['blueprint' => 'runway::post', 'nested' => 'false'], false, true],
+ 'nested' => [['blueprint' => 'runway::post', 'nested' => 'true'], false, false],
+ 'other owner' => [['blueprint' => 'collections.blog.post', 'nested' => 'false'], false, false],
+ 'fieldset' => [['fieldset' => 'common'], false, false],
+ 'no context' => [[], false, false],
+ 'form' => [['blueprint' => 'runway::post', 'nested' => 'false'], true, false],
+ ];
+ }
+
+ #[Test]
+ public function hidden_fieldtypes_remain_available_outside_the_picker(): void
+ {
+ $this->getJson('/cp/fields/fieldtypes')->assertOk()
+ ->assertJsonFragment(['handle' => 'contextual']);
+
+ $this->postJson('/cp/fields/edit', [
+ 'type' => 'contextual',
+ 'values' => ['handle' => 'author'],
+ 'context' => ['blueprint' => 'collections.blog.post', 'nested' => true],
+ ])->assertOk()->assertJsonPath('fieldtype.handle', 'contextual');
+ }
+
+ #[Test]
+ public function settings_receive_context_separately_from_values_on_edit_and_update(): void
+ {
+ $context = ['blueprint' => 'runway::post', 'field_path' => ['author'], 'nested' => false];
+
+ $this->postJson('/cp/fields/edit', [
+ 'type' => 'contextual',
+ 'context' => $context,
+ 'values' => ['handle' => 'author'],
+ ])->assertOk()->assertJsonPath('values.owner', 'runway::post');
+
+ $this->postJson('/cp/fields/update', [
+ 'type' => 'contextual',
+ 'context' => $context,
+ 'values' => ['handle' => 'author', 'display' => 'Author', 'owner' => 'wrong'],
+ ])->assertUnprocessable()->assertJsonValidationErrors('owner');
+
+ $this->postJson('/cp/fields/update', [
+ 'type' => 'contextual',
+ 'context' => $context,
+ 'values' => ['handle' => 'author', 'display' => 'Author', 'owner' => 'runway::post'],
+ ])->assertOk()->assertJsonMissingPath('context');
+ }
+
+ #[Test]
+ public function config_fields_do_not_leak_between_contexts(): void
+ {
+ $first = new ContextualFieldtype;
+ $second = new ContextualFieldtype;
+
+ $this->assertEquals('runway::post', $first->context(['blueprint' => 'runway::post'])->configFields()->get('owner')->defaultValue());
+ $this->assertEquals('runway::video', $second->context(['blueprint' => 'runway::video'])->configFields()->get('owner')->defaultValue());
+ $this->assertEquals('none', $first->context([])->configFields()->get('owner')->defaultValue());
+ }
+}
+
+class ContextualFieldtype extends Fieldtype
+{
+ public function isVisible(): bool
+ {
+ return ($this->context['blueprint'] ?? null) === 'runway::post'
+ && ($this->context['field_path'] ?? null) === []
+ && ! ($this->context['nested'] ?? false);
+ }
+
+ protected function configFieldItems(): array
+ {
+ $owner = $this->context['blueprint'] ?? 'none';
+
+ return ['owner' => ['type' => 'text', 'default' => $owner, 'validate' => 'in:'.$owner]];
+ }
+}
+
+class UnselectableContextualFieldtype extends ContextualFieldtype
+{
+ protected $selectable = false;
+}