{{ error }}
+
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;