From f49944726c7da0b505c3595f2973c88816f89d4a Mon Sep 17 00:00:00 2001 From: Rias Date: Mon, 21 Sep 2026 17:44:01 +0200 Subject: [PATCH 1/2] Restore cross-site field value copying Amp-Thread-ID: https://ampcode.com/threads/T-01a0c465-a3bd-74bc-8a9f-8a1b9b3d9acb --- .../js/modules/forms/CrossSiteCopyModal.vue | 66 +++++++ resources/js/modules/forms/FieldNode.vue | 147 +++++++++++++--- .../forms/FieldNodeCrossSiteCopy.test.ts | 162 ++++++++++++++++++ src/FieldLayout/LayoutElements/BaseField.php | 69 +++++++- .../Elements/CopyElementValuesController.php | 2 + .../CopyElementValuesControllerTest.php | 51 +++++- 6 files changed, 470 insertions(+), 27 deletions(-) create mode 100644 resources/js/modules/forms/CrossSiteCopyModal.vue create mode 100644 resources/js/modules/forms/FieldNodeCrossSiteCopy.test.ts diff --git a/resources/js/modules/forms/CrossSiteCopyModal.vue b/resources/js/modules/forms/CrossSiteCopyModal.vue new file mode 100644 index 00000000000..7d03480007b --- /dev/null +++ b/resources/js/modules/forms/CrossSiteCopyModal.vue @@ -0,0 +1,66 @@ + + + diff --git a/resources/js/modules/forms/FieldNode.vue b/resources/js/modules/forms/FieldNode.vue index 5f809aefdea..770496b05dc 100644 --- a/resources/js/modules/forms/FieldNode.vue +++ b/resources/js/modules/forms/FieldNode.vue @@ -1,7 +1,10 @@ diff --git a/resources/js/modules/forms/FieldNodeCrossSiteCopy.test.ts b/resources/js/modules/forms/FieldNodeCrossSiteCopy.test.ts new file mode 100644 index 00000000000..8b91fe875fb --- /dev/null +++ b/resources/js/modules/forms/FieldNodeCrossSiteCopy.test.ts @@ -0,0 +1,162 @@ +import {actionClient} from '@craftcms/ui'; +import {createApp, defineComponent, h, nextTick, reactive} from 'vue'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vite-plus/test'; +import {FormFailure} from './runtime'; +import ActionMenuNode from './ActionMenuNode.vue'; +import FieldNode from './FieldNode.vue'; +import type {FormChange, FormNodePayload, FormValues} from './types'; + +const TextControl = defineComponent({ + props: ['value'], + render() { + return h('input', {value: this.value, 'data-text-control': ''}); + }, +}); + +describe('FieldNode cross-site copying', () => { + let app: ReturnType; + let container: HTMLElement; + const post = vi.spyOn(actionClient, 'post'); + + const node = { + type: 'Field', + component: 'craft:field', + props: {label: 'Summary', layoutUid: 'layout-uid'}, + control: { + type: 'Text', + component: 'craft:text', + path: ['editor', 'fields', 'summary'], + deltaGroup: ['editor', 'fields', 'summary'], + mode: 'editable', + props: {}, + }, + children: [ + { + type: 'ActionMenu', + component: 'craft:action-menu', + uid: 'field-actions:editor.fields.summary:menu', + props: { + label: 'Field actions', + items: [ + { + type: 'button', + label: 'Copy value from site…', + action: { + type: 'event', + name: 'craft:copy-value-from-site', + detail: { + elementType: 'CraftCms\\Cms\\Entry\\Elements\\Entry', + elementId: 42, + draftId: null, + siteId: 1, + layoutElementUid: 'layout-uid', + label: 'Summary', + siteIds: [2], + }, + }, + }, + ], + }, + }, + ], + } as FormNodePayload; + + beforeEach(() => { + post.mockReset(); + container = document.createElement('div'); + document.body.append(container); + window.Craft = { + sites: [ + {id: 1, uid: 'primary', handle: 'primary', name: 'Primary'}, + {id: 2, uid: 'secondary', handle: 'secondary', name: 'Secondary'}, + ], + cp: { + displayNotice: vi.fn(), + displayError: vi.fn(), + }, + } as never; + }); + + afterEach(() => { + app?.unmount(); + container.remove(); + delete (window as {Craft?: unknown}).Craft; + }); + + it('copies the selected site value into the field', async () => { + const values = reactive({ + editor: {fields: {summary: 'Original'}}, + }); + const changed = vi.fn<(change: FormChange) => void>(); + post.mockResolvedValue({ + data: { + message: 'Field value copied.', + field: node, + values: {editor: {fields: {summary: 'Copied'}}}, + }, + }); + + app = createApp({ + setup() { + return () => + h(FieldNode, { + node, + values, + errors: [], + touchedPaths: new Set(), + scope: ['editor'], + refreshable: false, + onChange: changed, + }); + }, + }); + app.component('craft:text', TextControl); + app.component('craft:action-menu', ActionMenuNode); + app.provide(FormFailure, vi.fn()); + app.mount(container); + + await vi.waitFor(() => { + expect( + [...container.querySelectorAll('craft-action-item')].some( + (item) => item.textContent === 'Copy value from site…' + ) + ).toBe(true); + }); + [...container.querySelectorAll('craft-action-item')] + .find((item) => item.textContent === 'Copy value from site…')! + .click(); + + await vi.waitFor(() => { + expect( + document.body.querySelector('[data-cross-site-copy-modal]') + ).not.toBeNull(); + }); + expect( + ( + document.body.querySelector('craft-pane') as HTMLElement & { + label: string; + } + ).label + ).toBe('Copy “Summary” value'); + const select = document.body.querySelector( + '[data-cross-site-copy-modal] select' + )!; + expect([...select.options].map((option) => option.textContent)).toEqual([ + 'Secondary', + ]); + select.value = '2'; + select.dispatchEvent(new Event('change', {bubbles: true})); + select.closest('form')!.requestSubmit(); + + await vi.waitFor(() => expect(post).toHaveBeenCalledOnce()); + await nextTick(); + + expect( + container.querySelector('[data-text-control]')!.value + ).toBe('Copied'); + expect(changed).toHaveBeenCalledOnce(); + expect(window.Craft.cp!.displayNotice).toHaveBeenCalledWith( + 'Field value copied.' + ); + }); +}); diff --git a/src/FieldLayout/LayoutElements/BaseField.php b/src/FieldLayout/LayoutElements/BaseField.php index 3c6249668ea..6dc08468d77 100644 --- a/src/FieldLayout/LayoutElements/BaseField.php +++ b/src/FieldLayout/LayoutElements/BaseField.php @@ -7,6 +7,7 @@ use CraftCms\Cms\Cp\Icons; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\ElementAttributeRenderer; +use CraftCms\Cms\Element\ElementHelper; use CraftCms\Cms\Field\Icon; use CraftCms\Cms\FieldLayout\Events\FieldLayoutComponentActionMenuItemsResolving; use CraftCms\Cms\FieldLayout\FieldLayoutElement; @@ -487,7 +488,73 @@ protected function resolveActionMenuItems(FieldLayoutElementContext $context): a mode: $context->mode, )); - return $event->items; + $copyAction = $this->crossSiteCopyAction($context); + if ($copyAction === null) { + return $event->items; + } + + return [ + $copyAction, + ...($event->items === [] ? [] : [['type' => 'hr']]), + ...$event->items, + ]; + } + + /** @return array|null */ + private function crossSiteCopyAction(FieldLayoutElementContext $context): ?array + { + $element = $context->element; + + if ( + ! $this->uid || + ! $element?->id || + $context->mode !== ControlMode::Editable || + ! $this->isCrossSiteCopyable($element) || + ! $this->translatable($element) || + ! $element->getIsCrossSiteCopyable() + ) { + return null; + } + + $namespace = InputNamespace::get(); + if (! $namespace || $namespace === 'fields') { + $namespace = null; + } elseif (str_ends_with($namespace, '[fields]')) { + $namespace = substr($namespace, 0, -strlen('[fields]')); + } + + $siteIds = array_values(array_filter( + array_keys(ElementHelper::siteStatusesForElement($element, true)), + fn (int $siteId): bool => $siteId !== $element->siteId, + )); + $label = $this->showLabel() ? $this->label() : null; + + return [ + 'icon' => 'clone', + 'label' => t('Copy value from site…'), + 'action' => [ + 'type' => 'event', + 'name' => 'craft:copy-value-from-site', + 'detail' => [ + 'elementType' => $element::class, + 'elementId' => $element->id, + 'draftId' => $element->draftId, + 'siteId' => $element->siteId, + 'layoutElementUid' => $this->uid, + 'label' => $label, + 'siteIds' => $siteIds, + ], + ], + 'attributes' => [ + 'data' => [ + 'cross-site-copy' => true, + 'element-id' => $element->id, + 'layout-element' => $this->uid, + 'label' => $label, + 'namespace' => $namespace, + ], + ], + ]; } /** @return list> */ diff --git a/src/Http/Controllers/Elements/CopyElementValuesController.php b/src/Http/Controllers/Elements/CopyElementValuesController.php index 6f56f9a8a69..d7e76cad90e 100644 --- a/src/Http/Controllers/Elements/CopyElementValuesController.php +++ b/src/Http/Controllers/Elements/CopyElementValuesController.php @@ -108,6 +108,8 @@ public function __invoke(): Response return new ElementResponse()->success($element, t('Field value copied.'), [ 'fieldHtml' => $html, + 'field' => $node, + 'values' => $payload->values, 'headHtml' => HtmlStack::headHtml(), 'bodyHtml' => HtmlStack::bodyHtml(), ]); diff --git a/tests/Feature/Http/Controllers/Elements/CopyElementValuesControllerTest.php b/tests/Feature/Http/Controllers/Elements/CopyElementValuesControllerTest.php index 96b9ff95b27..8bcc06e32a1 100644 --- a/tests/Feature/Http/Controllers/Elements/CopyElementValuesControllerTest.php +++ b/tests/Feature/Http/Controllers/Elements/CopyElementValuesControllerTest.php @@ -8,6 +8,9 @@ use CraftCms\Cms\Entry\Models\EntryType; use CraftCms\Cms\Field\Models\Field; use CraftCms\Cms\Field\PlainText; +use CraftCms\Cms\FieldLayout\FieldLayoutElementContext; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\ActionMenu; use CraftCms\Cms\Http\Controllers\Elements\CopyElementValuesController; use CraftCms\Cms\Section\Models\Section; use CraftCms\Cms\Section\Models\SectionSiteSettings; @@ -180,7 +183,7 @@ ])->assertBadRequest(); }); -it('copies a title field value from another site and returns updated field html', function () { +it('does not offer to copy non-translatable field values', function () { $entry = EntryModel::factory() ->forSection($this->section) ->forEntryType($this->entryType) @@ -190,35 +193,73 @@ ]); localizeEntry($entry, $this->secondarySite->id); + $layoutElement = $entry->getFieldLayout()->getCustomFieldElements()[0]; + $layoutElement->getField()->translationMethod = 'none'; + + expect(fieldActionLabels($entry))->not->toContain(t('Copy value from site…')); +}); + +it('copies a field value from another site and returns the updated field', function () { + $entry = EntryModel::factory() + ->forSection($this->section) + ->forEntryType($this->entryType) + ->createElement([ + 'title' => 'Primary Title', + 'slug' => 'primary-title', + ]); + localizeEntry($entry, $this->secondarySite->id); + $layoutElementUid = customFieldUid($entry); + + expect(fieldActionLabels($entry))->toContain(t('Copy value from site…')); + /** @var Entry $secondaryEntry */ $secondaryEntry = Entry::find() ->id($entry->id) ->siteId($this->secondarySite->id) ->status(null) ->one(); - $secondaryEntry->setFieldValue('copyField', 'Secondary field value'); + $secondaryEntry->title = 'Secondary Title'; + $secondaryEntry->slug = 'secondary-title'; + $secondaryEntry->setFieldValueFromRequest('copyField', 'Secondary field value'); $secondaryEntry->setAuthorIds([auth()->id()]); - Elements::saveElement($secondaryEntry); + expect(Elements::saveElement($secondaryEntry))->toBeTrue(); postJson(action(CopyElementValuesController::class), [ 'elementType' => Entry::class, 'elementId' => $entry->id, 'siteId' => $entry->siteId, 'fromSiteId' => $this->secondarySite->id, - 'layoutElementUid' => customFieldUid($entry), + 'layoutElementUid' => $layoutElementUid, 'namespace' => 'copyNamespace', ])->assertOk() ->assertJson(fn (AssertableJson $json) => $json ->where('message', t('Field value copied.')) ->where('modelName', 'element') ->where('fieldHtml', fn (string $html) => $html !== '' - && str_contains($html, 'data-layout-element="'.customFieldUid($entry).'"')) + && str_contains($html, 'data-layout-element="'.$layoutElementUid.'"')) + ->where('field.control.path', ['copyNamespace', 'fields', 'copyField']) + ->where('values.copyNamespace.fields.copyField', 'Secondary field value') ->has('headHtml') ->has('bodyHtml') ->etc() ); }); +/** @return list */ +function fieldActionLabels(Entry $entry): array +{ + $layoutElement = $entry->getFieldLayout()->getCustomFieldElements()[0]; + $fieldNode = $layoutElement->formNode(new FieldLayoutElementContext($entry, new FormContext)); + + return collect($fieldNode->children()) + ->filter(fn ($node) => $node instanceof ActionMenu) + ->flatMap(fn (ActionMenu $node) => $node->props()['items']) + ->pluck('label') + ->filter() + ->values() + ->all(); +} + function customFieldUid(Entry $entry): string { $layoutElement = $entry->getFieldLayout()->getCustomFieldElements()[0] ?? null; From 6c129b46395d86b2bd8f291cecff46164d87bab3 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 22 Sep 2026 10:41:04 -0700 Subject: [PATCH 2/2] Release note --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37a8a465542..da0f6366b41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ ### Fields - `CraftCms\Cms\Field\Contracts\FieldInterface::modifyQuery()` now accepts an `Illuminate\Database\Query\Builder` object for its `$query` argument, and has a new `CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface $elementQuery` argument, and a `void` return type. ([#19562](https://github.com/craftcms/cms/pull/19562), [#19585](https://github.com/craftcms/cms/pull/19585)) - Moved legacy relation-field settings HTML and entry-title input HTML into the Yii adapter. ([#19591](https://github.com/craftcms/cms/pull/19591)) +- Restored “Copy value from site” functionality. ([#19683](https://github.com/craftcms/cms/pull/19683)) - Fixed multiple bugs with Table fields, including new columns moving or defaulting to Checkbox, settings rows not being draggable, and element edit forms failing when the field value was null. ([#19687](https://github.com/craftcms/cms/pull/19687)) ### Entries & Sections