diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 5fe2e77f914..e9ff6c9d0f7 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -46,6 +46,8 @@ - Subnav items within the global control panel navigation can now have icons. ([#17879](https://github.com/craftcms/cms/pull/17879)) - Added `craft\base\ElementIndex::multiPageSources()`. ([#17779](https://github.com/craftcms/cms/pull/17779)) - Added `craft\base\ElementTrait::$hasProvisionalChanges`. ([#17915](https://github.com/craftcms/cms/pull/17915)) +- Added `craft\base\ElementTrait::$propagateRequired`. +- Added `craft\base\FieldInterface::propagateValue()`. - Added `craft\elements\User::isInGroups()`. ([#17989](https://github.com/craftcms/cms/discussions/17989)) - Added `craft\elements\conditions\HintableConditionRuleTrait`. ([#17909](https://github.com/craftcms/cms/pull/17909)) - Added `craft\events\RegisterElementCardAttributesEvent::$fieldLayout`. ([#17920](https://github.com/craftcms/cms/pull/17920)) @@ -100,4 +102,5 @@ - Fixed a bug where elements with unsaved changes could show outdated attribute/field values within element index tables, chips, and cards throughout the control panel. ([#17915](https://github.com/craftcms/cms/pull/17915)) - Fixed a bug where Table fields with the “Static Rows” setting enabled would lose track of which values belonged to which row headings, if the “Default Values” table was reordered. ([#17090](https://github.com/craftcms/cms/issues/17090)) - Fixed a bug where requests with invalid tokens would throw an exception before the application was fully initialized, which could lead to other errors. ([#18000](https://github.com/craftcms/cms/issues/18000)) +- Fixed a bug where titles, slugs, and required custom field values weren’t always getting propagated to other sites when creating a new element. ([#17955](https://github.com/craftcms/cms/issues/17955)) - Updated Twig to 3.21. ([#17603](https://github.com/craftcms/cms/discussions/17603)) diff --git a/src/base/Element.php b/src/base/Element.php index 24c7eba2866..36eba18f8f8 100644 --- a/src/base/Element.php +++ b/src/base/Element.php @@ -2751,6 +2751,7 @@ public function attributes(): array $names['isNewSite'], $names['previewing'], $names['propagateAll'], + $names['propagateRequired'], $names['propagating'], $names['propagatingFrom'], $names['resaving'], diff --git a/src/base/ElementTrait.php b/src/base/ElementTrait.php index d0a64591447..cb3ae567958 100644 --- a/src/base/ElementTrait.php +++ b/src/base/ElementTrait.php @@ -196,6 +196,13 @@ trait ElementTrait */ public bool $propagateAll = false; + /** + * @var bool Whether all required element attributes should be propagated across all its supported sites, but only if otherwise + * they wouldn’t validate. + * @since 5.9.0 + */ + public bool $propagateRequired = false; + /** * @var int[] The site IDs that the element was just propagated to for the first time. * @since 3.2.9 diff --git a/src/base/Field.php b/src/base/Field.php index 859826a0708..d8dd502f2a1 100644 --- a/src/base/Field.php +++ b/src/base/Field.php @@ -214,6 +214,7 @@ abstract class Field extends SavableComponent implements FieldInterface, Iconic, 'prevSibling', 'previewing', 'propagateAll', + 'propagateRequired', 'propagating', 'ref', 'relatedToAssets', @@ -1367,4 +1368,12 @@ protected function isFresh(?ElementInterface $element = null): bool return true; } + + /** + * @inheritdoc + */ + public function propagateValue(ElementInterface $from, ElementInterface $to): void + { + $to->setFieldValue($this->handle, $from->getFieldValue($this->handle)); + } } diff --git a/src/base/FieldInterface.php b/src/base/FieldInterface.php index 46607f58e74..8f53fd99b76 100644 --- a/src/base/FieldInterface.php +++ b/src/base/FieldInterface.php @@ -500,6 +500,15 @@ public function modifyElementIndexQuery(ElementQueryInterface $query): void; */ public function setIsFresh(?bool $isFresh = null): void; + /** + * Copies the field value from one site to another. + * + * @param ElementInterface $from + * @param ElementInterface $to + * @since 5.9.0 + */ + public function propagateValue(ElementInterface $from, ElementInterface $to): void; + /** * Returns whether the field should be included in the given GraphQL schema. * diff --git a/src/controllers/ElementsController.php b/src/controllers/ElementsController.php index da6b495f2a2..3f926b832f9 100644 --- a/src/controllers/ElementsController.php +++ b/src/controllers/ElementsController.php @@ -2135,6 +2135,11 @@ public function actionApplyDraft(): ?Response $element->setScenario(Element::SCENARIO_LIVE); } + // if we're about to apply an unpublished draft, set propagateRequired to true + if ($isUnpublishedDraft) { + $element->propagateRequired = true; + } + $namespace = $this->request->getHeaders()->get('X-Craft-Namespace'); if (!$elementsService->saveElement($element, crossSiteValidate: ($namespace === null && Craft::$app->getIsMultiSite()))) { return $this->_asAppyDraftFailure($element); @@ -2154,6 +2159,7 @@ public function actionApplyDraft(): ?Response } try { + $element->propagateRequired = false; $canonical = Craft::$app->getDrafts()->applyDraft($element, $attributes); } catch (InvalidElementException) { return $this->_asAppyDraftFailure($element); diff --git a/src/elements/NestedElementManager.php b/src/elements/NestedElementManager.php index 761c98100e0..c3cc456861b 100644 --- a/src/elements/NestedElementManager.php +++ b/src/elements/NestedElementManager.php @@ -680,7 +680,7 @@ public function maintainNestedElements(ElementInterface $owner, bool $isNew): vo $this->duplicateNestedElements($owner->duplicateOf, $owner, true, !$isNew); } $resetValue = true; - } elseif ($this->isDirty($owner) || !empty($owner->newSiteIds)) { + } elseif ($this->isDirty($owner) || !empty($owner->newSiteIds) || $owner->propagateRequired) { $this->saveNestedElements($owner); } elseif ($owner->mergingCanonicalChanges) { $this->mergeCanonicalChanges($owner); @@ -798,6 +798,11 @@ private function saveNestedElements(ElementInterface $owner): void $elementsService->restoreElement($element); } + // if the owner is propagating required fields and attributes, so should the nested elements + if ($owner->propagateRequired) { + $element->propagateRequired = true; + } + $sortOrder++; if ($saveAll || !$element->id || $element->forceSave) { $element->setOwner($owner); @@ -849,8 +854,11 @@ private function saveNestedElements(ElementInterface $owner): void // Should we duplicate the elements to other sites? if ( - $this->propagationMethod !== PropagationMethod::All && - ($owner->propagateAll || !empty($owner->newSiteIds)) + ( + $this->propagationMethod !== PropagationMethod::All && + ($owner->propagateAll || !empty($owner->newSiteIds)) + ) || + ($owner->propagateRequired && $this->field->layoutElement->required) ) { // Find the owner's site IDs that *aren't* supported by this site's nested elements $ownerSiteIds = array_map( @@ -861,7 +869,7 @@ private function saveNestedElements(ElementInterface $owner): void $otherSiteIds = array_diff($ownerSiteIds, $fieldSiteIds); // If propagateAll isn't set, only deal with sites that the element was just propagated to for the first time - if (!$owner->propagateAll) { + if (!$owner->propagateAll && !$owner->propagateRequired) { $preexistingOtherSiteIds = array_diff($otherSiteIds, $owner->newSiteIds); $otherSiteIds = array_intersect($otherSiteIds, $owner->newSiteIds); } else { @@ -915,7 +923,16 @@ private function saveNestedElements(ElementInterface $owner): void } else { // Duplicate the elements, but **don't track** the duplications, so the edit page doesn’t think // its elements have been replaced by the other sites’ nested elements - $this->duplicateNestedElements($owner, $localizedOwner, force: true); + if ($owner->propagateAll) { + $this->duplicateNestedElements($owner, $localizedOwner, force: true); + } elseif ($owner->propagateRequired && $this->field->layoutElement->required) { + // if we're propagating required and the field is required, and it doesn't validate because of this field, + // duplicate like above + $localizedOwner->setScenario(Element::SCENARIO_LIVE); + if (!$localizedOwner->validate() && !empty($localizedOwner->getErrors($this->field->handle))) { + $this->duplicateNestedElements($owner, $localizedOwner, force: true); + } + } } // Make sure we don't duplicate elements for any of the sites that were just propagated to @@ -993,7 +1010,7 @@ private function deleteOtherNestedElements(ElementInterface $owner, array $excep * which weren’t included in the duplication * @param bool $force Whether to force duplication, even if it looks like only the nested element ownership was duplicated */ - private function duplicateNestedElements( + public function duplicateNestedElements( ElementInterface $source, ElementInterface $target, bool $checkOtherSites = false, diff --git a/src/services/Elements.php b/src/services/Elements.php index ddccdad1b8b..a3785b58343 100644 --- a/src/services/Elements.php +++ b/src/services/Elements.php @@ -4328,7 +4328,10 @@ private function _propagateElement( // Copy the title value? if ( $element::hasTitles() && - $siteElement->getTitleTranslationKey() === $element->getTitleTranslationKey() + ( + $siteElement->getTitleTranslationKey() === $element->getTitleTranslationKey() || + ($element->propagateRequired && empty($siteElement->title)) + ) ) { $siteElement->title = $element->title; } @@ -4336,7 +4339,10 @@ private function _propagateElement( // Copy the slug value? if ( $element->slug !== null && - $siteElement->getSlugTranslationKey() === $element->getSlugTranslationKey() + ( + $siteElement->getSlugTranslationKey() === $element->getSlugTranslationKey() || + ($element->propagateRequired && empty($siteElement->slug)) + ) ) { $siteElement->slug = $element->slug; } @@ -4359,6 +4365,19 @@ private function _propagateElement( } } + // Save it + $siteElement->setScenario(Element::SCENARIO_ESSENTIALS); + + // validate element against "live" scenario across all sites, if element is enabled for the site + if ( + ($crossSiteValidate || $element->propagateRequired) && + $siteElement->enabled && + $siteElement->getEnabledForSite() + ) { + $siteElement->setScenario(Element::SCENARIO_LIVE); + } + + // Copy the dirty attributes (except title, slug and uri, which may be translatable) $siteElement->setDirtyAttributes(array_filter($element->getDirtyAttributes(), fn(string $attribute): bool => $attribute !== 'title' && $attribute !== 'slug')); @@ -4371,32 +4390,28 @@ private function _propagateElement( $fieldLayout = $element->getFieldLayout(); if ($fieldLayout !== null) { - // Only copy the non-translatable field values foreach ($fieldLayout->getCustomFields() as $field) { - // Has this field changed, and does it produce the same translation key as it did for the initial element? if ( $element->propagateAll || + // If propagateRequired is set, is the field value invalid on the propagated site element? + ( + $element->propagateRequired && + $field->layoutElement->required && + !$siteElement->validate("field:$field->handle") + ) || + // Has this field changed, and does it produce the same translation key as it did for the initial element? ( $element->isFieldDirty($field->handle) && $field->getTranslationKey($siteElement) === $field->getTranslationKey($element) ) ) { - // Copy the initial element’s value over - $siteElement->setFieldValue($field->handle, $element->getFieldValue($field->handle)); + $field->propagateValue($element, $siteElement); } } } } } - // Save it - $siteElement->setScenario(Element::SCENARIO_ESSENTIALS); - - // validate element against "live" scenario across all sites, if element is enabled for the site - if ($crossSiteValidate && $siteElement->enabled && $siteElement->getEnabledForSite()) { - $siteElement->setScenario(Element::SCENARIO_LIVE); - } - $siteElement->propagating = true; $siteElement->propagatingFrom = $element;