0

I created a module that adds a password protection to a site I'm testing craft on (I'm new to Craft and Yii, so please bear with me). In the front end there's a form where users enter a password and send it for validation.

I followed craft's recommendations for setting up the form: action attribute is empty, and there is a hidden field with the controller's route. The issue is that when there are validation errors, the page is reloaded (which is expected), but I cannot, or don't know how to, access the model nor its errors.

My controller has a function that is called when there's a validation error, and works like this:

    Form action:
    
    <input type="hidden" name="action" value="password-global/default/knock-knock">
    DefaultController.php:
    
    public function actionKnockKnock(
        // control var
        $validated = false;      
        // creates model instance
        $model = new PasswordGlobalModel();
        // gets form's posted data
        $params = Craft::$app->request->getBodyParams();
        // passes form's data to model
        $model->attributes = $params;
    
        // verifies data validity
        if ( $model->validate() ):
            $validated = true;      
            // some other logic
        endif;    
    
       // ready to send response
       if ($validated):
            // redirection stuff
       else:
            Craft::$app->urlManager->setRouteParams(['myModel' => $model], true);
            return null; 
       endif;
    
    )

If I do a print_r of $model before doing the setRouteParams(..) part, I see the data model object as I expected... beginning like this:

    abc\passwordglobal\models\PasswordGlobalModel Object
    (
        [theDate] => 
        [theGreeting] => 
        [action] => password-global/default/knock-knock
        [theCurrentPage] => /contact
        [_errors:yii\base\Model:private] => Array
            (
                [theGreeting] => Array
                    (
                        [0] => The password cannot be validated.
                    )
    
            )
        ...
    )
    // In the reloaded form I have:
    craft.app.request.getParam('myModel') // returns null
    craft.app.request.getBodyParams() // returns array of data entered and submitted in the form's fields

The answer to this question helped me clarify things, but the proposed solution doesn't work for me.

Any suggestions on how to get the model/or its errors are greatly appreciated. Thank you!

EDIT 1, THE PROBLEM: I did several changes, and now things are working. The issue was that my module had a form template that was rendered (using renderTemplage) when a twig extension function was called from within the front end. I assume that doing it this way was preventing the rendered form from reading the model passed with setRouteParams in the controller.

If I include the form as a front end template, then I have full access to the model.

I'm marking @August Miller's solution as the correct one, because it is the best and most generous overview of how Craft modules work. Thank you!

2
  • Could you post more of your Controller—specifically, the part of the action that calls this private method? Oct 24, 2021 at 0:17
  • @AugustMiller, thank you, I updated my question. To simplify things I removed the private method.
    – mauricio
    Oct 24, 2021 at 13:35

2 Answers 2

1

I suspect the real answer will reveal itself by reducing your test case to the simplest possible implementation—but I wanted to double-check some of my own assumptions about how this worked, so I cobbled together a basic Module, Controller, Model, and Template from existing material…

Validation Example

Routing

