From 30627244ad33b35caf146972318166161b4f54d4 Mon Sep 17 00:00:00 2001
From: Nathaniel Hammond <nathaniel@pixelandtonic.com>
Date: Tue, 8 Oct 2024 08:20:56 +0100
Subject: [PATCH 1/3] Add ability to have multiple element selection condition
 rules

---
 CHANGELOG-WIP.md                              |   3 +
 .../BaseElementsSelectConditionRule.php       | 237 ++++++++++++++++++
 .../entries/EntriesConditionRule.php          |  68 +++++
 .../conditions/entries/EntryCondition.php     |   1 +
 src/translations/en/app.php                   |   2 +
 5 files changed, 311 insertions(+)
 create mode 100644 src/base/conditions/BaseElementsSelectConditionRule.php
 create mode 100644 src/elements/conditions/entries/EntriesConditionRule.php

diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md
index ac8184c5346..f415ad390e8 100644
--- a/CHANGELOG-WIP.md
+++ b/CHANGELOG-WIP.md
@@ -7,6 +7,7 @@
 - Action button cells within editable tables are now center-aligned vertically.
 - Dropdown cells within editable tables are no longer center-aligned. ([#15742](https://github.com/craftcms/cms/issues/15742))
 - Link fields marked as translatable now swap the selected element with the localized version when their value is getting propagated to a new site for a freshly-created element. ([#15821](https://github.com/craftcms/cms/issues/15821))
+- Entry conditions can now have a “Entries” rule.
 
 ### Accessibility
 - Improved the control panel for screen readers. ([#15665](https://github.com/craftcms/cms/pull/15665))
@@ -20,7 +21,9 @@
 - Added the `--except`, `--minor-only`, and `--patch-only` options to the `update` command. ([#15829](https://github.com/craftcms/cms/pull/15829))
 
 ### Extensibility
+- Added `craft\base\conditions\BaseElementsSelectConditionRule`.
 - Added `craft\base\RequestTrait::getIsWebRequest()`. ([#15690](https://github.com/craftcms/cms/pull/15690))
+- Added `craft\elements\condition\EntriesConditionRule`.
 - Added `craft\events\DefineAddressCountriesEvent`. ([#15711](https://github.com/craftcms/cms/pull/15711))
 - Added `craft\filters\BasicHttpAuthLogin`. ([#15720](https://github.com/craftcms/cms/pull/15720))
 - Added `craft\filters\BasicHttpAuthStatic`. ([#15720](https://github.com/craftcms/cms/pull/15720))
diff --git a/src/base/conditions/BaseElementsSelectConditionRule.php b/src/base/conditions/BaseElementsSelectConditionRule.php
new file mode 100644
index 00000000000..0d5e73fb9b5
--- /dev/null
+++ b/src/base/conditions/BaseElementsSelectConditionRule.php
@@ -0,0 +1,237 @@
+<?php
+
+namespace craft\base\conditions;
+
+use Craft;
+use craft\base\ElementInterface;
+use craft\elements\conditions\ElementCondition;
+use craft\elements\conditions\ElementConditionInterface;
+use craft\helpers\App;
+use craft\helpers\Cp;
+use stdClass;
+use yii\base\Exception;
+
+/**
+ * BaseElementsSelectConditionRule provides a base implementation for element query condition rules that are composed of an multi-element select input.
+ *
+ * @property int|null $elementId
+ * @author Pixel & Tonic, Inc. <support@pixelandtonic.com>
+ * @since 5.5.0
+ */
+abstract class BaseElementsSelectConditionRule extends BaseConditionRule
+{
+    /**
+     * @var string|array|null
+     * @see getElementIds()
+     * @see setElementIds()
+     */
+    private string|array|null $_elementIds = null;
+
+    /**
+     * @inheritdoc
+     */
+    public string $operator = self::OPERATOR_IN;
+
+    /**
+     * Returns the element type that can be selected.
+     *
+     * @return string
+     */
+    abstract protected function elementType(): string;
+
+    /**
+     * Returns the element source(s) that the element can be selected from.
+     *
+     * @return array|null
+     */
+    protected function sources(): ?array
+    {
+        return null;
+    }
+
+    /**
+     * Returns the element condition that filters which elements can be selected.
+     *
+     * @return ElementConditionInterface|null
+     */
+    protected function selectionCondition(): ?ElementConditionInterface
+    {
+        return null;
+    }
+
+    /**
+     * Returns the criteria that determines which elements can be selected.
+     *
+     * @return array|null
+     */
+    protected function criteria(): ?array
+    {
+        return null;
+    }
+
+    /**
+     * @inheritdoc
+     */
+    protected function operators(): array
+    {
+        return array_merge(parent::operators(), [
+            self::OPERATOR_IN,
+            self::OPERATOR_NOT_IN,
+        ]);
+    }
+
+    /**
+     * @param bool $parse Whether to parse the value for an environment variable
+     * @return array|string|null
+     * @throws Exception
+     * @throws \Throwable
+     */
+    public function getElementIds(bool $parse = true): array|string|null
+    {
+        if ($parse && is_string($this->_elementIds)) {
+            $elementId = App::parseEnv($this->_elementIds);
+            if ($this->condition instanceof ElementCondition && isset($this->condition->referenceElement)) {
+                $referenceElement = $this->condition->referenceElement;
+            } else {
+                $referenceElement = new stdClass();
+            }
+
+            $elementIds = Craft::$app->getView()->renderObjectTemplate($elementId, $referenceElement);
+
+            if (str_contains($elementIds, ',')) {
+                $elementIds = explode(',', $elementIds);
+            }
+
+            return $elementIds;
+        }
+
+        return $this->_elementIds;
+    }
+
+    /**
+     * @param array|string|null $elementIds
+     * @phpstan-param array<int|string>|int|string|null $elementIds
+     */
+    public function setElementIds(array|string|null $elementIds): void
+    {
+        $this->_elementIds = $elementIds ?: null;
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function getConfig(): array
+    {
+        return array_merge(parent::getConfig(), [
+            'elementIds' => $this->getElementIds(false),
+        ]);
+    }
+
+    /**
+     * @inheritdoc
+     */
+    protected function inputHtml(): string
+    {
+        if ($this->getCondition()->forProjectConfig) {
+            return Cp::autosuggestFieldHtml([
+                'suggestEnvVars' => true,
+                'suggestionFilter' => fn($value) => is_string($value) && strlen($value) > 0,
+                'required' => true,
+                'id' => 'elementIds',
+                'class' => 'code',
+                'name' => 'elementIds',
+                'value' => $this->getElementIds(false),
+                'tip' => Craft::t('app', 'This can be set to an environment variable, or a Twig template that outputs a comma separated list of IDs.'),
+                'placeholder' => Craft::t('app', '{type} IDs', [
+                    'type' => $this->elementType()::displayName(),
+                ]),
+            ]);
+        }
+
+        $elements = $this->_elements();
+
+        return Cp::elementSelectHtml([
+            'name' => 'elementIds',
+            'elements' => $elements ?: [],
+            'elementType' => $this->elementType(),
+            'sources' => $this->sources(),
+            'criteria' => $this->criteria(),
+            'condition' => $this->selectionCondition(),
+            'single' => false,
+        ]);
+    }
+
+    /**
+     * @return ElementInterface[]|null
+     * @throws Exception
+     * @throws \Throwable
+     */
+    private function _elements(): ?array
+    {
+        $elementIds = $this->getElementIds();
+        if (!$elementIds) {
+            return null;
+        }
+
+        /** @var string|ElementInterface $elementType */
+        /** @phpstan-var class-string<ElementInterface>|ElementInterface $elementType */
+        $elementType = $this->elementType();
+        return $elementType::find()
+            ->id($elementIds)
+            ->status(null)
+            ->all();
+    }
+
+    /**
+     * @inheritdoc
+     */
+    protected function defineRules(): array
+    {
+        $rules = parent::defineRules();
+        $rules[] = [['elementIds'], 'safe'];
+        return $rules;
+    }
+
+    /**
+     * Returns whether the condition rule matches the given value.
+     *
+     * @param ElementInterface|int|array|null $value
+     * @return bool
+     * @throws Exception
+     * @throws \Throwable
+     */
+    protected function matchValue(mixed $value): bool
+    {
+        $elementIds = $this->getElementIds();
+
+        if (!$elementIds) {
+            return true;
+        }
+
+        if (!$value) {
+            return false;
+        }
+
+        if ($value instanceof ElementInterface) {
+            $value = [$value->id];
+        } elseif (is_numeric($value)) {
+            $value = [(int)$value];
+        } elseif (is_array($value)) {
+            $values = [];
+            foreach ($value as $val) {
+                if ($val instanceof ElementInterface) {
+                    $values[] = $val->id;
+                } elseif (is_numeric($val)) {
+                    $values[] = (int)$val;
+                }
+            }
+            $value = $values;
+        }
+
+        return match ($this->operator) {
+            self::OPERATOR_IN => !empty(array_intersect($value, $elementIds)),
+            self::OPERATOR_NOT_IN => empty(array_intersect($value, $elementIds)),
+            default => false,
+        };
+    }
+}
diff --git a/src/elements/conditions/entries/EntriesConditionRule.php b/src/elements/conditions/entries/EntriesConditionRule.php
new file mode 100644
index 00000000000..4d76e839049
--- /dev/null
+++ b/src/elements/conditions/entries/EntriesConditionRule.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace craft\elements\conditions\entries;
+
+use Craft;
+use craft\base\conditions\BaseElementsSelectConditionRule;
+use craft\base\ElementInterface;
+use craft\elements\conditions\ElementConditionRuleInterface;
+use craft\elements\db\ElementQueryInterface;
+use craft\elements\db\EntryQuery;
+use craft\elements\Entry;
+use craft\helpers\ArrayHelper;
+
+/**
+ * Entries condition rule.
+ *
+ * @author Pixel & Tonic, Inc. <support@pixelandtonic.com>
+ * @since 5.5.0
+ */
+class EntriesConditionRule extends BaseElementsSelectConditionRule implements ElementConditionRuleInterface
+{
+    /**
+     * @inheritdoc
+     */
+    public function getLabel(): string
+    {
+        return Craft::t('app', 'Entries');
+    }
+
+    /**
+     * @inheritdoc
+     */
+    protected function elementType(): string
+    {
+        return Entry::class;
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function getExclusiveQueryParams(): array
+    {
+        return ['id'];
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function modifyQuery(ElementQueryInterface $query): void
+    {
+        $elementIds = $this->getElementIds();
+
+        if ($this->operator === self::OPERATOR_NOT_IN) {
+            ArrayHelper::prependOrAppend($elementIds, 'not', true);
+        }
+        /** @var EntryQuery $query */
+        $query->id($elementIds);
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function matchElement(ElementInterface $element): bool
+    {
+        /** @var Entry $element */
+        return $this->matchValue($element->id);
+    }
+}
diff --git a/src/elements/conditions/entries/EntryCondition.php b/src/elements/conditions/entries/EntryCondition.php
index de929730771..fcd5f386446 100644
--- a/src/elements/conditions/entries/EntryCondition.php
+++ b/src/elements/conditions/entries/EntryCondition.php
@@ -22,6 +22,7 @@ protected function selectableConditionRules(): array
         return array_merge(parent::selectableConditionRules(), [
             AuthorConditionRule::class,
             AuthorGroupConditionRule::class,
+            EntriesConditionRule::class,
             ExpiryDateConditionRule::class,
             HasDescendantsRule::class,
             LevelConditionRule::class,
diff --git a/src/translations/en/app.php b/src/translations/en/app.php
index 3b37f29cfff..bd1840e4433 100644
--- a/src/translations/en/app.php
+++ b/src/translations/en/app.php
@@ -1653,6 +1653,7 @@
     'This can be set to an environment variable with a valid language ID ({examples}).' => 'This can be set to an environment variable with a valid language ID ({examples}).',
     'This can be set to an environment variable with a value of a [supported time zone]({url}).' => 'This can be set to an environment variable with a value of a [supported time zone]({url}).',
     'This can be set to an environment variable, or a Twig template that outputs an ID.' => 'This can be set to an environment variable, or a Twig template that outputs an ID.',
+    'This can be set to an environment variable, or a Twig template that outputs a comma separated list of IDs.' => 'This can be set to an environment variable, or a Twig template that outputs a comma separated list of IDs.',
     'This can be set to an environment variable, or begin with an alias.' => 'This can be set to an environment variable, or begin with an alias.',
     'This can be set to an environment variable.' => 'This can be set to an environment variable.',
     'This draft’s entry type is no longer available. You can still view it, but not apply it.' => 'This draft’s entry type is no longer available. You can still view it, but not apply it.',
@@ -2155,6 +2156,7 @@
     '{type} Condition' => '{type} Condition',
     '{type} Criteria' => '{type} Criteria',
     '{type} ID' => '{type} ID',
+    '{type} IDs' => '{type} IDs',
     '{type} Per Page' => '{type} Per Page',
     '{type} Settings' => '{type} Settings',
     '{type} Sources' => '{type} Sources',

From 3d55d44019427435271709d1e5f231f4f0a4fa8a Mon Sep 17 00:00:00 2001
From: Nathaniel Hammond <nathaniel@pixelandtonic.com>
Date: Tue, 8 Oct 2024 08:22:22 +0100
Subject: [PATCH 2/3] Changelog tweak

---
 CHANGELOG-WIP.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md
index 2ee3d98cd2c..fe72d2c8804 100644
--- a/CHANGELOG-WIP.md
+++ b/CHANGELOG-WIP.md
@@ -8,7 +8,7 @@
 - Dropdown cells within editable tables are no longer center-aligned. ([#15742](https://github.com/craftcms/cms/issues/15742))
 - Link fields marked as translatable now swap the selected element with the localized version when their value is getting propagated to a new site for a freshly-created element. ([#15821](https://github.com/craftcms/cms/issues/15821))
 - Pressing <kbd>Return</kbd> when an inline-editable field is focused now submits the inline form. (Previously <kbd>Ctrl</kbd>/<kbd>Command</kbd> had to be pressed as well.) ([#15841](https://github.com/craftcms/cms/issues/15841))
-- Entry conditions can now have a “Entries” rule.
+- Entry conditions can now have an “Entries” rule.
 
 ### Accessibility
 - Improved the control panel for screen readers. ([#15665](https://github.com/craftcms/cms/pull/15665))

From ab629155cfd3a42ba20e8d79bf256d938673cd82 Mon Sep 17 00:00:00 2001
From: Nathaniel Hammond <nathaniel@pixelandtonic.com>
Date: Tue, 8 Oct 2024 09:17:03 +0100
Subject: [PATCH 3/3] PHPstan fix

---
 src/base/conditions/BaseElementsSelectConditionRule.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/base/conditions/BaseElementsSelectConditionRule.php b/src/base/conditions/BaseElementsSelectConditionRule.php
index 0d5e73fb9b5..65a844b3b08 100644
--- a/src/base/conditions/BaseElementsSelectConditionRule.php
+++ b/src/base/conditions/BaseElementsSelectConditionRule.php
@@ -110,7 +110,7 @@ public function getElementIds(bool $parse = true): array|string|null
 
     /**
      * @param array|string|null $elementIds
-     * @phpstan-param array<int|string>|int|string|null $elementIds
+     * @phpstan-param array<int|string>|string|null $elementIds
      */
     public function setElementIds(array|string|null $elementIds): void
     {