From 4e72678597c6a0195e8435d836da459f1e8b20ba Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Mon, 8 Dec 2025 15:53:30 -0800 Subject: [PATCH] Initial work on enabledByOwner --- src/elements/db/ElementQuery.php | 21 +++++++ src/elements/db/ElementQueryInterface.php | 9 +++ src/migrations/Install.php | 2 + .../m251208_193926_enabledByOwner.php | 35 ++++++++++++ src/records/Element_SiteSettings.php | 1 + src/services/Elements.php | 57 ++++++++++++++++++- 6 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 src/migrations/m251208_193926_enabledByOwner.php diff --git a/src/elements/db/ElementQuery.php b/src/elements/db/ElementQuery.php index bb52ac08d6d..db2e567dba2 100644 --- a/src/elements/db/ElementQuery.php +++ b/src/elements/db/ElementQuery.php @@ -288,6 +288,13 @@ class ElementQuery extends Query implements ElementQueryInterface Element::STATUS_ENABLED, ]; + /** + * @var bool Whether to only include elements whose owner is also enabled (recursively). + * @used-by enabledByOwner() + * @since 5.9.0 + */ + public bool $enabledByOwner = false; + /** * @var bool Whether to return only archived elements. * @used-by archived() @@ -986,6 +993,16 @@ public function status(array|string|null $value): static return $this; } + /** + * @inheritdoc + * @uses $enabledByOwner + */ + public function enabledByOwner(bool $value = true): static + { + $this->enabledByOwner = $value; + return $this; + } + /** * @inheritdoc * @uses $archived @@ -1708,6 +1725,10 @@ public function prepare($builder): Query $this->subQuery->andWhere(Db::parseNumericParam('elements_sites.id', $this->siteSettingsId)); } + if ($this->enabledByOwner) { + $this->subQuery->andWhere(['elements_sites.enabledByOwner' => true]); + } + if ($this->archived) { $this->subQuery->andWhere(['elements.archived' => true]); } else { diff --git a/src/elements/db/ElementQueryInterface.php b/src/elements/db/ElementQueryInterface.php index c4dc61fc1fb..92f31a46898 100644 --- a/src/elements/db/ElementQueryInterface.php +++ b/src/elements/db/ElementQueryInterface.php @@ -570,6 +570,15 @@ public function fixedOrder(bool $value = true): static; */ public function status(array|string|null $value): static; + /** + * Narrows the query results to those whose owner is also enabled (recursively). + * + * @param bool $value The property value + * @return static self reference + * @since 5.9.0 + */ + public function enabledByOwner(bool $value = true): static; + /** * Sets the [[$archived]] property. * diff --git a/src/migrations/Install.php b/src/migrations/Install.php index f7e6c0452e9..13dba8a37d5 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -382,6 +382,7 @@ public function createTables(): void 'uri' => $this->string(), 'content' => $this->json(), 'enabled' => $this->boolean()->notNull()->defaultValue(true), + 'enabledByOwner' => $this->boolean()->notNull()->defaultValue(true), 'dateCreated' => $this->dateTime()->notNull(), 'dateUpdated' => $this->dateTime()->notNull(), 'uid' => $this->uid(), @@ -907,6 +908,7 @@ public function createIndexes(): void $this->createIndex(null, Table::ELEMENTS_SITES, ['title', 'siteId'], false); $this->createIndex(null, Table::ELEMENTS_SITES, ['slug', 'siteId'], false); $this->createIndex(null, Table::ELEMENTS_SITES, ['enabled'], false); + $this->createIndex(null, Table::ELEMENTS_SITES, ['enabledByOwner'], false); $this->createIndex(null, Table::SYSTEMMESSAGES, ['key', 'language'], true); $this->createIndex(null, Table::SYSTEMMESSAGES, ['language'], false); $this->createIndex(null, Table::ENTRIES, ['postDate'], false); diff --git a/src/migrations/m251208_193926_enabledByOwner.php b/src/migrations/m251208_193926_enabledByOwner.php new file mode 100644 index 00000000000..ae857f4ea69 --- /dev/null +++ b/src/migrations/m251208_193926_enabledByOwner.php @@ -0,0 +1,35 @@ +addColumn(Table::ELEMENTS_SITES, 'enabledByOwner', $this->boolean()->notNull()->defaultValue(true)->after('enabled')); + $this->createIndex(null, Table::ELEMENTS_SITES, ['enabledByOwner'], false); + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + if ($this->db->columnExists(Table::ELEMENTS_SITES, 'enabledByOwner')) { + $this->dropIndexIfExists(Table::ELEMENTS_SITES, 'enabledByOwner'); + $this->dropColumn(Table::ELEMENTS_SITES, 'enabledByOwner'); + } + + return true; + } +} diff --git a/src/records/Element_SiteSettings.php b/src/records/Element_SiteSettings.php index 1814483f5cf..afe9fc9928b 100644 --- a/src/records/Element_SiteSettings.php +++ b/src/records/Element_SiteSettings.php @@ -22,6 +22,7 @@ * @property string|null $uri URI * @property array|string|null $content Content * @property bool $enabled Enabled + * @property bool $enabledByOwner Enabled by owner * @property Element $element Element * @property Site $site Site * @author Pixel & Tonic, Inc. diff --git a/src/services/Elements.php b/src/services/Elements.php index f2cfef4c6e8..e416dfac984 100644 --- a/src/services/Elements.php +++ b/src/services/Elements.php @@ -3691,6 +3691,7 @@ public function propagateElement( * regardless of whether it’s being resaved * @param bool $crossSiteValidate Whether the element should be validated across all supported sites * @param bool $saveContent Whether all the element’s content should be saved. When false (default) only dirty fields will be saved. + * @param bool $statusChanged Whether the element’s `enabled` status just changed * @return bool * @throws ElementNotFoundException if $element has an invalid $id * @throws UnsupportedSiteException if the element is being saved for a site it doesn’t support @@ -3705,6 +3706,7 @@ private function _saveElementInternal( bool $forceTouch = false, bool $crossSiteValidate = false, bool $saveContent = false, + bool $statusChanged = false, ): bool { /** @var ElementInterface&DraftBehavior $element */ $isNewElement = !$element->id; @@ -3835,6 +3837,7 @@ private function _saveElementInternal( $originalPropagateAll, $forceTouch, $saveContent, + $statusChanged, $trackChanges, $dirtyAttributes, $updateSearchIndex, @@ -3854,6 +3857,8 @@ private function _saveElementInternal( $newSiteIds = $element->newSiteIds; $element->newSiteIds = []; + $siteStatusChanged = false; + $transaction = Craft::$app->getDb()->beginTransaction(); try { @@ -3881,11 +3886,18 @@ private function _saveElementInternal( $elementRecord->draftId = (int)$element->draftId ?: null; $elementRecord->revisionId = (int)$element->revisionId ?: null; $elementRecord->fieldLayoutId = $element->fieldLayoutId = (int)($element->fieldLayoutId ?? $fieldLayout->id ?? 0) ?: null; - $elementRecord->enabled = (bool)$element->enabled; $elementRecord->archived = (bool)$element->archived; $elementRecord->dateLastMerged = Db::prepareDateForDb($element->dateLastMerged); $elementRecord->dateDeleted = Db::prepareDateForDb($element->dateDeleted); + // Avoid `enabled` getting marked as dirty if it’s not really changing + if ($isNewElement || $elementRecord->enabled != $element->enabled) { + $elementRecord->enabled = (bool)$element->enabled; + if (!$isNewElement) { + $statusChanged = true; + } + } + if ($isNewElement) { if (isset($element->dateCreated)) { $elementRecord->dateCreated = Db::prepareValueForDb($element->dateCreated); @@ -3961,6 +3973,31 @@ private function _saveElementInternal( $enabledForSite = $element->getEnabledForSite(); if ($siteSettingsRecord->getIsNewRecord() || $siteSettingsRecord->enabled != $enabledForSite) { $siteSettingsRecord->enabled = $enabledForSite; + if (!$siteSettingsRecord->getIsNewRecord()) { + $siteStatusChanged = true; + } + } + + if ($element instanceof NestedElementInterface && $siteSettingsRecord->getIsNewRecord()) { + $ownerId = $element->getPrimaryOwnerId(); + if ($ownerId) { + $ownerStatuses = (new Query()) + ->select(['e.enabled', 'enabledForSite' => 's.enabled', 's.enabledByOwner']) + ->from(['e' => Table::ELEMENTS]) + ->innerJoin(['s' => Table::ELEMENTS_SITES], '[[s.elementId]] = [[e.id]]') + ->where([ + 'e.id' => $ownerId, + 's.siteId' => $element->siteId, + ]) + ->one(); + if ($ownerStatuses) { + $siteSettingsRecord->enabledByOwner = ( + $ownerStatuses['enabled'] && + $ownerStatuses['enabledForSite'] && + $ownerStatuses['enabledByOwner'] + ); + } + } } // Update our list of dirty attributes @@ -4056,6 +4093,10 @@ private function _saveElementInternal( $element->setDirtyAttributes($dirtyAttributes, false); } + if ($statusChanged || $siteStatusChanged) { + $this->updateNestedEnabledByOwnerValues($element->id, $element->siteId, $element->enabled && $enabledForSite); + } + // It is now officially saved $element->afterSave($isNewElement); @@ -4087,7 +4128,7 @@ private function _saveElementInternal( $siteId, $siteElement, crossSiteValidate: $runValidation && $crossSiteValidate, - saveContent: true, + statusChanged: $statusChanged, )) { throw new InvalidConfigException(); } @@ -4219,6 +4260,13 @@ private function _saveElementInternal( return true; } + private function updateNestedEnabledByOwnerValues(int $elementId, int $siteId, bool $enabledByOwner): void + { + // update nested elements' enabledByOwner values, + // but only if they are primarily owned by this element (recursively) + // ... + } + private function updateSearchIndex( ElementInterface $element, array $searchableDirtyFields, @@ -4263,6 +4311,7 @@ private function updateSearchIndex( * @param-out ElementInterface $siteElement * @param bool $crossSiteValidate Whether the element should be validated across all supported sites * @param bool $saveContent Whether the element’s content should be saved + * @param bool $statusChanged Whether the element’s `enabled` status just changed * @retrun bool * @throws Exception if the element couldn't be propagated */ @@ -4272,6 +4321,7 @@ private function _propagateElement( int $siteId, ElementInterface|false|null &$siteElement = null, bool $crossSiteValidate = false, + bool $statusChanged = false, bool $saveContent = true, ): bool { // Make sure the element actually supports the site it's being saved in @@ -4420,7 +4470,8 @@ private function _propagateElement( $crossSiteValidate, false, supportedSites: $supportedSites, - saveContent: $saveContent + saveContent: $saveContent, + statusChanged: $statusChanged, ); if (!$success) {