From 946ac44a327520cf3f7120d51b5ea058d57f7ba2 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Wed, 6 Mar 2024 15:28:43 -0800 Subject: [PATCH 1/6] Redundant --- src/Field.php | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/Field.php b/src/Field.php index 6b06a996..e06cd403 100644 --- a/src/Field.php +++ b/src/Field.php @@ -461,18 +461,6 @@ public function getSettings(): array return $settings; } - /** - * @inheritdoc - */ - public function normalizeValue(mixed $value, ?ElementInterface $element = null): mixed - { - if (!$this->isCpRequest()) { - $value = $this->prepValueForInput($value, $element); - } - - return parent::normalizeValue($value, $element); - } - /** * @inheritdoc */ From edccbc6ce76f15a611523f3b904da91c6736801f Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Thu, 7 Mar 2024 06:31:29 -0800 Subject: [PATCH 2/6] Multi-instance support --- src/Field.php | 412 +++++++++++++++++++++---------------------------- src/Plugin.php | 52 +++++++ 2 files changed, 229 insertions(+), 235 deletions(-) diff --git a/src/Field.php b/src/Field.php index e06cd403..6e808747 100644 --- a/src/Field.php +++ b/src/Field.php @@ -7,10 +7,10 @@ namespace craft\ckeditor; -use Closure; use Craft; use craft\base\ElementContainerFieldInterface; use craft\base\ElementInterface; +use craft\base\FieldInterface; use craft\base\NestedElementInterface; use craft\behaviors\EventBehavior; use craft\ckeditor\events\DefineLinkOptionsEvent; @@ -99,6 +99,11 @@ class Field extends HtmlField implements ElementContainerFieldInterface */ public const EVENT_MODIFY_CONFIG = 'modifyConfig'; + /** + * @var NestedElementManager[] + */ + private static array $entryManagers = []; + /** * @inheritdoc */ @@ -133,6 +138,176 @@ public static function textPartLanguage(): array ->all(); } + /** + * Returns the nested element manager for a given CKEditor field. + * + * @param self $field + * @return NestedElementManager + */ + public static function entryManager(self $field): NestedElementManager + { + if (!isset(self::$entryManagers[$field->id])) { + self::$entryManagers[$field->id] = $entryManager = new NestedElementManager( + Entry::class, + fn(ElementInterface $owner) => self::createEntryQuery($owner, $field), + [ + 'field' => $field, + 'propagationMethod' => match ($field->translationMethod) { + self::TRANSLATION_METHOD_NONE => PropagationMethod::All, + self::TRANSLATION_METHOD_SITE => PropagationMethod::None, + self::TRANSLATION_METHOD_SITE_GROUP => PropagationMethod::SiteGroup, + self::TRANSLATION_METHOD_LANGUAGE => PropagationMethod::Language, + self::TRANSLATION_METHOD_CUSTOM => PropagationMethod::Custom, + }, + 'propagationKeyFormat' => $field->translationKeyFormat, + 'criteria' => [ + 'fieldId' => $field->id, + ], + 'valueGetter' => function(ElementInterface $owner, bool $fetchAll = false) use ($field) { + $entryIds = array_merge(...array_map(function(self $fieldInstance) use ($owner) { + $value = $owner->getFieldValue($fieldInstance->handle); + preg_match_all('/]*>/i', $value, $matches); + return array_map(fn($match) => (int)$match, $matches[1]); + }, self::fieldInstances($owner, $field))); + + $query = self::createEntryQuery($owner, $field); + $query->where(['in', 'elements.id', $entryIds]); + if (!empty($entryIds)) { + $query->orderBy(new FixedOrderExpression('elements.id', $entryIds, Craft::$app->getDb())); + } + + return $query; + }, + 'valueSetter' => false, + ], + ); + $entryManager->on( + NestedElementManager::EVENT_AFTER_DUPLICATE_NESTED_ELEMENTS, + function(DuplicateNestedElementsEvent $event) use ($field) { + self::afterDuplicateNestedElements($event, $field); + }, + ); + $entryManager->on( + NestedElementManager::EVENT_AFTER_CREATE_REVISIONS, + function(DuplicateNestedElementsEvent $event) use ($field) { + self::afterCreateRevisions($event, $field); + }, + ); + } + + return self::$entryManagers[$field->id]; + } + + private static function fieldInstances(ElementInterface $element, self $field): array + { + $customFields = $element->getFieldLayout()?->getCustomFields() ?? []; + return array_values(array_filter($customFields, fn(FieldInterface $f) => $f->id === $field->id)); + } + + private static function createEntryQuery(?ElementInterface $owner, self $field): EntryQuery + { + $query = Entry::find(); + + // Existing element? + if ($owner && $owner->id) { + $query->attachBehavior(self::class, new EventBehavior([ + ElementQuery::EVENT_BEFORE_PREPARE => function( + CancelableEvent $event, + EntryQuery $query, + ) use ($owner) { + $query->ownerId = $owner->id; + + // Clear out id=false if this query was populated previously + if ($query->id === false) { + $query->id = null; + } + + // If the owner is a revision, allow revision entries to be returned as well + if ($owner->getIsRevision()) { + $query + ->revisions(null) + ->trashed(null); + } + }, + ], true)); + + // Prepare the query for lazy eager loading + $query->prepForEagerLoading($field->handle, $owner); + } else { + $query->id = false; + } + + $query + ->fieldId($field->id) + ->siteId($owner->siteId ?? null); + + return $query; + } + + private static function afterDuplicateNestedElements(DuplicateNestedElementsEvent $event, self $field): void + { + $oldEntryIds = array_keys($event->newElementIds); + $newElementIds = array_values($event->newElementIds); + self::adjustFieldValues($event->target, $field, $oldEntryIds, $newElementIds, true); + } + + private static function afterCreateRevisions(DuplicateNestedElementsEvent $event, self $field): void + { + $revisionOwners = [ + $event->target, + ...$event->target->getLocalized()->status(null)->all(), + ]; + + $oldElementIds = array_keys($event->newElementIds); + $newElementIds = array_values($event->newElementIds); + + foreach ($revisionOwners as $revisionOwner) { + self::adjustFieldValues($revisionOwner, $field, $oldElementIds, $newElementIds, false); + } + } + + private static function adjustFieldValues( + ElementInterface $owner, + self $field, + array $oldEntryIds, + array $newEntryIds, + bool $propagate, + ): void { + $resave = false; + + foreach (self::fieldInstances($owner, $field) as $fieldInstance) { + /** @var HtmlFieldData|null $oldValue */ + $oldValue = $owner->getFieldValue($fieldInstance->handle); + $oldValue = $oldValue?->getRawContent(); + + if (!$oldValue || empty($oldEntryIds) || $oldEntryIds === $newEntryIds) { + return; + } + + // and in the field value replace elementIds from original (duplicateOf) with elementIds from the new owner + $newValue = preg_replace_callback( + '/(]*>)/i', + function(array $match) use ($oldEntryIds, $newEntryIds) { + $key = array_search($match[2], $oldEntryIds); + if (isset($newEntryIds[$key])) { + return $match[1] . $newEntryIds[$key] . $match[3]; + } + return $match[1] . $match[2] . $match[3]; + }, + $oldValue, + ); + + if ($oldValue !== $newValue) { + $owner->setFieldValue($fieldInstance->handle, $newValue); + $resave = true; + } + } + + if ($resave) { + Craft::$app->getElements()->saveElement($owner, false, $propagate, false); + } + } + /** * @var string|null The CKEditor config UUID * @since 3.0.0 @@ -194,11 +369,6 @@ public static function textPartLanguage(): array */ private array $_entryTypes = []; - /** - * @see entryManager() - */ - private NestedElementManager $_entryManager; - /** * @inheritdoc */ @@ -230,14 +400,6 @@ public function init(): void } } - /** - * @inheritdoc - */ - public static function isMultiInstance(): bool - { - return false; - } - /** * @inheritdoc */ @@ -320,7 +482,7 @@ public function getSupportedSitesForElement(NestedElementInterface $element): ar return [Craft::$app->getSites()->getPrimarySite()->id]; } - return $this->entryManager()->getSupportedSiteIds($owner); + return self::entryManager($this)->getSupportedSiteIds($owner); } /** @@ -539,68 +701,6 @@ public function getEagerLoadingMap(array $sourceElements): array|null|false ]; } - /** - * @inheritdoc - */ - public function afterElementPropagate(ElementInterface $element, bool $isNew): void - { - $this->entryManager()->maintainNestedElements($element, $isNew); - parent::afterElementPropagate($element, $isNew); - } - - /** - * Performs actions after the nested element has been duplicated. - * - * @param DuplicateNestedElementsEvent $event - */ - public function afterDuplicateNestedElements(DuplicateNestedElementsEvent $event): void - { - $oldEntryIds = array_keys($event->newElementIds); - $newElementIds = array_values($event->newElementIds); - $this->_adjustFieldValue($event->target, $oldEntryIds, $newElementIds, true); - } - - public function afterCreateRevisions(DuplicateNestedElementsEvent $event): void - { - $revisionOwners = [ - $event->target, - ...$event->target->getLocalized()->status(null)->all(), - ]; - - $oldElementIds = array_keys($event->newElementIds); - $newElementIds = array_values($event->newElementIds); - - foreach ($revisionOwners as $revisionOwner) { - $this->_adjustFieldValue($revisionOwner, $oldElementIds, $newElementIds, false); - } - } - - /** - * @inheritdoc - */ - public function beforeElementDelete(ElementInterface $element): bool - { - if (!parent::beforeElementDelete($element)) { - return false; - } - - // Delete any entries that primarily belong to this element - $this->entryManager()->deleteNestedElements($element, $element->hardDelete); - - return true; - } - - /** - * @inheritdoc - */ - public function afterElementRestore(ElementInterface $element): void - { - // Also restore any entries for this element - $this->entryManager()->restoreNestedElements($element); - - parent::afterElementRestore($element); - } - /** * @inheritdoc */ @@ -876,164 +976,6 @@ protected function prepValueForInput($value, ?ElementInterface $element, bool $s return parent::prepValueForInput($value, $element); } - /** - * Instantiate and return the NestedElementManager - * - * @return NestedElementManager - */ - private function entryManager(): NestedElementManager - { - if (!isset($this->_entryManager)) { - $this->_entryManager = new NestedElementManager( - Entry::class, - fn(ElementInterface $owner) => $this->createEntryQuery($owner), - [ - 'field' => $this, - 'propagationMethod' => match ($this->translationMethod) { - self::TRANSLATION_METHOD_NONE => PropagationMethod::All, - self::TRANSLATION_METHOD_SITE => PropagationMethod::None, - self::TRANSLATION_METHOD_SITE_GROUP => PropagationMethod::SiteGroup, - self::TRANSLATION_METHOD_LANGUAGE => PropagationMethod::Language, - self::TRANSLATION_METHOD_CUSTOM => PropagationMethod::Custom, - }, - 'propagationKeyFormat' => $this->translationKeyFormat, - 'criteria' => [ - 'fieldId' => $this->id, - ], - 'valueGetter' => $this->_entryManagerValueGetter(), - 'valueSetter' => false, - ], - ); - $this->_entryManager->on( - NestedElementManager::EVENT_AFTER_DUPLICATE_NESTED_ELEMENTS, - [$this, 'afterDuplicateNestedElements'], - ); - $this->_entryManager->on( - NestedElementManager::EVENT_AFTER_CREATE_REVISIONS, - [$this, 'afterCreateRevisions'], - ); - } - - return $this->_entryManager; - } - - /** - * Returns an array of entryIds that are present in the string (field value). - * - * @param string $string - * @return array - */ - private function _getEntryIdsFromString(?string $string): array - { - if ($string === null) { - return []; - } - - preg_match_all('/]*>/is', $string, $matches); - - return array_map(fn($match) => (int)$match, $matches[1]); - } - - /** - * Used to get value via NestedElementManager->getValue(); - * - * @return Closure - */ - private function _entryManagerValueGetter(): Closure - { - return function(ElementInterface $owner, bool $fetchAll = false) { - $value = $owner->getFieldValue($this->handle); - $entryIds = $this->_getEntryIdsFromString($value); - - $query = $this->createEntryQuery($owner); - $query->where(['in', 'elements.id', $entryIds]); - if (!empty($entryIds)) { - $query->orderBy(new FixedOrderExpression('elements.id', $entryIds, Craft::$app->getDb())); - } - - return $query; - }; - } - - /** - * Adjusts owner element's CKE field value with updated nested element ids. - * E.g. on draft apply, propagation to a new site, revision creation etc - * - * @param ElementInterface $owner - * @param array $oldEntryIds - * @param array $newEntryIds - */ - private function _adjustFieldValue(ElementInterface $owner, array $oldEntryIds, array $newEntryIds, bool $propagate): void - { - /** @var HtmlFieldData|null $oldValue */ - $oldValue = $owner->getFieldValue($this->handle); - $oldValue = $oldValue?->getRawContent(); - - if (!$oldValue || empty($oldEntryIds) || $oldEntryIds === $newEntryIds) { - return; - } - - // and in the field value replace elementIds from original (duplicateOf) with elementIds from the new owner - $newValue = preg_replace_callback( - '/(]*>)/i', - function(array $match) use ($oldEntryIds, $newEntryIds) { - $key = array_search($match[2], $oldEntryIds); - if (isset($newEntryIds[$key])) { - return $match[1] . $newEntryIds[$key] . $match[3]; - } - return $match[1] . $match[2] . $match[3]; - }, - $oldValue, - ); - - if ($oldValue !== $newValue) { - $owner->setFieldValue($this->handle, $newValue); - $owner->mergingCanonicalChanges = true; - - Craft::$app->getElements()->saveElement($owner, false, $propagate, false); - } - } - - private function createEntryQuery(?ElementInterface $owner): EntryQuery - { - $query = Entry::find(); - - // Existing element? - if ($owner && $owner->id) { - $query->attachBehavior(self::class, new EventBehavior([ - ElementQuery::EVENT_BEFORE_PREPARE => function( - CancelableEvent $event, - EntryQuery $query, - ) use ($owner) { - $query->ownerId = $owner->id; - - // Clear out id=false if this query was populated previously - if ($query->id === false) { - $query->id = null; - } - - // If the owner is a revision, allow revision entries to be returned as well - if ($owner->getIsRevision()) { - $query - ->revisions(null) - ->trashed(null); - } - }, - ], true)); - - // Prepare the query for lazy eager loading - $query->prepForEagerLoading($this->handle, $owner); - } else { - $query->id = false; - } - - $query - ->fieldId($this->id) - ->siteId($owner->siteId ?? null); - - return $query; - } - /** * Returns entry type options in form of an array with 'label' and 'value' keys for each option. * diff --git a/src/Plugin.php b/src/Plugin.php index 4bb37cf5..0540181b 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -8,9 +8,12 @@ namespace craft\ckeditor; use Craft; +use craft\base\Element; use craft\ckeditor\web\assets\BaseCkeditorPackageAsset; use craft\ckeditor\web\assets\ckeditor\CkeditorAsset; +use craft\elements\NestedElementManager; use craft\events\AssetBundleEvent; +use craft\events\ModelEvent; use craft\events\RegisterComponentTypesEvent; use craft\events\RegisterUrlRulesEvent; use craft\services\Fields; @@ -85,6 +88,55 @@ public function init() } } }); + + // keep track of which elements we're already in the middle of working on, + // so we don't end up redundantly maintaining nested elements + $activeElements = []; + + Event::on(Element::class, Element::EVENT_AFTER_PROPAGATE, function(ModelEvent $event) use (&$activeElements) { + /** @var Element $element */ + $element = $event->sender; + if (isset($activeElements[$element->id])) { + return; + } + $activeElements[$element->id] = true; + foreach ($this->entryManagers($element) as $entryManager) { + $entryManager->maintainNestedElements($element, $event->isNew); + } + unset($activeElements[$element->id]); + }); + + Event::on(Element::class, Element::EVENT_BEFORE_DELETE, function(ModelEvent $event) { + /** @var Element $element */ + $element = $event->sender; + foreach ($this->entryManagers($element) as $entryManager) { + // Delete any entries that primarily belong to this element + $entryManager->deleteNestedElements($element, $element->hardDelete); + } + }); + + Event::on(Element::class, Element::EVENT_AFTER_RESTORE, function(Event $event) { + /** @var Element $element */ + $element = $event->sender; + foreach ($this->entryManagers($element) as $entryManager) { + $entryManager->restoreNestedElements($element); + } + }); + } + + /** + * @param Element $element + * @return NestedElementManager[] + */ + private function entryManagers(Element $element): array + { + $entryManagers = []; + foreach ($element->getFieldLayout()?->getCustomFields() as $field) { + if ($field instanceof Field && !isset($entryManagers[$field->id])) { + $entryManagers[$field->id] = Field::entryManager($field); + } + } + return array_values($entryManagers); } public function getCkeConfigs(): CkeConfigs From 46725fb63dc0a0ca7c26d12ecfc0bad1a1158419 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Thu, 7 Mar 2024 08:05:12 -0800 Subject: [PATCH 3/6] =?UTF-8?q?return=20=E2=86=92=20continue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Field.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Field.php b/src/Field.php index 6e808747..c6e861f3 100644 --- a/src/Field.php +++ b/src/Field.php @@ -281,7 +281,7 @@ private static function adjustFieldValues( $oldValue = $oldValue?->getRawContent(); if (!$oldValue || empty($oldEntryIds) || $oldEntryIds === $newEntryIds) { - return; + continue; } // and in the field value replace elementIds from original (duplicateOf) with elementIds from the new owner From 32f4f00ada8f806c3f7cbac6ab6d6f25f944f659 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Thu, 7 Mar 2024 08:06:39 -0800 Subject: [PATCH 4/6] Only check $oldEntryIds / $newEntryIds once --- src/Field.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Field.php b/src/Field.php index c6e861f3..f6c0d3e5 100644 --- a/src/Field.php +++ b/src/Field.php @@ -273,6 +273,10 @@ private static function adjustFieldValues( array $newEntryIds, bool $propagate, ): void { + if (empty($oldEntryIds) || $oldEntryIds === $newEntryIds) { + return; + } + $resave = false; foreach (self::fieldInstances($owner, $field) as $fieldInstance) { @@ -280,7 +284,7 @@ private static function adjustFieldValues( $oldValue = $owner->getFieldValue($fieldInstance->handle); $oldValue = $oldValue?->getRawContent(); - if (!$oldValue || empty($oldEntryIds) || $oldEntryIds === $newEntryIds) { + if (!$oldValue) { continue; } From 4903dc09230f3bb9581e9bdb960c8800499879e1 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Thu, 7 Mar 2024 11:27:05 -0800 Subject: [PATCH 5/6] `@since` --- src/Field.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Field.php b/src/Field.php index f6c0d3e5..69efdc62 100644 --- a/src/Field.php +++ b/src/Field.php @@ -143,6 +143,7 @@ public static function textPartLanguage(): array * * @param self $field * @return NestedElementManager + * @since 4.0.0 */ public static function entryManager(self $field): NestedElementManager { From 240a84ad7213ee18cc2795475a99c94da27af9dc Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Thu, 7 Mar 2024 11:27:08 -0800 Subject: [PATCH 6/6] Release notes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cc68957..34ad272c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ ## Unreleased +- CKEditor fields now support multiple instances. ([#188](https://github.com/craftcms/ckeditor/pull/188)) - CKEditor config edit pages now warn when switching the Config Options setting from JavaScript to JSON if the JavaScript code contains any functions. ([#152](https://github.com/craftcms/ckeditor/issues/152), [#180](https://github.com/craftcms/ckeditor/pull/180)) +- Added `craft\ckeditor\Field::entryManager()`. - Fixed a bug where the “Link to an asset” option was showing up when there weren’t any available volumes with URLs. ([#179](https://github.com/craftcms/ckeditor/issues/179)) - Fixed a bug where an error occurred when editing an unsaved element with a CKEditor field. ([#181](https://github.com/craftcms/ckeditor/issues/181)) - Fixed a bug where “New entry” menus weren’t listing entry types in the field-defined order. ([#185](https://github.com/craftcms/ckeditor/issues/185))