This just provides a nicer way for us to hit the landing "form" template, at /validation-demo. The key is the URI, and the value is an action path, with the segments being the Module ID (as it's registered in app.php), the Controller handle (kebab-cased), and the Action ID (also kebab-cased):

return [
    'validation-demo' => 'playground/example/configure',
];

Module

Dime-a-dozen Module class—nothing special here.

namespace modules\playground;

class Playground extends \yii\base\Module
{
    public function init()
    {
        $this->controllerNamespace = 'modules\\playground\\controllers';

        parent::init();
    }
}

Controller

This one's a little longer! There are two Actions—one responsible for rendering the form (configure) and one for receiving data (validate).

Neither make any changes to the database—they're just here to demonstrate the lifecycle of a model when dealing with route params.

namespace modules\playground\controllers;

use Craft;
use craft\web\Controller as BaseController;
use craft\web\Response;

use modules\playground\models\Thing;

class ExampleController extends BaseController
{
    protected $allowAnonymous = self::ALLOW_ANONYMOUS_LIVE;

    /**
     * This is how we first encounter the view—see `routes.php`!
     * 
     * @param Thing|null $thing An existing Thing instance, when passed via route params.
     */
    public function actionConfigure(?Thing $thing): Response
    {
        if (is_null($thing)) {
            $thing = new Thing;
        }

        return $this->renderTemplate('_validation/form', [
            'thing' => $thing,
        ]);
    }

    /**
     * The action that receives our POST request and actually processes data.
     */
    public function actionValidate(): ?Response
    {
        $this->requirePostRequest();

        // We're creating this virtually, but it could also be loaded from the database by sending an ID from the front-end:
        $thing = new Thing;
        $thing->size = $this->request->getBodyParam('size', $thing->size);

        // Using a "happy path," we bail early if it fails validation:
        if (!$thing->validate()) {
            // Set a notice that we can display in the front-end...
            Craft::$app->getSession()->setNotice('Thing was misconfigured! Please try again.');

            // ...and include our Thing in "route params" that get applied when we fall back through to the action above (or the original template). Note that the key in this array has to correspond to the argument name in `actionConfigure`!
            Craft::$app->getUrlManager()->setRouteParams(['thing' => $thing]);

            // Don't forget to actually exit early!
            return null;
        }

        Craft::$app->getSession()->setNotice('Thing was properly configured!');

        return $this->redirectToPostedUrl();
    }
}

Model

The model just has a single property, and validates when a size greater than 100 is assigned.

namespace modules\playground\models;

use craft\base\Model;

class Thing extends Model
{
    public $size;

    public function rules(): array
    {
        $rules = [];

        $rules[] = [['size'], 'required'];
        $rules[] = [['size'], 'number', 'min' => 100];

        return $rules;
    }
}

Template

This is stored at templates/_validation/form.twig (your main templates folder, not that of your module), and contains only the code absolutely required to demonstrate the concepts.

The combination of routes.php and ExampleController handles instantiating a Thing instance, and rendering this when accessing /validation-demo. Note that thing isn't defined in the template—it's set in the Action, above, and passed to the template explicitly!

{% set flashes = craft.app.session.getAllFlashes() %}

{% for type, flash in flashes %}
    <div class="flash {{ type }}">{{ flash }}</div>
{% endfor %}

<form method="POST">
    {{ actionInput('playground/example/validate') }}
    {{ csrfInput() }}

    <fieldset>
        <label for="size">Thing Size</label>
        <input
            type="number"
            id="size"
            name="size"
            step="0.01"
            value="{{ thing.size }}"
            required>

        {% if thing.hasErrors('size') %}
            <p>{{ thing.getErrors('size') | join(', ') }}</p>
        {% endif %}
    </fieldset>

    <button>Validate</button>
</form>

This can also be exposed as a regular template based Craft's routing behavior (item four in the list).

Putting it All Together

Once the files are organized in your Craft installation / module, you should be able to visit /validation-demo in your browser, and see a form. Submitting the form with no information should present a validation error. Submitting with valid info (a number greater than 100) should display a message, and reset the form.

Note that the first time you visit /validation-demo, the Controller takes care of populating a Thing for you, as one is not yet available in Route params.

When processing the subsequent POST request, a new Thing is created as a container for the data + validation. When validation fails, we call ->setRouteParams(['key' => $model]) which keeps track of the model in-memory, and tries to match it up with and argument of the Action that the request "falls through" to, after a null value comes back from the original Action.

Bonus: Can we do this without ExampleController::actionConfigure()?

If your original form appears in a normal template (without vanity route mapping via routes.php to a Controller), anything set as a route param will be available in the template—although on the first view, it won't have any idea what thing is, so you would have to code the form “defensively,” i.e. gratuitous use of the null coalescing operator: {% set errors = thing.getErrors('size') ?? [] %}.

Try deleting your route from routes.php and moving the template to templates/validation-demo.twig. Replace any presumptive use of the thing variable in the template with if statements or the null-coalesce operator, and start from the top. There should be no difference in overall behavior, except that thing is a little more volatile to work with, when you're not sure if it'll exist.

I'd like to publish this as a ready-made bundle, but I don't know how useful it is without the AR/storage portion. Let me know if you have ideas!

2
  • thanks a lot for this generous explanation of how Modules work. I think what Craft is sorely missing is this kind of tutorials for intermediate and advanced features. In their docs. I always find one line description of what things do, and, at best, partial examples that don't show the full picture. In my edit I wrote what the issue was. Comparing your examples against mine helped me out figure the error. Regarding your question, I would publish this as it is. If later you want to add the storage portion, even better. Clear and complete examples are needed badly.
    – mauricio
    Oct 25, 2021 at 17:26
  • Keep in mind that Craft is built on top of Yii, and shares a lot of its DNA—in fact, this demo is predominantly Yii functionality! So, when in doubt, be sure and look at the Yii 2 knowledge-base. I leaned heavily on the validating input and Actions articles. I'll consider how to package this up, and talk to Matt about what aspects if any would be useful to PR into the documentation. Oct 25, 2021 at 19:29
2

In the twig template, myModel should be available as a regular twig variable.

Try this:

{{ myModel.id }}
3
  • thank you for the feedback. Unfortunately, I'm not able to produce any results that way. If I try this {% if myModel is defined %} {{ 'myModel.action }}{% endif %} nothing is outputted.
    – mauricio
    Oct 23, 2021 at 23:12
  • Ah, must be something else then. Will need to see more context. For example, controller action itself needs to return null on validation errors, so are you doing return $this->validationProblems($myModel) or $this->validationProblems($myModel); return null? Oct 24, 2021 at 2:08
  • thanks so much for chcking on this. I edited my question to include the controller action.
    – mauricio
    Oct 24, 2021 at 3:32

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.