From 92bfdc1ed6867c1e1fc91a60844590ca28b3e90b Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Sat, 20 Dec 2025 15:25:49 +0000 Subject: [PATCH 01/17] wip --- .../fieldtypes/replicator/Replicator.vue | 44 +++++++++++----- routes/cp.php | 2 + src/Fields/Blueprint.php | 1 + src/Fieldtypes/Replicator.php | 18 ------- .../CP/Fieldtypes/ReplicatorSetController.php | 52 +++++++++++++++++++ 5 files changed, 87 insertions(+), 30 deletions(-) create mode 100644 src/Http/Controllers/CP/Fieldtypes/ReplicatorSetController.php diff --git a/resources/js/components/fieldtypes/replicator/Replicator.vue b/resources/js/components/fieldtypes/replicator/Replicator.vue index f9ee88045ab..29131ff1d21 100644 --- a/resources/js/components/fieldtypes/replicator/Replicator.vue +++ b/resources/js/components/fieldtypes/replicator/Replicator.vue @@ -91,6 +91,7 @@ import ReplicatorSet from './Set.vue'; import AddSetButton from './AddSetButton.vue'; import ManagesSetMeta from './ManagesSetMeta'; import { SortableList } from '../../sortable/Sortable'; +import {injectPublishContext as injectContainerContext, injectPublishContext} from "@ui"; export default { mixins: [Fieldtype, ManagesSetMeta], @@ -101,6 +102,12 @@ export default { AddSetButton, }, + setup() { + const { blueprint } = injectContainerContext(); + + return { blueprint }; + }, + data() { return { focused: false, @@ -205,18 +212,31 @@ export default { }, addSet(handle, index) { - const set = { - ...JSON.parse(JSON.stringify(this.meta.defaults[handle])), - _id: uniqid(), - type: handle, - enabled: true, - }; - - this.updateSetMeta(set._id, this.meta.new[handle]); - - this.update([...this.value.slice(0, index), set, ...this.value.slice(index)]); - - this.expandSet(set._id); + let field = `${this.handle}.${handle}`; + + if (this.fieldPathPrefix) { + field = `${this.fieldPathPrefix}.${field}`; + } + + // todo: cache defaults/new to avoid duplicate requests + this.$axios.post(cp_url('fieldtypes/replicator/set'), { + blueprint: this.blueprint.fqh, + field: field, + }) + .then(response => { + const set = { + ...JSON.parse(JSON.stringify(response.data.defaults)), + _id: uniqid(), + type: handle, + enabled: true, + }; + + this.updateSetMeta(set._id, response.data.new); + + this.update([...this.value.slice(0, index), set, ...this.value.slice(index)]); + + this.expandSet(set._id); + }); }, duplicateSet(old_id) { diff --git a/routes/cp.php b/routes/cp.php index 39df34535f8..dfd6affccd5 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -59,6 +59,7 @@ use Statamic\Http\Controllers\CP\Fieldtypes\IconFieldtypeController; use Statamic\Http\Controllers\CP\Fieldtypes\MarkdownFieldtypeController; use Statamic\Http\Controllers\CP\Fieldtypes\RelationshipFieldtypeController; +use Statamic\Http\Controllers\CP\Fieldtypes\ReplicatorSetController; use Statamic\Http\Controllers\CP\Forms\ActionController as FormActionController; use Statamic\Http\Controllers\CP\Forms\FormBlueprintController; use Statamic\Http\Controllers\CP\Forms\FormExportController; @@ -386,6 +387,7 @@ Route::post('files/upload', [FilesFieldtypeController::class, 'upload'])->name('files.upload'); Route::get('dictionaries/{dictionary}', DictionaryFieldtypeController::class)->name('dictionary-fieldtype'); Route::post('icons', IconFieldtypeController::class)->name('icon-fieldtype'); + Route::post('replicator/set', ReplicatorSetController::class)->name('replicator-fieldtype.set'); }); Route::group(['prefix' => 'field-action-modal'], function () { diff --git a/src/Fields/Blueprint.php b/src/Fields/Blueprint.php index 7a53868bbb2..ac7dba18112 100644 --- a/src/Fields/Blueprint.php +++ b/src/Fields/Blueprint.php @@ -451,6 +451,7 @@ public function toPublishArray() 'handle' => $this->handle(), 'tabs' => $this->tabs()->map->toPublishArray()->values()->all(), 'empty' => $this->isEmpty(), + 'fqh' => $this->fullyQualifiedHandle(), ]; } diff --git a/src/Fieldtypes/Replicator.php b/src/Fieldtypes/Replicator.php index ad8d3ed6c9b..3aa30421785 100644 --- a/src/Fieldtypes/Replicator.php +++ b/src/Fieldtypes/Replicator.php @@ -228,26 +228,8 @@ public function preload() return [$set['_id'] => $this->fields($set['type'], $index)->addValues($set)->meta()->put('_', '_')]; })->toArray(); - $blink = md5(json_encode($this->flattenedSetsConfig())); - - $defaults = Blink::once($blink.'-defaults', function () { - return collect($this->flattenedSetsConfig())->map(function ($set, $handle) { - return $this->fields($handle)->all()->map(function ($field) { - return $field->fieldtype()->preProcess($field->defaultValue()); - })->all(); - })->all(); - }); - - $new = Blink::once($blink.'-new', function () use ($defaults) { - return collect($this->flattenedSetsConfig())->map(function ($set, $handle) use ($defaults) { - return $this->fields($handle)->addValues($defaults[$handle])->meta()->put('_', '_'); - })->toArray(); - }); - return [ 'existing' => $existing, - 'new' => $new, - 'defaults' => $defaults, 'collapsed' => [], ]; } diff --git a/src/Http/Controllers/CP/Fieldtypes/ReplicatorSetController.php b/src/Http/Controllers/CP/Fieldtypes/ReplicatorSetController.php new file mode 100644 index 00000000000..9b524faf564 --- /dev/null +++ b/src/Http/Controllers/CP/Fieldtypes/ReplicatorSetController.php @@ -0,0 +1,52 @@ +validate([ + 'blueprint' => ['required', 'string'], + 'field' => ['required', 'string'], + ]); + + $blueprint = Blueprint::find($request->blueprint); + + // todo: handle nested replicators + $replicator = $blueprint->field(Str::before($request->field, '.')); + + $setHandle = Str::afterLast($request->field, '.'); + $setConfig = null; + + foreach ($replicator->get('sets') as $setGroup) { + if (isset($setGroup['sets'][$setHandle])) { + $setConfig = $setGroup['sets'][$setHandle]; + } + } + + if (! $setConfig) { + throw new \Exception("Couldn't find replicator set."); + } + + // todo: make sure the $parent and $parentIndex we pass in here is correct + $replicatorFields = new Fields($setConfig['fields'], parentField: $replicator); + + $defaults = $replicatorFields->all()->map(function ($field) { + return $field->fieldtype()->preProcess($field->defaultValue()); + })->all(); + + $new = $replicatorFields->addValues($defaults)->meta()->put('_', '_')->toArray(); + + return [ + 'new' => $new, + 'defaults' => $defaults, + ]; + } +} From 460ffad4b7696a06d29ec51f9ef365048c009bb0 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 5 Jan 2026 15:26:49 +0000 Subject: [PATCH 02/17] wip --- .../fieldtypes/replicator/Replicator.vue | 75 ++++++++++++------ src/Fields/Blueprint.php | 2 +- .../CP/Fieldtypes/ReplicatorSetController.php | 79 ++++++++++++++----- 3 files changed, 114 insertions(+), 42 deletions(-) diff --git a/resources/js/components/fieldtypes/replicator/Replicator.vue b/resources/js/components/fieldtypes/replicator/Replicator.vue index 29131ff1d21..a13b7858f66 100644 --- a/resources/js/components/fieldtypes/replicator/Replicator.vue +++ b/resources/js/components/fieldtypes/replicator/Replicator.vue @@ -59,6 +59,7 @@ :sets="setConfigs" :index="index" :enabled="canAddSet" + :loading="true" :is-first="index === 0" @added="addSet" /> @@ -69,6 +70,7 @@ { + // todo: add loading state + this.fetchSet(handle) + .then(data => { const set = { - ...JSON.parse(JSON.stringify(response.data.defaults)), + ...JSON.parse(JSON.stringify(data.defaults)), _id: uniqid(), type: handle, enabled: true, }; - this.updateSetMeta(set._id, response.data.new); + this.updateSetMeta(set._id, data.new); this.update([...this.value.slice(0, index), set, ...this.value.slice(index)]); this.expandSet(set._id); - }); + }) + .catch(error => this.$toast.error(__('Something went wrong'))); }, + /** + * Returns the path to the Replicator field, replacing any set indexes with handles. + */ + replicatorFieldPath() { + if (!this.fieldPathPrefix) { + return this.handle; + } + + return this.fieldPathKeys + .map((key, index) => { + if (Number.isInteger(parseInt(key))) { + return data_get(this.publishContainer.values, this.fieldPathKeys.slice(0, index + 1).join('.'))?.type; + } + + return key; + }) + .filter((key) => key !== undefined) + .concat(this.handle) + .join('.'); + }, + + async fetchSet(set) { + return new Promise(async (resolve, reject) => { + const field = this.replicatorFieldPath(); + const setCacheKey = `${field}.${set}`; + const reference = this.publishContainer.reference; + const blueprint = this.publishContainer.blueprint.fqh; + + if (this.setsCache[setCacheKey]) { + resolve(this.setsCache[setCacheKey]); + return; + } + + this.$axios.post(cp_url('fieldtypes/replicator/set'), { blueprint, reference, field, set }) + .then(response => { + this.setsCache[setCacheKey] = response.data; + resolve(response.data); + }) + .catch(error => reject(error)); + }); + }, + duplicateSet(old_id) { const index = this.value.findIndex((v) => v._id === old_id); const old = this.value[index]; diff --git a/src/Fields/Blueprint.php b/src/Fields/Blueprint.php index ac7dba18112..8c7f073085f 100644 --- a/src/Fields/Blueprint.php +++ b/src/Fields/Blueprint.php @@ -80,7 +80,7 @@ public function renderableNamespace(): string return str_replace('.', ' ', Str::humanize($this->namespace)); } - public function fullyQualifiedHandle(): string + public function fullyQualifiedHandle(): ?string { $handle = $this->handle(); diff --git a/src/Http/Controllers/CP/Fieldtypes/ReplicatorSetController.php b/src/Http/Controllers/CP/Fieldtypes/ReplicatorSetController.php index 9b524faf564..00851d24658 100644 --- a/src/Http/Controllers/CP/Fieldtypes/ReplicatorSetController.php +++ b/src/Http/Controllers/CP/Fieldtypes/ReplicatorSetController.php @@ -4,7 +4,11 @@ use Illuminate\Http\Request; use Illuminate\Support\Str; -use Statamic\Facades\Blueprint; +use Statamic\Exceptions\NotFoundHttpException; +use Statamic\Facades; +use Statamic\Facades\Data; +use Statamic\Fields\Blueprint; +use Statamic\Fields\Field; use Statamic\Fields\Fields; use Statamic\Http\Controllers\CP\CpController; @@ -14,29 +18,33 @@ public function __invoke(Request $request) { $request->validate([ 'blueprint' => ['required', 'string'], + 'reference' => ['nullable', 'string'], 'field' => ['required', 'string'], + 'set' => ['required', 'string'], ]); - $blueprint = Blueprint::find($request->blueprint); + $blueprint = Facades\Blueprint::find($request->blueprint); - // todo: handle nested replicators - $replicator = $blueprint->field(Str::before($request->field, '.')); + if (! $blueprint) { + throw new NotFoundHttpException(); + } - $setHandle = Str::afterLast($request->field, '.'); - $setConfig = null; + $field = $this->getReplicatorField($blueprint, $request->field); - foreach ($replicator->get('sets') as $setGroup) { - if (isset($setGroup['sets'][$setHandle])) { - $setConfig = $setGroup['sets'][$setHandle]; - } - } + $replicatorSet = collect($field->get('sets')) + ->flatMap(fn (array $setGroup) => $setGroup['sets'] ?? []) + ->get($request->set); - if (! $setConfig) { - throw new \Exception("Couldn't find replicator set."); + if (! $replicatorSet) { + throw new \Exception("Cannot find replicator set [$request->set]"); } - // todo: make sure the $parent and $parentIndex we pass in here is correct - $replicatorFields = new Fields($setConfig['fields'], parentField: $replicator); + $replicatorFields = new Fields( + items: $replicatorSet['fields'], + parent: Data::find($request->reference), + parentField: $field, + parentIndex: -1 + ); $defaults = $replicatorFields->all()->map(function ($field) { return $field->fieldtype()->preProcess($field->defaultValue()); @@ -44,9 +52,42 @@ public function __invoke(Request $request) $new = $replicatorFields->addValues($defaults)->meta()->put('_', '_')->toArray(); - return [ - 'new' => $new, - 'defaults' => $defaults, - ]; + return compact('new', 'defaults'); + } + + private function getReplicatorField(Blueprint $blueprint, string $field): Field + { + $remainingFieldPathComponents = explode('.', $field); + + $config = $blueprint->field($remainingFieldPathComponents[0])->config(); + unset($remainingFieldPathComponents[0]); + + foreach ($remainingFieldPathComponents as $index => $fieldPathComponent) { + unset($remainingFieldPathComponents[$fieldPathComponent]); + + if (isset($config['sets'])) { + $config = collect($config['sets']) + ->flatMap(fn (array $setGroup): array => $setGroup['sets'] ?? []) + ->get($fieldPathComponent); + + continue; + } + + if (isset($config['fields'])) { + $config = collect($config['fields']) + ->where('handle', $remainingFieldPathComponents[$index]) + ->first()['field'] ?? null; + + continue; + } + + throw new \Exception("Cannot resolve field path component [$fieldPathComponent]"); + } + + if (! isset($config['type'])) { + throw new \Exception("Cannot find replicator field [$field]"); + } + + return new Field(Str::afterLast($field, '.'), $config); } } From b217973cf8a72b3f8d1fd577a5b4e4cb51ddcda3 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 5 Jan 2026 15:57:58 +0000 Subject: [PATCH 03/17] wip --- .../fieldtypes/bard/BardFieldtype.vue | 85 ++++++++++++++----- .../fieldtypes/replicator/Replicator.vue | 4 +- routes/cp.php | 1 + src/Fieldtypes/Bard.php | 22 ++--- .../CP/Fieldtypes/ReplicatorSetController.php | 2 +- 5 files changed, 80 insertions(+), 34 deletions(-) diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index a1a52cb69c6..26ab7a165aa 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -215,6 +215,7 @@ export default { }, errorsById: {}, debounceNextUpdate: true, + setsCache: {}, }; }, @@ -469,27 +470,73 @@ export default { methods: { addSet(handle) { - const id = uniqid(); - const deepCopy = JSON.parse(JSON.stringify(this.meta.defaults[handle])); - const values = Object.assign({}, { type: handle }, deepCopy); - - this.updateSetMeta(id, this.meta.new[handle]); - - const { $head } = this.editor.view.state.selection; - const { nodeBefore } = $head; - - this.debounceNextUpdate = false; - - // Perform this in nextTick because the meta data won't be ready until then. - this.$nextTick(() => { - if (nodeBefore) { - this.editor.commands.setAt({ attrs: { id, values }, pos: $head.pos }); - } else { - this.editor.commands.set({ id, values }); - } - }); + this.fetchSet(handle) + .then(data => { + const id = uniqid(); + const deepCopy = JSON.parse(JSON.stringify(data.defaults)); + const values = Object.assign({}, { type: handle }, deepCopy); + + this.updateSetMeta(id, data.new); + + const { $head } = this.editor.view.state.selection; + const { nodeBefore } = $head; + + this.debounceNextUpdate = false; + + // Perform this in nextTick because the meta data won't be ready until then. + this.$nextTick(() => { + if (nodeBefore) { + this.editor.commands.setAt({ attrs: { id, values }, pos: $head.pos }); + } else { + this.editor.commands.set({ id, values }); + } + }); + }) + // .catch(() => this.$toast.error(__('Something went wrong'))); }, + /** + * Returns the path to the Bard field, replacing any set indexes with handles. + */ + bardFieldPath() { + if (!this.fieldPathPrefix) { + return this.handle; + } + + return this.fieldPathKeys + .map((key, index) => { + if (Number.isInteger(parseInt(key))) { + return data_get(this.publishContainer.values, this.fieldPathKeys.slice(0, index + 1).join('.'))?.attrs?.values.type; + } + + return key; + }) + .filter((key) => key !== undefined) + .concat(this.handle) + .join('.'); + }, + + async fetchSet(set) { + return new Promise(async (resolve, reject) => { + const field = this.bardFieldPath(); + const setCacheKey = `${field}.${set}`; + const reference = this.publishContainer.reference; + const blueprint = this.publishContainer.blueprint.fqh; + + if (this.setsCache[setCacheKey]) { + resolve(this.setsCache[setCacheKey]); + return; + } + + this.$axios.post(cp_url('fieldtypes/bard/set'), { blueprint, reference, field, set }) + .then(response => { + this.setsCache[setCacheKey] = response.data; + resolve(response.data); + }) + .catch(error => reject(error)); + }); + }, + duplicateSet(old_id, attrs, pos) { const id = uniqid(); const enabled = attrs.enabled; diff --git a/resources/js/components/fieldtypes/replicator/Replicator.vue b/resources/js/components/fieldtypes/replicator/Replicator.vue index a13b7858f66..a9a535e13ca 100644 --- a/resources/js/components/fieldtypes/replicator/Replicator.vue +++ b/resources/js/components/fieldtypes/replicator/Replicator.vue @@ -59,7 +59,6 @@ :sets="setConfigs" :index="index" :enabled="canAddSet" - :loading="true" :is-first="index === 0" @added="addSet" /> @@ -70,7 +69,6 @@ this.$toast.error(__('Something went wrong'))); + .catch(() => this.$toast.error(__('Something went wrong'))); }, /** diff --git a/routes/cp.php b/routes/cp.php index dfd6affccd5..407766a2cd6 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -387,6 +387,7 @@ Route::post('files/upload', [FilesFieldtypeController::class, 'upload'])->name('files.upload'); Route::get('dictionaries/{dictionary}', DictionaryFieldtypeController::class)->name('dictionary-fieldtype'); Route::post('icons', IconFieldtypeController::class)->name('icon-fieldtype'); + Route::post('bard/set', ReplicatorSetController::class)->name('bard-fieldtype.set'); Route::post('replicator/set', ReplicatorSetController::class)->name('replicator-fieldtype.set'); }); diff --git a/src/Fieldtypes/Bard.php b/src/Fieldtypes/Bard.php index bf820d220d2..41ac64867ae 100644 --- a/src/Fieldtypes/Bard.php +++ b/src/Fieldtypes/Bard.php @@ -606,15 +606,15 @@ public function preload() return [$set['attrs']['id'] => $this->fields($values['type'], $index)->addValues($values)->meta()->put('_', '_')]; })->toArray(); - $defaults = collect($this->flattenedSetsConfig())->map(function ($set, $handle) { - return $this->fields($handle)->all()->map(function ($field) { - return $field->fieldtype()->preProcess($field->defaultValue()); - })->all(); - })->all(); - - $new = collect($this->flattenedSetsConfig())->map(function ($set, $handle) use ($defaults) { - return $this->fields($handle)->addValues($defaults[$handle])->meta()->put('_', '_'); - })->toArray(); +// $defaults = collect($this->flattenedSetsConfig())->map(function ($set, $handle) { +// return $this->fields($handle)->all()->map(function ($field) { +// return $field->fieldtype()->preProcess($field->defaultValue()); +// })->all(); +// })->all(); +// +// $new = collect($this->flattenedSetsConfig())->map(function ($set, $handle) use ($defaults) { +// return $this->fields($handle)->addValues($defaults[$handle])->meta()->put('_', '_'); +// })->toArray(); $previews = collect($existing)->map(function ($fields) { return collect($fields)->map(function () { @@ -636,8 +636,8 @@ public function preload() $data = [ 'existing' => $existing, - 'new' => $new, - 'defaults' => $defaults, +// 'new' => $new, +// 'defaults' => $defaults, 'collapsed' => [], 'previews' => $previews, '__collaboration' => ['existing'], diff --git a/src/Http/Controllers/CP/Fieldtypes/ReplicatorSetController.php b/src/Http/Controllers/CP/Fieldtypes/ReplicatorSetController.php index 00851d24658..4daf465b672 100644 --- a/src/Http/Controllers/CP/Fieldtypes/ReplicatorSetController.php +++ b/src/Http/Controllers/CP/Fieldtypes/ReplicatorSetController.php @@ -36,7 +36,7 @@ public function __invoke(Request $request) ->get($request->set); if (! $replicatorSet) { - throw new \Exception("Cannot find replicator set [$request->set]"); + throw new \Exception("Cannot find Replicator set [$request->set]"); } $replicatorFields = new Fields( From bf59139d28bcbffd3093c55d971b2422787d936d Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 5 Jan 2026 16:41:05 +0000 Subject: [PATCH 04/17] wip --- .../js/components/fieldtypes/bard/BardFieldtype.vue | 5 +++-- .../components/fieldtypes/replicator/Replicator.vue | 2 +- routes/cp.php | 1 - src/Fieldtypes/Bard.php | 12 ------------ 4 files changed, 4 insertions(+), 16 deletions(-) diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index 26ab7a165aa..7a02d69e065 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -174,6 +174,7 @@ import { common, createLowlight } from 'lowlight'; import 'highlight.js/styles/github.css'; import importTiptap from '@/util/tiptap.js'; import { computed } from 'vue'; +import { data_get } from "@/bootstrap/globals.js"; const lowlight = createLowlight(common); let tiptap = null; @@ -492,7 +493,7 @@ export default { } }); }) - // .catch(() => this.$toast.error(__('Something went wrong'))); + .catch(() => this.$toast.error(__('Something went wrong'))); }, /** @@ -528,7 +529,7 @@ export default { return; } - this.$axios.post(cp_url('fieldtypes/bard/set'), { blueprint, reference, field, set }) + this.$axios.post(cp_url('fieldtypes/replicator/set'), { blueprint, reference, field, set }) .then(response => { this.setsCache[setCacheKey] = response.data; resolve(response.data); diff --git a/resources/js/components/fieldtypes/replicator/Replicator.vue b/resources/js/components/fieldtypes/replicator/Replicator.vue index a9a535e13ca..51b6a631daa 100644 --- a/resources/js/components/fieldtypes/replicator/Replicator.vue +++ b/resources/js/components/fieldtypes/replicator/Replicator.vue @@ -91,7 +91,7 @@ import ReplicatorSet from './Set.vue'; import AddSetButton from './AddSetButton.vue'; import ManagesSetMeta from './ManagesSetMeta'; import { SortableList } from '../../sortable/Sortable'; -import {data_get} from "@/bootstrap/globals.js"; +import { data_get } from "@/bootstrap/globals.js"; export default { mixins: [Fieldtype, ManagesSetMeta], diff --git a/routes/cp.php b/routes/cp.php index 407766a2cd6..dfd6affccd5 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -387,7 +387,6 @@ Route::post('files/upload', [FilesFieldtypeController::class, 'upload'])->name('files.upload'); Route::get('dictionaries/{dictionary}', DictionaryFieldtypeController::class)->name('dictionary-fieldtype'); Route::post('icons', IconFieldtypeController::class)->name('icon-fieldtype'); - Route::post('bard/set', ReplicatorSetController::class)->name('bard-fieldtype.set'); Route::post('replicator/set', ReplicatorSetController::class)->name('replicator-fieldtype.set'); }); diff --git a/src/Fieldtypes/Bard.php b/src/Fieldtypes/Bard.php index 41ac64867ae..15abf2c21ff 100644 --- a/src/Fieldtypes/Bard.php +++ b/src/Fieldtypes/Bard.php @@ -606,16 +606,6 @@ public function preload() return [$set['attrs']['id'] => $this->fields($values['type'], $index)->addValues($values)->meta()->put('_', '_')]; })->toArray(); -// $defaults = collect($this->flattenedSetsConfig())->map(function ($set, $handle) { -// return $this->fields($handle)->all()->map(function ($field) { -// return $field->fieldtype()->preProcess($field->defaultValue()); -// })->all(); -// })->all(); -// -// $new = collect($this->flattenedSetsConfig())->map(function ($set, $handle) use ($defaults) { -// return $this->fields($handle)->addValues($defaults[$handle])->meta()->put('_', '_'); -// })->toArray(); - $previews = collect($existing)->map(function ($fields) { return collect($fields)->map(function () { return null; @@ -636,8 +626,6 @@ public function preload() $data = [ 'existing' => $existing, -// 'new' => $new, -// 'defaults' => $defaults, 'collapsed' => [], 'previews' => $previews, '__collaboration' => ['existing'], From eb8e1c7f36f884cf256af0bfb9afef05c015b1f8 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 5 Jan 2026 16:57:51 +0000 Subject: [PATCH 05/17] Update tests --- tests/Fieldtypes/BardTest.php | 35 ----------------------------- tests/Fieldtypes/ReplicatorTest.php | 35 ----------------------------- 2 files changed, 70 deletions(-) diff --git a/tests/Fieldtypes/BardTest.php b/tests/Fieldtypes/BardTest.php index cf43e330fe9..7eca35f1d5a 100644 --- a/tests/Fieldtypes/BardTest.php +++ b/tests/Fieldtypes/BardTest.php @@ -747,39 +747,6 @@ public function it_preloads($areSetsGrouped) '_' => '_', // An empty key to enforce an object in JavaScript. // The "foo" key doesn't appear here since there's no corresponding "nope" set config. ], $meta['existing']['random-string-4']); - - // Assert about the "defaults" sub-array. - // These are the initial values used for subfields when a new set is added. - $this->assertCount(1, $meta['defaults']); - $this->assertArrayHasKey('main', $meta['defaults']); - $this->assertEquals([ - 'a_text_field' => 'the default', - 'a_grid_field' => [ - ['_id' => 'random-string-5', 'one' => 'default in nested'], - ['_id' => 'random-string-6', 'one' => 'default in nested'], - ], - ], $meta['defaults']['main']); - - // Assert about the "new" sub-array. - // This is meta data for subfields when a new set is added. - $this->assertCount(1, $meta['new']); - $this->assertArrayHasKey('main', $meta['new']); - $this->assertEquals([ - '_' => '_', // An empty key to enforce an object in JavaScript. - 'a_text_field' => null, // the text field doesn't have meta data. - 'a_grid_field' => [ // this array is the preloaded meta for the grid field - 'defaults' => [ - 'one' => 'default in nested', // default value for the text field - ], - 'new' => [ - 'one' => null, // meta for the text field - ], - 'existing' => [ - 'random-string-5' => ['one' => null], - 'random-string-6' => ['one' => null], - ], - ], - ], $meta['new']['main']); } #[Test] @@ -1258,8 +1225,6 @@ public function augment($value) $value = $field->fieldtype()->preload(); $this->assertEquals('test.0.words', $value['existing']['set-id-1']['words']['fieldPathPrefix']); $this->assertEquals('test.1.words', $value['existing']['set-id-2']['words']['fieldPathPrefix']); - $this->assertEquals('test.-1.words', $value['new']['one']['words']['fieldPathPrefix']); - $this->assertEquals('test.-1.words', $value['defaults']['one']['words']); } #[Test] diff --git a/tests/Fieldtypes/ReplicatorTest.php b/tests/Fieldtypes/ReplicatorTest.php index 26cac9eb20a..06019cf07b8 100644 --- a/tests/Fieldtypes/ReplicatorTest.php +++ b/tests/Fieldtypes/ReplicatorTest.php @@ -483,39 +483,6 @@ public function it_preloads($areSetsGrouped) '_' => '_', // An empty key to enforce an object in JavaScript. // The "foo" key doesn't appear here since there's no corresponding "nope" set config. ], $meta['existing']['random-string-4']); - - // Assert about the "defaults" sub-array. - // These are the initial values used for subfields when a new set is added. - $this->assertCount(1, $meta['defaults']); - $this->assertArrayHasKey('main', $meta['defaults']); - $this->assertEquals([ - 'a_text_field' => 'the default', - 'a_grid_field' => [ - ['_id' => 'random-string-5', 'one' => 'default in nested'], - ['_id' => 'random-string-6', 'one' => 'default in nested'], - ], - ], $meta['defaults']['main']); - - // Assert about the "new" sub-array. - // This is meta data for subfields when a new set is added. - $this->assertCount(1, $meta['new']); - $this->assertArrayHasKey('main', $meta['new']); - $this->assertEquals([ - '_' => '_', // An empty key to enforce an object in JavaScript. - 'a_text_field' => null, // the text field doesn't have meta data. - 'a_grid_field' => [ // this array is the preloaded meta for the grid field - 'defaults' => [ - 'one' => 'default in nested', // default value for the text field - ], - 'new' => [ - 'one' => null, // meta for the text field - ], - 'existing' => [ - 'random-string-5' => ['one' => null], - 'random-string-6' => ['one' => null], - ], - ], - ], $meta['new']['main']); } #[Test] @@ -674,8 +641,6 @@ public function augment($value) $value = $field->fieldtype()->preload(); $this->assertEquals('test.0.words', $value['existing']['set-id-1']['words']['fieldPathPrefix']); $this->assertEquals('test.1.words', $value['existing']['set-id-2']['words']['fieldPathPrefix']); - $this->assertEquals('test.-1.words', $value['new']['one']['words']['fieldPathPrefix']); - $this->assertEquals('test.-1.words', $value['defaults']['one']['words']); } #[Test] From 2cd6dd52cf35d8681bcfc13af81074093a5746c6 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 5 Jan 2026 16:57:54 +0000 Subject: [PATCH 06/17] Add test --- tests/Fieldtypes/ReplicatorTest.php | 93 +++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/tests/Fieldtypes/ReplicatorTest.php b/tests/Fieldtypes/ReplicatorTest.php index 06019cf07b8..067eb7c3d6d 100644 --- a/tests/Fieldtypes/ReplicatorTest.php +++ b/tests/Fieldtypes/ReplicatorTest.php @@ -6,15 +6,19 @@ use Mockery\MockInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Statamic\Facades; use Statamic\Fields\Field; use Statamic\Fields\Fieldtype; use Statamic\Fields\Values; use Statamic\Fieldtypes\Replicator; use Statamic\Fieldtypes\RowId; +use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; class ReplicatorTest extends TestCase { + use PreventSavingStacheItemsToDisk; + #[Test] #[DataProvider('groupedSetsProvider')] public function it_preprocesses_with_empty_value($areSetsGrouped) @@ -737,6 +741,95 @@ public function augment($value) $this->assertEquals('test.1.nested.1.words', $value[1]['nested'][1]['words']); } + #[Test] + public function it_can_return_set_defaults() + { + $this->partialMock(RowId::class, function (MockInterface $mock) { + $mock->shouldReceive('generate')->andReturn('random-string-1', 'random-string-2'); + }); + + tap(Facades\Collection::make('pages'))->save(); + + $blueprint = Facades\Blueprint::make()->setHandle('default')->setNamespace('collections.pages'); + $blueprint->setContents([ + 'sections' => [ + 'main' => [ + 'fields' => [ + [ + 'handle' => 'content', + 'field' => [ + 'type' => 'replicator', + 'sets' => [ + 'main' => [ + 'sets' => [ + 'text' => [ + 'fields' => [ + [ + 'handle' => 'a_text_field', + 'field' => [ + 'type' => 'text', + 'default' => 'the default', + ], + ], + [ + 'handle' => 'a_grid_field', + 'field' => [ + 'type' => 'grid', + 'min_rows' => 2, + 'fields' => [ + ['handle' => 'one', 'field' => ['type' => 'text', 'default' => 'default in nested']], + ], + ], + ], + ], + ], + ], + ], + ], + ], + ], + ], + ], + ], + ]); + + $blueprint->save(); + + $response = $this + ->actingAs(tap(Facades\User::make()->makeSuper())->save()) + ->postJson(cp_route('replicator-fieldtype.set'), [ + 'blueprint' => 'collections.pages.default', + 'field' => 'content', + 'set' => 'text', + ]) + ->assertOk(); + + $this->assertEquals([ + 'a_text_field' => 'the default', + 'a_grid_field' => [ + ['_id' => 'random-string-1', 'one' => 'default in nested'], + ['_id' => 'random-string-2', 'one' => 'default in nested'], + ], + ], $response->json('defaults')); + + $this->assertEquals([ + '_' => '_', + 'a_text_field' => null, + 'a_grid_field' => [ + 'defaults' => [ + 'one' => 'default in nested', + ], + 'new' => [ + 'one' => null, + ], + 'existing' => [ + 'random-string-1' => ['one' => null], + 'random-string-2' => ['one' => null], + ], + ], + ], $response->json('new')); + } + public static function groupedSetsProvider() { return [ From 4d475448c083ee5aa4abde39461598da670f01fe Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 5 Jan 2026 17:04:14 +0000 Subject: [PATCH 07/17] formatting --- tests/Fieldtypes/ReplicatorTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Fieldtypes/ReplicatorTest.php b/tests/Fieldtypes/ReplicatorTest.php index 067eb7c3d6d..5bdfb848e73 100644 --- a/tests/Fieldtypes/ReplicatorTest.php +++ b/tests/Fieldtypes/ReplicatorTest.php @@ -18,7 +18,7 @@ class ReplicatorTest extends TestCase { use PreventSavingStacheItemsToDisk; - + #[Test] #[DataProvider('groupedSetsProvider')] public function it_preprocesses_with_empty_value($areSetsGrouped) From 1fbc003e4022d0c30044c725afe5fae6dbd8f47e Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 6 Jan 2026 10:35:03 +0000 Subject: [PATCH 08/17] make it work with fieldsets --- .../CP/Fieldtypes/ReplicatorSetController.php | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Http/Controllers/CP/Fieldtypes/ReplicatorSetController.php b/src/Http/Controllers/CP/Fieldtypes/ReplicatorSetController.php index 4daf465b672..7151775bc53 100644 --- a/src/Http/Controllers/CP/Fieldtypes/ReplicatorSetController.php +++ b/src/Http/Controllers/CP/Fieldtypes/ReplicatorSetController.php @@ -75,8 +75,20 @@ private function getReplicatorField(Blueprint $blueprint, string $field): Field if (isset($config['fields'])) { $config = collect($config['fields']) - ->where('handle', $remainingFieldPathComponents[$index]) - ->first()['field'] ?? null; + ->flatMap(function ($field): array { + if (isset($field['import'])) { + return (new Fields([$field])) + ->all() + ->map(fn (Field $field) => [ + 'handle' => $field->handle(), + 'field' => $field->config(), + ]) + ->all(); + } + + return [$field]; + }) + ->firstWhere('handle', $remainingFieldPathComponents[$index])['field'] ?? null; continue; } @@ -85,7 +97,7 @@ private function getReplicatorField(Blueprint $blueprint, string $field): Field } if (! isset($config['type'])) { - throw new \Exception("Cannot find replicator field [$field]"); + throw new \Exception("Cannot find Replicator field [$field]"); } return new Field(Str::afterLast($field, '.'), $config); From 779ee17a244519c853b7804a552642d60858b9a9 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 6 Jan 2026 11:14:09 +0000 Subject: [PATCH 09/17] wip --- tests/Fields/BlueprintTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Fields/BlueprintTest.php b/tests/Fields/BlueprintTest.php index bcaa1b75e0e..464c425f137 100644 --- a/tests/Fields/BlueprintTest.php +++ b/tests/Fields/BlueprintTest.php @@ -495,6 +495,7 @@ public function converts_to_array_suitable_for_rendering_fields_in_publish_compo ], ], 'empty' => false, + 'fqh' => 'test', ], $blueprint->toPublishArray()); } @@ -615,6 +616,7 @@ public function converts_to_array_suitable_for_rendering_prefixed_conditional_fi ], ], 'empty' => false, + 'fqh' => 'test', ], $blueprint->toPublishArray()); } From c23a6f10bc52961e44d9b4a5013e99d1bb67003e Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 6 Jan 2026 11:50:20 +0000 Subject: [PATCH 10/17] Include fieldset in test --- tests/Fieldtypes/ReplicatorTest.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/Fieldtypes/ReplicatorTest.php b/tests/Fieldtypes/ReplicatorTest.php index 5bdfb848e73..879e72e439b 100644 --- a/tests/Fieldtypes/ReplicatorTest.php +++ b/tests/Fieldtypes/ReplicatorTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\Test; use Statamic\Facades; use Statamic\Fields\Field; +use Statamic\Fields\Fieldset; use Statamic\Fields\Fieldtype; use Statamic\Fields\Values; use Statamic\Fieldtypes\Replicator; @@ -750,6 +751,10 @@ public function it_can_return_set_defaults() tap(Facades\Collection::make('pages'))->save(); + Fieldset::make('foreign_fields')->setContents(['fields' => [ + ['handle' => 'an_imported_field', 'field' => ['type' => 'text', 'default' => 'default from foreign field']], + ]])->save(); + $blueprint = Facades\Blueprint::make()->setHandle('default')->setNamespace('collections.pages'); $blueprint->setContents([ 'sections' => [ @@ -781,6 +786,9 @@ public function it_can_return_set_defaults() ], ], ], + [ + 'import' => 'foreign_fields', + ], ], ], ], @@ -810,6 +818,7 @@ public function it_can_return_set_defaults() ['_id' => 'random-string-1', 'one' => 'default in nested'], ['_id' => 'random-string-2', 'one' => 'default in nested'], ], + 'an_imported_field' => 'default from foreign field', ], $response->json('defaults')); $this->assertEquals([ @@ -827,6 +836,7 @@ public function it_can_return_set_defaults() 'random-string-2' => ['one' => null], ], ], + 'an_imported_field' => null, ], $response->json('new')); } From 1c7b1b160077ed4e70a3f0bcaaed10cc905c123f Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 6 Jan 2026 12:24:24 +0000 Subject: [PATCH 11/17] Add loading state --- .../fieldtypes/bard/BardFieldtype.vue | 11 ++++- .../fieldtypes/replicator/AddSetButton.vue | 5 ++- .../fieldtypes/replicator/Replicator.vue | 13 +++++- .../fieldtypes/replicator/SetPicker.vue | 42 +++++++++++++++---- 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index 7a02d69e065..8e8a5a284a1 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -93,6 +93,7 @@ v-if="showAddSetButton" :sets="groupConfigs" class="bard-set-selector" + :loading-set="loadingSet" @added="addSet" >