diff --git a/src/base/CopyableFieldInterface.php b/src/base/CopyableFieldInterface.php new file mode 100644 index 00000000000..7d00672c2ce --- /dev/null +++ b/src/base/CopyableFieldInterface.php @@ -0,0 +1,36 @@ + + * @since 4.4.0 + */ +interface CopyableFieldInterface +{ + /** + * Returns whether the field is copyable between sites. + * + * @param ElementInterface|null $element + * @return bool + */ + public function getIsCopyable(?ElementInterface $element = null): bool; + + /** + * Copies field’s value from one element to another. + * + * @param ElementInterface $from + * @param ElementInterface $to + * @return bool + * @since 5.0.0 + */ + public function copyValueBetweenSites(ElementInterface $from, ElementInterface $to): bool; +} diff --git a/src/base/Element.php b/src/base/Element.php index 95d2fb8a942..908cd5e3030 100644 --- a/src/base/Element.php +++ b/src/base/Element.php @@ -3477,6 +3477,19 @@ protected function safeActionMenuItems(): array ]; } + if (ElementHelper::supportsFieldCopying($this)) { + $copyId = sprintf('action-copy-content-%s', mt_rand()); + $items[] = [ + 'id' => $copyId, + 'icon' => 'world', + 'label' => Craft::t('app', 'Copy content from site'), + 'type' => MenuItemType::Button, + 'attributes' => [ + 'class' => ['copy-all-from-site'], + ], + ]; + } + // Edit if (Craft::$app->getElements()->canView($this)) { $editId = sprintf('action-edit-%s', mt_rand()); diff --git a/src/base/Field.php b/src/base/Field.php index abd9e270003..4ffe4848d85 100644 --- a/src/base/Field.php +++ b/src/base/Field.php @@ -474,6 +474,30 @@ public function getTranslationKey(ElementInterface $element): string return ElementHelper::translationKey($element, $this->translationMethod, $this->translationKeyFormat); } + /** + * Returns whether the field is copyable between sites. + */ + public function getIsCopyable(?ElementInterface $element = null): bool + { + return false; + } + + /** + * @inheritdoc + */ + public function copyValueBetweenSites(ElementInterface $from, ElementInterface $to): bool + { + $fromValue = $this->serializeValue($from->getFieldValue($this->handle), $from); + $toValue = $this->serializeValue($to->getFieldValue($this->handle), $to); + + if ($fromValue != $toValue) { + $to->setFieldValue($this->handle, $fromValue); + return true; + } + + return false; + } + /** * @inheritdoc */ @@ -719,7 +743,7 @@ public function serializeValue(mixed $value, ?ElementInterface $element): mixed } /** - * @inheritdoc + * Copies field’s value from one element to another. */ public function copyValue(ElementInterface $from, ElementInterface $to): void { diff --git a/src/controllers/ElementsController.php b/src/controllers/ElementsController.php index e2fb8c91e76..92795292cd9 100644 --- a/src/controllers/ElementsController.php +++ b/src/controllers/ElementsController.php @@ -29,9 +29,11 @@ use craft\i18n\Locale; use craft\models\ElementActivity; use craft\models\FieldLayoutForm; +use craft\models\Site; use craft\web\Controller; use craft\web\CpScreenResponseBehavior; use craft\web\View; +use Illuminate\Support\Collection; use Throwable; use yii\helpers\Markdown; use yii\web\BadRequestHttpException; @@ -527,6 +529,129 @@ public function actionEdit(?ElementInterface $element, ?int $elementId = null): return $response; } + /** + * Returns result of copying field value from another site + * + * @return Response + * @throws BadRequestHttpException + * @throws ForbiddenHttpException + * @throws ServerErrorHttpException + * @throws Throwable + * @throws \craft\errors\ElementNotFoundException + * @throws \craft\errors\InvalidFieldException + * @throws \craft\errors\MissingComponentException + * @throws \yii\base\Exception + * @throws \yii\db\Exception + */ + public function actionCopyFieldValuesFromSite(): Response + { + $this->requireCpRequest(); + + /** @var Element|null $element */ + $element = $this->_element(); + + if (!$element || $element->getIsRevision()) { + throw new BadRequestHttpException('No element was identified by the request.'); + } + + // if $fieldHandle is null, we're copying all element fields + $fieldHandle = $this->request->getBodyParam('copyFieldHandle', null); + $copyFromSiteId = $this->request->getRequiredBodyParam('copyFromSiteId'); + $isFullPage = $this->request->getBodyParam('isFullPage'); + + if ($fieldHandle === '' || empty($copyFromSiteId)) { + throw new BadRequestHttpException("Request missing required param"); + } + + $elementsService = Craft::$app->getElements(); + $user = static::currentUser(); + + // if we can't create drafts for this element, just bail; + // this check is to both check if user can create drafts AND if element supports them + if (!$elementsService->canCreateDrafts($element, $user)) { + throw new ForbiddenHttpException('Can‘t create drafts for this element.'); + } + + // check if this entry exists for other sites + if (empty($siteIdsForElement = $elementsService->getEnabledSiteIdsForElement($element->id))) { + $errorMsg = Craft::t('app', 'Couldn’t find this {type} on other sites.', [ + 'type' => $element::lowerDisplayName(), + ]); + + Craft::$app->session->setError($errorMsg); + + return $this->_asFailure($element, $errorMsg); + } + + if (!in_array($copyFromSiteId, $siteIdsForElement, false)) { + $errorMsg = Craft::t('app', 'Couldn’t find this {type} on the site you selected.', [ + 'type' => $element::lowerDisplayName(), + ]); + + Craft::$app->session->setError($errorMsg); + + return $this->_asFailure($element, $errorMsg); + } + + $result = $elementsService->copyFieldValuesFromSite($element, $fieldHandle, $copyFromSiteId); + if ($result['success'] === false) { + if ($isFullPage) { + Craft::$app->session->setError($result['message']); + } + + return $this->_asFailure($result['element'], $result['message']); + } + + if (!$this->request->getAcceptsJson()) { + // Tell all browser windows about the element save + Craft::$app->getSession()->broadcastToJs([ + 'event' => 'saveElement', + 'id' => $element->id, + ]); + } + + if ($isFullPage) { + Craft::$app->session->setNotice($result['message']); + } + + return $this->_asSuccess($result['message'], $result['element']); + } + + public function actionCopyFromSiteForm(): Response + { + $this->requireAcceptsJson(); + $this->requireCpRequest(); + + $viewMode = Craft::$app->getRequest()->getParam('viewMode', 'modal'); + $copyFieldHandle = Craft::$app->getRequest()->getParam('copyFieldHandle'); + $template = $viewMode === 'modal' ? '_special/copy-content-modal.twig' : '_special/copy-content-fields.twig'; + + $element = $this->_element(); + $sitesService = Craft::$app->getSites(); + + $siteOptions = Collection::make(ElementHelper::editableSiteIdsForElement($element)) + ->filter(fn(int $siteId) => $siteId !== $this->_siteId) + ->map(fn(int $siteId) => $sitesService->getSiteById($siteId)) + ->map(fn(Site $site) => [ + 'label' => $site->name, + 'value' => $site->id, + ]); + + $view = $this->getView(); + $html = $view->renderTemplate($template, [ + 'viewMode' => $viewMode, + 'siteOptions' => $siteOptions, + 'copyFieldHandle' => $copyFieldHandle, + 'elementId' => $element->id, + 'draftId' => $this->_draftId, + 'provisional' => $this->_provisional, + ]); + + return $this->asJson([ + 'html' => $html, + ]); + } + /** * Returns an element revisions index screen. * diff --git a/src/fieldlayoutelements/BaseField.php b/src/fieldlayoutelements/BaseField.php index 379752abef5..016f333e147 100644 --- a/src/fieldlayoutelements/BaseField.php +++ b/src/fieldlayoutelements/BaseField.php @@ -340,6 +340,7 @@ public function formHtml(?ElementInterface $element = null, bool $static = false 'orientation' => $this->orientation($element, $static), 'translatable' => $this->translatable($element, $static), 'translationDescription' => $this->translationDescription($element, $static), + 'copyable' => $this->isCopyable($element, $static), 'errors' => !$static ? $this->errors($element) : [], ]); } @@ -686,4 +687,16 @@ protected function translationDescription(?ElementInterface $element = null, boo { return null; } + + /** + * Returns whether field supports copying its value across sites + * + * @param ElementInterface|null $element + * @param bool $static + * @return bool + */ + public function isCopyable(?ElementInterface $element = null, bool $static = false): bool + { + return false; + } } diff --git a/src/fieldlayoutelements/CustomField.php b/src/fieldlayoutelements/CustomField.php index 7d8aa214e83..bc94e86464f 100644 --- a/src/fieldlayoutelements/CustomField.php +++ b/src/fieldlayoutelements/CustomField.php @@ -8,6 +8,7 @@ namespace craft\fieldlayoutelements; use Craft; +use craft\base\CopyableFieldInterface; use craft\base\ElementInterface; use craft\base\FieldInterface; use craft\base\PreviewableFieldInterface; @@ -378,4 +379,16 @@ protected function translationDescription(?ElementInterface $element = null, boo { return $this->_field->getTranslationDescription($element); } + + /** + * @inheritdoc + */ + public function isCopyable(?ElementInterface $element = null, bool $static = false): bool + { + if ($this->_field instanceof CopyableFieldInterface) { + return $this->_field->getIsCopyable($element); + } + + return false; + } } diff --git a/src/fieldlayoutelements/entries/EntryTitleField.php b/src/fieldlayoutelements/entries/EntryTitleField.php index b3f09cb23f1..9adb33d266f 100644 --- a/src/fieldlayoutelements/entries/EntryTitleField.php +++ b/src/fieldlayoutelements/entries/EntryTitleField.php @@ -79,4 +79,12 @@ public function inputHtml(?ElementInterface $element = null, bool $static = fals return parent::inputHtml($element, $static); } + + /** + * @inheritdoc + */ + public function isCopyable(?ElementInterface $element = null, bool $static = false): bool + { + return $this->translatable($element) && ElementHelper::supportsFieldCopying($element); + } } diff --git a/src/fields/BaseOptionsField.php b/src/fields/BaseOptionsField.php index b9322195821..f25ca11cb17 100644 --- a/src/fields/BaseOptionsField.php +++ b/src/fields/BaseOptionsField.php @@ -8,6 +8,7 @@ namespace craft\fields; use Craft; +use craft\base\CopyableFieldInterface; use craft\base\ElementInterface; use craft\base\Field; use craft\base\PreviewableFieldInterface; @@ -21,6 +22,7 @@ use craft\gql\resolvers\OptionField as OptionFieldResolver; use craft\helpers\ArrayHelper; use craft\helpers\Cp; +use craft\helpers\ElementHelper; use craft\helpers\Json; use craft\helpers\StringHelper; use GraphQL\Type\Definition\Type; @@ -32,7 +34,7 @@ * @author Pixel & Tonic, Inc. * @since 3.0.0 */ -abstract class BaseOptionsField extends Field implements PreviewableFieldInterface +abstract class BaseOptionsField extends Field implements PreviewableFieldInterface, CopyableFieldInterface { /** * @event DefineInputOptionsEvent Event triggered when defining the options for the field's input. @@ -504,6 +506,14 @@ public function getContentGqlMutationArgumentType(): Type|array ]; } + /** + * @inheritdoc + */ + public function getIsCopyable(?ElementInterface $element = null): bool + { + return $this->getIsTranslatable($element) && ElementHelper::supportsFieldCopying($element); + } + /** * Returns the label for the Options setting. * diff --git a/src/fields/BaseRelationField.php b/src/fields/BaseRelationField.php index a3269b9f4f0..776dd019867 100644 --- a/src/fields/BaseRelationField.php +++ b/src/fields/BaseRelationField.php @@ -9,6 +9,7 @@ use Craft; use craft\base\conditions\ConditionInterface; +use craft\base\CopyableFieldInterface; use craft\base\EagerLoadingFieldInterface; use craft\base\Element; use craft\base\ElementInterface; @@ -50,7 +51,7 @@ * @author Pixel & Tonic, Inc. * @since 3.0.0 */ -abstract class BaseRelationField extends Field implements InlineEditableFieldInterface, EagerLoadingFieldInterface +abstract class BaseRelationField extends Field implements InlineEditableFieldInterface, EagerLoadingFieldInterface, CopyableFieldInterface { /** * @event ElementCriteriaEvent The event that is triggered when defining the selection criteria for this field. @@ -918,6 +919,14 @@ public function getContentGqlMutationArgumentType(): Type|array ]; } + /** + * @inheritdoc + */ + public function getIsCopyable(?ElementInterface $element = null): bool + { + return $this->getIsTranslatable($element) && ElementHelper::supportsFieldCopying($element); + } + // Events // ------------------------------------------------------------------------- diff --git a/src/fields/Color.php b/src/fields/Color.php index c83eb1ec433..2c416fd7edd 100644 --- a/src/fields/Color.php +++ b/src/fields/Color.php @@ -8,11 +8,13 @@ namespace craft\fields; use Craft; +use craft\base\CopyableFieldInterface; use craft\base\ElementInterface; use craft\base\Field; use craft\base\InlineEditableFieldInterface; use craft\fields\data\ColorData; use craft\helpers\Cp; +use craft\helpers\ElementHelper; use craft\helpers\Html; use craft\validators\ColorValidator; use yii\db\Schema; @@ -23,7 +25,7 @@ * @author Pixel & Tonic, Inc. * @since 3.0.0 */ -class Color extends Field implements InlineEditableFieldInterface +class Color extends Field implements InlineEditableFieldInterface, CopyableFieldInterface { /** * @inheritdoc @@ -162,4 +164,12 @@ public function getPreviewHtml(mixed $value, ElementInterface $element): string return "
" . "
{$value->getHex()}
"; } + + /** + * @inheritdoc + */ + public function getIsCopyable(?ElementInterface $element = null): bool + { + return $this->getIsTranslatable($element) && ElementHelper::supportsFieldCopying($element); + } } diff --git a/src/fields/Country.php b/src/fields/Country.php index 4fcb8b3166e..4dc05d846a8 100644 --- a/src/fields/Country.php +++ b/src/fields/Country.php @@ -8,11 +8,13 @@ namespace craft\fields; use Craft; +use craft\base\CopyableFieldInterface; use craft\base\ElementInterface; use craft\base\Field; use craft\base\InlineEditableFieldInterface; use craft\fields\conditions\CountryFieldConditionRule; use craft\helpers\Cp; +use craft\helpers\ElementHelper; use yii\db\Schema; /** @@ -21,7 +23,7 @@ * @author Pixel & Tonic, Inc. * @since 4.6.0 */ -class Country extends Field implements InlineEditableFieldInterface +class Country extends Field implements InlineEditableFieldInterface, CopyableFieldInterface { /** * @inheritdoc @@ -90,4 +92,12 @@ public function getPreviewHtml(mixed $value, ElementInterface $element): string $list = Craft::$app->getAddresses()->getCountryRepository()->getList(Craft::$app->language); return $list[$value] ?? $value; } + + /** + * @inheritdoc + */ + public function getIsCopyable(?ElementInterface $element = null): bool + { + return $this->getIsTranslatable($element) && ElementHelper::supportsFieldCopying($element); + } } diff --git a/src/fields/Date.php b/src/fields/Date.php index 0ee8383a259..3509308bafa 100644 --- a/src/fields/Date.php +++ b/src/fields/Date.php @@ -8,6 +8,7 @@ namespace craft\fields; use Craft; +use craft\base\CopyableFieldInterface; use craft\base\ElementInterface; use craft\base\Field; use craft\base\InlineEditableFieldInterface; @@ -17,6 +18,7 @@ use craft\gql\types\DateTime as DateTimeType; use craft\helpers\DateTimeHelper; use craft\helpers\Db; +use craft\helpers\ElementHelper; use craft\helpers\Gql; use craft\helpers\Html; use craft\i18n\Locale; @@ -33,7 +35,7 @@ * @author Pixel & Tonic, Inc. * @since 3.0.0 */ -class Date extends Field implements InlineEditableFieldInterface, SortableFieldInterface +class Date extends Field implements InlineEditableFieldInterface, SortableFieldInterface, CopyableFieldInterface { /** * @inheritdoc @@ -459,4 +461,12 @@ public function getContentGqlMutationArgumentType(): Type|array 'description' => $this->instructions, ]; } + + /** + * @inheritdoc + */ + public function getIsCopyable(?ElementInterface $element = null): bool + { + return $this->getIsTranslatable($element) && ElementHelper::supportsFieldCopying($element); + } } diff --git a/src/fields/Email.php b/src/fields/Email.php index db8f8572138..da0ea804e75 100644 --- a/src/fields/Email.php +++ b/src/fields/Email.php @@ -8,12 +8,14 @@ namespace craft\fields; use Craft; +use craft\base\CopyableFieldInterface; use craft\base\ElementInterface; use craft\base\Field; use craft\base\InlineEditableFieldInterface; use craft\fields\conditions\TextFieldConditionRule; use craft\helpers\App; use craft\helpers\Cp; +use craft\helpers\ElementHelper; use craft\helpers\Html; use craft\helpers\StringHelper; use yii\db\Schema; @@ -24,7 +26,7 @@ * @author Pixel & Tonic, Inc. * @since 3.0.0 */ -class Email extends Field implements InlineEditableFieldInterface +class Email extends Field implements InlineEditableFieldInterface, CopyableFieldInterface { /** * @inheritdoc @@ -143,4 +145,12 @@ public function getPreviewHtml(mixed $value, ElementInterface $element): string $value = Html::encode($value); return "$value"; } + + /** + * @inheritdoc + */ + public function getIsCopyable(?ElementInterface $element = null): bool + { + return $this->getIsTranslatable($element) && ElementHelper::supportsFieldCopying($element); + } } diff --git a/src/fields/Lightswitch.php b/src/fields/Lightswitch.php index 36358b85011..7bd729036c2 100644 --- a/src/fields/Lightswitch.php +++ b/src/fields/Lightswitch.php @@ -8,6 +8,7 @@ namespace craft\fields; use Craft; +use craft\base\CopyableFieldInterface; use craft\base\ElementInterface; use craft\base\Field; use craft\base\InlineEditableFieldInterface; @@ -16,6 +17,7 @@ use craft\helpers\ArrayHelper; use craft\helpers\Cp; use craft\helpers\Db; +use craft\helpers\ElementHelper; use craft\helpers\Html; use GraphQL\Type\Definition\Type; use yii\db\Schema; @@ -26,7 +28,7 @@ * @author Pixel & Tonic, Inc. * @since 3.0.0 */ -class Lightswitch extends Field implements InlineEditableFieldInterface, SortableFieldInterface +class Lightswitch extends Field implements InlineEditableFieldInterface, SortableFieldInterface, CopyableFieldInterface { /** * @inheritdoc @@ -220,6 +222,14 @@ public function getContentGqlQueryArgumentType(): Type|array ]; } + /** + * @inheritdoc + */ + public function getIsCopyable(?ElementInterface $element = null): bool + { + return $this->getIsTranslatable($element) && ElementHelper::supportsFieldCopying($element); + } + /** * @inheritdoc */ diff --git a/src/fields/Matrix.php b/src/fields/Matrix.php index 0cd64c1cd9b..43c12d720ba 100644 --- a/src/fields/Matrix.php +++ b/src/fields/Matrix.php @@ -8,6 +8,7 @@ namespace craft\fields; use Craft; +use craft\base\CopyableFieldInterface; use craft\base\EagerLoadingFieldInterface; use craft\base\Element; use craft\base\ElementContainerFieldInterface; @@ -39,6 +40,7 @@ use craft\gql\types\generators\EntryType as EntryTypeGenerator; use craft\gql\types\input\Matrix as MatrixInputType; use craft\helpers\ArrayHelper; +use craft\helpers\ElementHelper; use craft\helpers\Gql; use craft\helpers\Json; use craft\helpers\Queue; @@ -69,7 +71,8 @@ class Matrix extends Field implements ElementContainerFieldInterface, EagerLoadingFieldInterface, - GqlInlineFragmentFieldInterface + GqlInlineFragmentFieldInterface, + CopyableFieldInterface { /** * @event DefineEntryTypesForFieldEvent The event that is triggered when defining the available entry types. @@ -721,6 +724,7 @@ public function serializeValue(mixed $value, ?ElementInterface $element): mixed 'type' => $entry->getType()->handle, 'enabled' => $entry->enabled, 'collapsed' => $entry->collapsed, + 'title' => $entry->title ?? null, 'fields' => $entry->getSerializedFieldValues(), ]; } @@ -1107,6 +1111,14 @@ public function getGqlFragmentEntityByName(string $fragmentName): GqlInlineFragm return $entryType; } + /** + * @inheritdoc + */ + public function getIsCopyable(?ElementInterface $element = null): bool + { + return $this->getIsTranslatable($element) && ElementHelper::supportsFieldCopying($element); + } + // Events // ------------------------------------------------------------------------- diff --git a/src/fields/Money.php b/src/fields/Money.php index ca36ac63207..07bc7efa929 100644 --- a/src/fields/Money.php +++ b/src/fields/Money.php @@ -8,6 +8,7 @@ namespace craft\fields; use Craft; +use craft\base\CopyableFieldInterface; use craft\base\ElementInterface; use craft\base\Field; use craft\base\InlineEditableFieldInterface; @@ -16,6 +17,7 @@ use craft\gql\types\Money as MoneyType; use craft\helpers\Cp; use craft\helpers\Db; +use craft\helpers\ElementHelper; use craft\helpers\MoneyHelper; use craft\validators\MoneyValidator; use GraphQL\Type\Definition\Type; @@ -35,7 +37,7 @@ * @author Pixel & Tonic, Inc. * @since 4.0.0 */ -class Money extends Field implements InlineEditableFieldInterface, SortableFieldInterface +class Money extends Field implements InlineEditableFieldInterface, SortableFieldInterface, CopyableFieldInterface { /** * @inheritdoc @@ -335,4 +337,12 @@ public function getContentGqlMutationArgumentType(): Type|array 'description' => $this->instructions, ]; } + + /** + * @inheritdoc + */ + public function getIsCopyable(?ElementInterface $element = null): bool + { + return $this->getIsTranslatable($element) && ElementHelper::supportsFieldCopying($element); + } } diff --git a/src/fields/Number.php b/src/fields/Number.php index e16ab87708f..8f3c37be8e4 100644 --- a/src/fields/Number.php +++ b/src/fields/Number.php @@ -8,6 +8,7 @@ namespace craft\fields; use Craft; +use craft\base\CopyableFieldInterface; use craft\base\ElementInterface; use craft\base\Field; use craft\base\InlineEditableFieldInterface; @@ -15,6 +16,7 @@ use craft\fields\conditions\NumberFieldConditionRule; use craft\gql\types\Number as NumberType; use craft\helpers\Db; +use craft\helpers\ElementHelper; use craft\helpers\Localization; use craft\i18n\Locale; use GraphQL\Type\Definition\Type; @@ -28,7 +30,7 @@ * @author Pixel & Tonic, Inc. * @since 3.0.0 */ -class Number extends Field implements InlineEditableFieldInterface, SortableFieldInterface +class Number extends Field implements InlineEditableFieldInterface, SortableFieldInterface, CopyableFieldInterface { /** * @since 3.5.11 @@ -339,4 +341,12 @@ public function getContentGqlMutationArgumentType(): Type|array 'description' => $this->instructions, ]; } + + /** + * @inheritdoc + */ + public function getIsCopyable(?ElementInterface $element = null): bool + { + return $this->getIsTranslatable($element) && ElementHelper::supportsFieldCopying($element); + } } diff --git a/src/fields/PlainText.php b/src/fields/PlainText.php index 64381a607d4..f67ecea0244 100644 --- a/src/fields/PlainText.php +++ b/src/fields/PlainText.php @@ -8,11 +8,13 @@ namespace craft\fields; use Craft; +use craft\base\CopyableFieldInterface; use craft\base\ElementInterface; use craft\base\Field; use craft\base\InlineEditableFieldInterface; use craft\base\SortableFieldInterface; use craft\fields\conditions\TextFieldConditionRule; +use craft\helpers\ElementHelper; use craft\helpers\StringHelper; /** @@ -21,7 +23,7 @@ * @author Pixel & Tonic, Inc. * @since 3.0.0 */ -class PlainText extends Field implements InlineEditableFieldInterface, SortableFieldInterface +class PlainText extends Field implements InlineEditableFieldInterface, SortableFieldInterface, CopyableFieldInterface { /** * @inheritdoc @@ -218,4 +220,12 @@ public function getElementConditionRuleType(): ?string { return TextFieldConditionRule::class; } + + /** + * @inheritdoc + */ + public function getIsCopyable(?ElementInterface $element = null): bool + { + return $this->getIsTranslatable($element) && ElementHelper::supportsFieldCopying($element); + } } diff --git a/src/fields/Table.php b/src/fields/Table.php index 45018fb0c97..7e2fd20e278 100644 --- a/src/fields/Table.php +++ b/src/fields/Table.php @@ -8,6 +8,7 @@ namespace craft\fields; use Craft; +use craft\base\CopyableFieldInterface; use craft\base\ElementInterface; use craft\base\Field; use craft\fields\data\ColorData; @@ -16,6 +17,7 @@ use craft\gql\types\TableRow; use craft\helpers\Cp; use craft\helpers\DateTimeHelper; +use craft\helpers\ElementHelper; use craft\helpers\Json; use craft\helpers\StringHelper; use craft\validators\ColorValidator; @@ -34,7 +36,7 @@ * @author Pixel & Tonic, Inc. * @since 3.0.0 */ -class Table extends Field +class Table extends Field implements CopyableFieldInterface { /** * @inheritdoc @@ -574,6 +576,14 @@ public function getContentGqlMutationArgumentType(): Type|array ]))); } + /** + * @inheritdoc + */ + public function getIsCopyable(?ElementInterface $element = null): bool + { + return $this->getIsTranslatable($element) && ElementHelper::supportsFieldCopying($element); + } + /** * Normalizes a cell’s value. * diff --git a/src/fields/Time.php b/src/fields/Time.php index b63b80b3445..11a233ef7b1 100644 --- a/src/fields/Time.php +++ b/src/fields/Time.php @@ -8,12 +8,14 @@ namespace craft\fields; use Craft; +use craft\base\CopyableFieldInterface; use craft\base\ElementInterface; use craft\base\Field; use craft\base\InlineEditableFieldInterface; use craft\base\SortableFieldInterface; use craft\gql\types\DateTime as DateTimeType; use craft\helpers\DateTimeHelper; +use craft\helpers\ElementHelper; use craft\i18n\Locale; use craft\validators\TimeValidator; use DateTime; @@ -26,7 +28,7 @@ * @author Pixel & Tonic, Inc. * @since 3.5.12 */ -class Time extends Field implements InlineEditableFieldInterface, SortableFieldInterface +class Time extends Field implements InlineEditableFieldInterface, SortableFieldInterface, CopyableFieldInterface { /** * @inheritdoc @@ -231,4 +233,12 @@ public function getContentGqlMutationArgumentType(): Type|array 'description' => $this->instructions, ]; } + + /** + * @inheritdoc + */ + public function getIsCopyable(?ElementInterface $element = null): bool + { + return $this->getIsTranslatable($element) && ElementHelper::supportsFieldCopying($element); + } } diff --git a/src/fields/Url.php b/src/fields/Url.php index 5e2f0b376e4..68cba833b39 100644 --- a/src/fields/Url.php +++ b/src/fields/Url.php @@ -8,11 +8,13 @@ namespace craft\fields; use Craft; +use craft\base\CopyableFieldInterface; use craft\base\ElementInterface; use craft\base\Field; use craft\base\InlineEditableFieldInterface; use craft\fields\conditions\TextFieldConditionRule; use craft\helpers\Cp; +use craft\helpers\ElementHelper; use craft\helpers\Html; use craft\helpers\StringHelper; use craft\helpers\UrlHelper; @@ -29,7 +31,7 @@ * @author Pixel & Tonic, Inc. * @since 3.0.0 */ -class Url extends Field implements InlineEditableFieldInterface +class Url extends Field implements InlineEditableFieldInterface, CopyableFieldInterface { /** * @since 3.6.0 @@ -350,6 +352,14 @@ public function getPreviewHtml(mixed $value, ElementInterface $element): string return "$value"; } + /** + * @inheritdoc + */ + public function getIsCopyable(?ElementInterface $element = null): bool + { + return $this->getIsTranslatable($element) && ElementHelper::supportsFieldCopying($element); + } + /** * Returns what type of URL a given value is. * diff --git a/src/helpers/Cp.php b/src/helpers/Cp.php index a3f1cc97ba1..3bb5bafaae7 100644 --- a/src/helpers/Cp.php +++ b/src/helpers/Cp.php @@ -1162,6 +1162,7 @@ public static function fieldHtml(string|callable $input, array $config = []): st $instructionsPosition = $config['instructionsPosition'] ?? 'before'; $orientation = $config['orientation'] ?? ($site ? $site->getLocale() : Craft::$app->getLocale())->getOrientation(); $translatable = Craft::$app->getIsMultiSite() ? ($config['translatable'] ?? ($site !== null)) : false; + $copyable = (bool)($config['copyable'] ?? false); $fieldClass = array_merge(array_filter([ 'field', @@ -1198,16 +1199,17 @@ public static function fieldHtml(string|callable $input, array $config = []): st ]) : '') . ($translatable - ? Html::tag('span', '', [ - 'class' => ['t9n-indicator'], + ? Html::tag($copyable ? 'button' : 'span', '', [ + 'class' => array_filter(['t9n-indicator', ($copyable ? 'copyable' : '')]), 'title' => $config['translationDescription'] ?? Craft::t('app', 'This field is translatable.'), 'data' => [ 'icon' => 'language', + 'handle' => $attribute, ], 'aria' => [ 'label' => $config['translationDescription'] ?? Craft::t('app', 'This field is translatable.'), + 'role' => 'button', ], - 'role' => 'img', ]) : '') ); diff --git a/src/helpers/ElementHelper.php b/src/helpers/ElementHelper.php index 4e75a60a761..8ff001f422c 100644 --- a/src/helpers/ElementHelper.php +++ b/src/helpers/ElementHelper.php @@ -404,6 +404,14 @@ public static function isElementEditable(ElementInterface $element): bool return false; } + public static function supportsFieldCopying($element): bool + { + return !(!Craft::$app->getIsMultiSite() || + $element === null || + isset($element->ownerId) || + count(static::editableSiteIdsForElement($element)) < 2); + } + /** * Returns the editable site IDs for a given element, taking user permissions into account. * diff --git a/src/services/Elements.php b/src/services/Elements.php index 8ce8282bae6..3b6b7a80fa7 100644 --- a/src/services/Elements.php +++ b/src/services/Elements.php @@ -8,11 +8,13 @@ namespace craft\services; use Craft; +use craft\base\CopyableFieldInterface; use craft\base\Element; use craft\base\ElementActionInterface; use craft\base\ElementExporterInterface; use craft\base\ElementInterface; use craft\base\ExpirableElementInterface; +use craft\base\Field; use craft\base\FieldInterface; use craft\base\NestedElementInterface; use craft\behaviors\DraftBehavior; @@ -2514,6 +2516,169 @@ public function restoreElements(array $elements): bool return true; } + /** + * Copy value of a field from another site + * + * @param ElementInterface $element + * @param null|string $fieldHandle + * @param int $copyFromSiteId + * @return array + * @throws ElementNotFoundException + * @throws Exception + * @throws Throwable + * @throws \craft\errors\InvalidFieldException + * @throws \yii\db\Exception + */ + public function copyFieldValuesFromSite(ElementInterface $element, ?string $fieldHandle, int $copyFromSiteId): array + { + // get element for selected site + /** @var string|ElementInterface $elementType */ + $elementType = get_class($element); + + $user = Craft::$app->getUser()->getIdentity(); + + $fromElement = $this->getElementById($element->getCanonicalId(), $elementType, $copyFromSiteId); + + if (!$fromElement) { + return [ + 'success' => false, + 'message' => Craft::t('app', 'Couldn’t find this {type} on the site you selected.', [ + 'type' => $element::lowerDisplayName(), + ]), + 'element' => $element, + ]; + } + + $transaction = Craft::$app->getDb()->beginTransaction(); + try { + // check if element is a draft - if not, create one + if (!$element->getIsDraft()) { + /** @var Element|DraftBehavior $element */ + $draft = Craft::$app->getDrafts()->createDraft($element, $user->id, null, null, [], true); + $draft->setCanonical($element); + $element = $draft; + } + + $valueChanged = false; + + // if $fieldHandle === null - we're doing it for all element's fields + if ($fieldHandle === null) { + $translatableFields = array_merge( + $this->_getTranslatableCustomFieldHandles($element), + $this->_getTranslatableNativeFieldHandles($element) + ); + + foreach ($translatableFields as $translatableField) { + $changed = $this->_copyFieldValueByHandle($fromElement, $element, $translatableField); + if ($changed === true && $valueChanged !== true) { + $valueChanged = true; + } + } + } else { + $valueChanged = $this->_copyFieldValueByHandle($fromElement, $element, $fieldHandle); + } + + + if (!$valueChanged) { + $transaction->rollBack(); + return [ + 'success' => true, + 'message' => Craft::t('app', 'Nothing to copy.'), + 'element' => $element, + ]; + } + + if (!$this->saveElement($element, true, false, false)) { + return [ + 'success' => false, + 'message' => Craft::t('app', 'Couldn’t copy content.'), + 'element' => $element, + ]; + } + + $transaction->commit(); + return [ + 'success' => true, + 'message' => Craft::t('app', 'Content copied.'), + 'element' => $element, + ]; + } catch (Throwable $e) { + $transaction->rollBack(); + throw $e; + } + } + + /** + * Get field handles of all translatable custom fields used in the element + * + * @param ElementInterface $element + * @return array + */ + private function _getTranslatableCustomFieldHandles(ElementInterface $element): array + { + $customFields = $element->getFieldLayout()?->getVisibleCustomFields($element); + return array_map( + function($field) { + /** @var Field $field */ + return $field->handle; + }, + array_filter($customFields, + fn($field) => $field instanceof CopyableFieldInterface && $field->getIsCopyable($element) + ) + ); + } + + /** + * Get field handles of all translatable native fields used in the element + * + * @param ElementInterface $element + * @return array + * @throws \yii\base\InvalidConfigException + */ + private function _getTranslatableNativeFieldHandles(ElementInterface $element): array + { + $fields = $element->fields(); + return array_map( + function($field) { + return $field->attribute(); + }, + array_filter( + $element->getFieldLayout()?->getAvailableNativeFields(), + fn($field) => isset($fields[$field->attribute()]) && $field->isCopyable($element) + ) + ); + } + + /** + * Copy field value from one element to another and check if it changed + * + * @param $from + * @param $to + * @param $fieldHandle + * @return bool + * @since 5.0.0 + */ + private function _copyFieldValueByHandle($from, $to, $fieldHandle): bool + { + // reserved $fieldHandles which we need to treat differently + $reservedHandles = ['title', 'slug']; + + $valueChanged = false; + // it's a reserved handle - handle differently + if (in_array($fieldHandle, $reservedHandles) && $to->{$fieldHandle} != $from->{$fieldHandle}) { + $to->{$fieldHandle} = $from->{$fieldHandle}; + $valueChanged = true; + } else { + /** @var FieldInterface $field */ + $field = $from->getFieldLayout()?->getFieldByHandle($fieldHandle); + if ($field instanceof CopyableFieldInterface) { + $valueChanged = $field->copyValueBetweenSites($from, $to); + } + } + + return $valueChanged; + } + /** * Returns the recent activity for an element. * diff --git a/src/templates/_includes/field.twig b/src/templates/_includes/field.twig index bf2acf6a015..7257a206ed2 100644 --- a/src/templates/_includes/field.twig +++ b/src/templates/_includes/field.twig @@ -30,6 +30,7 @@ attribute: field.handle, translatable: translatable, translationDescription: field.getTranslationDescription(element), + copyable: field.getIsCopyable(element), siteId: siteId, required: (not static ? required : false), instructions: instructions|e, diff --git a/src/templates/_special/copy-content-fields.twig b/src/templates/_special/copy-content-fields.twig new file mode 100644 index 00000000000..04390cb34d0 --- /dev/null +++ b/src/templates/_special/copy-content-fields.twig @@ -0,0 +1,30 @@ +{# +Copy content fields, fetched via XHR by the "Copy content from site" tooltip +#} + +{% import '_includes/forms.twig' as forms %} + +{{ csrfInput() }} +{{ actionInput('elements/copy-field-values-from-site') }} + +{{ hiddenInput('elementId', elementId) }} +{{ hiddenInput('provisional', provisional ?? false) }} + +{% if copyFieldHandle ?? null %} + {{ hiddenInput('copyFieldHandle', copyFieldHandle) }} +{% endif %} + +{% if draftId ?? false %} + {{ hiddenInput('draftId', draftId) }} +{% endif %} + +
+ {{ forms.selectField({ + label: 'Copy from' | t('app'), + name: 'copyFromSiteId', + options: siteOptions, + class: [ + viewMode == 'tooltip' ? 'fullwidth' : false + ] | filter + }) }} +
diff --git a/src/templates/_special/copy-content-modal.twig b/src/templates/_special/copy-content-modal.twig new file mode 100644 index 00000000000..637c1d212a9 --- /dev/null +++ b/src/templates/_special/copy-content-modal.twig @@ -0,0 +1,38 @@ +{# + Copy content modal, fetched via XHR by the "Copy content from site" element action +#} + +{% import '_includes/forms.twig' as forms %} + + \ No newline at end of file diff --git a/src/translations/en/app.php b/src/translations/en/app.php index d3c48577e69..716dc77ad95 100644 --- a/src/translations/en/app.php +++ b/src/translations/en/app.php @@ -319,6 +319,7 @@ 'Constraints' => 'Constraints', 'Contact Developer Support' => 'Contact Developer Support', 'Content' => 'Content', + 'Content copied.' => 'Content copied.', 'Contents of {path}' => 'Contents of {path}', 'Context' => 'Context', 'Continue anyway' => 'Continue anyway', @@ -328,8 +329,10 @@ 'Cookies must be enabled to access the Craft CMS control panel.' => 'Cookies must be enabled to access the Craft CMS control panel.', 'Cooldown Time Remaining' => 'Cooldown Time Remaining', 'Copied to clipboard.' => 'Copied to clipboard.', + 'Copy' => 'Copy', 'Copy URL' => 'Copy URL', 'Copy activation URL…' => 'Copy activation URL…', + 'Copy content from site' => 'Copy content from site', 'Copy impersonation URL…' => 'Copy impersonation URL…', 'Copy password reset URL…' => 'Copy password reset URL…', 'Copy reference tag' => 'Copy reference tag', @@ -356,6 +359,7 @@ 'Couldn’t apply new migrations.' => 'Couldn’t apply new migrations.', 'Couldn’t backup the database. How would you like to proceed?' => 'Couldn’t backup the database. How would you like to proceed?', 'Couldn’t change Craft CMS edition.' => 'Couldn’t change Craft CMS edition.', + 'Couldn’t copy content.' => 'Couldn’t copy content.', 'Couldn’t create {type}.' => 'Couldn’t create {type}.', 'Couldn’t delete all {type}.' => 'Couldn’t delete all {type}.', 'Couldn’t delete {type}.' => 'Couldn’t delete {type}.', @@ -365,6 +369,8 @@ 'Couldn’t enable plugin.' => 'Couldn’t enable plugin.', 'Couldn’t generate a password reset URL: {error}' => 'Couldn’t generate a password reset URL: {error}', 'Couldn’t generate an activation URL: {error}' => 'Couldn’t generate an activation URL: {error}', + 'Couldn’t find this {type} on other sites.' => 'Couldn’t find this {type} on other sites.', + 'Couldn’t find this {type} on the site you selected.' => 'Couldn’t find this {type} on the site you selected.', 'Couldn’t install plugin.' => 'Couldn’t install plugin.', 'Couldn’t load CMS editions.' => 'Couldn’t load CMS editions.', 'Couldn’t load active trials.' => 'Couldn’t load active trials.', @@ -1045,6 +1051,7 @@ 'Notes about your changes' => 'Notes about your changes', 'Notes' => 'Notes', 'Nothing selected.' => 'Nothing selected.', + 'Nothing to copy.' => 'Nothing to copy.', 'Nothing to update.' => 'Nothing to update.', 'Notice' => 'Notice', 'Notification Duration' => 'Notification Duration', @@ -1065,6 +1072,7 @@ 'Only save entries to the site they were created in' => 'Only save entries to the site they were created in', 'Only show for users who match the following rules:' => 'Only show for users who match the following rules:', 'Only show when editing {type} that match the following rules:' => 'Only show when editing {type} that match the following rules:', + 'Only translatable () field values will be copied if their content differs from the current.' => 'Only translatable () field values will be copied if their content differs from the current.', 'Only up to {version} is compatible with your version of Craft.' => 'Only up to {version} is compatible with your version of Craft.', 'Oops!' => 'Oops!', 'Open PRs' => 'Open PRs', @@ -1331,6 +1339,7 @@ 'See logs' => 'See logs', 'Select All' => 'Select All', 'Select a filesystem' => 'Select a filesystem', + 'Select a site' => 'Select a site', 'Select a volume' => 'Select a volume', 'Select all' => 'Select all', 'Select context' => 'Select context', diff --git a/src/web/assets/cp/CpAsset.php b/src/web/assets/cp/CpAsset.php index e28e0996894..5f4379baf16 100644 --- a/src/web/assets/cp/CpAsset.php +++ b/src/web/assets/cp/CpAsset.php @@ -151,6 +151,8 @@ private function _registerTranslations(View $view): void 'Content', 'Continue', 'Copied to clipboard.', + 'Copy', + 'Copy content from site', 'Copy the URL', 'Copy the reference tag', 'Copy to clipboard', @@ -264,6 +266,7 @@ private function _registerTranslations(View $view): void 'Notes', 'Notice', 'OK', + 'Only translatable () field values will be copied if their content differs from the current.', 'Open the full edit page in a new tab', 'Options', 'Password', @@ -300,6 +303,7 @@ private function _registerTranslations(View $view): void 'Saving', 'Score', 'Search in subfolders', + 'Select a site', 'Select all', 'Select element', 'Select transform', diff --git a/src/web/assets/cp/dist/cp.js b/src/web/assets/cp/dist/cp.js index c803b83e573..436505d2de1 100644 --- a/src/web/assets/cp/dist/cp.js +++ b/src/web/assets/cp/dist/cp.js @@ -1,3 +1,3 @@ /*! For license information please see cp.js.LICENSE.txt */ -(function(){var __webpack_modules__={463:function(){Craft.Accordion=Garnish.Base.extend({$trigger:null,targetSelector:null,_$target:null,init:function(t){var e=this;this.$trigger=$(t),this.$trigger.data("accordion")&&(console.warn("Double-instantiating an accordion trigger on an element"),this.$trigger.data("accordion").destroy()),this.$trigger.data("accordion",this),this.targetSelector=this.$trigger.attr("aria-controls")?"#".concat(this.$trigger.attr("aria-controls")):null,this.targetSelector&&(this._$target=$(this.targetSelector)),this.addListener(this.$trigger,"click","onTriggerClick"),this.addListener(this.$trigger,"keypress",(function(t){var i=t.keyCode;i!==Garnish.SPACE_KEY&&i!==Garnish.RETURN_KEY||(t.preventDefault(),e.onTriggerClick())}))},onTriggerClick:function(){"true"===this.$trigger.attr("aria-expanded")?this.hideTarget(this._$target):this.showTarget(this._$target)},showTarget:function(t){var e=this;if(t&&t.length){this.showTarget._currentHeight=t.height(),t.removeClass("hidden"),this.$trigger.removeClass("collapsed").addClass("expanded").attr("aria-expanded","true");for(var i=0;i=this.settings.maxItems)){var e=$(t).appendTo(this.$tbody),i=e.find(".delete");this.settings.sortable&&this.sorter.addItems(e),this.$deleteBtns=this.$deleteBtns.add(i),this.addListener(i,"click","handleDeleteBtnClick"),this.totalItems++,this.updateUI()}},reorderItems:function(){var t=this;if(this.settings.sortable){for(var e=[],i=0;i=this.settings.maxItems?$(this.settings.newItemBtnSelector).addClass("hidden"):$(this.settings.newItemBtnSelector).removeClass("hidden"))}},{defaults:{tableSelector:null,noItemsSelector:null,newItemBtnSelector:null,idAttribute:"data-id",nameAttribute:"data-name",sortable:!1,allowDeleteAll:!0,minItems:0,maxItems:null,reorderAction:null,deleteAction:null,reorderSuccessMessage:Craft.t("app","New order saved."),reorderFailMessage:Craft.t("app","Couldn’t save new order."),confirmDeleteMessage:Craft.t("app","Are you sure you want to delete “{name}”?"),deleteSuccessMessage:Craft.t("app","“{name}” deleted."),deleteFailMessage:Craft.t("app","Couldn’t delete “{name}”."),onReorderItems:$.noop,onDeleteItem:$.noop}})},6872:function(){Craft.AssetImageEditor=Garnish.Modal.extend({$body:null,$footer:null,$imageTools:null,$buttons:null,$cancelBtn:null,$replaceBtn:null,$saveBtn:null,$focalPointBtn:null,$editorContainer:null,$straighten:null,$croppingCanvas:null,$spinner:null,$constraintContainer:null,$constraintRadioInputs:null,$customConstraints:null,canvas:null,image:null,viewport:null,focalPoint:null,grid:null,croppingCanvas:null,clipper:null,croppingRectangle:null,cropperHandles:null,cropperGrid:null,croppingShade:null,imageStraightenAngle:0,viewportRotation:0,originalWidth:0,originalHeight:0,imageVerticeCoords:null,zoomRatio:1,animationInProgress:!1,currentView:"",assetId:null,cacheBust:null,draggingCropper:!1,scalingCropper:!1,draggingFocal:!1,previousMouseX:0,previousMouseY:0,shiftKeyHeld:!1,editorHeight:0,editorWidth:0,cropperState:!1,scaleFactor:1,flipData:{},focalPointState:!1,maxImageSize:null,lastLoadedDimensions:null,imageIsLoading:!1,mouseMoveEvent:null,croppingConstraint:!1,constraintOrientation:"landscape",showingCustomConstraint:!1,saving:!1,renderImage:null,renderCropper:null,_queue:null,init:function(t,e){var i=this;this._queue=new Craft.Queue,this.cacheBust=Date.now(),this.setSettings(e,Craft.AssetImageEditor.defaults),null===this.settings.allowDegreeFractions&&(this.settings.allowDegreeFractions=Craft.isImagick),Garnish.prefersReducedMotion()&&(this.settings.animationDuration=1),this.assetId=t,this.flipData={x:0,y:0},this.$container=$('').appendTo(Garnish.$bod),this.$body=$('
').appendTo(this.$container),this.$footer=$('