Skip to content

Commit

Permalink
Merge pull request #14056 from craftcms/feature/cms-979-copy-field-va…
Browse files Browse the repository at this point in the history
…lues-from-other-sites

Copy field values from other sites
  • Loading branch information
brandonkelly authored Jan 11, 2025
2 parents 069b9b7 + b317726 commit defb577
Show file tree
Hide file tree
Showing 50 changed files with 645 additions and 221 deletions.
8 changes: 7 additions & 1 deletion CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Release notes for Craft CMS 5.6 (WIP)

### Content Management
- It’s now possible to copy custom field values from other sites. ([#14056](https://github.com/craftcms/cms/pull/14056))
- “Related To”, “Not Related To”, “Author”, and relational field condition rules now allow multiple elements to be specified. ([#16121](https://github.com/craftcms/cms/discussions/16121))
- Improved the styling of inline code fragments. ([#16141](https://github.com/craftcms/cms/pull/16141))
- Improved the styling of attribute previews in card view. ([#16324](https://github.com/craftcms/cms/pull/16324))
Expand All @@ -13,7 +14,7 @@
- Entry indexes now only show table column options and sort options for custom fields associated with the selected sections/entry types within custom entry sources’ conditions.
- Structure views are now available to element indexes on mobile browsers. ([#16190](https://github.com/craftcms/cms/discussions/16190))
- Datepickers now include a dropdown menu for selecting the year. ([#16376](https://github.com/craftcms/cms/pull/16376))
- Custom fields within element edit pages can now have action menus with “Edit field” and “Copy field handle” items. ([#16415](https://github.com/craftcms/cms/pull/16415))
- Custom fields within element edit pages can now have action menus with “Copy value from site…”, “Edit field” and “Copy field handle” items. ([#16415](https://github.com/craftcms/cms/pull/16415), [#14056](https://github.com/craftcms/cms/pull/14056))
- Heads-up displays now reposition themselves on window scroll.

### Accessibility
Expand All @@ -24,6 +25,7 @@
- Improved the accessibility of action menus on the Plugins index page.
- Improved the accessibility of “More” and “Advanced” toggle triggers. ([#16293]](https://github.com/craftcms/cms/pull/16293))
- Improved the accessibility of the Craft Support widget. ([#16293]](https://github.com/craftcms/cms/pull/16293))
- Improved the accessibility of field translatable indicators and tooltips.

### Administration
- Added the “Affiliated Site” native user field. ([#16174](https://github.com/craftcms/cms/pull/16174))
Expand Down Expand Up @@ -121,6 +123,10 @@
- Added `craft\web\User::setImpersonatorId()`.
- Added `craft\web\View::setTwig()`.
- Added `craft\web\twig\variables\Cp::EVENT_REGISTER_READ_ONLY_CP_SETTINGS`. ([#16265](https://github.com/craftcms/cms/pull/16265))
- Added `craft\base\CrossSiteCopyableFieldInterface`. ([#14056](https://github.com/craftcms/cms/pull/14056))
- Added `craft\base\ElementInterface::getIsCrossSiteCopyable()`. ([#14056](https://github.com/craftcms/cms/pull/14056))
- Added `craft\base\Field::copyCrossSiteValue()`. ([#14056](https://github.com/craftcms/cms/pull/14056))
- Added `craft\fieldlayoutelements\BaseField::isCrossSiteCopyable()`.
- `GuzzleHttp\Client` is now instantiated via `Craft::createObject()`. ([#16366](https://github.com/craftcms/cms/pull/16366))
- `craft\elements\NestedElementManager::getIndexHtml()` now supports passing `defaultSort` in the `$config` array. ([#16236](https://github.com/craftcms/cms/discussions/16236))
- `craft\elements\conditions\entries\MatrixFieldConditionRule` is now an alias of `FieldConditionRule`.
Expand Down
4 changes: 2 additions & 2 deletions packages/craftcms-sass/_mixins.scss
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,10 @@ $menuBorderRadius: $mediumBorderRadius;
sans-serif;
}

@mixin fixed-width-font {
@mixin fixed-width-font($size: 0.9em) {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier,
monospace;
font-size: 0.9em !important;
font-size: $size !important;
}

@function toRem($values...) {
Expand Down
10 changes: 10 additions & 0 deletions packages/craftcms-webpack/Craft.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,24 @@ interface ProgressBarInterface {
showProgressBar(): void;
}

type Site = {
handle: string;
id: number;
name: string;
uid: string;
};

// Declare existing variables, mock the things we'll use.
declare var Craft: {
csrfTokenName?: string;
csrfTokenValue?: string;
ProgressBar: ProgressBarInterface;
t(category: string, message: string, params?: object): string;
sendActionRequest(method: string, action: string, options?: object): Promise;
initUiElements($container: JQuery): void;
expandPostArray(arr: object): any;
escapeHtml(str: string);
sites: Site[];
Preview: any;
cp: any;
};
Expand Down
26 changes: 26 additions & 0 deletions src/base/CrossSiteCopyableFieldInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license https://craftcms.github.io/license/
*/

namespace craft\base;

/**
* CrossSiteCopyableFieldInterface defines the common interface to be implemented by field classes
* that wish to support copying their values between sites in a multisite installation.
*
* @author Pixel & Tonic, Inc. <[email protected]>
* @since 5.6.0
*/
interface CrossSiteCopyableFieldInterface
{
/**
* Copies the field’s value from one site to another.
*
* @param ElementInterface $from
* @param ElementInterface $to
*/
public function copyCrossSiteValue(ElementInterface $from, ElementInterface $to): void;
}
24 changes: 24 additions & 0 deletions src/base/Element.php
Original file line number Diff line number Diff line change
Expand Up @@ -2437,6 +2437,12 @@ private static function _indexOrderByColumns(
*/
private $_serializeFields = false;

/**
* @var bool
* @see getIsCrossSiteCopyable()
*/
private bool $_isCrossSiteCopyable;

/**
* @inheritdoc
*/
Expand Down Expand Up @@ -5485,6 +5491,24 @@ public function getCurrentRevision(): ?ElementInterface
return $this->_currentRevision ?: null;
}

/**
* @inheritdoc
*/
public function getIsCrossSiteCopyable(): bool
{
if (!isset($this->_isCrossSiteCopyable)) {
$this->_isCrossSiteCopyable = (
Craft::$app->getIsMultiSite() &&
// check if user can edit this element in other sites
count(ElementHelper::editableSiteIdsForElement($this)) > 1 &&
// also check if the element exists in other sites
!empty(array_diff(array_keys(ElementHelper::siteStatusesForElement($this, true)), [$this->siteId]))
);
}

return $this->_isCrossSiteCopyable;
}

// Indexes, etc.
// -------------------------------------------------------------------------

Expand Down
10 changes: 10 additions & 0 deletions src/base/ElementInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -1670,6 +1670,16 @@ public function setRevisionNotes(?string $notes): void;
*/
public function getCurrentRevision(): ?self;

/**
* Return if the element is copyable between sites.
* Checks if it's a multisite installation, if user can edit the element in other sites,
* and if the element actually exists in other sites.
*
* @return bool
* @since 5.6.0
*/
public function getIsCrossSiteCopyable(): bool;

// Indexes, etc.
// -------------------------------------------------------------------------

Expand Down
9 changes: 9 additions & 0 deletions src/base/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,15 @@ public function copyValue(ElementInterface $from, ElementInterface $to): void
$to->setFieldValue($this->handle, $value);
}

/**
* @see CrossSiteCopyableFieldInterface::copyCrossSiteValue()
* @since 5.6.0
*/
public function copyCrossSiteValue(ElementInterface $from, ElementInterface $to): void
{
$this->copyValue($from, $to);
}

/**
* @inheritdoc
*/
Expand Down
72 changes: 72 additions & 0 deletions src/controllers/ElementsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use craft\events\DefineElementEditorHtmlEvent;
use craft\events\DraftEvent;
use craft\fieldlayoutelements\BaseField;
use craft\fieldlayoutelements\CustomField;
use craft\fields\Matrix;
use craft\helpers\ArrayHelper;
use craft\helpers\Component;
Expand Down Expand Up @@ -483,6 +484,74 @@ public function actionEdit(?ElementInterface $element, ?int $elementId = null):
return $response;
}

/**
* Copies field/attribute values on an element from one site to another.
*
* @return Response
* @since 5.6.0
*/
public function actionCopyValuesFromSite(): Response
{
$this->requireCpRequest();

/** @var Element|Response|null $element */
$element = $this->_element(checkForProvisionalDraft: true);

if ($element instanceof Response) {
return $element;
}

if (!$element || $element->getIsRevision()) {
throw new BadRequestHttpException('No element was identified by the request.');
}

$copyFromSiteId = (int)$this->request->getRequiredBodyParam('fromSiteId');
$this->requirePermission("editSite:$copyFromSiteId");

$layoutElementUid = $this->request->getRequiredBodyParam('layoutElementUid');
$namespace = $this->request->getBodyParam('namespace');

$fromElement = $element::find()
->id($element->id)
->structureId($element->structureId)
->siteId($copyFromSiteId)
->drafts(null)
->provisionalDrafts(null)
->one();

if (!$fromElement) {
throw new UnsupportedSiteException($element, $copyFromSiteId, 'Attempting to copy element content from an unsupported site.');
}

$layoutElement = $element->getFieldLayout()->getElementByUid($layoutElementUid);
if (!$layoutElement instanceof BaseField || !$layoutElement->isCrossSiteCopyable($element)) {
throw new BadRequestHttpException("Invalid layout element UUID: $layoutElementUid");
}
if ($layoutElement instanceof CustomField) {
$layoutElement->getField()->copyCrossSiteValue($fromElement, $element);
} else {
$attribute = $layoutElement->attribute();
$element->$attribute = $fromElement->$attribute;
}

$view = $this->getView();
$html = $view->namespaceInputs(fn() => $layoutElement->formHtml($element), $namespace);

if ($html) {
$html = Html::modifyTagAttributes($html, [
'data' => [
'layout-element' => $layoutElement->uid,
],
]);
}

return $this->_asSuccess(Craft::t('app', 'Field value copied.'), $element, [
'fieldHtml' => $html,
'headHtml' => $view->getHeadHtml(),
'bodyHtml' => $view->getBodyHtml(),
]);
}

/**
* Returns an element revisions index screen.
*
Expand Down Expand Up @@ -2279,6 +2348,9 @@ private function _elementById(
->siteId($siteId)
->preferSites($preferSites)
->unique()
->drafts(null)
->provisionalDrafts(null)
->revisions(null)
->status(null)
->one();

Expand Down
55 changes: 51 additions & 4 deletions src/fieldlayoutelements/BaseField.php
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,40 @@ public function formHtml(?ElementInterface $element = null, bool $static = false
$instructions = $this->instructions($element, $static);
$tip = $this->tip($element, $static);
$warning = $this->warning($element, $static);
$translatable = $this->translatable($element, $static);
$actionMenuItems = $this->actionMenuItems($element, $static);

if (
$this->uid &&
$element?->id &&
!$static &&
$this->isCrossSiteCopyable($element) &&
$this->translatable($element, $static) &&
$element->getIsCrossSiteCopyable()
) {
// prepare namespace for the purpose of copying
$namespace = Craft::$app->getView()->getNamespace();

$actionMenuItems = array_filter([
[
'icon' => 'clone',
'label' => Craft::t('app', 'Copy value from site…'),
'attributes' => [
'data' => [
'cross-site-copy' => true,
'element-id' => $element->id,
'layout-element' => $this->uid,
'label' => $label,
'namespace' => ($namespace && $namespace !== 'field')
? StringHelper::removeRight($namespace, '[fields]')
: null,
],
],
],
!empty($actionMenuItems) ? ['type' => 'hr'] : null,
...$actionMenuItems,
]);
}

return Cp::fieldHtml($inputHtml, [
'fieldset' => $this->useFieldset(),
Expand All @@ -395,9 +429,9 @@ public function formHtml(?ElementInterface $element = null, bool $static = false
'tip' => $tip !== null ? Html::encode($tip) : null,
'warning' => $warning !== null ? Html::encode($warning) : null,
'orientation' => $this->orientation($element, $static),
'translatable' => $this->translatable($element, $static),
'translatable' => $translatable,
'translationDescription' => $this->translationDescription($element, $static),
'actionMenuItems' => $this->actionMenuItems(),
'actionMenuItems' => $actionMenuItems,
'errors' => !$static ? $this->errors($element) : [],
]);
}
Expand Down Expand Up @@ -780,15 +814,28 @@ protected function translationDescription(?ElementInterface $element = null, boo
return null;
}

/**
* Returns whether field supports copying its value across sites.
*
* @param ElementInterface $element
* @return bool
*/
public function isCrossSiteCopyable(ElementInterface $element): bool
{
return false;
}

/**
* Returns any action menu items that should be shown for the field.
*
* See [[\craft\helpers\Cp::disclosureMenu()]] for documentation on supported item properties.
* See [[\craft\helpers\Cp::disclosureMenu()]] for documentation on supported item properties.
*
* @param ElementInterface|null $element The element the form is being rendered for
* @param bool $static Whether the form should be static (non-interactive)
* @return array
* @since 5.6.0
*/
protected function actionMenuItems(): array
protected function actionMenuItems(?ElementInterface $element = null, bool $static = false): array
{
return [];
}
Expand Down
11 changes: 10 additions & 1 deletion src/fieldlayoutelements/CustomField.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

use Craft;
use craft\base\Actionable;
use craft\base\CrossSiteCopyableFieldInterface;
use craft\base\ElementInterface;
use craft\base\FieldInterface;
use craft\base\PreviewableFieldInterface;
Expand Down Expand Up @@ -428,7 +429,15 @@ protected function translationDescription(?ElementInterface $element = null, boo
/**
* @inheritdoc
*/
protected function actionMenuItems(): array
public function isCrossSiteCopyable(ElementInterface $element): bool
{
return $this->_field instanceof CrossSiteCopyableFieldInterface && $this->_field->getIsTranslatable($element);
}

/**
* @inheritdoc
*/
protected function actionMenuItems(?ElementInterface $element = null, bool $static = false): array
{
if ($this->_field instanceof Actionable) {
$items = $this->_field->getActionMenuItems();
Expand Down
8 changes: 8 additions & 0 deletions src/fieldlayoutelements/TitleField.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,12 @@ public function defaultLabel(?ElementInterface $element = null, bool $static = f
{
return Craft::t('app', 'Title');
}

/**
* @inheritdoc
*/
public function isCrossSiteCopyable(ElementInterface $element): bool
{
return true;
}
}
8 changes: 8 additions & 0 deletions src/fieldlayoutelements/assets/AltField.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,12 @@ protected function translationDescription(?ElementInterface $element = null, boo

return ElementHelper::translationDescription($element->getVolume()->altTranslationMethod);
}

/**
* @inheritdoc
*/
public function isCrossSiteCopyable(ElementInterface $element): bool
{
return true;
}
}
Loading

0 comments on commit defb577

Please sign in to comment.