From e3691c0d3c12ef7bb076aeb662d032a3293f59a7 Mon Sep 17 00:00:00 2001 From: Maurice Hildebrandt Date: Tue, 27 Jan 2026 11:36:48 +0100 Subject: [PATCH 1/8] Support reference updates for custom fieldtypes --- src/Assets/AssetReferenceUpdater.php | 45 ++++++++ src/Data/DataReferenceUpdater.php | 34 ++++++ src/Fieldtypes/UpdatesReferences.php | 136 ++++++++++++++++++++++++ src/Taxonomies/TermReferenceUpdater.php | 45 ++++++++ 4 files changed, 260 insertions(+) create mode 100644 src/Fieldtypes/UpdatesReferences.php diff --git a/src/Assets/AssetReferenceUpdater.php b/src/Assets/AssetReferenceUpdater.php index e0e86c1c77f..2eff491fa02 100644 --- a/src/Assets/AssetReferenceUpdater.php +++ b/src/Assets/AssetReferenceUpdater.php @@ -4,6 +4,7 @@ use Statamic\Data\DataReferenceUpdater; use Statamic\Facades\AssetContainer; +use Statamic\Fieldtypes\UpdatesReferences; use Statamic\Support\Arr; class AssetReferenceUpdater extends DataReferenceUpdater @@ -34,6 +35,7 @@ public function filterByContainer(string $container) protected function recursivelyUpdateFields($fields, $dottedPrefix = null) { $this + ->updateCustomFieldtypeValues($fields, $dottedPrefix) ->updateAssetsFieldValues($fields, $dottedPrefix) ->updateLinkFieldValues($fields, $dottedPrefix) ->updateBardFieldValues($fields, $dottedPrefix) @@ -41,6 +43,49 @@ protected function recursivelyUpdateFields($fields, $dottedPrefix = null) ->updateNestedFieldValues($fields, $dottedPrefix); } + /** + * Update custom fieldtype values that have asset references. + * + * @param \Illuminate\Support\Collection $fields + * @param null|string $dottedPrefix + * @return $this + */ + protected function updateCustomFieldtypeValues($fields, $dottedPrefix) + { + $fields + ->filter(fn ($field) => in_array(UpdatesReferences::class, class_uses_recursive($field->fieldtype()))) + ->each(function ($field) use ($dottedPrefix) { + $data = $this->item->data()->all(); + $dottedKey = $dottedPrefix.$field->handle(); + $oldData = Arr::get($data, $dottedKey); + + if (! $oldData) { + return; + } + + $newData = $field->fieldtype()->replaceAssetReferences( + $oldData, + $this->newValue, + $this->originalValue + ); + + if (json_encode($oldData) === json_encode($newData)) { + return; + } + + if ($newData === null && $this->isRemovingValue()) { + Arr::forget($data, $dottedKey); + } else { + Arr::set($data, $dottedKey, $newData); + } + + $this->item->data($data); + $this->updated = true; + }); + + return $this; + } + /** * Update assets field values. * diff --git a/src/Data/DataReferenceUpdater.php b/src/Data/DataReferenceUpdater.php index 8c5cc343a5b..4053187bc83 100644 --- a/src/Data/DataReferenceUpdater.php +++ b/src/Data/DataReferenceUpdater.php @@ -3,6 +3,7 @@ namespace Statamic\Data; use Statamic\Fields\Fields; +use Statamic\Fieldtypes\UpdatesReferences; use Statamic\Git\Subscriber as GitSubscriber; use Statamic\Support\Arr; @@ -107,9 +108,42 @@ protected function updateNestedFieldValues($fields, $dottedPrefix) $this->{$method}($field, $dottedKey); }); + // Handle custom fieldtypes with nested fields + $fields + ->filter(fn ($field) => in_array(UpdatesReferences::class, class_uses_recursive($field->fieldtype()))) + ->each(function ($field) use ($dottedPrefix) { + $this->updateNestedFieldsInCustomFieldtype($field, $dottedPrefix); + }); + return $this; } + /** + * Update nested fields in custom fieldtype. + * + * @param \Statamic\Fields\Field $field + * @param null|string $dottedPrefix + */ + protected function updateNestedFieldsInCustomFieldtype($field, $dottedPrefix) + { + $fieldKey = $dottedPrefix.$field->handle(); + $fieldData = Arr::get($this->item->data()->all(), $fieldKey); + + if (! $fieldData) { + return; + } + + $fieldtype = $field->fieldtype(); + + $fieldtype->processNestedFieldsForReferences( + $fieldData, + function ($nestedFields, $relativePrefix) use ($fieldKey) { + $absolutePrefix = $fieldKey.'.'.$relativePrefix; + $this->recursivelyUpdateFields($nestedFields->all(), $absolutePrefix); + } + ); + } + /** * Update replicator field children. * diff --git a/src/Fieldtypes/UpdatesReferences.php b/src/Fieldtypes/UpdatesReferences.php new file mode 100644 index 00000000000..025cf671b94 --- /dev/null +++ b/src/Fieldtypes/UpdatesReferences.php @@ -0,0 +1,136 @@ +resolveFieldsConfigForReferenceUpdates($fieldsConfig); + $processFields($fields, ''); + } + + /** + * Helper: Process fields for an array structure at root level. + * Resulting prefix: "0.", "1.", "2."... + * + * @param mixed $data + * @param callable $processFields + * @param array|string $fieldsConfig + */ + protected function processArrayNestedFields($data, callable $processFields, $fieldsConfig) + { + $fields = $this->resolveFieldsConfigForReferenceUpdates($fieldsConfig); + + foreach (array_keys($data ?? []) as $idx) { + $processFields($fields, "{$idx}."); + } + } + + /** + * Helper: Process fields for an array nested under a specific key. + * Resulting prefix: "{key}.0.", "{key}.1."... + * + * @param mixed $data + * @param callable $processFields + * @param string $key + * @param array|string $fieldsConfig + */ + protected function processArrayNestedFieldsAtKey($data, callable $processFields, $key, $fieldsConfig) + { + $fields = $this->resolveFieldsConfigForReferenceUpdates($fieldsConfig); + $arrayData = $data[$key] ?? []; + + foreach (array_keys($arrayData) as $idx) { + $processFields($fields, "{$key}.{$idx}."); + } + } + + /** + * Helper: Process fields for a single structure nested under a key. + * Resulting prefix: "{key}." + * + * @param callable $processFields + * @param string $key + * @param array|string $fieldsConfig + */ + protected function processSingleNestedFieldsAtKey(callable $processFields, $key, $fieldsConfig) + { + $fields = $this->resolveFieldsConfigForReferenceUpdates($fieldsConfig); + $processFields($fields, "{$key}."); + } + + /** + * Resolve fields config to Fields instance. + * + * @param array|string $fieldsConfig + * @return \Statamic\Fields\Fields + */ + private function resolveFieldsConfigForReferenceUpdates($fieldsConfig) + { + if (is_string($fieldsConfig)) { + $config = $this->config($fieldsConfig); + } else { + $config = $fieldsConfig; + } + + return new Fields($config ?? []); + } +} diff --git a/src/Taxonomies/TermReferenceUpdater.php b/src/Taxonomies/TermReferenceUpdater.php index 633ccfaf405..dd3e1de3438 100644 --- a/src/Taxonomies/TermReferenceUpdater.php +++ b/src/Taxonomies/TermReferenceUpdater.php @@ -3,6 +3,7 @@ namespace Statamic\Taxonomies; use Statamic\Data\DataReferenceUpdater; +use Statamic\Fieldtypes\UpdatesReferences; use Statamic\Support\Arr; class TermReferenceUpdater extends DataReferenceUpdater @@ -38,11 +39,55 @@ public function filterByTaxonomy(string $taxonomy) protected function recursivelyUpdateFields($fields, $dottedPrefix = null) { $this + ->updateCustomFieldtypeValues($fields, $dottedPrefix) ->updateTermsFieldValues($fields, $dottedPrefix) ->updateScopedTermsFieldValues($fields, $dottedPrefix) ->updateNestedFieldValues($fields, $dottedPrefix); } + /** + * Update custom fieldtype values that have term references. + * + * @param \Illuminate\Support\Collection $fields + * @param null|string $dottedPrefix + * @return $this + */ + protected function updateCustomFieldtypeValues($fields, $dottedPrefix) + { + $fields + ->filter(fn ($field) => in_array(UpdatesReferences::class, class_uses_recursive($field->fieldtype()))) + ->each(function ($field) use ($dottedPrefix) { + $data = $this->item->data()->all(); + $dottedKey = $dottedPrefix.$field->handle(); + $oldData = Arr::get($data, $dottedKey); + + if (! $oldData) { + return; + } + + $newData = $field->fieldtype()->replaceTermReferences( + $oldData, + $this->newValue, + $this->originalValue + ); + + if (json_encode($oldData) === json_encode($newData)) { + return; + } + + if ($newData === null && $this->isRemovingValue()) { + Arr::forget($data, $dottedKey); + } else { + Arr::set($data, $dottedKey, $newData); + } + + $this->item->data($data); + $this->updated = true; + }); + + return $this; + } + /** * Update terms field values. * From 5bf56bbfc6d67a7014faab87565a420fc944e4f8 Mon Sep 17 00:00:00 2001 From: Maurice Hildebrandt Date: Tue, 10 Feb 2026 13:51:12 +0100 Subject: [PATCH 2/8] Fix lint --- src/Fieldtypes/UpdatesReferences.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Fieldtypes/UpdatesReferences.php b/src/Fieldtypes/UpdatesReferences.php index 025cf671b94..29eb587f271 100644 --- a/src/Fieldtypes/UpdatesReferences.php +++ b/src/Fieldtypes/UpdatesReferences.php @@ -21,7 +21,7 @@ trait UpdatesReferences * @param mixed $data Current field data * @param string|null $newValue New asset path (null if removing) * @param string $oldValue Old asset path - * @return mixed Modified data (or null to remove field value) + * @return mixed Modified data (or null to remove field value) */ public function replaceAssetReferences($data, $newValue, $oldValue) { @@ -35,7 +35,7 @@ public function replaceAssetReferences($data, $newValue, $oldValue) * @param mixed $data Current field data * @param string|null $newValue New term slug (null if removing) * @param string $oldValue Old term slug - * @return mixed Modified data (or null to remove field value) + * @return mixed Modified data (or null to remove field value) */ public function replaceTermReferences($data, $newValue, $oldValue) { @@ -58,7 +58,6 @@ public function processNestedFieldsForReferences($data, callable $processFields) * Helper: Process fields for a single (group-like) structure. * Resulting prefix: "" * - * @param callable $processFields * @param array|string $fieldsConfig */ protected function processSingleNestedFields(callable $processFields, $fieldsConfig) @@ -72,7 +71,6 @@ protected function processSingleNestedFields(callable $processFields, $fieldsCon * Resulting prefix: "0.", "1.", "2."... * * @param mixed $data - * @param callable $processFields * @param array|string $fieldsConfig */ protected function processArrayNestedFields($data, callable $processFields, $fieldsConfig) @@ -89,7 +87,6 @@ protected function processArrayNestedFields($data, callable $processFields, $fie * Resulting prefix: "{key}.0.", "{key}.1."... * * @param mixed $data - * @param callable $processFields * @param string $key * @param array|string $fieldsConfig */ @@ -107,7 +104,6 @@ protected function processArrayNestedFieldsAtKey($data, callable $processFields, * Helper: Process fields for a single structure nested under a key. * Resulting prefix: "{key}." * - * @param callable $processFields * @param string $key * @param array|string $fieldsConfig */ From ffdb60b77bcc9ebb5f97cb44c91202372346a435 Mon Sep 17 00:00:00 2001 From: Maurice Hildebrandt Date: Tue, 10 Feb 2026 14:03:43 +0100 Subject: [PATCH 3/8] Fix edge cases and improve API consistency in UpdatesReferences - Use strict null checks instead of falsy checks to avoid skipping valid data like empty arrays or 0 - Replace json_encode comparison with native PHP strict equality for change detection - Add non-array guards in helper methods to prevent TypeError on unexpected data - Standardize helper parameter ordering: callable always last --- src/Assets/AssetReferenceUpdater.php | 4 ++-- src/Data/DataReferenceUpdater.php | 2 +- src/Fieldtypes/UpdatesReferences.php | 17 +++++++++++------ src/Taxonomies/TermReferenceUpdater.php | 4 ++-- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/Assets/AssetReferenceUpdater.php b/src/Assets/AssetReferenceUpdater.php index 2eff491fa02..70787efccf4 100644 --- a/src/Assets/AssetReferenceUpdater.php +++ b/src/Assets/AssetReferenceUpdater.php @@ -59,7 +59,7 @@ protected function updateCustomFieldtypeValues($fields, $dottedPrefix) $dottedKey = $dottedPrefix.$field->handle(); $oldData = Arr::get($data, $dottedKey); - if (! $oldData) { + if ($oldData === null) { return; } @@ -69,7 +69,7 @@ protected function updateCustomFieldtypeValues($fields, $dottedPrefix) $this->originalValue ); - if (json_encode($oldData) === json_encode($newData)) { + if ($oldData === $newData) { return; } diff --git a/src/Data/DataReferenceUpdater.php b/src/Data/DataReferenceUpdater.php index 4053187bc83..b0dacbea73a 100644 --- a/src/Data/DataReferenceUpdater.php +++ b/src/Data/DataReferenceUpdater.php @@ -129,7 +129,7 @@ protected function updateNestedFieldsInCustomFieldtype($field, $dottedPrefix) $fieldKey = $dottedPrefix.$field->handle(); $fieldData = Arr::get($this->item->data()->all(), $fieldKey); - if (! $fieldData) { + if ($fieldData === null) { return; } diff --git a/src/Fieldtypes/UpdatesReferences.php b/src/Fieldtypes/UpdatesReferences.php index 29eb587f271..01ed5988ef8 100644 --- a/src/Fieldtypes/UpdatesReferences.php +++ b/src/Fieldtypes/UpdatesReferences.php @@ -60,7 +60,7 @@ public function processNestedFieldsForReferences($data, callable $processFields) * * @param array|string $fieldsConfig */ - protected function processSingleNestedFields(callable $processFields, $fieldsConfig) + protected function processSingleNestedFields($fieldsConfig, callable $processFields) { $fields = $this->resolveFieldsConfigForReferenceUpdates($fieldsConfig); $processFields($fields, ''); @@ -73,11 +73,15 @@ protected function processSingleNestedFields(callable $processFields, $fieldsCon * @param mixed $data * @param array|string $fieldsConfig */ - protected function processArrayNestedFields($data, callable $processFields, $fieldsConfig) + protected function processArrayNestedFields($data, $fieldsConfig, callable $processFields) { + if (! is_array($data)) { + return; + } + $fields = $this->resolveFieldsConfigForReferenceUpdates($fieldsConfig); - foreach (array_keys($data ?? []) as $idx) { + foreach (array_keys($data) as $idx) { $processFields($fields, "{$idx}."); } } @@ -90,10 +94,11 @@ protected function processArrayNestedFields($data, callable $processFields, $fie * @param string $key * @param array|string $fieldsConfig */ - protected function processArrayNestedFieldsAtKey($data, callable $processFields, $key, $fieldsConfig) + protected function processArrayNestedFieldsAtKey($data, $key, $fieldsConfig, callable $processFields) { + $arrayData = is_array($data) ? ($data[$key] ?? []) : []; + $fields = $this->resolveFieldsConfigForReferenceUpdates($fieldsConfig); - $arrayData = $data[$key] ?? []; foreach (array_keys($arrayData) as $idx) { $processFields($fields, "{$key}.{$idx}."); @@ -107,7 +112,7 @@ protected function processArrayNestedFieldsAtKey($data, callable $processFields, * @param string $key * @param array|string $fieldsConfig */ - protected function processSingleNestedFieldsAtKey(callable $processFields, $key, $fieldsConfig) + protected function processSingleNestedFieldsAtKey($key, $fieldsConfig, callable $processFields) { $fields = $this->resolveFieldsConfigForReferenceUpdates($fieldsConfig); $processFields($fields, "{$key}."); diff --git a/src/Taxonomies/TermReferenceUpdater.php b/src/Taxonomies/TermReferenceUpdater.php index dd3e1de3438..cadf0767c58 100644 --- a/src/Taxonomies/TermReferenceUpdater.php +++ b/src/Taxonomies/TermReferenceUpdater.php @@ -61,7 +61,7 @@ protected function updateCustomFieldtypeValues($fields, $dottedPrefix) $dottedKey = $dottedPrefix.$field->handle(); $oldData = Arr::get($data, $dottedKey); - if (! $oldData) { + if ($oldData === null) { return; } @@ -71,7 +71,7 @@ protected function updateCustomFieldtypeValues($fields, $dottedPrefix) $this->originalValue ); - if (json_encode($oldData) === json_encode($newData)) { + if ($oldData === $newData) { return; } From ab32699a8561faf7c22e7a7eb5bc5ccab8aeb6eb Mon Sep 17 00:00:00 2001 From: Maurice Hildebrandt Date: Tue, 10 Feb 2026 15:47:22 +0100 Subject: [PATCH 4/8] Move reference update logic into fieldtypes via UpdatesReferences trait Instead of hardcoding type checks in the updater classes, each fieldtype now owns its reference update logic. Built-in fieldtypes use the same trait as addon developers, serving as examples. Updater classes are simplified to generic loops with cached fieldtype detection via Blink. --- src/Assets/AssetReferenceUpdater.php | 321 +----------------------- src/Data/DataReferenceUpdater.php | 265 +++---------------- src/Fieldtypes/Assets/Assets.php | 26 ++ src/Fieldtypes/Bard.php | 54 ++++ src/Fieldtypes/Grid.php | 20 ++ src/Fieldtypes/Group.php | 13 + src/Fieldtypes/Link.php | 14 ++ src/Fieldtypes/Markdown.php | 17 +- src/Fieldtypes/Replicator.php | 18 +- src/Fieldtypes/Terms.php | 23 ++ src/Fieldtypes/UpdatesReferences.php | 106 +++----- src/Taxonomies/TermReferenceUpdater.php | 100 +------- 12 files changed, 260 insertions(+), 717 deletions(-) diff --git a/src/Assets/AssetReferenceUpdater.php b/src/Assets/AssetReferenceUpdater.php index 70787efccf4..4f1e5ba59bd 100644 --- a/src/Assets/AssetReferenceUpdater.php +++ b/src/Assets/AssetReferenceUpdater.php @@ -3,8 +3,6 @@ namespace Statamic\Assets; use Statamic\Data\DataReferenceUpdater; -use Statamic\Facades\AssetContainer; -use Statamic\Fieldtypes\UpdatesReferences; use Statamic\Support\Arr; class AssetReferenceUpdater extends DataReferenceUpdater @@ -34,26 +32,7 @@ public function filterByContainer(string $container) */ protected function recursivelyUpdateFields($fields, $dottedPrefix = null) { - $this - ->updateCustomFieldtypeValues($fields, $dottedPrefix) - ->updateAssetsFieldValues($fields, $dottedPrefix) - ->updateLinkFieldValues($fields, $dottedPrefix) - ->updateBardFieldValues($fields, $dottedPrefix) - ->updateMarkdownFieldValues($fields, $dottedPrefix) - ->updateNestedFieldValues($fields, $dottedPrefix); - } - - /** - * Update custom fieldtype values that have asset references. - * - * @param \Illuminate\Support\Collection $fields - * @param null|string $dottedPrefix - * @return $this - */ - protected function updateCustomFieldtypeValues($fields, $dottedPrefix) - { - $fields - ->filter(fn ($field) => in_array(UpdatesReferences::class, class_uses_recursive($field->fieldtype()))) + $this->fieldsWithReferenceUpdates($fields) ->each(function ($field) use ($dottedPrefix) { $data = $this->item->data()->all(); $dottedKey = $dottedPrefix.$field->handle(); @@ -66,7 +45,8 @@ protected function updateCustomFieldtypeValues($fields, $dottedPrefix) $newData = $field->fieldtype()->replaceAssetReferences( $oldData, $this->newValue, - $this->originalValue + $this->originalValue, + $this->container ); if ($oldData === $newData) { @@ -83,299 +63,6 @@ protected function updateCustomFieldtypeValues($fields, $dottedPrefix) $this->updated = true; }); - return $this; - } - - /** - * Update assets field values. - * - * @param \Illuminate\Support\Collection $fields - * @param null|string $dottedPrefix - * @return $this - */ - protected function updateAssetsFieldValues($fields, $dottedPrefix) - { - $fields - ->filter(function ($field) { - return $field->type() === 'assets' - && $this->getConfiguredAssetsFieldContainer($field) === $this->container; - }) - ->each(function ($field) use ($dottedPrefix) { - $this->hasStringValue($field, $dottedPrefix) - ? $this->updateStringValue($field, $dottedPrefix) - : $this->updateArrayValue($field, $dottedPrefix); - }); - - return $this; - } - - /** - * Update link field values. - * - * @param \Illuminate\Support\Collection $fields - * @param null|string $dottedPrefix - * @return $this - */ - protected function updateLinkFieldValues($fields, $dottedPrefix) - { - $fields - ->filter(function ($field) { - return $field->type() === 'link' - && $field->get('container') === $this->container; - }) - ->each(function ($field) use ($dottedPrefix) { - $this->updateStatamicUrlsInLinkValue($field, $dottedPrefix); - }); - - return $this; - } - - /** - * Update bard field values. - * - * @param \Illuminate\Support\Collection $fields - * @param null|string $dottedPrefix - * @return $this - */ - protected function updateBardFieldValues($fields, $dottedPrefix) - { - $fields - ->filter(function ($field) { - return $field->type() === 'bard' - && $field->get('container') === $this->container; - }) - ->each(function ($field) use ($dottedPrefix) { - $this->hasStringValue($field, $dottedPrefix) - ? $this->updateStatamicUrlsInStringValue($field, $dottedPrefix) - : $this->updateStatamicUrlsInArrayValue($field, $dottedPrefix); - }); - - return $this; - } - - /** - * Update markdown field values. - * - * @param \Illuminate\Support\Collection $fields - * @param null|string $dottedPrefix - * @return $this - */ - protected function updateMarkdownFieldValues($fields, $dottedPrefix) - { - $fields - ->filter(function ($field) { - return $field->type() === 'markdown' - && $field->get('container') === $this->container; - }) - ->each(function ($field) use ($dottedPrefix) { - $this->updateStatamicUrlsInStringValue($field, $dottedPrefix); - }); - - return $this; - } - - /** - * Get configured assets field container, or implied asset container if only one exists. - * - * @param \Statamic\Fields\Field $field - * @return string - */ - protected function getConfiguredAssetsFieldContainer($field) - { - if ($container = $field->get('container')) { - return $container; - } - - $containers = AssetContainer::all(); - - return $containers->count() === 1 - ? $containers->first()->handle() - : null; - } - - /** - * Update `statamic://` urls in string value on item. - * - * @param \Statamic\Fields\Field $field - * @param null|string $dottedPrefix - */ - protected function updateStatamicUrlsInStringValue($field, $dottedPrefix) - { - $data = $this->item->data()->all(); - - $dottedKey = $dottedPrefix.$field->handle(); - - $originalValue = $value = Arr::get($data, $dottedKey); - - if (! $originalValue) { - return; - } - - $value = preg_replace_callback('/([("])(statamic:\/\/[^()"]*::)([^)"]*)([)"])/im', function ($matches) { - $newValue = $this->isRemovingValue() ? '' : $matches[2].$this->newValue; - - return $matches[3] === $this->originalValue - ? $matches[1].$newValue.$matches[4] - : $matches[0]; - }, $value); - - if ($originalValue === $value) { - return; - } - - Arr::set($data, $dottedKey, $value); - - $this->item->data($data); - - $this->updated = true; - } - - /** - * Update asset references in link values. - * - * @param \Statamic\Fields\Field $field - * @param null|string $dottedPrefix - */ - private function updateStatamicUrlsInLinkValue($field, $dottedPrefix) - { - $data = $this->item->data()->all(); - - $dottedKey = $dottedPrefix.$field->handle(); - - $originalValue = $value = Arr::get($data, $dottedKey); - - if (! $originalValue) { - return; - } - - if ($value !== "asset::{$this->container}::{$this->originalValue}") { - return; - } - - $newValue = $this->isRemovingValue() - ? null - : "asset::{$this->container}::{$this->newValue}"; - - if ($originalValue === $newValue) { - return; - } - - if ($this->isRemovingValue()) { - Arr::forget($data, $dottedKey); - } else { - Arr::set($data, $dottedKey, $newValue); - } - - $this->item->data($data); - - $this->updated = true; - } - - /** - * Update asset references in bard set on item. - * - * @param \Statamic\Fields\Field $field - * @param null|string $dottedPrefix - */ - protected function updateStatamicUrlsInArrayValue($field, $dottedPrefix) - { - $this->updateStatamicUrlsInImageNodes($field, $dottedPrefix); - $this->updateStatamicUrlsInLinkNodes($field, $dottedPrefix); - } - - /** - * Update asset references in bard image nodes. - * - * @param \Statamic\Fields\Field $field - * @param null|string $dottedPrefix - */ - private function updateStatamicUrlsInImageNodes($field, $dottedPrefix) - { - $data = $this->item->data()->all(); - - $dottedKey = $dottedPrefix.$field->handle(); - - $bardPayload = Arr::get($data, $dottedKey, []); - - if (! $bardPayload) { - return; - } - - $changed = collect(Arr::dot($bardPayload)) - ->filter(function ($value, $key) { - return preg_match('/(.*)\.(type)/', $key) && $value === 'image'; - }) - ->mapWithKeys(function ($value, $key) use ($bardPayload) { - $key = str_replace('.type', '.attrs.src', $key); - - return [$key => Arr::get($bardPayload, $key)]; - }) - ->filter(function ($value) { - return $value === "asset::{$this->container}::{$this->originalValue}"; - }) - ->map(function ($value) { - return "asset::{$this->container}::{$this->newValue}"; - }) - ->each(function ($value, $key) use (&$bardPayload) { - Arr::set($bardPayload, $key, $this->isRemovingValue() ? '' : $value); - }); - - if ($changed->isEmpty()) { - return; - } - - Arr::set($data, $dottedKey, $bardPayload); - - $this->item->data($data); - - $this->updated = true; - } - - /** - * Update asset references in bard link nodes. - * - * @param \Statamic\Fields\Field $field - * @param null|string $dottedPrefix - */ - private function updateStatamicUrlsInLinkNodes($field, $dottedPrefix) - { - $data = $this->item->data()->all(); - - $dottedKey = $dottedPrefix.$field->handle(); - - $bardPayload = Arr::get($data, $dottedKey, []); - - if (! $bardPayload) { - return; - } - - $changed = collect(Arr::dot($bardPayload)) - ->filter(function ($value, $key) { - return preg_match('/(.*)\.(type)/', $key) && $value === 'link'; - }) - ->mapWithKeys(function ($value, $key) use ($bardPayload) { - $key = str_replace('.type', '.attrs.href', $key); - - return [$key => Arr::get($bardPayload, $key)]; - }) - ->filter(function ($value) { - return $value === "statamic://asset::{$this->container}::{$this->originalValue}"; - }) - ->map(function ($value) { - return "statamic://asset::{$this->container}::{$this->newValue}"; - }) - ->each(function ($value, $key) use (&$bardPayload) { - Arr::set($bardPayload, $key, $this->isRemovingValue() ? '' : $value); - }); - - if ($changed->isEmpty()) { - return; - } - - Arr::set($data, $dottedKey, $bardPayload); - - $this->item->data($data); - - $this->updated = true; + $this->updateNestedFieldValues($fields, $dottedPrefix); } } diff --git a/src/Data/DataReferenceUpdater.php b/src/Data/DataReferenceUpdater.php index b0dacbea73a..fd720564bbf 100644 --- a/src/Data/DataReferenceUpdater.php +++ b/src/Data/DataReferenceUpdater.php @@ -2,7 +2,7 @@ namespace Statamic\Data; -use Statamic\Fields\Fields; +use Statamic\Facades\Blink; use Statamic\Fieldtypes\UpdatesReferences; use Statamic\Git\Subscriber as GitSubscriber; use Statamic\Support\Arr; @@ -89,165 +89,49 @@ protected function getTopLevelFields() abstract protected function recursivelyUpdateFields($fields, $dottedPrefix = null); /** - * Update nested field values. + * Get fieldtypes that use the UpdatesReferences trait, filtered by cheap string type check. * * @param \Illuminate\Support\Collection $fields - * @param null|string $dottedPrefix - * @return $this - */ - protected function updateNestedFieldValues($fields, $dottedPrefix) - { - $fields - ->filter(function ($field) { - return in_array($field->type(), ['replicator', 'grid', 'group', 'bard']); - }) - ->each(function ($field) use ($dottedPrefix) { - $method = 'update'.ucfirst($field->type()).'Children'; - $dottedKey = $dottedPrefix.$field->handle(); - - $this->{$method}($field, $dottedKey); - }); - - // Handle custom fieldtypes with nested fields - $fields - ->filter(fn ($field) => in_array(UpdatesReferences::class, class_uses_recursive($field->fieldtype()))) - ->each(function ($field) use ($dottedPrefix) { - $this->updateNestedFieldsInCustomFieldtype($field, $dottedPrefix); - }); - - return $this; - } - - /** - * Update nested fields in custom fieldtype. - * - * @param \Statamic\Fields\Field $field - * @param null|string $dottedPrefix + * @return \Illuminate\Support\Collection */ - protected function updateNestedFieldsInCustomFieldtype($field, $dottedPrefix) + protected function fieldsWithReferenceUpdates($fields) { - $fieldKey = $dottedPrefix.$field->handle(); - $fieldData = Arr::get($this->item->data()->all(), $fieldKey); - - if ($fieldData === null) { - return; - } - - $fieldtype = $field->fieldtype(); - - $fieldtype->processNestedFieldsForReferences( - $fieldData, - function ($nestedFields, $relativePrefix) use ($fieldKey) { - $absolutePrefix = $fieldKey.'.'.$relativePrefix; - $this->recursivelyUpdateFields($nestedFields->all(), $absolutePrefix); - } + $handles = Blink::once('fieldtypes-with-reference-updates', fn () => app('statamic.fieldtypes') + ->filter(fn ($class) => in_array(UpdatesReferences::class, class_uses_recursive($class))) + ->keys()->all() ); - } - - /** - * Update replicator field children. - * - * @param \Statamic\Fields\Field $field - * @param string $dottedKey - */ - protected function updateReplicatorChildren($field, $dottedKey) - { - $data = $this->item->data(); - $sets = Arr::get($data, $dottedKey); - - collect($sets)->each(function ($set, $setKey) use ($dottedKey, $field) { - $dottedPrefix = "{$dottedKey}.{$setKey}."; - $setHandle = Arr::get($set, 'type'); - $fields = Arr::get($field->fieldtype()->flattenedSetsConfig(), "{$setHandle}.fields"); - - if ($setHandle && $fields) { - $this->recursivelyUpdateFields((new Fields($fields))->all(), $dottedPrefix); - } - }); + return $fields->filter(fn ($field) => in_array($field->type(), $handles)); } /** - * Update grid field children. + * Update nested field values by delegating to fieldtype iterateReferenceFields. * - * @param \Statamic\Fields\Field $field - * @param string $dottedKey - */ - protected function updateGridChildren($field, $dottedKey) - { - $data = $this->item->data(); - - $sets = Arr::get($data, $dottedKey); - - collect($sets)->each(function ($set, $setKey) use ($dottedKey, $field) { - $dottedPrefix = "{$dottedKey}.{$setKey}."; - $fields = Arr::get($field->config(), 'fields'); - - if ($fields) { - $this->recursivelyUpdateFields((new Fields($fields))->all(), $dottedPrefix); - } - }); - } - - /** - * Update group field children. - * - * @param \Statamic\Fields\Field $field - * @param string $dottedKey - */ - protected function updateGroupChildren($field, $dottedKey) - { - $data = $this->item->data(); - - $dottedPrefix = "{$dottedKey}."; - $fields = Arr::get($field->config(), 'fields'); - - if ($fields) { - $this->recursivelyUpdateFields((new Fields($fields))->all(), $dottedPrefix); - } - } - - /** - * Update bard field children. - * - * @param \Statamic\Fields\Field $field - * @param string $dottedKey + * @param \Illuminate\Support\Collection $fields + * @param null|string $dottedPrefix + * @return $this */ - protected function updateBardChildren($field, $dottedKey) + protected function updateNestedFieldValues($fields, $dottedPrefix) { - $data = $this->item->data(); - - $sets = Arr::get($data, $dottedKey); - - collect($sets)->each(function ($set, $setKey) use ($dottedKey, $field) { - $dottedPrefix = "{$dottedKey}.{$setKey}.attrs.values."; - $setHandle = Arr::get($set, 'attrs.values.type'); - $fields = Arr::get($field->fieldtype()->flattenedSetsConfig(), "{$setHandle}.fields"); + $this->fieldsWithReferenceUpdates($fields) + ->each(function ($field) use ($dottedPrefix) { + $fieldKey = $dottedPrefix.$field->handle(); + $fieldData = Arr::get($this->item->data()->all(), $fieldKey); - if ($setHandle && $fields) { - $this->recursivelyUpdateFields((new Fields($fields))->all(), $dottedPrefix); - } - }); - } + if ($fieldData === null) { + return; + } - /** - * Get original value. - * - * @return mixed - */ - protected function originalValue() - { - return $this->originalValue; - } + $field->fieldtype()->iterateReferenceFields( + $fieldData, + function ($nestedFields, $relativePrefix) use ($fieldKey) { + $absolutePrefix = $fieldKey.'.'.$relativePrefix; + $this->recursivelyUpdateFields($nestedFields->all(), $absolutePrefix); + } + ); + }); - /** - * Get new value. - * - * @return mixed - */ - protected function newValue() - { - return $this->newValue; + return $this; } /** @@ -260,97 +144,6 @@ public function isRemovingValue() return is_null($this->newValue); } - /** - * Determine if field has string value. - * - * @param \Statamic\Fields\Field $field - * @param null|string $dottedPrefix - * @return bool - */ - protected function hasStringValue($field, $dottedPrefix) - { - $data = $this->item->data()->all(); - - $dottedKey = $dottedPrefix.$field->handle(); - - return is_string(Arr::get($data, $dottedKey)); - } - - /** - * Update string value on item. - * - * @param \Statamic\Fields\Field $field - * @param null|string $dottedPrefix - */ - protected function updateStringValue($field, $dottedPrefix) - { - $data = $this->item->data()->all(); - - $dottedKey = $dottedPrefix.$field->handle(); - - if (Arr::get($data, $dottedKey) !== $this->originalValue()) { - return; - } - - if ($this->isRemovingValue()) { - Arr::forget($data, $dottedKey); - } else { - Arr::set($data, $dottedKey, $this->newValue()); - } - - $this->item->data($data); - - $this->updated = true; - } - - /** - * Update array value on item. - * - * @param \Statamic\Fields\Field $field - * @param null|string $dottedPrefix - */ - protected function updateArrayValue($field, $dottedPrefix) - { - $data = $this->item->data()->all(); - - $dottedKey = $dottedPrefix.$field->handle(); - - $fieldData = Arr::get($data, $dottedKey, []); - - if (! $fieldData) { - return; - } - - $fieldData = collect(Arr::dot($fieldData)); - - if (! $fieldData->contains($this->originalValue())) { - return; - } - - $fieldData = $fieldData - ->map(function ($value) { - if ($value === $this->originalValue() && $this->isRemovingValue()) { - return null; - } elseif ($value === $this->originalValue()) { - return $this->newValue(); - } else { - return $value; - } - }) - ->filter() - ->values(); - - if ($fieldData->isEmpty()) { - Arr::forget($data, $dottedKey); - } else { - Arr::set($data, $dottedKey, $fieldData->all()); - } - - $this->item->data($data); - - $this->updated = true; - } - /** * Save item without triggering individual git commits, as these should be batched into one larger commit. */ diff --git a/src/Fieldtypes/Assets/Assets.php b/src/Fieldtypes/Assets/Assets.php index 6b7e3e00c7e..0004b67739c 100644 --- a/src/Fieldtypes/Assets/Assets.php +++ b/src/Fieldtypes/Assets/Assets.php @@ -16,6 +16,7 @@ use Statamic\Facades\Scope; use Statamic\Facades\User; use Statamic\Fields\Fieldtype; +use Statamic\Fieldtypes\UpdatesReferences; use Statamic\GraphQL\Types\AssetInterface; use Statamic\Http\Resources\CP\Assets\Asset as AssetResource; use Statamic\Query\Scopes\Filter; @@ -24,6 +25,7 @@ class Assets extends Fieldtype { + use UpdatesReferences; protected $categories = ['media', 'relationship']; protected $keywords = ['file', 'files', 'image', 'images', 'video', 'videos', 'audio', 'upload']; protected $selectableInForms = true; @@ -507,4 +509,28 @@ public function toQueryableValue($value) ? collect($value)->first() : collect($value)->filter()->all(); } + + public function replaceAssetReferences($data, ?string $newValue, string $oldValue, string $container) + { + if ($this->configuredContainerHandle() !== $container) { + return $data; + } + + return is_string($data) + ? $this->replaceValue($data, $newValue, $oldValue) + : $this->replaceValuesInArray($data, $newValue, $oldValue); + } + + protected function configuredContainerHandle(): ?string + { + if ($container = $this->config('container')) { + return $container; + } + + $containers = AssetContainer::all(); + + return $containers->count() === 1 + ? $containers->first()->handle() + : null; + } } diff --git a/src/Fieldtypes/Bard.php b/src/Fieldtypes/Bard.php index e3568eefd51..33513db7280 100644 --- a/src/Fieldtypes/Bard.php +++ b/src/Fieldtypes/Bard.php @@ -14,6 +14,7 @@ use Statamic\Facades\GraphQL; use Statamic\Facades\Site; use Statamic\Fields\Field; +use Statamic\Fields\Fields; use Statamic\Fields\Value; use Statamic\Fieldtypes\Bard\Augmentor; use Statamic\GraphQL\Types\BardSetsType; @@ -811,6 +812,59 @@ public static function setDefaultButtons(array $buttons): void static::$defaultButtons = $buttons; } + public function replaceAssetReferences($data, ?string $newValue, string $oldValue, string $container) + { + if ($this->config('container') !== $container) { + return $data; + } + + if (is_string($data)) { + return $data ? $this->replaceStatamicUrls($data, $newValue, $oldValue) : $data; + } + + if (! is_array($data) || empty($data)) { + return $data; + } + + $flat = collect(Arr::dot($data)); + + $flat->filter(fn ($value, $key) => preg_match('/(.*)\.(type)/', $key) && $value === 'image') + ->each(function ($value, $key) use (&$data, $newValue, $oldValue, $container) { + $srcKey = str_replace('.type', '.attrs.src', $key); + + if (Arr::get($data, $srcKey) === "asset::{$container}::{$oldValue}") { + Arr::set($data, $srcKey, $newValue === null ? '' : "asset::{$container}::{$newValue}"); + } + }); + + $flat->filter(fn ($value, $key) => preg_match('/(.*)\.(type)/', $key) && $value === 'link') + ->each(function ($value, $key) use (&$data, $newValue, $oldValue, $container) { + $hrefKey = str_replace('.type', '.attrs.href', $key); + + if (Arr::get($data, $hrefKey) === "statamic://asset::{$container}::{$oldValue}") { + Arr::set($data, $hrefKey, $newValue === null ? '' : "statamic://asset::{$container}::{$newValue}"); + } + }); + + return $data; + } + + public function iterateReferenceFields($data, callable $callback): void + { + if (! is_array($data)) { + return; + } + + collect($data)->each(function ($set, $setKey) use ($callback) { + $setHandle = Arr::get($set, 'attrs.values.type'); + $fields = Arr::get($this->flattenedSetsConfig(), "{$setHandle}.fields"); + + if ($setHandle && $fields) { + $callback(new Fields($fields), "{$setKey}.attrs.values."); + } + }); + } + private function containerRequiredRule(): ValidationRule { return new class implements DataAwareRule, ValidationRule diff --git a/src/Fieldtypes/Grid.php b/src/Fieldtypes/Grid.php index f396130a2c4..46c0db6adcd 100644 --- a/src/Fieldtypes/Grid.php +++ b/src/Fieldtypes/Grid.php @@ -15,6 +15,7 @@ class Grid extends Fieldtype { use AddsEntryValidationReplacements; + use UpdatesReferences; protected $categories = ['structured']; protected $defaultable = false; @@ -277,4 +278,23 @@ public function toQueryableValue($value) { return empty($value) ? null : $value; } + + public function iterateReferenceFields($data, callable $callback): void + { + if (! is_array($data)) { + return; + } + + $fields = $this->config('fields'); + + if (! $fields) { + return; + } + + $fields = new Fields($fields); + + foreach (array_keys($data) as $idx) { + $callback($fields, "{$idx}."); + } + } } diff --git a/src/Fieldtypes/Group.php b/src/Fieldtypes/Group.php index 28fe7c0f7a1..2f948d500d9 100644 --- a/src/Fieldtypes/Group.php +++ b/src/Fieldtypes/Group.php @@ -12,6 +12,8 @@ class Group extends Fieldtype { + use UpdatesReferences; + protected $categories = ['structured']; protected $defaultable = false; protected $selectableInForms = true; @@ -199,4 +201,15 @@ public function hasJsDriverDataBinding(): bool { return false; } + + public function iterateReferenceFields($data, callable $callback): void + { + $fieldsConfig = $this->config('fields'); + + if (! $fieldsConfig) { + return; + } + + $callback(new Fields($fieldsConfig), ''); + } } diff --git a/src/Fieldtypes/Link.php b/src/Fieldtypes/Link.php index 310166ca404..c9c63d7967f 100644 --- a/src/Fieldtypes/Link.php +++ b/src/Fieldtypes/Link.php @@ -17,6 +17,7 @@ class Link extends Fieldtype { + use UpdatesReferences; protected $categories = ['relationship']; protected function configFieldItems(): array @@ -219,4 +220,17 @@ private function availableSites() ->values() ->all(); } + + public function replaceAssetReferences($data, ?string $newValue, string $oldValue, string $container) + { + if ($this->config('container') !== $container) { + return $data; + } + + if ($data !== "asset::{$container}::{$oldValue}") { + return $data; + } + + return $newValue !== null ? "asset::{$container}::{$newValue}" : null; + } } diff --git a/src/Fieldtypes/Markdown.php b/src/Fieldtypes/Markdown.php index 6d8bc2f41ed..088a9ff1e6d 100644 --- a/src/Fieldtypes/Markdown.php +++ b/src/Fieldtypes/Markdown.php @@ -10,11 +10,11 @@ class Markdown extends Fieldtype { + use Concerns\ResolvesStatamicUrls, UpdatesReferences; + protected $categories = ['text']; protected $keywords = ['md', 'content', 'html']; - use Concerns\ResolvesStatamicUrls; - protected function configFieldItems(): array { return [ @@ -244,4 +244,17 @@ public function shouldParseAntlersFromRawString(): bool { return $this->config('smartypants', false); } + + public function replaceAssetReferences($data, ?string $newValue, string $oldValue, string $container) + { + if ($this->config('container') !== $container) { + return $data; + } + + if (! is_string($data) || ! $data) { + return $data; + } + + return $this->replaceStatamicUrls($data, $newValue, $oldValue); + } } diff --git a/src/Fieldtypes/Replicator.php b/src/Fieldtypes/Replicator.php index 04552676ad9..dbb9c953070 100644 --- a/src/Fieldtypes/Replicator.php +++ b/src/Fieldtypes/Replicator.php @@ -16,7 +16,7 @@ class Replicator extends Fieldtype { - use AddsEntryValidationReplacements; + use AddsEntryValidationReplacements, UpdatesReferences; protected $categories = ['structured']; protected $keywords = ['builder', 'page builder', 'content']; @@ -319,4 +319,20 @@ public function toQueryableValue($value) { return empty($value) ? null : $value; } + + public function iterateReferenceFields($data, callable $callback): void + { + if (! is_array($data)) { + return; + } + + collect($data)->each(function ($set, $setKey) use ($callback) { + $setHandle = Arr::get($set, 'type'); + $fields = Arr::get($this->flattenedSetsConfig(), "{$setHandle}.fields"); + + if ($setHandle && $fields) { + $callback(new Fields($fields), "{$setKey}."); + } + }); + } } diff --git a/src/Fieldtypes/Terms.php b/src/Fieldtypes/Terms.php index 1d8f2017afd..6a7142e80e0 100644 --- a/src/Fieldtypes/Terms.php +++ b/src/Fieldtypes/Terms.php @@ -30,6 +30,7 @@ class Terms extends Relationship { + use UpdatesReferences; protected $canEdit = true; protected $canCreate = true; protected $canSearch = true; @@ -530,4 +531,26 @@ public function getItemHint($item): ?string count($this->getConfiguredTaxonomies()) > 1 ? __($item->taxonomy()->title()) : null, ])->filter()->implode(' • '); } + + public function replaceTermReferences($data, ?string $newValue, string $oldValue, string $taxonomy) + { + $configuredTaxonomies = Arr::wrap($this->config('taxonomies')); + + if (count($configuredTaxonomies) > 0) { + if (! in_array($taxonomy, $configuredTaxonomies)) { + return $data; + } + + return is_string($data) + ? $this->replaceValue($data, $newValue, $oldValue) + : $this->replaceValuesInArray($data, $newValue, $oldValue); + } + + $scopedOldValue = "{$taxonomy}::{$oldValue}"; + $scopedNewValue = $newValue !== null ? "{$taxonomy}::{$newValue}" : null; + + return is_string($data) + ? $this->replaceValue($data, $scopedNewValue, $scopedOldValue) + : $this->replaceValuesInArray($data, $scopedNewValue, $scopedOldValue); + } } diff --git a/src/Fieldtypes/UpdatesReferences.php b/src/Fieldtypes/UpdatesReferences.php index 01ed5988ef8..93390cfb20b 100644 --- a/src/Fieldtypes/UpdatesReferences.php +++ b/src/Fieldtypes/UpdatesReferences.php @@ -2,136 +2,112 @@ namespace Statamic\Fieldtypes; -use Statamic\Fields\Fields; +use Statamic\Support\Arr; /** - * Trait for custom fieldtypes to participate in reference updates (assets, terms, etc.). + * Trait for fieldtypes to participate in reference updates (assets, terms, etc.). * * Override only the methods you need: * - replaceAssetReferences() for direct asset references * - replaceTermReferences() for direct term references - * - processNestedFieldsForReferences() for nested Statamic fields + * - iterateReferenceFields() for nested Statamic fields */ trait UpdatesReferences { /** * Replace asset references in the fieldtype's data. - * Override this if your fieldtype stores direct asset references. * * @param mixed $data Current field data * @param string|null $newValue New asset path (null if removing) * @param string $oldValue Old asset path + * @param string $container Asset container handle * @return mixed Modified data (or null to remove field value) */ - public function replaceAssetReferences($data, $newValue, $oldValue) + public function replaceAssetReferences($data, ?string $newValue, string $oldValue, string $container) { return $data; } /** * Replace term references in the fieldtype's data. - * Override this if your fieldtype stores direct term references. * * @param mixed $data Current field data * @param string|null $newValue New term slug (null if removing) * @param string $oldValue Old term slug + * @param string $taxonomy Taxonomy handle * @return mixed Modified data (or null to remove field value) */ - public function replaceTermReferences($data, $newValue, $oldValue) + public function replaceTermReferences($data, ?string $newValue, string $oldValue, string $taxonomy) { return $data; } /** - * Process nested fields for reference updates. + * Iterate nested fields for reference updates. * Override this if your fieldtype contains nested Statamic fields. * * @param mixed $data Current field data - * @param callable $processFields fn(Fields $fields, string $relativeDottedPrefix): void + * @param callable $callback fn(Fields $fields, string $relativeDottedPrefix): void */ - public function processNestedFieldsForReferences($data, callable $processFields) + public function iterateReferenceFields($data, callable $callback): void { // Default: no nested fields to process } /** - * Helper: Process fields for a single (group-like) structure. - * Resulting prefix: "" + * Helper: Replace a single value by exact comparison. * - * @param array|string $fieldsConfig + * @param mixed $data + * @param mixed $newValue + * @param mixed $oldValue + * @return mixed */ - protected function processSingleNestedFields($fieldsConfig, callable $processFields) + protected function replaceValue($data, $newValue, $oldValue) { - $fields = $this->resolveFieldsConfigForReferenceUpdates($fieldsConfig); - $processFields($fields, ''); + return $data === $oldValue ? $newValue : $data; } /** - * Helper: Process fields for an array structure at root level. - * Resulting prefix: "0.", "1.", "2."... + * Helper: Replace values in a flat or nested array. * * @param mixed $data - * @param array|string $fieldsConfig + * @param mixed $newValue + * @param mixed $oldValue + * @return mixed */ - protected function processArrayNestedFields($data, $fieldsConfig, callable $processFields) + protected function replaceValuesInArray($data, $newValue, $oldValue) { - if (! is_array($data)) { - return; + if (! is_array($data) || ! $data) { + return $data; } - $fields = $this->resolveFieldsConfigForReferenceUpdates($fieldsConfig); + $flat = collect(Arr::dot($data)); - foreach (array_keys($data) as $idx) { - $processFields($fields, "{$idx}."); + if (! $flat->contains($oldValue)) { + return $data; } - } - - /** - * Helper: Process fields for an array nested under a specific key. - * Resulting prefix: "{key}.0.", "{key}.1."... - * - * @param mixed $data - * @param string $key - * @param array|string $fieldsConfig - */ - protected function processArrayNestedFieldsAtKey($data, $key, $fieldsConfig, callable $processFields) - { - $arrayData = is_array($data) ? ($data[$key] ?? []) : []; - $fields = $this->resolveFieldsConfigForReferenceUpdates($fieldsConfig); + $result = $flat + ->map(fn ($value) => $value === $oldValue ? $newValue : $value) + ->filter() + ->values(); - foreach (array_keys($arrayData) as $idx) { - $processFields($fields, "{$key}.{$idx}."); - } + return $result->isEmpty() ? null : $result->all(); } /** - * Helper: Process fields for a single structure nested under a key. - * Resulting prefix: "{key}." - * - * @param string $key - * @param array|string $fieldsConfig + * Helper: Replace statamic:// URLs in a string. */ - protected function processSingleNestedFieldsAtKey($key, $fieldsConfig, callable $processFields) + protected function replaceStatamicUrls(string $data, ?string $newValue, string $oldValue): string { - $fields = $this->resolveFieldsConfigForReferenceUpdates($fieldsConfig); - $processFields($fields, "{$key}."); - } + return preg_replace_callback('/([("])(statamic:\/\/[^()"]*::)([^)"]*)([)"])/im', function ($matches) use ($newValue, $oldValue) { + if ($matches[3] !== $oldValue) { + return $matches[0]; + } - /** - * Resolve fields config to Fields instance. - * - * @param array|string $fieldsConfig - * @return \Statamic\Fields\Fields - */ - private function resolveFieldsConfigForReferenceUpdates($fieldsConfig) - { - if (is_string($fieldsConfig)) { - $config = $this->config($fieldsConfig); - } else { - $config = $fieldsConfig; - } + $replacement = $newValue === null ? '' : $matches[2].$newValue; - return new Fields($config ?? []); + return $matches[1].$replacement.$matches[4]; + }, $data); } } diff --git a/src/Taxonomies/TermReferenceUpdater.php b/src/Taxonomies/TermReferenceUpdater.php index cadf0767c58..97821484e33 100644 --- a/src/Taxonomies/TermReferenceUpdater.php +++ b/src/Taxonomies/TermReferenceUpdater.php @@ -3,7 +3,6 @@ namespace Statamic\Taxonomies; use Statamic\Data\DataReferenceUpdater; -use Statamic\Fieldtypes\UpdatesReferences; use Statamic\Support\Arr; class TermReferenceUpdater extends DataReferenceUpdater @@ -13,11 +12,6 @@ class TermReferenceUpdater extends DataReferenceUpdater */ protected $taxonomy; - /** - * @var null|string - */ - protected $scope; - /** * Filter by taxonomy. * @@ -38,24 +32,7 @@ public function filterByTaxonomy(string $taxonomy) */ protected function recursivelyUpdateFields($fields, $dottedPrefix = null) { - $this - ->updateCustomFieldtypeValues($fields, $dottedPrefix) - ->updateTermsFieldValues($fields, $dottedPrefix) - ->updateScopedTermsFieldValues($fields, $dottedPrefix) - ->updateNestedFieldValues($fields, $dottedPrefix); - } - - /** - * Update custom fieldtype values that have term references. - * - * @param \Illuminate\Support\Collection $fields - * @param null|string $dottedPrefix - * @return $this - */ - protected function updateCustomFieldtypeValues($fields, $dottedPrefix) - { - $fields - ->filter(fn ($field) => in_array(UpdatesReferences::class, class_uses_recursive($field->fieldtype()))) + $this->fieldsWithReferenceUpdates($fields) ->each(function ($field) use ($dottedPrefix) { $data = $this->item->data()->all(); $dottedKey = $dottedPrefix.$field->handle(); @@ -68,7 +45,8 @@ protected function updateCustomFieldtypeValues($fields, $dottedPrefix) $newData = $field->fieldtype()->replaceTermReferences( $oldData, $this->newValue, - $this->originalValue + $this->originalValue, + $this->taxonomy ); if ($oldData === $newData) { @@ -85,76 +63,6 @@ protected function updateCustomFieldtypeValues($fields, $dottedPrefix) $this->updated = true; }); - return $this; - } - - /** - * Update terms field values. - * - * @param \Illuminate\Support\Collection $fields - * @param null|string $dottedPrefix - * @return $this - */ - protected function updateTermsFieldValues($fields, $dottedPrefix) - { - $this->scope = null; - - $fields - ->filter(function ($field) { - return $field->type() === 'terms' - && in_array($this->taxonomy, Arr::wrap($field->get('taxonomies'))); - }) - ->each(function ($field) use ($dottedPrefix) { - $this->hasStringValue($field, $dottedPrefix) - ? $this->updateStringValue($field, $dottedPrefix) - : $this->updateArrayValue($field, $dottedPrefix); - }); - - return $this; - } - - /** - * Update scoped terms field values. - * - * @param \Illuminate\Support\Collection $fields - * @param null|string $dottedPrefix - * @return $this - */ - protected function updateScopedTermsFieldValues($fields, $dottedPrefix) - { - $this->scope = "{$this->taxonomy}::"; - - $fields - ->filter(function ($field) { - return $field->type() === 'terms' - && count(Arr::wrap($field->get('taxonomies'))) === 0; - }) - ->each(function ($field) use ($dottedPrefix) { - $this->hasStringValue($field, $dottedPrefix) - ? $this->updateStringValue($field, $dottedPrefix) - : $this->updateArrayValue($field, $dottedPrefix); - }); - - return $this; - } - - /** - * Get original value. - * - * @return mixed - */ - protected function originalValue() - { - return $this->scope.$this->originalValue; - } - - /** - * Get new value. - * - * @return mixed - */ - protected function newValue() - { - return $this->scope.$this->newValue; + $this->updateNestedFieldValues($fields, $dottedPrefix); } } From 95a79686dc67f23719fdcc22c5b2fdd8e32ad252 Mon Sep 17 00:00:00 2001 From: Maurice Hildebrandt Date: Tue, 10 Feb 2026 16:33:26 +0100 Subject: [PATCH 5/8] Replace callable with NestedFieldUpdater class for iterateReferenceFields Improves IDE discoverability for addon developers by replacing the opaque callable parameter with a typed NestedFieldUpdater class. --- src/Data/DataReferenceUpdater.php | 16 ++++++++++++---- src/Data/NestedFieldUpdater.php | 21 +++++++++++++++++++++ src/Fieldtypes/Bard.php | 7 ++++--- src/Fieldtypes/Grid.php | 5 +++-- src/Fieldtypes/Group.php | 5 +++-- src/Fieldtypes/Replicator.php | 7 ++++--- src/Fieldtypes/UpdatesReferences.php | 5 +++-- 7 files changed, 50 insertions(+), 16 deletions(-) create mode 100644 src/Data/NestedFieldUpdater.php diff --git a/src/Data/DataReferenceUpdater.php b/src/Data/DataReferenceUpdater.php index fd720564bbf..8ae8f0f1f27 100644 --- a/src/Data/DataReferenceUpdater.php +++ b/src/Data/DataReferenceUpdater.php @@ -104,6 +104,17 @@ protected function fieldsWithReferenceUpdates($fields) return $fields->filter(fn ($field) => in_array($field->type(), $handles)); } + /** + * Process nested fields for reference updates at the given dotted prefix. + * + * @param \Statamic\Fields\Fields $fields + * @param string $dottedPrefix + */ + public function processNestedFields($fields, $dottedPrefix): void + { + $this->recursivelyUpdateFields($fields->all(), $dottedPrefix); + } + /** * Update nested field values by delegating to fieldtype iterateReferenceFields. * @@ -124,10 +135,7 @@ protected function updateNestedFieldValues($fields, $dottedPrefix) $field->fieldtype()->iterateReferenceFields( $fieldData, - function ($nestedFields, $relativePrefix) use ($fieldKey) { - $absolutePrefix = $fieldKey.'.'.$relativePrefix; - $this->recursivelyUpdateFields($nestedFields->all(), $absolutePrefix); - } + new NestedFieldUpdater($this, $fieldKey) ); }); diff --git a/src/Data/NestedFieldUpdater.php b/src/Data/NestedFieldUpdater.php new file mode 100644 index 00000000000..7cf8cbba26f --- /dev/null +++ b/src/Data/NestedFieldUpdater.php @@ -0,0 +1,21 @@ +updater->processNestedFields($fields, $this->fieldKey.'.'.$dottedPrefix); + } +} diff --git a/src/Fieldtypes/Bard.php b/src/Fieldtypes/Bard.php index 33513db7280..cd8f9d9e250 100644 --- a/src/Fieldtypes/Bard.php +++ b/src/Fieldtypes/Bard.php @@ -13,6 +13,7 @@ use Statamic\Facades\Entry; use Statamic\Facades\GraphQL; use Statamic\Facades\Site; +use Statamic\Data\NestedFieldUpdater; use Statamic\Fields\Field; use Statamic\Fields\Fields; use Statamic\Fields\Value; @@ -849,18 +850,18 @@ public function replaceAssetReferences($data, ?string $newValue, string $oldValu return $data; } - public function iterateReferenceFields($data, callable $callback): void + public function iterateReferenceFields($data, NestedFieldUpdater $updater): void { if (! is_array($data)) { return; } - collect($data)->each(function ($set, $setKey) use ($callback) { + collect($data)->each(function ($set, $setKey) use ($updater) { $setHandle = Arr::get($set, 'attrs.values.type'); $fields = Arr::get($this->flattenedSetsConfig(), "{$setHandle}.fields"); if ($setHandle && $fields) { - $callback(new Fields($fields), "{$setKey}.attrs.values."); + $updater->update(new Fields($fields), "{$setKey}.attrs.values."); } }); } diff --git a/src/Fieldtypes/Grid.php b/src/Fieldtypes/Grid.php index 46c0db6adcd..fbb7ee5b3b6 100644 --- a/src/Fieldtypes/Grid.php +++ b/src/Fieldtypes/Grid.php @@ -4,6 +4,7 @@ use Facades\Statamic\Fieldtypes\RowId; use Statamic\Facades\GraphQL; +use Statamic\Data\NestedFieldUpdater; use Statamic\Fields\Fields; use Statamic\Fields\Fieldtype; use Statamic\Fields\Values; @@ -279,7 +280,7 @@ public function toQueryableValue($value) return empty($value) ? null : $value; } - public function iterateReferenceFields($data, callable $callback): void + public function iterateReferenceFields($data, NestedFieldUpdater $updater): void { if (! is_array($data)) { return; @@ -294,7 +295,7 @@ public function iterateReferenceFields($data, callable $callback): void $fields = new Fields($fields); foreach (array_keys($data) as $idx) { - $callback($fields, "{$idx}."); + $updater->update($fields, "{$idx}."); } } } diff --git a/src/Fieldtypes/Group.php b/src/Fieldtypes/Group.php index 2f948d500d9..d90e63bd16b 100644 --- a/src/Fieldtypes/Group.php +++ b/src/Fieldtypes/Group.php @@ -3,6 +3,7 @@ namespace Statamic\Fieldtypes; use Statamic\Facades\GraphQL; +use Statamic\Data\NestedFieldUpdater; use Statamic\Fields\Fields; use Statamic\Fields\Fieldtype; use Statamic\Fields\Values; @@ -202,7 +203,7 @@ public function hasJsDriverDataBinding(): bool return false; } - public function iterateReferenceFields($data, callable $callback): void + public function iterateReferenceFields($data, NestedFieldUpdater $updater): void { $fieldsConfig = $this->config('fields'); @@ -210,6 +211,6 @@ public function iterateReferenceFields($data, callable $callback): void return; } - $callback(new Fields($fieldsConfig), ''); + $updater->update(new Fields($fieldsConfig), ''); } } diff --git a/src/Fieldtypes/Replicator.php b/src/Fieldtypes/Replicator.php index dbb9c953070..d78c5c36813 100644 --- a/src/Fieldtypes/Replicator.php +++ b/src/Fieldtypes/Replicator.php @@ -5,6 +5,7 @@ use Facades\Statamic\Fieldtypes\RowId; use Statamic\Facades\Blink; use Statamic\Facades\GraphQL; +use Statamic\Data\NestedFieldUpdater; use Statamic\Fields\Fields; use Statamic\Fields\Fieldtype; use Statamic\Fields\Values; @@ -320,18 +321,18 @@ public function toQueryableValue($value) return empty($value) ? null : $value; } - public function iterateReferenceFields($data, callable $callback): void + public function iterateReferenceFields($data, NestedFieldUpdater $updater): void { if (! is_array($data)) { return; } - collect($data)->each(function ($set, $setKey) use ($callback) { + collect($data)->each(function ($set, $setKey) use ($updater) { $setHandle = Arr::get($set, 'type'); $fields = Arr::get($this->flattenedSetsConfig(), "{$setHandle}.fields"); if ($setHandle && $fields) { - $callback(new Fields($fields), "{$setKey}."); + $updater->update(new Fields($fields), "{$setKey}."); } }); } diff --git a/src/Fieldtypes/UpdatesReferences.php b/src/Fieldtypes/UpdatesReferences.php index 93390cfb20b..cd776a995ab 100644 --- a/src/Fieldtypes/UpdatesReferences.php +++ b/src/Fieldtypes/UpdatesReferences.php @@ -2,6 +2,7 @@ namespace Statamic\Fieldtypes; +use Statamic\Data\NestedFieldUpdater; use Statamic\Support\Arr; /** @@ -47,9 +48,9 @@ public function replaceTermReferences($data, ?string $newValue, string $oldValue * Override this if your fieldtype contains nested Statamic fields. * * @param mixed $data Current field data - * @param callable $callback fn(Fields $fields, string $relativeDottedPrefix): void + * @param NestedFieldUpdater $updater Call $updater->update(Fields $fields, string $relativeDottedPrefix) */ - public function iterateReferenceFields($data, callable $callback): void + public function iterateReferenceFields($data, NestedFieldUpdater $updater): void { // Default: no nested fields to process } From c22ea33b3e76fdf88496a9bf3f528d5e9cf9d739 Mon Sep 17 00:00:00 2001 From: Maurice Hildebrandt Date: Tue, 10 Feb 2026 16:45:00 +0100 Subject: [PATCH 6/8] Fix lint --- src/Data/NestedFieldUpdater.php | 3 ++- src/Fieldtypes/Bard.php | 2 +- src/Fieldtypes/Grid.php | 2 +- src/Fieldtypes/Group.php | 2 +- src/Fieldtypes/Replicator.php | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Data/NestedFieldUpdater.php b/src/Data/NestedFieldUpdater.php index 7cf8cbba26f..f00118fd9e2 100644 --- a/src/Data/NestedFieldUpdater.php +++ b/src/Data/NestedFieldUpdater.php @@ -9,7 +9,8 @@ class NestedFieldUpdater public function __construct( private DataReferenceUpdater $updater, private string $fieldKey - ) {} + ) { + } /** * Process nested fields for reference updates at the given dotted prefix. diff --git a/src/Fieldtypes/Bard.php b/src/Fieldtypes/Bard.php index cd8f9d9e250..bf43377fda1 100644 --- a/src/Fieldtypes/Bard.php +++ b/src/Fieldtypes/Bard.php @@ -6,6 +6,7 @@ use Facades\Statamic\Fieldtypes\RowId; use Illuminate\Contracts\Validation\DataAwareRule; use Illuminate\Contracts\Validation\ValidationRule; +use Statamic\Data\NestedFieldUpdater; use Statamic\Facades\Asset; use Statamic\Facades\AssetContainer; use Statamic\Facades\Blink; @@ -13,7 +14,6 @@ use Statamic\Facades\Entry; use Statamic\Facades\GraphQL; use Statamic\Facades\Site; -use Statamic\Data\NestedFieldUpdater; use Statamic\Fields\Field; use Statamic\Fields\Fields; use Statamic\Fields\Value; diff --git a/src/Fieldtypes/Grid.php b/src/Fieldtypes/Grid.php index fbb7ee5b3b6..c36478a9ed3 100644 --- a/src/Fieldtypes/Grid.php +++ b/src/Fieldtypes/Grid.php @@ -3,8 +3,8 @@ namespace Statamic\Fieldtypes; use Facades\Statamic\Fieldtypes\RowId; -use Statamic\Facades\GraphQL; use Statamic\Data\NestedFieldUpdater; +use Statamic\Facades\GraphQL; use Statamic\Fields\Fields; use Statamic\Fields\Fieldtype; use Statamic\Fields\Values; diff --git a/src/Fieldtypes/Group.php b/src/Fieldtypes/Group.php index d90e63bd16b..7a820e4f001 100644 --- a/src/Fieldtypes/Group.php +++ b/src/Fieldtypes/Group.php @@ -2,8 +2,8 @@ namespace Statamic\Fieldtypes; -use Statamic\Facades\GraphQL; use Statamic\Data\NestedFieldUpdater; +use Statamic\Facades\GraphQL; use Statamic\Fields\Fields; use Statamic\Fields\Fieldtype; use Statamic\Fields\Values; diff --git a/src/Fieldtypes/Replicator.php b/src/Fieldtypes/Replicator.php index d78c5c36813..8ff18e06573 100644 --- a/src/Fieldtypes/Replicator.php +++ b/src/Fieldtypes/Replicator.php @@ -3,9 +3,9 @@ namespace Statamic\Fieldtypes; use Facades\Statamic\Fieldtypes\RowId; +use Statamic\Data\NestedFieldUpdater; use Statamic\Facades\Blink; use Statamic\Facades\GraphQL; -use Statamic\Data\NestedFieldUpdater; use Statamic\Fields\Fields; use Statamic\Fields\Fieldtype; use Statamic\Fields\Values; From f79635cf69c034d81d84c53fbbf3671506889721 Mon Sep 17 00:00:00 2001 From: Maurice Hildebrandt Date: Tue, 10 Feb 2026 16:54:05 +0100 Subject: [PATCH 7/8] Make dottedPrefix optional and ensure trailing dot in NestedFieldUpdater --- src/Data/NestedFieldUpdater.php | 6 ++++-- src/Fieldtypes/Group.php | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Data/NestedFieldUpdater.php b/src/Data/NestedFieldUpdater.php index f00118fd9e2..84ce1813908 100644 --- a/src/Data/NestedFieldUpdater.php +++ b/src/Data/NestedFieldUpdater.php @@ -15,8 +15,10 @@ public function __construct( /** * Process nested fields for reference updates at the given dotted prefix. */ - public function update(Fields $fields, string $dottedPrefix): void + public function update(Fields $fields, string $dottedPrefix = ''): void { - $this->updater->processNestedFields($fields, $this->fieldKey.'.'.$dottedPrefix); + $prefix = rtrim($this->fieldKey.'.'.$dottedPrefix, '.').'.'; + + $this->updater->processNestedFields($fields, $prefix); } } diff --git a/src/Fieldtypes/Group.php b/src/Fieldtypes/Group.php index 7a820e4f001..c8a7bed26f1 100644 --- a/src/Fieldtypes/Group.php +++ b/src/Fieldtypes/Group.php @@ -211,6 +211,6 @@ public function iterateReferenceFields($data, NestedFieldUpdater $updater): void return; } - $updater->update(new Fields($fieldsConfig), ''); + $updater->update(new Fields($fieldsConfig)); } } From 452976b3b6a2afe117944c868f76aeae6751b870 Mon Sep 17 00:00:00 2001 From: Maurice Hildebrandt Date: Tue, 10 Feb 2026 18:28:49 +0100 Subject: [PATCH 8/8] Fix working copies did not receive data reference updates The current data reference update system only replaced data in the published entry. This commits also replaces the data of a working copy, if it exists. --- src/Data/DataReferenceUpdater.php | 21 +++++++++++++++++ src/Listeners/UpdateAssetReferences.php | 13 +++++++++++ src/Listeners/UpdateTermReferences.php | 30 ++++++++++++++++++++----- src/Revisions/Revisable.php | 12 ++++++++++ 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/Data/DataReferenceUpdater.php b/src/Data/DataReferenceUpdater.php index 8ae8f0f1f27..1eaaf2974ff 100644 --- a/src/Data/DataReferenceUpdater.php +++ b/src/Data/DataReferenceUpdater.php @@ -29,6 +29,11 @@ abstract class DataReferenceUpdater */ protected $updated; + /** + * @var bool + */ + protected $shouldSave = true; + /** * Instantiate data reference updater. * @@ -142,6 +147,18 @@ protected function updateNestedFieldValues($fields, $dottedPrefix) return $this; } + /** + * Disable saving after updating references. + * + * @return $this + */ + public function withoutSaving() + { + $this->shouldSave = false; + + return $this; + } + /** * Check if value is being removed. * @@ -157,6 +174,10 @@ public function isRemovingValue() */ protected function saveItem() { + if (! $this->shouldSave) { + return; + } + GitSubscriber::withoutListeners(function () { $this->item->save(); }); diff --git a/src/Listeners/UpdateAssetReferences.php b/src/Listeners/UpdateAssetReferences.php index a7deae9e6fb..1bd79b107ad 100644 --- a/src/Listeners/UpdateAssetReferences.php +++ b/src/Listeners/UpdateAssetReferences.php @@ -97,6 +97,19 @@ protected function replaceReferences($asset, $originalPath, $newPath) if ($updated) { $hasUpdatedItems = true; } + + if (method_exists($item, 'hasWorkingCopy') && $item->hasWorkingCopy()) { + $workingCopyEntry = $item->makeFromRevision($item->workingCopy()); + + $workingCopyUpdated = AssetReferenceUpdater::item($workingCopyEntry) + ->filterByContainer($container) + ->withoutSaving() + ->updateReferences($originalPath, $newPath); + + if ($workingCopyUpdated) { + $workingCopyEntry->saveToWorkingCopy(); + } + } }); if ($hasUpdatedItems) { diff --git a/src/Listeners/UpdateTermReferences.php b/src/Listeners/UpdateTermReferences.php index 308f5f35425..352a1fb15e5 100644 --- a/src/Listeners/UpdateTermReferences.php +++ b/src/Listeners/UpdateTermReferences.php @@ -71,16 +71,34 @@ protected function replaceReferences($term, $originalSlug, $newSlug) $taxonomy = $term->taxonomy()->handle(); - $updatedItems = $this + $hasUpdatedItems = false; + + $this ->getItemsContainingData() - ->map(function ($item) use ($taxonomy, $originalSlug, $newSlug) { - return TermReferenceUpdater::item($item) + ->each(function ($item) use ($taxonomy, $originalSlug, $newSlug, &$hasUpdatedItems) { + $updated = TermReferenceUpdater::item($item) ->filterByTaxonomy($taxonomy) ->updateReferences($originalSlug, $newSlug); - }) - ->filter(); - if ($updatedItems->isNotEmpty()) { + if ($updated) { + $hasUpdatedItems = true; + } + + if (method_exists($item, 'hasWorkingCopy') && $item->hasWorkingCopy()) { + $workingCopyEntry = $item->makeFromRevision($item->workingCopy()); + + $workingCopyUpdated = TermReferenceUpdater::item($workingCopyEntry) + ->filterByTaxonomy($taxonomy) + ->withoutSaving() + ->updateReferences($originalSlug, $newSlug); + + if ($workingCopyUpdated) { + $workingCopyEntry->saveToWorkingCopy(); + } + } + }); + + if ($hasUpdatedItems) { TermReferencesUpdated::dispatch($term); } } diff --git a/src/Revisions/Revisable.php b/src/Revisions/Revisable.php index 4a35e7d3cb8..8091eaa1d73 100644 --- a/src/Revisions/Revisable.php +++ b/src/Revisions/Revisable.php @@ -64,6 +64,18 @@ public function workingCopy() return $revision->toWorkingCopy(); } + public function saveToWorkingCopy() + { + if (! $this->revisionsEnabled() || ! $this->hasWorkingCopy()) { + return false; + } + + $workingCopy = $this->workingCopy(); + $workingCopy->attributes($this->revisionAttributes()); + + return $workingCopy->save(); + } + public function deleteWorkingCopy() { return optional($this->workingCopy())->delete();