Enhancing modules with Rules-based conditions was very easy in D7. Using hook_default_rules_configuration
we could dynamically generate a bunch of rules called mymodule_rule_[some_key]
, use rules_ui()->config_menu()
to add the menu items for the Rules admin UI, then invoke the generated components to evaluate conditions. Every entity or option would have its own Rules component that we can edit and add arbitrary conditions. Some examples of this in D7 were:
- Payment methods (Ubercart/Commerce)
- Coupons
- Tax rules
- Block visibility
- User access or eligibility
And anything where you could not possibly know of the conditions that would be needed. Some of the above were changed to use Core conditions in D8, but that didn’t cut it for our use case since I could not possibly write a new condition for every requirement that came up. Real life examples of these are:
- A user can only claim a certain kind of course credit when the credit code on the course contains specific characters and the user is from Florida.
- The user can only use the payment method when there is a valid role attached to the user and specific products are in the cart.
- A user is not eligible to receive a certain type of credit when they are eligible to receive a certain type of credit.
- A quiz taker can only see correct answers once two weeks have passed and the user exhausted two attempts.
These aren’t out of the ordinary and we would be writing custom PHP if/else trees every day. For a SaaS-like product this is not ideal.
It’s a little trickier to add arbitrary conditions to entities but well worth it in the end. Rules provides a test module that you can look at: rules_test_ui_embed
. This example illustrates using 1 rule component embedded into a page. But we need to build Rules into all instances of a configuration entity.
Rules provides an interface RulesUiComponentProviderInterface
that we can use to store component configuration on our entity types. This was added in https://www.drupal.org/project/rules/issues/2659016 and https://www.drupal.org/project/rules/issues/2671056 but so far, there don’t seem to be any contributed modules that implement this! Rules does use it for its own action and condition components.
There is documentation for extending Rules with new conditions and actions, but it is pretty lacking around integration. There is some embedded developer documentation so let’s take a look.
If we look at rules_test_ui_embed
we see that there is some sort of plugin file – rules_test_ui_embed.rules_ui.yml
. That must define something!
rules_test_ui_embed.rules.ui.yml
:
rules_test_ui_embed.settings_conditions:
label: 'Conditions embedded in Rules-test-UI-embed module settings'
base_route: rules_test_ui_embed.settings
settings:
config_name: rules_test_ui_embed.settings
config_key: conditions
The above defines a Rules UI plugin which will create routes on rules_test_ui_embed.settings
.The configuration for the Rules component will be saved to rules_test_ui_embed.settings
under the conditions
key. But that doesn’t work for us, we need to have multiple components on multiple entities.
There’s another parameter in RulesUiConfigHandler
we can use to allow wildcard editing of components: config_parameter
It appears that config_parameter
and config_key
can be used to dynamically set which configuration object and key will be updated. With a little trial and error I applied it to Quiz feedback types. Feedback types hold sets of review options that display feedback to quiz takers after they answer a question or finish an entire quiz. They can also be used for post-review feedback, in the case of revisiting the quiz after 2 weeks. Only seeing correct answers after 3 attempts, only seeing instructor feedback once given a role, etc…
Let’s assume that we already have a QuizFeedbackType
entity to allow creation of custom feedback “times”, and all the edit forms are already set up. We want to add conditions to each feedback type so that we can conditionally display their items. In Quiz we have two built in: “Question” and “End”.
Define route and *.rules_ui.yml
This will indicate that we want Rules UI functionality appended to a route that we will also create. It will also tell Rules that we want the component to be saved onto the object loaded from the quiz_feedback_type
parameter. Note how _rules_ui
option on the route matches the plugin name defined in quiz_rules.ui.yml
:
quiz.rules.ui.yml
:
quiz_feedback.rules_ui_conditions:
label: 'Embedded quiz feedback conditions'
base_route: entity.quiz_feedback_type.conditions
settings:
config_key: component
config_parameter: quiz_feedback_type
quiz.routing.yml
:
[...]
entity.quiz_feedback_type.conditions:
path: 'admin/quiz/feedback/type/{quiz_feedback_type}/conditions'
defaults:
_form: '\Drupal\quiz\Form\QuizFeedbackConditionsForm'
_title: 'Feedback conditions'
requirements:
_permission: 'administer quiz'
options:
_rules_ui: quiz_feedback.rules_ui_conditions
parameters:
quiz_feedback_type:
type: 'entity:quiz_feedback_type'
Define new form for editing a component
This is a normal form that extends ConfigFormBase, but is provided with a Rules UI handler from the plugin definition that matches the route above. Most of this code is copied from rules_test_ui_embed
:
QuizFeedbackConditionsForm.php
[...]
class QuizFeedbackConditionsForm extends ConfigFormBase {
/**
* The RulesUI handler of the currently active UI.
*
* @var RulesUiConfigHandler
*/
protected $rulesUiHandler;
/**
* {@inheritdoc}
*/
protected function getEditableConfigNames() {
return [];
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'quiz_feedback_conditions';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, RulesUiConfigHandler $rules_ui_handler = NULL) {
$form = parent::buildForm($form, $form_state);
$this->rulesUiHandler = $rules_ui_handler;
$form['conditions'] = $this->rulesUiHandler->getForm()
->buildForm([], $form_state);
$form['actions']['cancel'] = [
'#type' => 'submit',
'#limit_validation_errors' => [['locked']],
'#value' => $this->t('Cancel'),
'#submit' => ['::cancel'],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
parent::validateForm($form, $form_state);
$this->rulesUiHandler->getForm()
->validateForm($form['conditions'], $form_state);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->rulesUiHandler->getForm()
->submitForm($form['conditions'], $form_state);
// Save the configuration that submitForm() updated (the config entity).
$config = $this->rulesUiHandler->getConfig();
$config->save();
// Also remove the temporarily stored component, it has been persisted now.
$this->rulesUiHandler->clearTemporaryStorage();
parent::submitForm($form, $form_state);
}
/**
* Form submission handler for the 'cancel' action.
*/
public function cancel(array $form, FormStateInterface $form_state) {
$this->rulesUiHandler->clearTemporaryStorage();
$this->messenger()->addMessage($this->t('Canceled.'));
$form_state->setRedirectUrl($this->rulesUiHandler->getBaseRouteUrl());
}
}
In buildForm
we take in the Rules UI handler and use it to generate the condition form. In submitForm
, the Rules UI handler will notify our Rules component “provider” that there is a component that has to be saved.
Implement RulesUiComponentProviderInterface
The rulesUiHandler
from above requires the entity type to handle getting the Rules component and saving it onto itself since we are not specifying a static config_name
or config_key
We add the component
property to config_export
, then we implement RulesUiComponentProviderInterface
and implement the 2 methods:
/**
* @ConfigEntityType(
[...]
* config_export = {
* "id",
* "label",
* "component"
* },
*/
class QuizFeedbackType extends ConfigEntityBase implements RulesUiComponentProviderInterface {
/**
* {@inheritdoc}
*/
public function getComponent() {
if (empty($this->component)) {
// Provide a default for now.
$this->component = [
'expression' => ['id' => 'rules_and'],
'context_definitions' => [
'quiz_result_answer' => [
'type' => 'entity:quiz_result',
'label' => 'Quiz result',
'description' => 'Quiz result to evaluate feedback',
],
],
];
}
if (!isset($this->componentObject)) {
$this->componentObject = RulesComponent::createFromConfiguration($this->component);
}
return $this->componentObject;
}
/**
* {@inheritdoc}
*/
public function updateFromComponent(RulesComponent $component) {
$this->component = $component->getConfiguration();
$this->componentObject = $component;
return $this;
}
}
- In
getComponent()
we check to see if the entity already has conditions, and return aRulesComponent
. If it does not, we provide a default that intakes aQuizResult
entity to evaluate. - In
updateFromComponent()
, we get theRulesComponent
and store it on the entity.
And there it goes, components being attached to entities.
Now that the components are stored on an entity that implements RulesUiComponentProviderInterface
, we can invoke the rule in our code to validate the conditions:
/* @var $component RulesComponent */
$component = QuizFeedbackType::load('question')->getComponent();
// Answer 31 has not been answered.
$not_finished = QuizResult::load(31);
$component->setContextValue('quiz_result', $not_finished);
$x = $component->getExpression()->executeWithState($component->getState());
var_dump($x); // $x = FALSE
// Answer 32 has been answered, show feedback.
$finished = QuizResult::load(32);
$component->setContextValue('quiz_result', $finished);
$y = $component->getExpression()->executeWithState($component->getState());
var_dump($y); // $y = TRUE
Reference: https://www.drupal.org/project/rules/issues/3117749