From 17701ce4a04aa476e3f4b9ce1e49b234522bd400 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 8 Sep 2026 17:01:00 -0700 Subject: [PATCH 01/13] Initial work on condition rule groups --- src/Condition/BaseCondition.php | 279 +++++++++++------- src/Condition/ConditionRuleGroup.php | 47 +++ .../Contracts/ConditionInterface.php | 10 +- src/Element/Conditions/ElementCondition.php | 45 ++- src/Field/Fields.php | 22 +- src/Http/Controllers/ConditionsController.php | 21 +- .../ElementSelectorModalController.php | 29 +- 7 files changed, 318 insertions(+), 135 deletions(-) create mode 100644 src/Condition/ConditionRuleGroup.php diff --git a/src/Condition/BaseCondition.php b/src/Condition/BaseCondition.php index 89e81114d7f..bdd4eb359a9 100644 --- a/src/Condition/BaseCondition.php +++ b/src/Condition/BaseCondition.php @@ -32,6 +32,11 @@ abstract class BaseCondition extends Component implements ConditionInterface { use LegacyConstants; + public static function supportsGroups(): bool + { + return false; + } + /** * @var string The condition builder container tag name */ @@ -73,12 +78,12 @@ abstract class BaseCondition extends Component implements ConditionInterface * @see getConditionRules() * @see setConditionRules() * - * @var Collection + * @var Collection|Collection */ private Collection $_conditionRules; /** - * @var ConditionRuleInterface[] The rules this condition is configured with + * @var ConditionRuleInterface[]|ConditionRuleGroup[] The rules this condition is configured with, or condition groups if {@see supportsGroups()} is `true`. */ public array $conditionRules { get => $this->getConditionRules(); @@ -191,28 +196,40 @@ public function getConditionRules(): array return $this->_conditionRules->all(); } - /** @param array $rules */ + /** @param array $rules */ public function setConditionRules(array $rules): void { $this->_conditionRules = Collection::make(); - $projectConfig = app(ProjectConfig::class); + app(ProjectConfig::class); + + $group = -1; foreach ($rules as $rule) { - if (! $rule instanceof ConditionRuleInterface) { - try { - $rule = $this->createConditionRule($rule); - } catch (InvalidArgumentException $e) { - Log::warning("Invalid condition rule: {$e->getMessage()}"); + $isGroup = static::supportsGroups() && ( + $rule instanceof ConditionRuleGroup || + (is_array($rule) && isset($rule['conditionRules'])) + ); + + // starting a new group? + if ($group === -1 || $isGroup) { + $group++; + } + + if ($isGroup) { + $groupRules = $rule instanceof ConditionRuleGroup ? $rule->conditionRules->all() : $rule['conditionRules']; + foreach ($groupRules as $r) { + $r = $this->normalizeConditionRule($r); - continue; + if ($r !== null) { + $this->addConditionRule($r, $group); + } } - } + } else { + $rule = $this->normalizeConditionRule($rule); - // Don't validate the rule when we're applying project config changes. - // The rule type might depend on something that hasn't been added yet. - if ($projectConfig->isApplyingExternalChanges || $this->validateConditionRule($rule)) { - $this->_conditionRules->add($rule); - $rule->setCondition($this); + if ($rule !== null) { + $this->addConditionRule($rule, $group); + } } } @@ -221,14 +238,39 @@ public function setConditionRules(array $rules): void $this->_selectableConditionRules = null; } - public function addConditionRule(ConditionRuleInterface $rule): void + /** @param ConditionRuleInterface|array{class: string}|array{type: string}|string $rule */ + private function normalizeConditionRule(ConditionRuleInterface|array|string $rule): ?ConditionRuleInterface + { + if ($rule instanceof ConditionRuleInterface) { + return $rule; + } + + try { + return $this->createConditionRule($rule); + } catch (InvalidArgumentException $e) { + Log::warning("Invalid condition rule: {$e->getMessage()}"); + + return null; + } + } + + public function addConditionRule(ConditionRuleInterface $rule, int $group = 0): void { - if (! $this->validateConditionRule($rule)) { + // Don't validate the rule when we're applying project config changes. + // The rule type might depend on something that hasn't been added yet. + if (! app(ProjectConfig::class)->isApplyingExternalChanges && ! $this->validateConditionRule($rule)) { throw new InvalidArgumentException('Invalid condition rule'); } $rule->setCondition($this); - $this->_conditionRules->add($rule); + + if (static::supportsGroups()) { + /** @var ConditionRuleGroup $group */ + $conditionGroup = $this->_conditionRules->getOrPut($group, fn () => new ConditionRuleGroup); + $conditionGroup->conditionRules->add($rule); + } else { + $this->_conditionRules->add($rule); + } // Clear caches $this->_selectableConditionRules = null; @@ -298,97 +340,21 @@ public function getBuilderInnerHtml(bool $autofocusAddButton = false): string $html .= Html::hiddenInput('class', static::class); $html .= Html::hiddenInput('config', Json::encode($this->getBuilderConfig())); - foreach ($this->getConditionRules() as $rule) { - try { - $allRulesHtml .= InputNamespace::namespaceInputs(function () use ($rule, $ruleNum, $selectableRules) { - $ruleHtml = - Html::tag('legend', t('Condition {num, number}', [ - 'num' => $ruleNum, - ]), [ - 'class' => 'visually-hidden', - ]). - Html::hiddenInput('uid', $rule->uid). - Html::hiddenInput('class', $rule::class); - - if ($this->sortable) { - $ruleHtml .= Html::tag('div', - Html::tag('a', '', [ - 'class' => ['move', 'icon', 'draggable-handle'], - ]), - [ - 'class' => ['rule-move'], - ] - ); - } - - $ruleValue = Json::encode($rule->getConfig()); - $labelId = "$this->id-type-label"; - - $ruleHtml .= - // Rule type selector - Html::beginTag('div', ['class' => 'rule-switcher']). - Html::hiddenLabel(t('Rule Type'), 'type', [ - 'id' => $labelId, - ]). - $this->_ruleTypeMenu($selectableRules, $rule, $ruleValue, [ - 'icon' => 'chevron-down', - 'icon-position' => 'suffix', - 'aria' => [ - 'labelledby' => $labelId, - ], - ]). - Html::endTag('div'). - // Rule HTML - Html::tag('div', $rule->getHtml(), [ - 'class' => ['rule-body', 'flex items-center gap-1 flex-grow'], - ]). - // Remove button - Html::beginTag('div', [ - 'class' => ['rule-actions'], - ]). - Html::tag('craft-button', '', [ - 'type' => 'button', - 'icon' => 'x', - 'aria-label' => t('Remove'), - 'variant' => 'danger-plain', - 'size' => 'small', - 'hx' => [ - 'vals' => ['uid' => $rule->uid], - 'post' => Url::actionUrl('conditions/remove-rule'), - ], - ]). - Html::endTag('div'); - - return Html::tag('fieldset', $ruleHtml, [ - 'class' => ['condition-rule', 'flex', 'flex-start', 'draggable'], - ]); - }, 'conditionRules['.$ruleNum.']'); - } catch (Throwable) { - // The rule is misconfigured - continue; - } + if (static::supportsGroups()) { + /** @var ConditionRuleGroup[] $groups */ + $groups = $this->getConditionRules(); + foreach ($groups as $i => $group) { + /** @var ConditionRuleGroup $group */ + $html .= $this->getConditionRuleGroupHtml($group->conditionRules->all()); - $ruleNum++; - } - - $rulesJs = HtmlStack::clearJsBuffer(false); - - if ($rulesJs) { - HtmlStack::js($rulesJs); + if ($i < count($groups) - 1) { + $html .= '
--OR--
'; + } + } + } else { + $html .= $this->getConditionRuleGroupHtml($this->getConditionRules()); } - // Sortable rules div - $html .= Html::tag('div', $allRulesHtml, [ - 'class' => array_filter([ - 'condition', - $this->sortable ? 'sortable' : null, - ]), - 'hx' => [ - 'post' => Url::actionUrl('conditions/render'), - 'trigger' => 'end', // sortable library triggers this event - ], - ]); - $html .= Html::beginTag('div', [ 'class' => ['condition-footer', 'flex', 'flex-nowrap'], @@ -437,6 +403,103 @@ public function getBuilderInnerHtml(bool $autofocusAddButton = false): string }, $this->name); } + /** @param ConditionRuleInterface[] $rules */ + private function getConditionRuleGroupHtml(array $rules): string + { + $allRulesHtml = ''; + + foreach ($rules as $rule) { + try { + $allRulesHtml .= InputNamespace::namespaceInputs(function () use ($rule, $ruleNum, $selectableRules) { + $ruleHtml = + Html::tag('legend', t('Condition {num, number}', [ + 'num' => $ruleNum, + ]), [ + 'class' => 'visually-hidden', + ]). + Html::hiddenInput('uid', $rule->uid). + Html::hiddenInput('class', $rule::class); + + if ($this->sortable) { + $ruleHtml .= Html::tag('div', + Html::tag('a', '', [ + 'class' => ['move', 'icon', 'draggable-handle'], + ]), + [ + 'class' => ['rule-move'], + ] + ); + } + + $ruleValue = Json::encode($rule->getConfig()); + $labelId = "$this->id-type-label"; + + $ruleHtml .= + // Rule type selector + Html::beginTag('div', ['class' => 'rule-switcher']). + Html::hiddenLabel(t('Rule Type'), 'type', [ + 'id' => $labelId, + ]). + $this->_ruleTypeMenu($selectableRules, $rule, $ruleValue, [ + 'icon' => 'chevron-down', + 'icon-position' => 'suffix', + 'aria' => [ + 'labelledby' => $labelId, + ], + ]). + Html::endTag('div'). + // Rule HTML + Html::tag('div', $rule->getHtml(), [ + 'class' => ['rule-body', 'flex items-center gap-1 flex-grow'], + ]). + // Remove button + Html::beginTag('div', [ + 'class' => ['rule-actions'], + ]). + Html::tag('craft-button', '', [ + 'type' => 'button', + 'icon' => 'x', + 'aria-label' => t('Remove'), + 'variant' => 'danger-plain', + 'size' => 'small', + 'hx' => [ + 'vals' => ['uid' => $rule->uid], + 'post' => Url::actionUrl('conditions/remove-rule'), + ], + ]). + Html::endTag('div'); + + return Html::tag('fieldset', $ruleHtml, [ + 'class' => ['condition-rule', 'flex', 'flex-start', 'draggable'], + ]); + }, 'conditionRules['.$ruleNum.']'); + } catch (Throwable) { + // The rule is misconfigured + continue; + } + + $ruleNum++; + } + + $rulesJs = HtmlStack::clearJsBuffer(false); + + if ($rulesJs) { + HtmlStack::js($rulesJs); + } + + // Sortable rules div + return Html::tag('div', $allRulesHtml, [ + 'class' => array_filter([ + 'condition', + $this->sortable ? 'sortable' : null, + ]), + 'hx' => [ + 'post' => Url::actionUrl('conditions/render'), + 'trigger' => 'end', // sortable library triggers this event + ], + ]); + } + /** * @param ConditionRuleInterface[] $selectableRules * @param array $buttonAttributes @@ -575,7 +638,7 @@ final public function getConfig(): array return array_merge($this->config(), [ 'class' => static::class, 'conditionRules' => $this->_conditionRules - ->map(function (ConditionRuleInterface $rule) { + ->map(function (ConditionRuleInterface|ConditionRuleGroup $rule) { try { return $rule->getConfig(); } catch (RuntimeException) { diff --git a/src/Condition/ConditionRuleGroup.php b/src/Condition/ConditionRuleGroup.php new file mode 100644 index 00000000000..c54f2594977 --- /dev/null +++ b/src/Condition/ConditionRuleGroup.php @@ -0,0 +1,47 @@ + The rules this condition is configured with + */ + public array $conditionRules; + + public function __construct(object|array $config = []) + { + parent::__construct($config); + + if (! isset($this->conditionRules)) { + $this->conditionRules = Collection::make(); + } + } + + public function getConfig(): array + { + return [ + 'conditionRules' => $this->conditionRules + ->map(function (ConditionRuleInterface $rule) { + try { + return $rule->getConfig(); + } catch (RuntimeException) { + // The rule is misconfigured + return null; + } + }) + ->filter(fn (?array $config) => $config !== null) + ->values() + ->all(), + ]; + } +} diff --git a/src/Condition/Contracts/ConditionInterface.php b/src/Condition/Contracts/ConditionInterface.php index 1a279fb4fb2..88a6a6ea642 100644 --- a/src/Condition/Contracts/ConditionInterface.php +++ b/src/Condition/Contracts/ConditionInterface.php @@ -5,6 +5,7 @@ namespace CraftCms\Cms\Condition\Contracts; use CraftCms\Cms\Condition\BaseCondition; +use CraftCms\Cms\Condition\ConditionRuleGroup; use InvalidArgumentException; /** @@ -18,6 +19,11 @@ */ interface ConditionInterface { + /** + * Determines whether the condition supports condition rule groups. + */ + public static function supportsGroups(): bool; + /** * Renders the HTML for the condition builder, including its outer container element. */ @@ -67,7 +73,7 @@ public function getSelectableConditionRules(): array; /** * Returns the rules this condition is configured with. * - * @return ConditionRuleInterface[] + * @return ConditionRuleInterface[]|ConditionRuleGroup[] */ public function getConditionRules(): array; @@ -85,5 +91,5 @@ public function setConditionRules(array $rules): void; * * @throws InvalidArgumentException if the rule is not selectable */ - public function addConditionRule(ConditionRuleInterface $rule): void; + public function addConditionRule(ConditionRuleInterface $rule, int $group = 0): void; } diff --git a/src/Element/Conditions/ElementCondition.php b/src/Element/Conditions/ElementCondition.php index e3ad0d54c5a..42c59dbd292 100644 --- a/src/Element/Conditions/ElementCondition.php +++ b/src/Element/Conditions/ElementCondition.php @@ -5,6 +5,7 @@ namespace CraftCms\Cms\Element\Conditions; use CraftCms\Cms\Condition\BaseCondition; +use CraftCms\Cms\Condition\ConditionRuleGroup; use CraftCms\Cms\Condition\Contracts\ConditionRuleInterface; use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface; use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface; @@ -25,6 +26,11 @@ class ElementCondition extends BaseCondition implements ElementConditionInterface { + public static function supportsGroups(): bool + { + return true; + } + #[Override] public bool $sortable = false; @@ -252,13 +258,23 @@ public function modifyQuery(ElementQueryInterface $elementQuery): void { $elementQuery->beforeQuery(function (ElementQueryInterface $elementQuery) { $elementQuery->where(function (Builder $query) use ($elementQuery) { - foreach ($this->getConditionRules() as $rule) { - try { - /** @var ElementQueryConditionRuleInterface $rule */ - $rule->modifyQuery($query, $elementQuery); - } catch (RuntimeException) { - // The rule is misconfigured - } + /** @var ConditionRuleGroup[] $groups */ + $groups = $this->getConditionRules(); + + foreach ($groups as $group) { + /** @var ElementConditionRuleInterface[] $rules */ + $rules = $group->conditionRules; + + $query->orWhere(function (Builder $query) use ($elementQuery, $rules) { + foreach ($rules as $rule) { + try { + /** @var ElementQueryConditionRuleInterface $rule */ + $rule->modifyQuery($query, $elementQuery); + } catch (RuntimeException) { + // The rule is misconfigured + } + } + }); } }); }); @@ -266,9 +282,18 @@ public function modifyQuery(ElementQueryInterface $elementQuery): void public function matchElement(ElementInterface $element): bool { - /** @var ElementConditionRuleInterface[] $rules */ - $rules = $this->getConditionRules(); + /** @var ConditionRuleGroup[] $groups */ + $groups = $this->getConditionRules(); + + foreach ($groups as $group) { + /** @var ElementConditionRuleInterface[] $rules */ + $rules = $group->conditionRules; + + if (array_all($rules, fn (ElementConditionRuleInterface $rule) => $rule->matchElement($element))) { + return true; + } + } - return array_all($rules, fn (ElementConditionRuleInterface $rule) => $rule->matchElement($element)); + return false; } } diff --git a/src/Field/Fields.php b/src/Field/Fields.php index afca2b40764..46fa39b2ec5 100644 --- a/src/Field/Fields.php +++ b/src/Field/Fields.php @@ -9,6 +9,8 @@ use CraftCms\Cms\Component\ComponentHelper; use CraftCms\Cms\Component\Contracts\Iconic; use CraftCms\Cms\Component\Exceptions\MissingComponentException; +use CraftCms\Cms\Condition\ConditionRuleGroup; +use CraftCms\Cms\Condition\Contracts\ConditionRuleInterface; use CraftCms\Cms\Cp\Icons; use CraftCms\Cms\Database\Expressions\FixedOrderExpression; use CraftCms\Cms\Database\Migrator; @@ -1229,7 +1231,25 @@ private function updateElementCondition(?ElementConditionInterface $condition, a return; } - foreach ($condition->getConditionRules() as $rule) { + if ($condition::supportsGroups()) { + /** @var ConditionRuleGroup[] $groups */ + $groups = $condition->getConditionRules(); + + foreach ($groups as $group) { + $this->updateFieldUidInRules($group->conditionRules, $replacedFields); + } + } else { + $this->updateFieldUidInRules($condition->getConditionRules(), $replacedFields); + } + } + + /** + * @param ConditionRuleInterface[] $rules + * @param array $replacedFields + */ + private function updateFieldUidInRules(array $rules, array &$replacedFields): void + { + foreach ($rules as $rule) { if (! $rule instanceof FieldConditionRuleInterface) { continue; } diff --git a/src/Http/Controllers/ConditionsController.php b/src/Http/Controllers/ConditionsController.php index 78d8b311e75..7594805ba32 100644 --- a/src/Http/Controllers/ConditionsController.php +++ b/src/Http/Controllers/ConditionsController.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\Http\Controllers; +use CraftCms\Cms\Condition\ConditionRuleGroup; use CraftCms\Cms\Condition\Conditions; use CraftCms\Cms\Condition\Contracts\ConditionInterface; use CraftCms\Cms\Condition\Contracts\ConditionRuleInterface; @@ -123,11 +124,23 @@ public function destroy(): string ]); $ruleUid = $this->request->input('uid'); - $conditionRules = collect($this->condition->getConditionRules()) - ->filter(fn (ConditionRuleInterface $rule) => $rule->uid !== $ruleUid) - ->all(); - $this->condition->setConditionRules($conditionRules); + if ($this->condition::supportsGroups()) { + /** @var ConditionRuleGroup[] $groups */ + $groups = $this->condition->getConditionRules(); + + foreach ($groups as $group) { + $group->conditionRules = $group->conditionRules + ->filter(fn (ConditionRuleInterface $rule) => $rule->uid !== $ruleUid) + ->all(); + } + } else { + $conditionRules = collect($this->condition->getConditionRules()) + ->filter(fn (ConditionRuleInterface $rule) => $rule->uid !== $ruleUid) + ->all(); + + $this->condition->setConditionRules($conditionRules); + } return $this->condition->getBuilderInnerHtml(true); } diff --git a/src/Http/Controllers/Elements/ElementSelectorModalController.php b/src/Http/Controllers/Elements/ElementSelectorModalController.php index 5a00e720e76..deb8bb92e9e 100644 --- a/src/Http/Controllers/Elements/ElementSelectorModalController.php +++ b/src/Http/Controllers/Elements/ElementSelectorModalController.php @@ -4,12 +4,14 @@ namespace CraftCms\Cms\Http\Controllers\Elements; +use CraftCms\Cms\Condition\ConditionRuleGroup; use CraftCms\Cms\Cp\Html\ElementIndexHtml; use CraftCms\Cms\Element\Conditions\StatusConditionRule; use CraftCms\Cms\Element\CurrentElementIndex; use CraftCms\Cms\Http\Requests\ElementIndexRequest; use CraftCms\Cms\Http\ViewModels\ModalIndexViewModel; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Collection; use function CraftCms\Cms\t; @@ -37,18 +39,25 @@ public function __invoke(ElementIndexRequest $request, ElementIndexHtml $element $statuses = $elementType::statuses(); if ($condition) { - /** @var StatusConditionRule|null $statusRule */ - $statusRule = collect($condition->getConditionRules()) - ->firstWhere(fn ($rule) => $rule instanceof StatusConditionRule); + /** @var Collection $groups */ + $groups = collect($condition->getConditionRules()); - if ($statusRule) { - $statusValues = $statusRule->getValues(); - $statuses = collect($statuses) - ->filter(function ($info, string $status) use ($statusRule, $statusValues) { - $inValues = in_array($status, $statusValues); + if ($groups->count() === 1) { + /** @var ConditionRuleGroup $group */ + $group = $groups->first(); + /** @var StatusConditionRule|null $statusRule */ + $statusRule = $group->conditionRules + ->firstWhere(fn ($rule) => $rule instanceof StatusConditionRule); - return $statusRule->operator === 'in' ? $inValues : ! $inValues; - }); + if ($statusRule) { + $statusValues = $statusRule->getValues(); + $statuses = collect($statuses) + ->filter(function ($info, string $status) use ($statusRule, $statusValues) { + $inValues = in_array($status, $statusValues); + + return $statusRule->operator === 'in' ? $inValues : ! $inValues; + }); + } } } } From 1face1bb75213a41d78a4fd755bccd9c0d06fc3f Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Wed, 9 Sep 2026 05:44:05 -0700 Subject: [PATCH 02/13] Support for nested groups with AND/OR operators --- src/Condition/BaseCondition.php | 124 +++++++----------- src/Condition/BaseConditionGroup.php | 101 ++++++++++++++ src/Condition/BaseConditionRule.php | 1 - src/Condition/ConditionBuilderRenderer.php | 7 +- src/Condition/ConditionRuleGroup.php | 47 ------- .../Contracts/ConditionComponentInterface.php | 35 +++++ .../Contracts/ConditionGroupInterface.php | 36 +++++ .../Contracts/ConditionInterface.php | 16 ++- .../Contracts/ConditionRuleInterface.php | 26 +--- src/Element/Conditions/ElementCondition.php | 42 ++---- .../Conditions/ElementConditionGroup.php | 43 ++++++ src/Field/BaseRelationField.php | 4 +- src/Field/Fields.php | 29 ++-- src/FieldLayout/FieldLayoutComponent.php | 2 +- src/Http/Controllers/ConditionsController.php | 18 +-- .../ElementSelectorModalController.php | 14 +- .../elements/conditions/ElementCondition.php | 7 + 17 files changed, 313 insertions(+), 239 deletions(-) create mode 100644 src/Condition/BaseConditionGroup.php delete mode 100644 src/Condition/ConditionRuleGroup.php create mode 100644 src/Condition/Contracts/ConditionComponentInterface.php create mode 100644 src/Condition/Contracts/ConditionGroupInterface.php create mode 100644 src/Element/Conditions/ElementConditionGroup.php diff --git a/src/Condition/BaseCondition.php b/src/Condition/BaseCondition.php index c54a44d0bef..85cd51688c2 100644 --- a/src/Condition/BaseCondition.php +++ b/src/Condition/BaseCondition.php @@ -6,6 +6,7 @@ use CraftCms\Cms\Component\Component; use CraftCms\Cms\Condition\Concerns\LegacyConstants; +use CraftCms\Cms\Condition\Contracts\ConditionGroupInterface; use CraftCms\Cms\Condition\Contracts\ConditionInterface; use CraftCms\Cms\Condition\Contracts\ConditionRuleInterface; use CraftCms\Cms\Condition\Events\ConditionRulesResolving; @@ -16,7 +17,6 @@ use Illuminate\Support\Facades\Log; use InvalidArgumentException; use Override; -use RuntimeException; use function CraftCms\Cms\t; @@ -26,7 +26,7 @@ abstract class BaseCondition extends Component implements ConditionInterface public static function supportsGroups(): bool { - return false; + return true; } /** @@ -67,20 +67,11 @@ public static function supportsGroups(): bool } /** - * @see getConditionRules() - * @see setConditionRules() - * - * @var Collection|Collection + * @var ConditionGroupInterface The rules this condition is configured with. */ - private Collection $_conditionRules; - - /** - * @var ConditionRuleInterface[]|ConditionRuleGroup[] The rules this condition is configured with, or condition groups if {@see supportsGroups()} is `true`. - */ - public array $conditionRules { - get => $this->getConditionRules(); + public ConditionGroupInterface $conditionRules { set { - $this->setConditionRules($value); + $this->conditionRules = $this->normalizeConditionRules($value); } } @@ -169,70 +160,64 @@ protected function isConditionRuleSelectable(ConditionRuleInterface $rule): bool return true; } - public function getConditionRules(): array + public function getConditionRules(): ConditionGroupInterface { - return $this->_conditionRules->all(); + return $this->conditionRules; } - /** @param array $rules */ - public function setConditionRules(array $rules): void + public function setConditionRules(ConditionGroupInterface|array $rules): void { - $this->_conditionRules = Collection::make(); - app(ProjectConfig::class); + $this->conditionRules = $rules; + } - $group = -1; + /** @param ConditionGroupInterface|array{operator: string, rules: array{class: string}|array{type: string}}|array $rules */ + protected function normalizeConditionRules(ConditionGroupInterface|array $rules): ConditionGroupInterface + { + if ($rules instanceof ConditionGroupInterface) { + return $rules; + } - foreach ($rules as $rule) { - $isGroup = static::supportsGroups() && ( - $rule instanceof ConditionRuleGroup || - (is_array($rule) && isset($rule['conditionRules'])) - ); - - // starting a new group? - if ($group === -1 || $isGroup) { - $group++; + if (isset($rules['rules'])) { + $group = $this->normalizeConditionRules($rules['rules']); + + if (isset($rules['operator'])) { + $group->operator = $rules['operator']; } - if ($isGroup) { - $groupRules = $rule instanceof ConditionRuleGroup ? $rule->conditionRules->all() : $rule['conditionRules']; - foreach ($groupRules as $r) { - $r = $this->normalizeConditionRule($r); + return $group; + } - if ($r !== null) { - $this->addConditionRule($r, $group); - } - } - } else { - $rule = $this->normalizeConditionRule($rule); + $group = static::createGroup(); + $group->setCondition($this); + + $projectConfig = app(ProjectConfig::class); - if ($rule !== null) { - $this->addConditionRule($rule, $group); + foreach ($rules as $rule) { + if (! $rule instanceof ConditionRuleInterface) { + try { + $rule = $this->createConditionRule($rule); + } catch (InvalidArgumentException $e) { + Log::warning("Invalid condition rule: {$e->getMessage()}"); + + continue; } } + + if (! $projectConfig->isApplyingExternalChanges && ! $this->validateConditionRule($rule)) { + throw new InvalidArgumentException('Invalid condition rule'); + } + + $group->addRule($rule); } // Clear out our cache of selectable condition rules, in case any additional rules will depend on which // rules are already configured. $this->_selectableConditionRules = null; - } - /** @param ConditionRuleInterface|array{class: string}|array{type: string}|string $rule */ - private function normalizeConditionRule(ConditionRuleInterface|array|string $rule): ?ConditionRuleInterface - { - if ($rule instanceof ConditionRuleInterface) { - return $rule; - } - - try { - return $this->createConditionRule($rule); - } catch (InvalidArgumentException $e) { - Log::warning("Invalid condition rule: {$e->getMessage()}"); - - return null; - } + return $group; } - public function addConditionRule(ConditionRuleInterface $rule, int $group = 0): void + public function addConditionRule(ConditionRuleInterface $rule): void { // Don't validate the rule when we're applying project config changes. // The rule type might depend on something that hasn't been added yet. @@ -240,15 +225,7 @@ public function addConditionRule(ConditionRuleInterface $rule, int $group = 0): throw new InvalidArgumentException('Invalid condition rule'); } - $rule->setCondition($this); - - if (static::supportsGroups()) { - /** @var ConditionRuleGroup $group */ - $conditionGroup = $this->_conditionRules->getOrPut($group, fn () => new ConditionRuleGroup); - $conditionGroup->conditionRules->add($rule); - } else { - $this->_conditionRules->add($rule); - } + $this->conditionRules->addRule($rule); // Clear caches $this->_selectableConditionRules = null; @@ -287,18 +264,7 @@ public function getConfig(): array { return array_merge($this->config(), [ 'class' => static::class, - 'conditionRules' => $this->_conditionRules - ->map(function (ConditionRuleInterface|ConditionRuleGroup $rule) { - try { - return $rule->getConfig(); - } catch (RuntimeException) { - // The rule is misconfigured - return null; - } - }) - ->filter(fn (?array $config) => $config !== null) - ->values() - ->all(), + 'conditionRules' => $this->conditionRules->getConfig(), ]); } diff --git a/src/Condition/BaseConditionGroup.php b/src/Condition/BaseConditionGroup.php new file mode 100644 index 00000000000..66ed12b2ed9 --- /dev/null +++ b/src/Condition/BaseConditionGroup.php @@ -0,0 +1,101 @@ +operator = $value; + } + } + + /** + * @var Collection The rules/groups this condition is configured with + */ + private Collection $rules; + + private ConditionInterface $_condition; + + /** + * @param ConditionComponentInterface[] $rules + */ + public function __construct(string $operator = 'and', array $rules = []) + { + $this->operator = $operator; + $this->rules = Collection::make($rules); + } + + public function getCondition(): ConditionInterface + { + return $this->_condition; + } + + public function setCondition(ConditionInterface $condition): void + { + $this->_condition = $condition; + } + + public function getConfig(): array + { + return [ + 'operator' => $this->operator, + 'rules' => $this->rules + ->map(function (ConditionComponentInterface $rule) { + try { + return $rule->getConfig(); + } catch (RuntimeException) { + // The rule is misconfigured + return null; + } + }) + ->filter(fn (?array $config) => $config !== null) + ->values() + ->all(), + ]; + } + + public function getRules(): array + { + return $this->rules->all(); + } + + public function addRule(ConditionComponentInterface $rule): void + { + $this->rules->push($rule); + $rule->setCondition($this->_condition); + } + + public function removeRule(string $uid): void + { + $this->rules = $this->rules->filter(function (ConditionComponentInterface $rule) use ($uid) { + if ($rule instanceof ConditionGroupInterface) { + $rule->removeRule($uid); + + return true; + } + + /** @var ConditionRuleInterface $rule */ + return $rule->uid !== $uid; + }); + } +} diff --git a/src/Condition/BaseConditionRule.php b/src/Condition/BaseConditionRule.php index ee7f7ee72ab..2370b554538 100644 --- a/src/Condition/BaseConditionRule.php +++ b/src/Condition/BaseConditionRule.php @@ -136,7 +136,6 @@ public function getGroupLabel(): ?string return null; } - /** @return array */ public function getConfig(): array { $config = [ diff --git a/src/Condition/ConditionBuilderRenderer.php b/src/Condition/ConditionBuilderRenderer.php index 3e72cb0b434..3a449a8396a 100644 --- a/src/Condition/ConditionBuilderRenderer.php +++ b/src/Condition/ConditionBuilderRenderer.php @@ -73,7 +73,12 @@ public function renderInner(bool $autofocusAddButton = false): string $html .= Html::hiddenInput('class', $this->condition::class); $html .= Html::hiddenInput('config', Json::encode($this->condition->getBuilderConfig())); - foreach ($this->condition->getConditionRules() as $rule) { + foreach ($this->condition->getConditionRules()->getRules() as $rule) { + // todo: support for nested groups + if (! $rule instanceof ConditionRuleInterface) { + continue; + } + $allRulesHtml .= InputNamespace::namespaceInputs(function () use ($rule, $ruleNum, $selectableRules) { $ruleHtml = Html::tag('legend', t('Condition {num, number}', [ diff --git a/src/Condition/ConditionRuleGroup.php b/src/Condition/ConditionRuleGroup.php deleted file mode 100644 index c54f2594977..00000000000 --- a/src/Condition/ConditionRuleGroup.php +++ /dev/null @@ -1,47 +0,0 @@ - The rules this condition is configured with - */ - public array $conditionRules; - - public function __construct(object|array $config = []) - { - parent::__construct($config); - - if (! isset($this->conditionRules)) { - $this->conditionRules = Collection::make(); - } - } - - public function getConfig(): array - { - return [ - 'conditionRules' => $this->conditionRules - ->map(function (ConditionRuleInterface $rule) { - try { - return $rule->getConfig(); - } catch (RuntimeException) { - // The rule is misconfigured - return null; - } - }) - ->filter(fn (?array $config) => $config !== null) - ->values() - ->all(), - ]; - } -} diff --git a/src/Condition/Contracts/ConditionComponentInterface.php b/src/Condition/Contracts/ConditionComponentInterface.php new file mode 100644 index 00000000000..f4ab06fb824 --- /dev/null +++ b/src/Condition/Contracts/ConditionComponentInterface.php @@ -0,0 +1,35 @@ + $config The component’s portable config + */ +interface ConditionComponentInterface +{ + /** + * Returns the condition associated with this rule. + */ + public function getCondition(): ConditionInterface; + + /** + * Sets the condition associated with this rule. + */ + public function setCondition(ConditionInterface $condition): void; + + /** + * Returns the rule’s portable config. + * + * @return array + * + * @throws RuntimeException if the rule is misconfigured + */ + public function getConfig(): array; +} diff --git a/src/Condition/Contracts/ConditionGroupInterface.php b/src/Condition/Contracts/ConditionGroupInterface.php new file mode 100644 index 00000000000..e9445710bc1 --- /dev/null +++ b/src/Condition/Contracts/ConditionGroupInterface.php @@ -0,0 +1,36 @@ + $rules + * @param ConditionGroupInterface|array{operator: string, rules: array{class: string}|array{type: string}}|array $rules * * @throws InvalidArgumentException if any of the rules are not selectable */ - public function setConditionRules(array $rules): void; + public function setConditionRules(ConditionGroupInterface|array $rules): void; /** * Adds a rule to the condition. * * @throws InvalidArgumentException if the rule is not selectable */ - public function addConditionRule(ConditionRuleInterface $rule, int $group = 0): void; + public function addConditionRule(ConditionRuleInterface $rule): void; } diff --git a/src/Condition/Contracts/ConditionRuleInterface.php b/src/Condition/Contracts/ConditionRuleInterface.php index 590b06eefca..5ab3ba8875a 100644 --- a/src/Condition/Contracts/ConditionRuleInterface.php +++ b/src/Condition/Contracts/ConditionRuleInterface.php @@ -7,22 +7,19 @@ use CraftCms\Cms\Component\Contracts\ComponentInterface; use CraftCms\Cms\Condition\BaseConditionRule; use CraftCms\Cms\Form\Form; -use RuntimeException; /** * ConditionRuleInterface defines the common interface to be implemented by condition rule classes. * - * A base implementation is provided by [[BaseConditionRule]]. + * A base implementation is provided by {@see BaseConditionRule}. * - * @property ConditionInterface $condition The condition associated with this rule - * @property-read array $config The rule’s portable config * @property-read string $label The rule’s option label * * @mixin BaseConditionRule * * @phpstan-require-extends BaseConditionRule */ -interface ConditionRuleInterface extends ComponentInterface +interface ConditionRuleInterface extends ComponentInterface, ConditionComponentInterface { /** * Returns whether the rule is safe to include in conditions that are stored in the project config. @@ -54,15 +51,6 @@ public function showLabelHint(): bool; */ public function getGroupLabel(): ?string; - /** - * Returns the rule’s portable config. - * - * @return array - * - * @throws RuntimeException if the rule is misconfigured - */ - public function getConfig(): array; - /** * Returns the rule’s Form schema for a condition builder. */ @@ -73,16 +61,6 @@ public function getForm(): Form; */ public function setCondition(ConditionInterface $condition): void; - /** - * Returns the condition associated with this rule. - */ - public function getCondition(): ConditionInterface; - - /** - * Returns whether the rule’s type selector should be autofocused. - */ - public function getAutofocus(): bool; - /** * Sets whether the rule’s type selector should be autofocused. */ diff --git a/src/Element/Conditions/ElementCondition.php b/src/Element/Conditions/ElementCondition.php index 42c59dbd292..0cce89b7faa 100644 --- a/src/Element/Conditions/ElementCondition.php +++ b/src/Element/Conditions/ElementCondition.php @@ -5,7 +5,7 @@ namespace CraftCms\Cms\Element\Conditions; use CraftCms\Cms\Condition\BaseCondition; -use CraftCms\Cms\Condition\ConditionRuleGroup; +use CraftCms\Cms\Condition\Contracts\ConditionGroupInterface; use CraftCms\Cms\Condition\Contracts\ConditionRuleInterface; use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface; use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface; @@ -26,9 +26,9 @@ class ElementCondition extends BaseCondition implements ElementConditionInterface { - public static function supportsGroups(): bool + public static function createGroup(): ConditionGroupInterface { - return true; + return new ElementConditionGroup; } #[Override] @@ -258,42 +258,18 @@ public function modifyQuery(ElementQueryInterface $elementQuery): void { $elementQuery->beforeQuery(function (ElementQueryInterface $elementQuery) { $elementQuery->where(function (Builder $query) use ($elementQuery) { - /** @var ConditionRuleGroup[] $groups */ - $groups = $this->getConditionRules(); - - foreach ($groups as $group) { - /** @var ElementConditionRuleInterface[] $rules */ - $rules = $group->conditionRules; - - $query->orWhere(function (Builder $query) use ($elementQuery, $rules) { - foreach ($rules as $rule) { - try { - /** @var ElementQueryConditionRuleInterface $rule */ - $rule->modifyQuery($query, $elementQuery); - } catch (RuntimeException) { - // The rule is misconfigured - } - } - }); - } + /** @var ElementConditionGroup $group */ + $group = $this->getConditionRules(); + $group->modifyQuery($query, $elementQuery); }); }); } public function matchElement(ElementInterface $element): bool { - /** @var ConditionRuleGroup[] $groups */ - $groups = $this->getConditionRules(); - - foreach ($groups as $group) { - /** @var ElementConditionRuleInterface[] $rules */ - $rules = $group->conditionRules; - - if (array_all($rules, fn (ElementConditionRuleInterface $rule) => $rule->matchElement($element))) { - return true; - } - } + /** @var ElementConditionGroup $group */ + $group = $this->getConditionRules(); - return false; + return $group->matchElement($element); } } diff --git a/src/Element/Conditions/ElementConditionGroup.php b/src/Element/Conditions/ElementConditionGroup.php new file mode 100644 index 00000000000..dc8c98e2b20 --- /dev/null +++ b/src/Element/Conditions/ElementConditionGroup.php @@ -0,0 +1,43 @@ + $rules + */ +class ElementConditionGroup extends BaseConditionGroup +{ + public function modifyQuery(Builder $query, ElementQueryInterface $elementQuery): void + { + $method = $this->operator === 'and' ? 'where' : 'orWhere'; + + foreach ($this->getRules() as $rule) { + $query->$method(function (Builder $query) use ($elementQuery, $rule) { + try { + /** @var self|ElementQueryConditionRuleInterface $rule */ + $rule->modifyQuery($query, $elementQuery); + } catch (RuntimeException) { + // The rule is misconfigured + } + }); + } + } + + public function matchElement(ElementInterface $element): bool + { + $method = $this->operator === 'and' ? 'array_all' : 'array_any'; + + return $method($this->getRules(), fn (self|ElementConditionRuleInterface $rule) => $rule->matchElement($element)); + } +} diff --git a/src/Field/BaseRelationField.php b/src/Field/BaseRelationField.php index a235af1eb22..49fd758e61f 100644 --- a/src/Field/BaseRelationField.php +++ b/src/Field/BaseRelationField.php @@ -1793,7 +1793,7 @@ public function getSelectionCondition(): ?ElementConditionInterface if ($this->_selectionCondition !== null && ! $this->_selectionCondition instanceof ConditionInterface) { /** @var ElementConditionInterface $condition */ $condition = Conditions::createCondition($this->_selectionCondition); - if (! empty($condition->getConditionRules())) { + if (! empty($condition->getConditionRules()->getRules())) { $this->_selectionCondition = $condition; } else { $this->_selectionCondition = null; @@ -1812,7 +1812,7 @@ public function getSelectionCondition(): ?ElementConditionInterface */ public function setSelectionCondition(mixed $condition): void { - if ($condition instanceof ConditionInterface && ! $condition->getConditionRules()) { + if ($condition instanceof ConditionInterface && ! $condition->getConditionRules()->getRules()) { $condition = null; } diff --git a/src/Field/Fields.php b/src/Field/Fields.php index 46fa39b2ec5..c823dc37060 100644 --- a/src/Field/Fields.php +++ b/src/Field/Fields.php @@ -9,8 +9,7 @@ use CraftCms\Cms\Component\ComponentHelper; use CraftCms\Cms\Component\Contracts\Iconic; use CraftCms\Cms\Component\Exceptions\MissingComponentException; -use CraftCms\Cms\Condition\ConditionRuleGroup; -use CraftCms\Cms\Condition\Contracts\ConditionRuleInterface; +use CraftCms\Cms\Condition\Contracts\ConditionGroupInterface; use CraftCms\Cms\Cp\Icons; use CraftCms\Cms\Database\Expressions\FixedOrderExpression; use CraftCms\Cms\Database\Migrator; @@ -1231,25 +1230,19 @@ private function updateElementCondition(?ElementConditionInterface $condition, a return; } - if ($condition::supportsGroups()) { - /** @var ConditionRuleGroup[] $groups */ - $groups = $condition->getConditionRules(); - - foreach ($groups as $group) { - $this->updateFieldUidInRules($group->conditionRules, $replacedFields); - } - } else { - $this->updateFieldUidInRules($condition->getConditionRules(), $replacedFields); - } + $this->updateFieldUidInRules($condition->getConditionRules(), $replacedFields); } - /** - * @param ConditionRuleInterface[] $rules - * @param array $replacedFields - */ - private function updateFieldUidInRules(array $rules, array &$replacedFields): void + /** @param array $replacedFields */ + private function updateFieldUidInRules(ConditionGroupInterface $group, array &$replacedFields): void { - foreach ($rules as $rule) { + foreach ($group->getRules() as $rule) { + if ($rule instanceof ConditionGroupInterface) { + $this->updateFieldUidInRules($rule, $replacedFields); + + continue; + } + if (! $rule instanceof FieldConditionRuleInterface) { continue; } diff --git a/src/FieldLayout/FieldLayoutComponent.php b/src/FieldLayout/FieldLayoutComponent.php index 9e9e8a72995..96887ecf9f4 100644 --- a/src/FieldLayout/FieldLayoutComponent.php +++ b/src/FieldLayout/FieldLayoutComponent.php @@ -196,7 +196,7 @@ protected function normalizeCondition(mixed $condition): ?ConditionInterface $condition = Conditions::createCondition($condition); } - if (! $condition->getConditionRules()) { + if (! $condition->getConditionRules()->getRules()) { return null; } diff --git a/src/Http/Controllers/ConditionsController.php b/src/Http/Controllers/ConditionsController.php index b8241ed975f..ea812c0b3c1 100644 --- a/src/Http/Controllers/ConditionsController.php +++ b/src/Http/Controllers/ConditionsController.php @@ -5,7 +5,6 @@ namespace CraftCms\Cms\Http\Controllers; use CraftCms\Cms\Condition\ConditionBuilderRenderer; -use CraftCms\Cms\Condition\ConditionRuleGroup; use CraftCms\Cms\Condition\Conditions; use CraftCms\Cms\Condition\Contracts\ConditionInterface; use CraftCms\Cms\Condition\Contracts\ConditionRuleInterface; @@ -126,22 +125,7 @@ public function destroy(): string $ruleUid = $this->request->input('uid'); - if ($this->condition::supportsGroups()) { - /** @var ConditionRuleGroup[] $groups */ - $groups = $this->condition->getConditionRules(); - - foreach ($groups as $group) { - $group->conditionRules = $group->conditionRules - ->filter(fn (ConditionRuleInterface $rule) => $rule->uid !== $ruleUid) - ->all(); - } - } else { - $conditionRules = collect($this->condition->getConditionRules()) - ->filter(fn (ConditionRuleInterface $rule) => $rule->uid !== $ruleUid) - ->all(); - - $this->condition->setConditionRules($conditionRules); - } + $this->condition->getConditionRules()->removeRule($ruleUid); return new ConditionBuilderRenderer($this->condition)->renderInner(true); } diff --git a/src/Http/Controllers/Elements/ElementSelectorModalController.php b/src/Http/Controllers/Elements/ElementSelectorModalController.php index deb8bb92e9e..7306cb3f3bc 100644 --- a/src/Http/Controllers/Elements/ElementSelectorModalController.php +++ b/src/Http/Controllers/Elements/ElementSelectorModalController.php @@ -4,14 +4,13 @@ namespace CraftCms\Cms\Http\Controllers\Elements; -use CraftCms\Cms\Condition\ConditionRuleGroup; +use CraftCms\Cms\Condition\Contracts\ConditionGroupInterface; use CraftCms\Cms\Cp\Html\ElementIndexHtml; use CraftCms\Cms\Element\Conditions\StatusConditionRule; use CraftCms\Cms\Element\CurrentElementIndex; use CraftCms\Cms\Http\Requests\ElementIndexRequest; use CraftCms\Cms\Http\ViewModels\ModalIndexViewModel; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Collection; use function CraftCms\Cms\t; @@ -39,15 +38,11 @@ public function __invoke(ElementIndexRequest $request, ElementIndexHtml $element $statuses = $elementType::statuses(); if ($condition) { - /** @var Collection $groups */ - $groups = collect($condition->getConditionRules()); + $rules = collect($condition->getConditionRules()->getRules()); - if ($groups->count() === 1) { - /** @var ConditionRuleGroup $group */ - $group = $groups->first(); + if ($rules->doesntContain(fn ($rule) => $rule instanceof ConditionGroupInterface)) { /** @var StatusConditionRule|null $statusRule */ - $statusRule = $group->conditionRules - ->firstWhere(fn ($rule) => $rule instanceof StatusConditionRule); + $statusRule = $rules->firstWhere(fn ($rule) => $rule instanceof StatusConditionRule); if ($statusRule) { $statusValues = $statusRule->getValues(); @@ -59,6 +54,7 @@ public function __invoke(ElementIndexRequest $request, ElementIndexHtml $element }); } } + } } diff --git a/yii2-adapter/legacy/elements/conditions/ElementCondition.php b/yii2-adapter/legacy/elements/conditions/ElementCondition.php index 5270aa2af53..d8d9eb02779 100644 --- a/yii2-adapter/legacy/elements/conditions/ElementCondition.php +++ b/yii2-adapter/legacy/elements/conditions/ElementCondition.php @@ -6,12 +6,19 @@ use CraftCms\Cms\Condition\ConditionBuilderRenderer; use CraftCms\Cms\Condition\Conditions; +use CraftCms\Cms\Condition\Contracts\ConditionGroupInterface; use CraftCms\Cms\Condition\Contracts\ConditionRuleInterface; +use CraftCms\Cms\Element\Conditions\ElementConditionGroup; use CraftCms\Yii2Adapter\Form\LegacyConditionClasses; /** @deprecated 6.0.0 Use \CraftCms\Cms\Element\Conditions\ElementCondition instead. */ class ElementCondition extends \CraftCms\Cms\Element\Conditions\ElementCondition { + public static function createGroup(): ConditionGroupInterface + { + return new ElementConditionGroup(); + } + public function getBuilderHtml(): string { return new ConditionBuilderRenderer($this)->render(); From 9be896ba0bce78beb5ea3b680ec34e2fb540bb14 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Wed, 9 Sep 2026 05:57:07 -0700 Subject: [PATCH 03/13] Drop supportsGroups() --- src/Condition/BaseCondition.php | 5 ----- src/Condition/Contracts/ConditionInterface.php | 5 ----- 2 files changed, 10 deletions(-) diff --git a/src/Condition/BaseCondition.php b/src/Condition/BaseCondition.php index 85cd51688c2..e4cb06ba5b1 100644 --- a/src/Condition/BaseCondition.php +++ b/src/Condition/BaseCondition.php @@ -24,11 +24,6 @@ abstract class BaseCondition extends Component implements ConditionInterface { use LegacyConstants; - public static function supportsGroups(): bool - { - return true; - } - /** * @var string The condition builder container tag name */ diff --git a/src/Condition/Contracts/ConditionInterface.php b/src/Condition/Contracts/ConditionInterface.php index 4f7e660fbf5..f4451ca44bc 100644 --- a/src/Condition/Contracts/ConditionInterface.php +++ b/src/Condition/Contracts/ConditionInterface.php @@ -18,11 +18,6 @@ */ interface ConditionInterface { - /** - * Determines whether the condition supports condition rule groups. - */ - public static function supportsGroups(): bool; - /** * Creates new condition groups. */ From dd3d784e949151c0abd5f810d2eb19f7afe048b0 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Wed, 9 Sep 2026 06:07:06 -0700 Subject: [PATCH 04/13] Fixed an error --- src/Condition/BaseCondition.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Condition/BaseCondition.php b/src/Condition/BaseCondition.php index e4cb06ba5b1..308c69b70eb 100644 --- a/src/Condition/BaseCondition.php +++ b/src/Condition/BaseCondition.php @@ -65,7 +65,7 @@ abstract class BaseCondition extends Component implements ConditionInterface * @var ConditionGroupInterface The rules this condition is configured with. */ public ConditionGroupInterface $conditionRules { - set { + set(ConditionGroupInterface|array $value) { $this->conditionRules = $this->normalizeConditionRules($value); } } From 1fdab67fc36451c290e7899a36eba33a36e7dadf Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Wed, 9 Sep 2026 06:12:09 -0700 Subject: [PATCH 05/13] Add missing `@param` --- src/Condition/BaseCondition.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Condition/BaseCondition.php b/src/Condition/BaseCondition.php index 308c69b70eb..a7315f219e8 100644 --- a/src/Condition/BaseCondition.php +++ b/src/Condition/BaseCondition.php @@ -65,6 +65,7 @@ abstract class BaseCondition extends Component implements ConditionInterface * @var ConditionGroupInterface The rules this condition is configured with. */ public ConditionGroupInterface $conditionRules { + /** @param ConditionGroupInterface|array{operator: string, rules: array{class: string}|array{type: string}}|array $value */ set(ConditionGroupInterface|array $value) { $this->conditionRules = $this->normalizeConditionRules($value); } From 13d94d07c93abb0a8f161cf4a14aa2a7d2761199 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Wed, 9 Sep 2026 11:09:23 -0700 Subject: [PATCH 06/13] Update tests --- tests/Feature/Condition/BaseConditionTest.php | 16 ++++++++-------- .../Condition/ConditionsServiceTest.php | 2 +- .../Conditions/ElementConditionTest.php | 19 ++++++++++--------- .../Entry/Conditions/EntryConditionTest.php | 4 ++-- .../Elements/ElementSourcesControllerTest.php | 1 + .../Elements/SearchControllerTest.php | 7 +++++++ 6 files changed, 29 insertions(+), 20 deletions(-) diff --git a/tests/Feature/Condition/BaseConditionTest.php b/tests/Feature/Condition/BaseConditionTest.php index bbff98e1c48..84780432882 100644 --- a/tests/Feature/Condition/BaseConditionTest.php +++ b/tests/Feature/Condition/BaseConditionTest.php @@ -23,7 +23,7 @@ ['class' => TitleConditionRule::class], ]); - $rules = $condition->getConditionRules(); + $rules = $condition->getConditionRules()->getRules(); expect($rules)->toHaveCount(1); expect($rules[0])->toBeInstanceOf(TitleConditionRule::class); @@ -36,7 +36,7 @@ ['class' => SlugConditionRule::class], ]); - $rules = $condition->getConditionRules(); + $rules = $condition->getConditionRules()->getRules(); expect($rules)->toHaveCount(2); expect($rules[0])->toBeInstanceOf(TitleConditionRule::class); @@ -49,7 +49,7 @@ ['class' => TitleConditionRule::class], ]); - $rules = $condition->getConditionRules(); + $rules = $condition->getConditionRules()->getRules(); expect($rules[0]->getCondition())->toBe($condition); }); @@ -61,7 +61,7 @@ ['class' => TitleConditionRule::class], ]); - $rules = $condition->getConditionRules(); + $rules = $condition->getConditionRules()->getRules(); expect($rules)->toHaveCount(1); expect($rules[0])->toBeInstanceOf(TitleConditionRule::class); @@ -78,7 +78,7 @@ ['class' => IdConditionRule::class], ]); - $rules = $condition->getConditionRules(); + $rules = $condition->getConditionRules()->getRules(); expect($rules)->toHaveCount(1); expect($rules[0])->toBeInstanceOf(IdConditionRule::class); @@ -92,7 +92,7 @@ $condition->setConditionRules([$rule]); - $rules = $condition->getConditionRules(); + $rules = $condition->getConditionRules()->getRules(); expect($rules)->toHaveCount(1); expect($rules[0])->toBeInstanceOf(TitleConditionRule::class); @@ -109,7 +109,7 @@ $condition->addConditionRule($rule); - $rules = $condition->getConditionRules(); + $rules = $condition->getConditionRules()->getRules(); expect($rules)->toHaveCount(1); expect($rules[0])->toBe($rule); @@ -129,7 +129,7 @@ $slugRule->value = 'test'; $condition->addConditionRule($slugRule); - expect($condition->getConditionRules())->toHaveCount(2); + expect($condition->getConditionRules()->getRules())->toHaveCount(2); }); it('throws InvalidArgumentException for a rule not in selectable rules', function () { diff --git a/tests/Feature/Condition/ConditionsServiceTest.php b/tests/Feature/Condition/ConditionsServiceTest.php index 05f95c7eb2f..179d64d06dd 100644 --- a/tests/Feature/Condition/ConditionsServiceTest.php +++ b/tests/Feature/Condition/ConditionsServiceTest.php @@ -48,7 +48,7 @@ it('creates a condition with empty conditionRules by default', function () { $condition = $this->service->createCondition(ElementCondition::class); - expect($condition->getConditionRules())->toBeEmpty(); + expect($condition->getConditionRules()->getRules())->toBeEmpty(); }); }); diff --git a/tests/Feature/Element/Conditions/ElementConditionTest.php b/tests/Feature/Element/Conditions/ElementConditionTest.php index b7e151c4bcf..1c12576c02f 100644 --- a/tests/Feature/Element/Conditions/ElementConditionTest.php +++ b/tests/Feature/Element/Conditions/ElementConditionTest.php @@ -211,7 +211,8 @@ function createCondition(): ElementCondition ->toHaveKey('class', ElementCondition::class) ->toHaveKey('elementType', Entry::class) ->toHaveKey('conditionRules') - ->and($config['conditionRules'])->toBeArray()->toBeEmpty(); + ->and($config['conditionRules'])->toHaveKey('operator', 'and') + ->and($config['conditionRules']['rules'])->toBeArray()->toBeEmpty(); }); it('includes configured rules in config output', function () { @@ -229,12 +230,12 @@ function createCondition(): ElementCondition $config = $condition->getConfig(); - expect($config['conditionRules'])->toHaveCount(2) - ->and($config['conditionRules'][0])->toHaveKey('class', TitleConditionRule::class) - ->and($config['conditionRules'][0])->toHaveKey('value', 'Test Title') - ->and($config['conditionRules'][0])->toHaveKey('operator', '=') - ->and($config['conditionRules'][1])->toHaveKey('class', SlugConditionRule::class) - ->and($config['conditionRules'][1])->toHaveKey('value', 'test-slug'); + expect($config['conditionRules']['rules'])->toHaveCount(2) + ->and($config['conditionRules']['rules'][0])->toHaveKey('class', TitleConditionRule::class) + ->and($config['conditionRules']['rules'][0])->toHaveKey('value', 'Test Title') + ->and($config['conditionRules']['rules'][0])->toHaveKey('operator', '=') + ->and($config['conditionRules']['rules'][1])->toHaveKey('class', SlugConditionRule::class) + ->and($config['conditionRules']['rules'][1])->toHaveKey('value', 'test-slug'); }); it('preserves rule UIDs in config', function () { @@ -247,7 +248,7 @@ function createCondition(): ElementCondition $config = $condition->getConfig(); - expect($config['conditionRules'][0])->toHaveKey('uid') - ->and($config['conditionRules'][0]['uid'])->toBe($titleRule->uid); + expect($config['conditionRules']['rules'][0])->toHaveKey('uid') + ->and($config['conditionRules']['rules'][0]['uid'])->toBe($titleRule->uid); }); }); diff --git a/tests/Feature/Entry/Conditions/EntryConditionTest.php b/tests/Feature/Entry/Conditions/EntryConditionTest.php index 0fc3333a30f..a96c2c0b638 100644 --- a/tests/Feature/Entry/Conditions/EntryConditionTest.php +++ b/tests/Feature/Entry/Conditions/EntryConditionTest.php @@ -107,12 +107,12 @@ $postDateRule->endDate = '2025-12-31'; $condition->addConditionRule($postDateRule); - $ruleConfigs = array_map(fn ($rule) => $rule->getConfig(), $condition->getConditionRules()); + $ruleConfigs = array_map(fn ($rule) => $rule->getConfig(), $condition->getConditionRules()->getRules()); $restored = new EntryCondition(Entry::class); $restored->setConditionRules($ruleConfigs); - $restoredRules = $restored->getConditionRules(); + $restoredRules = $restored->getConditionRules()->getRules(); expect($restoredRules)->toHaveCount(2); expect($restoredRules[0])->toBeInstanceOf(SectionConditionRule::class); diff --git a/tests/Feature/Http/Controllers/Elements/ElementSourcesControllerTest.php b/tests/Feature/Http/Controllers/Elements/ElementSourcesControllerTest.php index bfb9c80453f..383a92edcab 100644 --- a/tests/Feature/Http/Controllers/Elements/ElementSourcesControllerTest.php +++ b/tests/Feature/Http/Controllers/Elements/ElementSourcesControllerTest.php @@ -361,6 +361,7 @@ function formControls(array $form): array 'fieldContext' => 'global', 'forQuery' => false, 'class' => ElementCondition::class, + 'conditionRules' => ['operator' => 'and'], ], 'sites' => false, 'userGroups' => ['group-editors'], diff --git a/tests/Feature/Http/Controllers/Elements/SearchControllerTest.php b/tests/Feature/Http/Controllers/Elements/SearchControllerTest.php index 1f126326d87..fe011d625bc 100644 --- a/tests/Feature/Http/Controllers/Elements/SearchControllerTest.php +++ b/tests/Feature/Http/Controllers/Elements/SearchControllerTest.php @@ -3,7 +3,9 @@ declare(strict_types=1); use CraftCms\Cms\Condition\BaseCondition; +use CraftCms\Cms\Condition\BaseConditionGroup; use CraftCms\Cms\Condition\Conditions; +use CraftCms\Cms\Condition\Contracts\ConditionGroupInterface; use CraftCms\Cms\Condition\Contracts\ConditionInterface; use CraftCms\Cms\Element\Conditions\ElementCondition; use CraftCms\Cms\Element\Conditions\IdConditionRule; @@ -221,6 +223,11 @@ public function createCondition(array|string $config): ConditionInterface return new class extends BaseCondition { + public static function createGroup(): ConditionGroupInterface + { + return new class extends BaseConditionGroup {}; + } + protected function selectableConditionRules(): array { return []; From 36a108f8837f75d0169724c6de64a970ad8a881b Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 9 Sep 2026 21:48:07 +0200 Subject: [PATCH 07/13] Replace the condition builder with a Vue editor and remove HTMX Support nested AND/OR groups and repeated rules through a shared Vue editor, portable condition payloads, and Form API rule controls. Integrate the editor with element filters and native form hosts, preserve edits during asynchronous refreshes, and block submission while rules are pending or invalid. Update core and adapter condition handling and cover the new behavior with focused tests. This commit contains the changelog and package changes for that migration: remove the legacy condition-builder and HTMX bundles, dependency, and styles; connect the legacy filter HUD to builder events; expose card border width and scope slot detection to direct children; and update the combobox serialization comment. The remaining implementation and tests stay in the working tree for subsequent commits. --- .agents/skills/testing-guidelines/SKILL.md | 2 + CHANGELOG.md | 2 + package-lock.json | 5 - .../conditionbuilder/src/ConditionBuilder.js | 16 - .../conditionbuilder/webpack.config.js | 17 - .../craftcms-legacy/cp/src/css/_main.scss | 60 --- .../cp/src/js/BaseElementIndex.js | 36 +- packages/craftcms-legacy/htmx/src/htmx.js | 85 ---- .../craftcms-legacy/htmx/webpack.config.js | 24 - packages/craftcms-legacy/package.json | 1 - .../src/components/card/card.styles.ts | 2 +- .../craftcms-ui/src/components/card/card.ts | 11 +- .../src/components/combobox/combobox.ts | 2 +- .../composables/useDelayedLoading.test.ts | 29 ++ .../common/composables/useDelayedLoading.ts | 28 ++ .../js/common/composables/useFetch.test.ts | 65 +++ resources/js/common/composables/useFetch.ts | 26 +- resources/js/cp.ts | 2 + resources/js/legacy.ts | 2 + .../js/modules/auth-method-setup/auth.scss | 2 +- .../conditions/ConditionBuilder.test.ts | 469 ++++++++++++++++++ .../modules/conditions/ConditionBuilder.vue | 296 +++++++++++ .../js/modules/conditions/ConditionGroup.vue | 153 ++++++ .../js/modules/conditions/ConditionRule.vue | 190 +++++++ .../conditions/ConditionRulePicker.vue | 59 +++ .../conditions/condition-builder-host.ts | 75 +++ resources/js/modules/conditions/types.ts | 54 ++ .../conditions/useConditionRuleRequest.ts | 62 +++ .../useModalElementIndex.ts | 8 +- .../components/ElementIndexToolbar.vue | 6 +- .../modules/elements/components/FilterHud.vue | 135 +++-- .../composables/useConditionBuilder.ts | 67 --- .../composables/useContentIndexData.ts | 2 +- .../useElementIndexFilters.test.ts | 19 +- .../composables/useElementIndexFilters.ts | 2 +- .../composables/useElementIndexPage.ts | 10 +- .../modules/forms/ConditionBuilderControl.vue | 102 ++-- .../js/modules/forms/FormRenderer.test.ts | 145 +++--- resources/js/modules/forms/FormRenderer.vue | 25 +- resources/js/modules/forms/GroupNode.vue | 18 +- resources/js/modules/forms/runtime.test.ts | 6 +- resources/js/modules/forms/runtime.ts | 2 +- .../modules/forms/useServerRenderedControl.ts | Bin 2815 -> 2797 bytes resources/legacy/cp/dist/cp.js | 2 +- resources/legacy/cp/dist/cp.js.map | 2 +- resources/legacy/cp/dist/css/cp.css | 2 +- resources/legacy/cp/dist/css/cp.css.map | 2 +- .../views/condition/rule-type-menu.blade.php | 37 -- routes/actions.php | 4 +- src/Condition/BaseCondition.php | 27 +- src/Condition/BaseConditionGroup.php | 26 +- src/Condition/BaseConditionRule.php | 3 +- src/Condition/BaseNumberConditionRule.php | 3 +- src/Condition/BaseSelectConditionRule.php | 4 +- src/Condition/ConditionBuilder.php | 97 ++++ src/Condition/ConditionBuilderPayload.php | 26 + src/Condition/ConditionBuilderRenderer.php | 309 +----------- src/Condition/ConditionRulePayload.php | 21 + src/Condition/ConditionRuleRenderer.php | 6 +- src/Condition/Conditions.php | 28 +- .../Contracts/ConditionRuleInterface.php | 3 +- src/Condition/Enums/GroupOperator.php | 11 + src/Cp/Components/Input.php | 1 - src/Cp/Components/Select.php | 1 - src/Element/Conditions/ElementCondition.php | 3 + .../Conditions/ElementConditionGroup.php | 5 +- src/Element/ElementSources.php | 22 + src/Field/BaseRelationField.php | 6 + src/FieldLayout/FieldLayout.php | 18 + src/FieldLayout/FieldLayoutComponent.php | 25 + .../LayoutElements/CustomField.php | 10 + src/Form/Controls/ConditionBuilder.php | 68 ++- src/Http/Controllers/ConditionsController.php | 153 +++--- .../ElementIndex/ElementIndexController.php | 7 +- .../ElementSelectorModalController.php | 6 +- src/Http/Controllers/FieldsController.php | 10 +- src/Http/Responses/CpModalResponse.php | 4 - src/Support/Facades/Conditions.php | 1 + src/Support/Html.php | 2 - src/Support/Utils.php | 2 + .../LegacyAssets/ConditionBuilderAsset.php | 9 +- src/View/LegacyAssets/HtmxAsset.php | 26 - .../Feature/Condition/ConditionGroupsTest.php | 91 ++++ .../Condition/ConditionsServiceTest.php | 39 ++ tests/Feature/Element/ElementSourcesTest.php | 18 + .../Controllers/ConditionsControllerTest.php | 243 ++++----- .../Elements/ElementIndexControllerTest.php | 11 +- .../Http/Controllers/FieldsControllerTest.php | 20 +- .../Condition/BaseSelectConditionRuleTest.php | 10 + .../Form/ConditionBuilderFieldLayoutsTest.php | 10 + tests/Unit/Support/UtilsTest.php | 30 ++ .../TypeScriptTransformerServiceProvider.php | 6 + .../AdministrativeAreaConditionRule.php | 6 - .../legacy/web/CpModalResponseFormatter.php | 2 - .../legacy/web/assets/htmx/HtmxAsset.php | 23 - .../Form/Concerns/LegacyConditionRuleForm.php | 14 +- .../Concerns/LegacyDateRangeConditionRule.php | 6 +- .../Concerns/LegacyRelatedToConditionRule.php | 6 - .../Legacy/ConditionFormCompatibilityTest.php | 14 + 99 files changed, 2553 insertions(+), 1310 deletions(-) delete mode 100644 packages/craftcms-legacy/conditionbuilder/src/ConditionBuilder.js delete mode 100644 packages/craftcms-legacy/conditionbuilder/webpack.config.js delete mode 100644 packages/craftcms-legacy/htmx/src/htmx.js delete mode 100644 packages/craftcms-legacy/htmx/webpack.config.js create mode 100644 resources/js/common/composables/useDelayedLoading.test.ts create mode 100644 resources/js/common/composables/useDelayedLoading.ts create mode 100644 resources/js/common/composables/useFetch.test.ts create mode 100644 resources/js/modules/conditions/ConditionBuilder.test.ts create mode 100644 resources/js/modules/conditions/ConditionBuilder.vue create mode 100644 resources/js/modules/conditions/ConditionGroup.vue create mode 100644 resources/js/modules/conditions/ConditionRule.vue create mode 100644 resources/js/modules/conditions/ConditionRulePicker.vue create mode 100644 resources/js/modules/conditions/condition-builder-host.ts create mode 100644 resources/js/modules/conditions/types.ts create mode 100644 resources/js/modules/conditions/useConditionRuleRequest.ts delete mode 100644 resources/js/modules/elements/composables/useConditionBuilder.ts delete mode 100644 resources/views/condition/rule-type-menu.blade.php create mode 100644 src/Condition/ConditionBuilder.php create mode 100644 src/Condition/ConditionBuilderPayload.php create mode 100644 src/Condition/ConditionRulePayload.php create mode 100644 src/Condition/Enums/GroupOperator.php delete mode 100644 src/View/LegacyAssets/HtmxAsset.php create mode 100644 tests/Feature/Condition/ConditionGroupsTest.php create mode 100644 tests/Unit/Support/UtilsTest.php delete mode 100644 yii2-adapter/legacy/web/assets/htmx/HtmxAsset.php diff --git a/.agents/skills/testing-guidelines/SKILL.md b/.agents/skills/testing-guidelines/SKILL.md index 72d217a2a69..898816c9cdf 100644 --- a/.agents/skills/testing-guidelines/SKILL.md +++ b/.agents/skills/testing-guidelines/SKILL.md @@ -37,6 +37,8 @@ uses(UnitTestCase::class)->in('Unit'); ## Core Rules - Read nearby tests first and follow their declaration and organization conventions. +- Before writing a test, identify the smallest regression it must catch and reuse an existing fixture, factory, or mount helper. For a small behavior change, prefer extending an existing test or adding one focused case. +- Keep setup and assertions proportional to the change's complexity and risk. If a short change needs a much larger test, simplify the setup before adding custom payloads or nested scenarios; retain extra coverage only for a distinct regression the change could cause. - Use Boost's `search-docs` for version-specific Pest and Laravel testing syntax. Confirm an assertion or feature before using it. - Test observable behavior and application contracts. Cover each changed decision and applicable high-value failure path, but leave framework behavior to framework tests. - Run the narrowest relevant test file or filter. Rerun a test after changing it. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a8e8117136..ad2a7132c65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ > [!IMPORTANT] > This update contains breaking changes for plugins. See [#19574](https://github.com/craftcms/cms/pull/19574), [#19563](https://github.com/craftcms/cms/pull/19563), [#19588](https://github.com/craftcms/cms/pull/19588), and [#19585](https://github.com/craftcms/cms/pull/19585) for details. +- Replaced the condition builder with a Vue editor supporting nested AND/OR groups and repeated rules. Condition rules are now validated when applying filters or saving conditions. ([#19587](https://github.com/craftcms/cms/pull/19587)) +- Removed HTMX. - Moved legacy relation-field settings HTML and entry-title input HTML into the Yii adapter. ([#19591](https://github.com/craftcms/cms/pull/19591)) - Migrated the reassign entries, replace relations, and replace references modals to the Form API. ([#19589](https://github.com/craftcms/cms/pull/19589)) - Added support for refreshable standard plugin settings forms and conditional configuration of core form nodes. ([#19545](https://github.com/craftcms/cms/pull/19545)) diff --git a/package-lock.json b/package-lock.json index 90950245c7a..3e176f07220 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14138,10 +14138,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/htmx.org": { - "version": "1.9.12", - "license": "0BSD" - }, "node_modules/http-cache-semantics": { "version": "4.2.0", "license": "BSD-2-Clause" @@ -24682,7 +24678,6 @@ "codemirror": "^5.65.21", "d3": "^7.9.0", "fabric": "^1.7.22", - "htmx.org": "^1.9.12", "iframe-resizer": "^4.4.5", "inputmask": "^5.0.9", "jquery": "^3.7.1", diff --git a/packages/craftcms-legacy/conditionbuilder/src/ConditionBuilder.js b/packages/craftcms-legacy/conditionbuilder/src/ConditionBuilder.js deleted file mode 100644 index 9f19b13de59..00000000000 --- a/packages/craftcms-legacy/conditionbuilder/src/ConditionBuilder.js +++ /dev/null @@ -1,16 +0,0 @@ -htmx.on('htmx:load', function (evt) { - if (evt.detail.elt === document.body) { - return; - } - - const container = evt.detail.elt.querySelector('.condition'); - if (container && container.classList.contains('sortable')) { - const sortItems = container.querySelectorAll('.condition-rule'); - if (sortItems.length) { - new Garnish.DragSort(sortItems, { - axis: Garnish.Y_AXIS, - handle: '.draggable-handle', - }); - } - } -}); diff --git a/packages/craftcms-legacy/conditionbuilder/webpack.config.js b/packages/craftcms-legacy/conditionbuilder/webpack.config.js deleted file mode 100644 index 304049a3f27..00000000000 --- a/packages/craftcms-legacy/conditionbuilder/webpack.config.js +++ /dev/null @@ -1,17 +0,0 @@ -/* jshint esversion: 6 */ -/* globals module, require, webpack */ -const {getConfig} = require('@craftcms/webpack'); - -module.exports = getConfig({ - context: __dirname, - config: { - entry: { - ConditionBuilder: './ConditionBuilder.js', - }, - output: { - path: - __dirname + - '/../../../cms-assets/resources/legacy/conditionbuilder/dist', - }, - }, -}); diff --git a/packages/craftcms-legacy/cp/src/css/_main.scss b/packages/craftcms-legacy/cp/src/css/_main.scss index df444831853..e0f11de8290 100644 --- a/packages/craftcms-legacy/cp/src/css/_main.scss +++ b/packages/craftcms-legacy/cp/src/css/_main.scss @@ -4677,66 +4677,6 @@ table.data tbody tr:not(.disabled).active-drop-target { } } -/* ---------------------------------------- -/* Condition builders -/* ---------------------------------------- */ - -.condition-rule, -.condition-footer { - padding: 7px; -} - -.condition-footer { - border: 1px dashed var(--border-hairline-medium); - border-radius: var(--radius-lg); - - .condition:not(:empty) + & { - border-block-start-width: 0; - border-start-start-radius: 0; - border-start-end-radius: 0; - } - - .spinner:not(.loading) { - display: none; - } -} - -.condition-rule { - margin: 0; - border: 1px solid var(--border-hairline); - background-color: var(--gray-050); - - &:first-child { - border-start-start-radius: var(--radius-lg); - border-start-end-radius: var(--radius-lg); - } - - & + .condition-rule { - border-block-start-width: 0; - } - - & > .rule-move, - & > .rule-actions { - margin-block-start: 5px; - } - - & > .rule-body { - .lightswitch { - margin-block-start: 6px; - display: block; - } - - .selectize { - min-width: 100px; - } - - .text.fullwidth { - min-width: 100px; - max-width: 100%; - } - } -} - /* ---------------------------------------- /* Progress bar /* ---------------------------------------- */ diff --git a/packages/craftcms-legacy/cp/src/js/BaseElementIndex.js b/packages/craftcms-legacy/cp/src/js/BaseElementIndex.js index 1c3323b1481..630b6a95a0e 100644 --- a/packages/craftcms-legacy/cp/src/js/BaseElementIndex.js +++ b/packages/craftcms-legacy/cp/src/js/BaseElementIndex.js @@ -4851,6 +4851,7 @@ const FilterHud = Garnish.HUD.extend({ serialized: null, $clearBtn: null, cleared: false, + applied: false, get isActive() { return this.showing || this.conditionConfig || this.serialized; @@ -4904,8 +4905,14 @@ const FilterHud = Garnish.HUD.extend({ this.$tip.remove(); this.$tip = null; - this.$body.on('submit', (ev) => { + this.$body.on('submit', async (ev) => { ev.preventDefault(); + + if (!(await this.$main.find('craft-condition-builder')[0].validate())) { + return; + } + + this.applied = true; this.hide(); }); @@ -4952,12 +4959,15 @@ const FilterHud = Garnish.HUD.extend({ this.clear(); }); - this.$hud.find('.condition-container').on('htmx:beforeRequest', () => { - this.setBusy(); + this.$hud.on('condition-builder-valid', (event) => { + const valid = event.originalEvent.detail.valid; + this.conditionValid = valid; + + this.$main.find('button[type="submit"]').prop('disabled', !valid); + valid ? this.setReady() : this.setBusy(); }); - this.$hud.find('.condition-container').on('htmx:load', () => { - this.setReady(); + this.$hud.on('condition-builder-change', () => { this.updateSizeAndPosition(true); }); this.setFocus(); @@ -4989,10 +4999,7 @@ const FilterHud = Garnish.HUD.extend({ setBusy: function () { this.$hud.attr('aria-busy', 'true'); - $('
', { - class: 'visually-hidden', - text: Craft.t('app', 'Loading'), - }).insertAfter(this.$main.find('.htmx-indicator')); + Craft.cp.announce(Craft.t('app', 'Loading')); }, setReady: function () { @@ -5072,10 +5079,15 @@ const FilterHud = Garnish.HUD.extend({ this.base(); // If something changed, update the elements - if (this.serialized !== (this.serialized = this.serialize())) { + if ( + (this.applied || this.cleared) && + this.serialized !== (this.serialized = this.serialize()) + ) { this.elementIndex.updateElements(); } + this.applied = false; + if (this.cleared) { this.destroy(); } else { @@ -5092,6 +5104,10 @@ const FilterHud = Garnish.HUD.extend({ }, serialize: function () { + if (!this.cleared && this.conditionValid === false) { + return this.serialized; + } + return !this.cleared && this.hasRules() ? this.$body.serialize() : null; }, diff --git a/packages/craftcms-legacy/htmx/src/htmx.js b/packages/craftcms-legacy/htmx/src/htmx.js deleted file mode 100644 index 80d11f362d3..00000000000 --- a/packages/craftcms-legacy/htmx/src/htmx.js +++ /dev/null @@ -1,85 +0,0 @@ -htmx.defineExtension('craft-cp', { - onEvent: function (name, evt) { - switch (name) { - case 'htmx:configRequest': - this.configureRequest(evt); - break; - case 'htmx:load': - this.onLoad(evt); - break; - } - }, - configureRequest: function (evt) { - // Add the standard Craft headers - Object.assign(evt.detail.headers, Craft._actionHeaders()); - }, - - // The best place to do this, until an event like `htmx:newContent` is introduced. - transformResponse: function (text, xhr, elt) { - const parser = new DOMParser(); - const doc = parser.parseFromString(text, 'text/html'); - - if (doc.body === document.body) { - return; - } - - const allHeadHtml = doc.querySelectorAll('template.hx-head-html'); - const allBodyHtml = doc.querySelectorAll('template.hx-body-html'); - - for (let i = 0; i < allHeadHtml.length; i++) { - const headHtml = allHeadHtml[i].innerHTML; - if (headHtml) { - Craft.appendHeadHtml(headHtml); - } - } - - for (let i = 0; i < allBodyHtml.length; i++) { - const bodyHtml = allBodyHtml[i].innerHTML; - if (bodyHtml) { - Craft.appendBodyHtml(bodyHtml); - } - } - - return text; - }, - onLoad: function (evt) { - Craft.initUiElements(evt.detail.elt); - }, -}); - -htmx.defineExtension('craft-condition', { - onEvent: function (name, evt) { - switch (name) { - case 'htmx:configRequest': - this.configureRequest(evt); - break; - } - }, - - configureRequest: function (evt) { - let $conditionContainer = $(evt.detail.target).children('.condition-main'); - if (!$conditionContainer.length) { - $conditionContainer = $(evt.detail.target).closest('.condition-main'); - } - const config = $conditionContainer.data('condition-config'); - if (config && config.name) { - const vals = - evt.detail.elt.getAttribute('hx-vals') || - evt.detail.elt.getAttribute('data-hx-vals'); - const valNames = vals ? Object.keys(JSON.parse(vals)) : []; - evt.detail.parameters = Object.fromEntries( - Object.entries(evt.detail.parameters).filter( - ([n]) => valNames.includes(n) || n.indexOf(config.name) === 0 - ) - ); - } - evt.detail.parameters.config = JSON.stringify(config || {}); - }, -}); - -if (typeof Craft !== 'undefined') { - Object.assign(htmx.config, { - indicatorClass: 'spinner', - requestClass: 'loading', - }); -} diff --git a/packages/craftcms-legacy/htmx/webpack.config.js b/packages/craftcms-legacy/htmx/webpack.config.js deleted file mode 100644 index 57f125ce4aa..00000000000 --- a/packages/craftcms-legacy/htmx/webpack.config.js +++ /dev/null @@ -1,24 +0,0 @@ -/* jshint esversion: 6 */ -/* globals module, require */ -const {getConfig} = require('@craftcms/webpack'); -const MergeIntoSingleFilePlugin = require('webpack-merge-and-include-globally'); - -module.exports = getConfig({ - context: __dirname, - config: { - entry: {}, - output: { - path: __dirname + '/../../../cms-assets/resources/legacy/htmx/dist', - }, - plugins: [ - new MergeIntoSingleFilePlugin({ - files: { - 'htmx.min.js': [ - require.resolve('htmx.org/dist/htmx.js'), - require.resolve('./src/htmx.js'), - ], - }, - }), - ], - }, -}); diff --git a/packages/craftcms-legacy/package.json b/packages/craftcms-legacy/package.json index 83e6852eff4..484d21a8e39 100644 --- a/packages/craftcms-legacy/package.json +++ b/packages/craftcms-legacy/package.json @@ -48,7 +48,6 @@ "codemirror": "^5.65.21", "d3": "^7.9.0", "fabric": "^1.7.22", - "htmx.org": "^1.9.12", "iframe-resizer": "^4.4.5", "inputmask": "^5.0.9", "jquery": "^3.7.1", diff --git a/packages/craftcms-ui/src/components/card/card.styles.ts b/packages/craftcms-ui/src/components/card/card.styles.ts index 97c0abd4712..628664ebec5 100644 --- a/packages/craftcms-ui/src/components/card/card.styles.ts +++ b/packages/craftcms-ui/src/components/card/card.styles.ts @@ -24,7 +24,7 @@ export default css` var(--c-color-fill-quiet, var(--c-color-neutral-fill-quiet)), transparent 70% ); - border: 1px solid + border: var(--c-card-border-width, 1px) solid var(--c-color-border-quiet, var(--c-color-neutral-border-quiet)); border-radius: var(--c-card-radius, var(--c-radius-md)); box-shadow: var(--c-card-shadow, var(--c-shadow-sm)); diff --git a/packages/craftcms-ui/src/components/card/card.ts b/packages/craftcms-ui/src/components/card/card.ts index 06587130569..b0a22f45b9d 100644 --- a/packages/craftcms-ui/src/components/card/card.ts +++ b/packages/craftcms-ui/src/components/card/card.ts @@ -33,6 +33,7 @@ import {classMap} from 'lit/directives/class-map.js'; * * @csspart label - The label slot within the header. * + * @cssproperty --c-card-border-width - Border width. Defaults to `1px`. * @cssproperty --c-card-radius - Corner radius. Defaults to `--c-radius-md`. * @cssproperty --c-card-shadow - Box shadow. Defaults to `--c-shadow-sm`. * @cssproperty --c-card-padding-inline - Inline (horizontal) padding of the @@ -100,11 +101,11 @@ export default class CraftCard extends LitElement { private _syncSlotPresence() { this._hasSlottedHeader = - !!this.querySelector('[slot="header"]') || - !!this.querySelector('[slot="label"]') || - !!this.querySelector('[slot="actions"]'); - this._hasSlottedFooter = !!this.querySelector('[slot="footer"]'); - this._hasThumbnail = !!this.querySelector('[slot="thumbnail"]'); + !!this.querySelector(':scope > [slot="header"]') || + !!this.querySelector(':scope > [slot="label"]') || + !!this.querySelector(':scope > [slot="actions"]'); + this._hasSlottedFooter = !!this.querySelector(':scope > [slot="footer"]'); + this._hasThumbnail = !!this.querySelector(':scope > [slot="thumbnail"]'); } private _handleThumbnailSlotChange(event: Event) { diff --git a/packages/craftcms-ui/src/components/combobox/combobox.ts b/packages/craftcms-ui/src/components/combobox/combobox.ts index c72c15d9abe..49e2146c96a 100644 --- a/packages/craftcms-ui/src/components/combobox/combobox.ts +++ b/packages/craftcms-ui/src/components/combobox/combobox.ts @@ -621,7 +621,7 @@ export default class CraftCombobox extends LionCombobox { this.inputs.setAttribute('data-combobox-inputs', ''); this.append(this.inputs); } - // HTMX also serializes condition builders that are not inside a form. + // Detached controls can still be serialized by their host. render( !this.name || this.disabled || this.fieldsetDisabled ? nothing diff --git a/resources/js/common/composables/useDelayedLoading.test.ts b/resources/js/common/composables/useDelayedLoading.test.ts new file mode 100644 index 00000000000..d3119920dff --- /dev/null +++ b/resources/js/common/composables/useDelayedLoading.test.ts @@ -0,0 +1,29 @@ +import {effectScope, shallowRef} from 'vue'; +import {afterEach, expect, it, vi} from 'vite-plus/test'; +import {useDelayedLoading} from './useDelayedLoading'; + +afterEach(() => vi.useRealTimers()); + +it('shows only sustained loading and hides immediately when it finishes', () => { + vi.useFakeTimers(); + const scope = effectScope(); + const loading = shallowRef(true); + const visible = scope.run(() => useDelayedLoading(loading))!; + + vi.advanceTimersByTime(199); + expect(visible.value).toBe(false); + loading.value = false; + vi.advanceTimersByTime(200); + expect(visible.value).toBe(false); + + loading.value = true; + vi.advanceTimersByTime(200); + expect(visible.value).toBe(true); + loading.value = false; + expect(visible.value).toBe(false); + + loading.value = true; + scope.stop(); + vi.advanceTimersByTime(200); + expect(visible.value).toBe(false); +}); diff --git a/resources/js/common/composables/useDelayedLoading.ts b/resources/js/common/composables/useDelayedLoading.ts new file mode 100644 index 00000000000..429ad4b6cf9 --- /dev/null +++ b/resources/js/common/composables/useDelayedLoading.ts @@ -0,0 +1,28 @@ +import {readonly, shallowRef, watch, type Ref} from 'vue'; + +/** Delay showing progress, but hide it immediately when loading finishes. */ +export function useDelayedLoading( + loading: Readonly>, + delay = 200 +) { + const visible = shallowRef(false); + + watch( + loading, + (loading, _, onCleanup) => { + if (!loading) { + visible.value = false; + + return; + } + + const timeout = setTimeout(() => { + visible.value = true; + }, delay); + onCleanup(() => clearTimeout(timeout)); + }, + {immediate: true, flush: 'sync'} + ); + + return readonly(visible); +} diff --git a/resources/js/common/composables/useFetch.test.ts b/resources/js/common/composables/useFetch.test.ts new file mode 100644 index 00000000000..cba2ae39472 --- /dev/null +++ b/resources/js/common/composables/useFetch.test.ts @@ -0,0 +1,65 @@ +import axios from 'axios'; +import {expect, it, vi} from 'vite-plus/test'; +import {useFetch} from './useFetch'; + +it('ignores a superseded HTTP response', async () => { + const client = axios.create(); + let finish!: () => void; + + vi.spyOn(client, 'request') + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = () => resolve({data: 'Old'}); + }) + ) + .mockResolvedValueOnce({data: 'New'}); + + const request = useFetch('/example', { + immediate: false, + axiosInstance: client, + }); + + const first = request.execute(); + expect(request.isLoading.value).toBe(true); + + await request.execute(); + finish(); + + expect(await first).toBeUndefined(); + expect(request.isSuccess.value).toBe(true); + expect(request.data.value).toBe('New'); +}); + +it('stays loading during a transform and discards its superseded result', async () => { + const client = axios.create(); + vi.spyOn(client, 'request').mockResolvedValue({data: 'Raw'}); + + let finish!: () => void; + const transform = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = () => resolve('Old'); + }) + ) + .mockResolvedValueOnce('New'); + + const request = useFetch('/example', { + immediate: false, + axiosInstance: client, + transform, + }); + + const first = request.execute(); + await vi.waitFor(() => expect(transform).toHaveBeenCalledOnce()); + expect(request.isLoading.value).toBe(true); + + expect(await request.execute()).toBe('New'); + finish(); + + expect(await first).toBeUndefined(); + expect(request.isSuccess.value).toBe(true); + expect(request.data.value).toBe('New'); +}); diff --git a/resources/js/common/composables/useFetch.ts b/resources/js/common/composables/useFetch.ts index 3fac532acbf..13337a41cb2 100644 --- a/resources/js/common/composables/useFetch.ts +++ b/resources/js/common/composables/useFetch.ts @@ -38,7 +38,7 @@ interface UseAxiosOptions extends Omit< immediate?: boolean; refetch?: boolean; params?: MaybeRef; - transform?: (data: T) => T; + transform?: (data: T) => T | Promise; enabled?: MaybeRef; debounce?: number; onSuccess?: (data: T, response: AxiosResponse) => void; @@ -52,11 +52,11 @@ interface UseAxiosReturn { data: Ref; error: Ref; state: Ref; - execute: (postData?: RequestData) => Promise; + execute: (postData?: RequestData) => Promise; isLoading: ComputedRef; isSuccess: ComputedRef; isError: ComputedRef; - refetch: () => Promise; + refetch: () => Promise; abort: () => void; } @@ -109,7 +109,7 @@ export function useFetch( let debounceTimer: ReturnType | null = null; // The actual fetch function - const execute = async (postData?: RequestData): Promise => { + const execute = async (postData?: RequestData): Promise => { if (!computedUrl.value || !computedEnabled.value) return; // Cancel previous request @@ -117,27 +117,35 @@ export function useFetch( cancelTokenSource.cancel('Request superseded by new request'); } - cancelTokenSource = axios.CancelToken.source(); + const request = axios.CancelToken.source(); + cancelTokenSource = request; state.value = 'loading'; error.value = null; try { - const response = await axiosInstance({ + const response = await axiosInstance.request({ method: computedMethod.value, url: computedUrl.value, params: computedParams.value, - cancelToken: cancelTokenSource.token, + cancelToken: request.token, data: computedMethod.value === 'get' ? undefined : postData, ...axiosOptions, }); + request.token.throwIfRequested(); const transformedData = transform - ? transform(response.data) + ? await transform(response.data) : response.data; + request.token.throwIfRequested(); + state.value = 'success'; data.value = transformedData; onSuccess?.(transformedData, response); + + return transformedData; } catch (err: unknown) { + if (request !== cancelTokenSource) return; + if (axios.isCancel(err)) { state.value = 'aborted'; } else if (axios.isAxiosError(err)) { @@ -196,7 +204,7 @@ export function useFetch( } // Manual refetch function - const refetch = (): Promise => execute(); + const refetch = (): Promise => execute(); // Cancel function const abort = (): void => { diff --git a/resources/js/cp.ts b/resources/js/cp.ts index 585d0f64731..9f83b5d7c4e 100644 --- a/resources/js/cp.ts +++ b/resources/js/cp.ts @@ -1,6 +1,7 @@ import '@craftcms/ui'; import '../../packages/craftcms-legacy/cp/src/js/UI.js'; import Cp from './bootstrap/cp.js'; +import {defineConditionBuilderHost} from './modules/conditions/condition-builder-host'; import {defineEntryFieldLayoutFormHost} from './modules/forms/entry-field-layout-form-host'; import {defineInlineAttributeFormHost} from './modules/forms/inline-attribute-form-host'; import {defineLayoutComponentSettingsFormHost} from './modules/forms/layout-component-settings-form-host'; @@ -55,5 +56,6 @@ import './modules/ui'; window.Cp = Cp; defineEntryFieldLayoutFormHost(Cp.$components); +defineConditionBuilderHost(Cp.$components); defineInlineAttributeFormHost(Cp.$components); defineLayoutComponentSettingsFormHost(Cp.$components); diff --git a/resources/js/legacy.ts b/resources/js/legacy.ts index 7a481743edd..11f81f29776 100644 --- a/resources/js/legacy.ts +++ b/resources/js/legacy.ts @@ -23,6 +23,7 @@ import './modules/auth/components/totp/totp-form.js'; import './modules/auth/components/recovery-codes/recovery-code-form.js'; import {mountElevatedSessionHost} from './modules/auth/elevated-session'; import {defineDashboardWidgetSettingsFormHost} from './modules/forms/dashboard-widget-settings-form-host'; +import {defineConditionBuilderHost} from './modules/conditions/condition-builder-host'; import {defineEntryFieldLayoutFormHost} from './modules/forms/entry-field-layout-form-host'; import {defineInlineAttributeFormHost} from './modules/forms/inline-attribute-form-host'; import {defineLayoutComponentSettingsFormHost} from './modules/forms/layout-component-settings-form-host'; @@ -79,6 +80,7 @@ Cp.init(); defineDashboardWidgetSettingsFormHost(Cp.$components); defineEntryFieldLayoutFormHost(Cp.$components); +defineConditionBuilderHost(Cp.$components); defineInlineAttributeFormHost(Cp.$components); defineLayoutComponentSettingsFormHost(Cp.$components); diff --git a/resources/js/modules/auth-method-setup/auth.scss b/resources/js/modules/auth-method-setup/auth.scss index 95d243fa415..d6b388f5cf4 100644 --- a/resources/js/modules/auth-method-setup/auth.scss +++ b/resources/js/modules/auth-method-setup/auth.scss @@ -84,7 +84,7 @@ ul.auth-method-recovery-codes-list { font-family: - SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, monospace; + SFMono-Regular, Consolas, 'Liberation Mono', Menlo, Courier, monospace; font-size: 0.9em !important; max-width: 20em; margin-inline: auto; diff --git a/resources/js/modules/conditions/ConditionBuilder.test.ts b/resources/js/modules/conditions/ConditionBuilder.test.ts new file mode 100644 index 00000000000..70fadd2891e --- /dev/null +++ b/resources/js/modules/conditions/ConditionBuilder.test.ts @@ -0,0 +1,469 @@ +import {createApp, nextTick, type App} from 'vue'; +import {afterEach, beforeEach, expect, it, vi} from 'vite-plus/test'; +import {actionClient} from '@craftcms/ui'; +import type CraftActionMenu from '@craftcms/ui/components/action-menu/action-menu'; +import {createCpComponentRegistry} from '@/bootstrap/components'; +import {registerFormComponents} from '@/modules/forms/register'; +import {expandFormData} from '@/common/utils/forms'; +import type {BuilderPayload, RulePayload, GroupConfig} from './types'; +import ConditionBuilder from './ConditionBuilder.vue'; +import {defineConditionBuilderHost} from './condition-builder-host'; + +function rule( + value = 'Alpha', + uid: string = crypto.randomUUID(), + type = 'Title' +): RulePayload { + const scope = ['_conditionRules', uid]; + + return { + config: {class: type, uid, operator: '=', value}, + label: type, + hint: null, + showHint: false, + form: { + scope, + refreshable: true, + nodes: [ + { + type: 'Field', + component: 'craft:field', + props: {label: 'Operator'}, + control: { + type: 'Choice', + component: 'craft:choice', + props: { + options: [ + {label: 'equals', value: '='}, + {label: 'contains', value: '**'}, + ], + multiple: false, + presentation: 'select', + }, + path: [...scope, 'operator'], + mode: 'editable', + deltaGroup: [...scope, 'operator'], + reactive: true, + }, + }, + { + type: 'Field', + component: 'craft:field', + props: {label: type}, + control: { + type: 'Text', + component: 'craft:text', + props: {}, + path: [...scope, 'value'], + mode: 'editable', + deltaGroup: [...scope, 'value'], + }, + }, + ], + values: {_conditionRules: {[uid]: {operator: '=', value}}}, + errors: [], + globalErrors: [], + }, + }; +} + +function response(payload = rule()) { + return {data: {rule: payload, headHtml: '', bodyHtml: ''}}; +} + +function builder(rules: RulePayload[] = []): BuilderPayload { + return { + config: {class: 'Entry'}, + value: { + class: 'Entry', + conditionRules: { + operator: 'and', + rules: rules.map((rule) => rule.config), + }, + }, + rules: Object.fromEntries(rules.map((rule) => [rule.config.uid, rule])), + ruleTypes: ['Title', 'Slug'].map((value) => ({ + value, + label: value, + hint: null, + showHint: false, + group: null, + })), + addRuleLabel: 'Add a rule', + }; +} + +const attachInternals = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'attachInternals' +); + +let app: App | undefined; +let editor: InstanceType; +let form: HTMLFormElement; +let container: HTMLElement; + +const components = createCpComponentRegistry(); +registerFormComponents(components); +defineConditionBuilderHost(components); + +beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, 'attachInternals', { + configurable: true, + value: () => ({setFormValue: vi.fn()}), + }); + vi.stubGlobal('confirm', vi.fn().mockReturnValue(true)); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ok: false})); + + form = document.createElement('form'); + container = document.createElement('div'); + form.append(container); + document.body.append(form); +}); + +afterEach(async () => { + if (app) { + components.uninstall(app); + app.unmount(); + app = undefined; + } + + form.remove(); + await nextTick(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + + if (attachInternals) { + Object.defineProperty( + HTMLElement.prototype, + 'attachInternals', + attachInternals + ); + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'attachInternals'); + } +}); + +async function mount(payload = builder(), editable = true): Promise { + app = createApp(ConditionBuilder, {payload, name: 'condition', editable}); + components.install(app); + editor = app.mount(container) as InstanceType; + await nextTick(); +} + +function button(label: string, parent: Element = container): HTMLElement { + const result = [...parent.querySelectorAll('craft-button')].find( + (element) => + element.textContent?.trim() === label || + element.getAttribute('aria-label') === label + ); + + if (!result) throw new Error(`Missing button: ${label}`); + + return result; +} + +async function selectType(label: string, menuIndex = 0): Promise { + const menu = + container.querySelectorAll('craft-action-menu')[ + menuIndex + ]!; + await vi.waitFor(() => + expect(menu.querySelector('craft-action-item')).not.toBeNull() + ); + + const item = [ + ...menu.querySelectorAll('craft-action-item'), + ].find((item) => item.textContent === label)!; + item.click(); + await nextTick(); +} + +async function changeOperator(): Promise { + const operator = container.querySelector( + '.condition-rule select' + )!; + operator.value = '**'; + operator.dispatchEvent(new Event('change', {bubbles: true})); + await nextTick(); +} + +function submitted(): GroupConfig { + return ( + expandFormData(new FormData(form)).condition as { + conditionRules: GroupConfig; + } + ).conditionRules; +} + +it('edits nested operators locally and prunes empty groups only in submitted values', async () => { + const post = vi.spyOn(actionClient, 'request'); + await mount(); + button('Any').click(); + button('Add a group').click(); + await nextTick(); + + expect(container.querySelectorAll('.condition-group')).toHaveLength(2); + expect(submitted().operator).toBe('or'); + expect(submitted().rules).toBeUndefined(); + expect(post).not.toHaveBeenCalled(); + + const confirm = vi.spyOn(window, 'confirm'); + button('Remove group').click(); + await nextTick(); + expect(container.querySelectorAll('.condition-group')).toHaveLength(1); + expect(confirm).not.toHaveBeenCalled(); +}); + +it('adds repeated rules inside a group and confirms removal of a populated group', async () => { + vi.spyOn(actionClient, 'request').mockImplementation(async () => response()); + await mount(); + button('Add a group').click(); + await nextTick(); + await selectType('Title'); + await vi.waitFor(() => + expect(container.querySelectorAll('.condition-rule')).toHaveLength(1) + ); + await selectType('Title', 1); + await vi.waitFor(() => + expect(container.querySelectorAll('.condition-rule')).toHaveLength(2) + ); + + const nested = submitted().rules[0] as GroupConfig; + expect(nested.rules).toHaveLength(2); + expect(nested.rules[0]!.uid).not.toBe(nested.rules[1]!.uid); + + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + button('Remove group').click(); + await nextTick(); + expect(container.querySelectorAll('.condition-rule')).toHaveLength(2); + + confirm.mockReturnValue(true); + button('Remove group').click(); + await nextTick(); + expect(container.querySelectorAll('.condition-rule')).toHaveLength(0); +}); + +it('retains values while switching type and replaces the menu label', async () => { + const initial = rule(); + const post = vi + .spyOn(actionClient, 'request') + .mockResolvedValue(response(rule('Alpha', initial.config.uid, 'Slug'))); + await mount(builder([initial])); + await selectType('Slug'); + await vi.waitFor(() => expect(submitted().rules[0]?.class).toBe('Slug')); + expect(post.mock.calls[0]?.[0]?.data).toMatchObject({ + rule: {class: 'Title', type: 'Slug', value: 'Alpha'}, + }); + expect(button('Slug')).toBeDefined(); + expect(submitted().rules[0]?.value).toBe('Alpha'); +}); + +it.each([ + [new Error('Offline'), 'Couldn’t update the condition rule.'], + [ + { + isAxiosError: true, + response: {data: {message: 'The selected condition rule is invalid.'}}, + }, + 'The selected condition rule is invalid.', + ], +])( + 'keeps edits and blocks submission after %s until a subsequent update succeeds', + async (failure, message) => { + const initial = rule(); + const post = vi + .spyOn(actionClient, 'request') + .mockRejectedValueOnce(failure) + .mockResolvedValueOnce( + response(rule('Alpha', initial.config.uid, 'Slug')) + ); + await mount(builder([initial])); + await selectType('Slug'); + await vi.waitFor(() => + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + message + ) + ); + expect(submitted().rules[0]?.class).toBe('Title'); + expect( + form.dispatchEvent(new Event('submit', {bubbles: true, cancelable: true})) + ).toBe(false); + + await selectType('Slug'); + await vi.waitFor(() => expect(submitted().rules[0]?.class).toBe('Slug')); + expect(post).toHaveBeenCalledTimes(2); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(form.dispatchEvent(new Event('submit', {cancelable: true}))).toBe( + true + ); + } +); + +it('preserves added rules and typed values when the native host disconnects and reconnects', async () => { + vi.spyOn(actionClient, 'request').mockResolvedValue(response()); + const host = document.createElement('craft-condition-builder'); + host.dataset.payload = JSON.stringify(builder()); + host.dataset.name = 'condition'; + container.append(host); + await nextTick(); + await selectType('Title'); + await vi.waitFor(() => + expect(host.querySelector('craft-input input')).not.toBeNull() + ); + + const input = host.querySelector('craft-input input')!; + input.value = 'Retained'; + input.dispatchEvent(new Event('input', {bubbles: true})); + await vi.waitFor(() => expect(submitted().rules[0]?.value).toBe('Retained')); + + host.remove(); + await nextTick(); + container.append(host); + await nextTick(); + await vi.waitFor(() => + expect( + host.querySelector('craft-input input')?.value + ).toBe('Retained') + ); + expect(submitted().rules[0]?.value).toBe('Retained'); +}); + +it('keeps later input while a reactive operator request is pending and becomes submittable again', async () => { + const initial = rule(); + let resolve!: (response: unknown) => void; + vi.spyOn(actionClient, 'request').mockImplementation( + () => + new Promise((done) => { + resolve = done; + }) + ); + await mount(builder([initial])); + await changeOperator(); + expect(form.dispatchEvent(new Event('submit', {cancelable: true}))).toBe( + false + ); + + const input = container.querySelector('craft-input input')!; + input.value = 'Later input'; + input.dispatchEvent(new Event('input', {bubbles: true})); + await vi.waitFor(() => + expect(submitted().rules[0]?.value).toBe('Later input') + ); + + resolve(response(initial)); + await vi.waitFor(() => + expect(container.querySelector('[aria-busy="true"]')).toBeNull() + ); + expect(submitted().rules[0]?.operator).toBe('**'); + expect(input.value).toBe('Later input'); + expect(form.dispatchEvent(new Event('submit', {cancelable: true}))).toBe( + true + ); +}); + +it('does not allow edits or submit values in disabled mode', async () => { + await mount(builder([rule()]), false); + await vi.waitFor(() => + expect( + container.querySelector('craft-input input')?.disabled + ).toBe(true) + ); + expect([...new FormData(form).entries()]).toHaveLength(0); + expect( + container.querySelector('craft-button[aria-label="Remove"]') + ).toBeNull(); +}); + +it('blocks native submission when a rule Form provider cannot render', async () => { + const initial = rule(); + initial.form.nodes[0] = { + ...initial.form.nodes[0]!, + control: {...initial.form.nodes[0]!.control!, component: 'missing:control'}, + }; + await mount(builder([initial])); + await vi.waitFor(() => + expect(container.querySelector('[role="alert"]')).not.toBeNull() + ); + expect(form.dispatchEvent(new Event('submit', {cancelable: true}))).toBe( + false + ); +}); + +it('aborts an operator refresh when switching type and ignores its late response', async () => { + const initial = rule(); + let finish!: () => void; + const post = vi + .spyOn(actionClient, 'request') + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = () => resolve(response(initial)); + }) + ) + .mockResolvedValueOnce(response(rule('Alpha', initial.config.uid, 'Slug'))); + await mount(builder([initial])); + await changeOperator(); + + await selectType('Slug'); + await vi.waitFor(() => expect(submitted().rules[0]?.class).toBe('Slug')); + expect(post.mock.calls[0]?.[0]?.cancelToken?.reason).toBeDefined(); + + finish(); + await post.mock.results[0]!.value; + await nextTick(); + expect(submitted().rules[0]?.class).toBe('Slug'); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(form.dispatchEvent(new Event('submit', {cancelable: true}))).toBe( + true + ); +}); + +it('aborts a pending refresh when its rule is removed', async () => { + let reject!: (reason: unknown) => void; + const pending = new Promise((_resolve, fail) => { + reject = fail; + }); + const post = vi.spyOn(actionClient, 'request').mockReturnValueOnce(pending); + await mount(builder([rule()])); + await changeOperator(); + const cancelToken = post.mock.calls[0]?.[0]?.cancelToken; + button('Remove').click(); + await nextTick(); + expect(cancelToken?.reason).toBeDefined(); + + reject(new DOMException('Aborted', 'AbortError')); + await vi.waitFor(() => + expect(container.querySelector('.condition-rule')).toBeNull() + ); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(form.dispatchEvent(new Event('submit', {cancelable: true}))).toBe( + true + ); +}); + +it('shows rule validation errors on apply and allows a successful retry', async () => { + const initial = rule(); + vi.spyOn(actionClient, 'request') + .mockRejectedValueOnce({ + isAxiosError: true, + response: { + data: { + errors: { + [`_conditionRules.${initial.config.uid}.value`]: ['Invalid value.'], + }, + }, + }, + }) + .mockResolvedValueOnce({data: {valid: true}}); + await mount(builder([initial])); + + expect(await editor.validate()).toBe(false); + await nextTick(); + expect(container.textContent).toContain('Invalid value.'); + expect(submitted().rules[0]?.value).toBe('Alpha'); + + expect(await editor.validate()).toBe(true); + await nextTick(); + expect(container.textContent).not.toContain('Invalid value.'); +}); diff --git a/resources/js/modules/conditions/ConditionBuilder.vue b/resources/js/modules/conditions/ConditionBuilder.vue new file mode 100644 index 00000000000..3bff4f6357b --- /dev/null +++ b/resources/js/modules/conditions/ConditionBuilder.vue @@ -0,0 +1,296 @@ + + + diff --git a/resources/js/modules/conditions/ConditionGroup.vue b/resources/js/modules/conditions/ConditionGroup.vue new file mode 100644 index 00000000000..1d717519fbe --- /dev/null +++ b/resources/js/modules/conditions/ConditionGroup.vue @@ -0,0 +1,153 @@ + + + diff --git a/resources/js/modules/conditions/ConditionRule.vue b/resources/js/modules/conditions/ConditionRule.vue new file mode 100644 index 00000000000..04115836afb --- /dev/null +++ b/resources/js/modules/conditions/ConditionRule.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/resources/js/modules/conditions/ConditionRulePicker.vue b/resources/js/modules/conditions/ConditionRulePicker.vue new file mode 100644 index 00000000000..0eecdb1c305 --- /dev/null +++ b/resources/js/modules/conditions/ConditionRulePicker.vue @@ -0,0 +1,59 @@ + + + diff --git a/resources/js/modules/conditions/condition-builder-host.ts b/resources/js/modules/conditions/condition-builder-host.ts new file mode 100644 index 00000000000..94f5f0495c6 --- /dev/null +++ b/resources/js/modules/conditions/condition-builder-host.ts @@ -0,0 +1,75 @@ +import type {CpComponentRegistry} from '@/bootstrap/components'; +import {createApp, h, shallowRef, type App} from 'vue'; +import ConditionBuilder from './ConditionBuilder.vue'; +import type {BuilderPayload, ConditionConfig} from './types'; + +type ConditionBuilderInstance = { + validate(): Promise; + snapshot(): BuilderPayload; +}; + +export function defineConditionBuilderHost( + components: CpComponentRegistry +): void { + if (customElements.get('craft-condition-builder')) return; + + customElements.define( + 'craft-condition-builder', + class extends HTMLElement { + #app: App | null = null; + readonly #builder = shallowRef(); + #payload: BuilderPayload | null = null; + + async validate(): Promise { + return (await this.#builder.value?.validate()) ?? false; + } + + connectedCallback(): void { + if (this.#app) return; + + this.#payload ??= JSON.parse(this.dataset.payload!); + this.#app = createApp({ + render: () => + h(ConditionBuilder, { + ref: this.#builder, + payload: this.#payload!, + name: this.dataset.name, + editable: this.dataset.editable !== '0', + autofocus: this.dataset.autofocus === '1', + onChange: (value: ConditionConfig) => { + this.dispatchEvent( + new CustomEvent('condition-builder-change', { + bubbles: true, + detail: {value}, + }) + ); + this.dispatchEvent(new Event('change', {bubbles: true})); + }, + onValid: (valid: boolean) => + this.dispatchEvent( + new CustomEvent('condition-builder-valid', { + bubbles: true, + detail: {valid}, + }) + ), + }), + }); + + components.install(this.#app); + this.#app.mount(this); + } + + disconnectedCallback(): void { + queueMicrotask(() => { + if (this.isConnected || !this.#app) return; + + this.#payload = this.#builder.value!.snapshot(); + + components.uninstall(this.#app); + this.#app.unmount(); + this.#app = null; + }); + } + } + ); +} diff --git a/resources/js/modules/conditions/types.ts b/resources/js/modules/conditions/types.ts new file mode 100644 index 00000000000..4a94595bcae --- /dev/null +++ b/resources/js/modules/conditions/types.ts @@ -0,0 +1,54 @@ +import type {FormPayload, FormValues} from '@/modules/forms/types'; +import type {InjectionKey} from 'vue'; + +export type RuleConfig = {class: string; uid?: string} & FormValues; + +export type GroupConfig = { + operator: CraftCms.Cms.Condition.Enums.GroupOperator; + rules: Array; +} & FormValues; + +export type ConditionConfig = { + class: string; + conditionRules?: GroupConfig | RuleConfig[]; +} & FormValues; + +export type RulePayload = Omit< + CraftCms.Cms.Condition.ConditionRulePayload, + 'config' | 'form' +> & { + config: RuleConfig & {uid: string}; + form: FormPayload; +}; + +export type BuilderPayload = Omit< + CraftCms.Cms.Condition.ConditionBuilderPayload, + 'config' | 'value' | 'rules' +> & { + config: FormValues; + value: ConditionConfig; + rules: Record; +}; + +export type RuleDraft = {kind: 'rule'; id: string}; + +export type GroupDraft = { + kind: 'group'; + id: string; + operator: CraftCms.Cms.Condition.Enums.GroupOperator; + rules: Array; +}; + +export const ConditionEditor: InjectionKey<{ + payload: () => BuilderPayload; + rules: Record; + errors: () => FormPayload['errors']; + editable: () => boolean; + value: () => ConditionConfig; + changed: () => void; + status: (id: string, valid: boolean) => void; + registerRule: ( + id: string, + rule?: {snapshot: () => RulePayload; canSubmit: () => boolean} + ) => void; +}> = Symbol('ConditionEditor'); diff --git a/resources/js/modules/conditions/useConditionRuleRequest.ts b/resources/js/modules/conditions/useConditionRuleRequest.ts new file mode 100644 index 00000000000..a5869e002f4 --- /dev/null +++ b/resources/js/modules/conditions/useConditionRuleRequest.ts @@ -0,0 +1,62 @@ +import {computed, inject, onBeforeUnmount, watch} from 'vue'; +import {actionClient, appendBodyHtml, appendHeadHtml} from '@craftcms/ui'; +import ConditionsController from '@actions/ConditionsController'; +import {useFetch} from '@/common/composables/useFetch'; +import type {FormValues} from '@/modules/forms/types'; +import {ConditionEditor, type RulePayload} from './types'; + +export function useConditionRuleRequest(id: string, fallbackMessage: string) { + const editor = inject(ConditionEditor)!; + const request = useFetch<{ + rule: RulePayload; + headHtml: string; + bodyHtml: string; + }>(ConditionsController.rule().url, { + method: 'post', + immediate: false, + axiosInstance: actionClient, + transform: async (data) => { + await appendHeadHtml(data.headHtml); + await appendBodyHtml(data.bodyHtml); + + return data; + }, + }); + + const error = computed(() => { + if (!request.isError.value) return; + + const failure = request.error.value; + + return typeof failure === 'object' && + failure !== null && + 'message' in failure && + typeof failure.message === 'string' + ? failure.message || fallbackMessage + : fallbackMessage; + }); + + watch( + () => !request.isLoading.value && !request.isError.value, + (valid) => editor.status(id, valid), + {flush: 'sync'} + ); + + onBeforeUnmount(() => { + request.abort(); + editor.status(id, true); + }); + + async function execute(rule: FormValues): Promise { + const data = await request.execute({ + config: editor.payload().config, + value: editor.value(), + rule, + editable: editor.editable(), + }); + + return data?.rule; + } + + return {execute, isLoading: request.isLoading, error}; +} diff --git a/resources/js/modules/element-selector-modal/useModalElementIndex.ts b/resources/js/modules/element-selector-modal/useModalElementIndex.ts index 8360f1e7314..2fad1170009 100644 --- a/resources/js/modules/element-selector-modal/useModalElementIndex.ts +++ b/resources/js/modules/element-selector-modal/useModalElementIndex.ts @@ -2,7 +2,7 @@ import {actionClient, type ElementInfo} from '@craftcms/ui'; import {getCoreRowModel, useVueTable} from '@tanstack/vue-table'; import type {RowSelectionState} from '@tanstack/table-core'; import {computed, ref, shallowRef, watch} from 'vue'; -import {useConditionBuilder} from '@/modules/elements/composables/useConditionBuilder'; +import type {ConditionConfig} from '@/modules/conditions/types'; import { useContentIndexData, type ContentIndexData, @@ -96,9 +96,9 @@ export function useModalElementIndex(options: Options) { const elementIndex = useContentIndexData(undefined, payload); const viewState = useElementIndexViewState(elementIndex); - const {conditions} = useConditionBuilder({ - initialState: elementIndex.currentCondition ?? null, - }); + const conditions = shallowRef( + elementIndex.currentCondition ?? null + ); // Not `useElementIndexFilters`: it submits through an Inertia form, which // would navigate the page behind the modal. Same params, sent the modal's way. const search = ref(elementIndex.search ?? ''); diff --git a/resources/js/modules/elements/components/ElementIndexToolbar.vue b/resources/js/modules/elements/components/ElementIndexToolbar.vue index 2a64b39d31f..e432a28ba4e 100644 --- a/resources/js/modules/elements/components/ElementIndexToolbar.vue +++ b/resources/js/modules/elements/components/ElementIndexToolbar.vue @@ -7,7 +7,7 @@ import type {CheckboxOption} from '@/common/types'; import type {SortOption, ViewMode} from '@/modules/elements/types/view-state'; import FilterHud from './FilterHud.vue'; - import type {ConditionConfig} from '@/modules/elements/composables/useConditionBuilder'; + import type {ConditionConfig} from '@/modules/conditions/types'; import {ref} from 'vue'; defineProps<{ @@ -38,6 +38,7 @@ }>(); const filterActive = ref(false); + const filterAnchor = ref();