Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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))
1 change: 1 addition & 0 deletions src/base/Element.php
Original file line number Diff line number Diff line change
Expand Up @@ -2751,6 +2751,7 @@ public function attributes(): array
$names['isNewSite'],
$names['previewing'],
$names['propagateAll'],
$names['propagateRequired'],
$names['propagating'],
$names['propagatingFrom'],
$names['resaving'],
Expand Down
7 changes: 7 additions & 0 deletions src/base/ElementTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/base/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ abstract class Field extends SavableComponent implements FieldInterface, Iconic,
'prevSibling',
'previewing',
'propagateAll',
'propagateRequired',
'propagating',
'ref',
'relatedToAssets',
Expand Down Expand Up @@ -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));
}
}
9 changes: 9 additions & 0 deletions src/base/FieldInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
6 changes: 6 additions & 0 deletions src/controllers/ElementsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
29 changes: 23 additions & 6 deletions src/elements/NestedElementManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
43 changes: 29 additions & 14 deletions src/services/Elements.php
Original file line number Diff line number Diff line change
Expand Up @@ -4328,15 +4328,21 @@ 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;
}

// 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;
}
Expand All @@ -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'));

Expand All @@ -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;

Expand Down
Loading