Ultra Double Secret Manual: Shared Form Part Three

So, we know how to build a form. A big ol’ array with a lot of options. But, I mean, that’s pretty unsatisfying. “Write out a complicated data structure in your Controller”, is a pretty terrible place to end things. So we’re not done. (Also, we really haven’t gone over Tables, Grid Fields, and Image Filepicker flows.)

So, in this Part, we’re going to go over what’s worked best for me to:

  1. Structure my Shared Form data into objects
  2. Working with Model objects and the Model Service with forms
  3. The complete POST flow working with Model Validation.

The Project Outline

To help keep things conceptually fun, let’s assume we have a client who wants us to build a Control Panel form that will allow editing of the ExpressionEngine Site details.

That’s it, pretty simple. Also, a huge waste of money but, also, a man’s gotta eat, so that’s what we’re building.

Which means we have the following flow for the program:

  1. Get the Site Model for the current site (ensure we have an accurate model)
  2. Setup our form details and set default values from the Model
  3. Check for POST request, validate the form data, and process
  4. Display form

So, really, I see 3 objects at this stage; the ExpressionEngine Control Panel object and a Form and Abstract object that’ll handle the generation of the particulars of a Form.

Little Pseudo

Just to keep me (personally) honest, I like to write out the general flow of my programs in pseudo code first. It helps me make sure I don’t miss anything and provides a reference point on the functionality that doesn’t require digging through emails or Pdf/Word docs.

$site = getSite();
if(!$site) {
    redirect();
}

$form = getForm();
$vars = [];
if(isPost()) {
    if($site->isValid()) {
        $site->save();
        redirect();
    }

    $vars = getErrors();
}

renderPage($form, $vars);

And, yeah, I really do want it that short and concise in the end; future me will be happy the code’s in a readable format.

So. Time to build.

The Form Object

When creating a Shared Form for a Model, it’s all about keeping things one to one in naming; your form fields should match your Model column names. They don’t have to match, but you’re going save yourself a ton of time not wrangling the data structures.

So, using the database table used by the Site Model (exp_sites) as an example, we can tell we’ll need form fields for:

I’m sure you can imagine how unwieldy this becomes if we put everything into the Control Panel object. Especially when you want to reuse the form (gotta create as well as update, after all).

So, to me, we need to abstract things a bit. I like my code to be lean and readable; I locked into the fat model skinny controller paradigm early and it really pays dividends in 2022.

The Form Abstract

For every project I do that requires Shared Form Views, I always start with an AbstractForm object that all the other Form objects will inherit. Just keeps things uniform; I want every form to work the same and have the same capabilities since clients are insane and shifting priorities are a thing.

At the most basic, it resembles the below:

abstract class AbstractForm
{
    protected $data = [];

    abstract public function generate(): array;

    public function setData(array $data): AbstractForm
    {
        $this->data = $data;

        return $this;
    }

    public function get(string $key = '', $default = '')
    {
        return isset($this->data[$key]) ? $this->data[$key] : $default;
    }
}

I have a more full fledged implementation that I use nowadays, but the above is really the core; an object that returns a form (to be defined) and a couple helpers for handling values.

Really though, all the above is is a simple base that allows setting an array as a data store and a getter to grab what we want without worrying about its existence. Just makes for a smooth dev experience for me since we don’t have to test return values.

A Form Object

The idea is that every Form I build is based off the Abstract, with one domain per Form. For example, a SiteForm and then a MemberForm, etc. Each stand-alone. Each contained to its own domain/namespace. And each able to be tested stand-alone.

So, let’s put it together.

class SiteForm extends AbstractForm
{
    public function generate(): array
    {
        return [
            [
                [
                    'title' => 'site_label',
                    'fields' => [
                        'site_label' => [
                            'name' => 'site_label',
                            'type' => 'text',
                            'value' => $this->get('site_label'),
                            'required' => true,
                        ],
                    ],
                ],
                [
                    'title' => 'site_name',
                    'fields' => [
                        'site_name' => [
                            'name' => 'site_name',
                            'type' => 'text',
                            'value' => $this->get('site_name'),
                            'required' => true,
                        ],
                    ],
                ],
                [
                    'title' => 'site_description',
                    'fields' => [
                        'site_description' => [
                            'name' => 'site_description',
                            'type' => 'text',
                            'value' => $this->get('site_description'),
                        ],
                    ],
                ],
                [
                    'title' => 'site_color',
                    'desc' => 'site_color_desc',
                    'fields' => [
                        'site_color' => [
                            'name' => 'site_color',
                            'type' => 'text',
                            'value' => $this->get('site_color'),
                            'required' => true,
                        ],
                    ],
                ],
            ],
        ];
    }
}

Control Panel

The below may look like a lot but it’s really just a basic POST flow with Model validation and heavy use of the Cp/Alert object.

class Custom_module_mcp
{
    public function index()
    {
        $base_url = 'addons/settings/custom-module';
        $site_model = ee('Model')->get('Site')
            ->filter('site_id', 1)
            ->first();

        if (!$site_model instanceof SiteModel) {
            ee('CP/Alert')->makeInline('shared-form')
                ->asIssue()
                ->withTitle(lang('site_not_found'))
                ->defer();
            ee()->functions->redirect(ee('CP/URL')->make($base_url . '/settings'));
        }

        $variables = [];
        $form = new MyFormObject();
        $form->setData($site_model->toArray());
        if ($_SERVER['REQUEST_METHOD'] === 'POST') {

            $site_model->set($_POST);
            $result = $site_model->validate();
            if ($result->isValid()) {
                $site_model->save();
                ee('CP/Alert')->makeInline('shared-form')
                    ->asSuccess()
                    ->withTitle(lang('form_success'))
                    ->defer();
                ee()->functions->redirect(ee('CP/URL')->make($base_url . '/index'));

            }  else {
                $variables['errors'] = $result;
                ee('CP/Alert')->makeInline('shared-form')
                    ->asIssue()
                    ->withTitle(lang('form_processing_failed'))
                    ->now();
            }
        }

        $variables = array_merge([
            'cp_page_title' => 'Your Custom Form',
            'save_btn_text' => 'save',
            'save_btn_text_working' => 'saving',
            'base_url' => ee('CP/URL')->make($base_url),
            'sections' => $form->getForm() //returns the Shared Form array
        ], $variables);

        return [
            'body' => ee('View')->make('ee:_shared/form')->render($variables)
        ];
    }
}

It’s actually pretty straightforward, though I admit; it looks complicated. So, let’s step through the index method.

Base URL Variable

To be honest, I usually make this a class property, so every Control Panel method can use this value, so it’s purely for example purposes in the example. Still, definitely want a $base_url variable since your view needs it and it’s handy for redirect URLs

$base_url = 'addons/settings/custom-module';

Get the Site Model and Handle Failure

When you’re working with Models (or, anything, really), always, always, ALWAYS, ensure its integrity matches the program’s expectations. Do you have what you expected? And handle failures gracefully for the user. In this case, “Do I have a Site Model?” and, if not, get the user out of there since everything relies on a healthy Site Model object.

$site_model = ee('Model')->get('Site')
    ->filter('site_id', 1)
    ->first();

if (!$site_model instanceof SiteModel) {
    ee('CP/Alert')->makeInline('shared-form')
        ->asIssue()
        ->withTitle(lang('site_not_found'))
        ->defer();
    ee()->functions->redirect(ee('CP/URL')->make($base_url . '/settings'));
}

Also, whenever the situation allows, I’m a big fan of dropping an Alert into the request to let the user know why they aren’t allowed to do what they wanted to do.

Define Things and Check Request

Once I know I have a valid Model, I start constructing the rest of the page, which includes checking if we have to process anything. Basically, “Is this a POST Request?” becomes a really important question at this point.

$variables = [];
$form = new MyFormObject();
$form->setData($site_model->toArray());
if ($_SERVER['REQUEST_METHOD'] === 'POST') {

Because, let’s start the form processing.

Form Processing

First things first, we set our POST data into our Model and validate the Model. This verifies we have the data, in the right format, that the Model requires to do any writes.

$site_model->set($_POST);
$result = $site_model->validate();
if ($result->isValid()) {}

This part’s a little bit of magic, which I know sucks, but the Model objects can define their own Validation Rules within themselves, so they’re a bit self moderating. Definitely a bigger discussion, but if you’re curious, the official ExpressionEngine docs are actually very informative on how Models are structured and how to handle Validation.

Upon Successful Validation

Upon a valid Validation check, we just call the Model save() method and redirect the user away with a handy success Alert.

$site_model->save();
ee('CP/Alert')->makeInline('shared-form')
    ->asSuccess()
    ->withTitle(lang('form_success'))
    ->defer();
ee()->functions->redirect(ee('CP/URL')->make($base_url . '/index'));
Upon Failed Validation

Upon a Validation check that fails, we let the View continue processing and let the Shared Form take over. It’ll automatically handle value keys and error message within form fields for us so we really only have to let the user know with a pretty Alert banner.

$variables['errors'] = $result;
ee('CP/Alert')->makeInline('shared-form')
    ->asIssue()
    ->withTitle(lang('form_processing_failed'))
    ->now();

Render View

If we’re just displaying the form, rather simple:

$variables = array_merge([
    'cp_page_title' => 'Your Custom Form',
    'save_btn_text' => 'save',
    'save_btn_text_working' => 'saving',
    'base_url' => ee('CP/URL')->make($base_url),
    'sections' => $form->getForm() //returns the Shared Form array
], $variables);

return [
    'body' => ee('View')->make('ee:_shared/form')->render($variables)
];

In Conclusion

So far, and still, easy enough, I think. Everything is nicely self contained, everything logically laid out and compartmentalized.

Next steps, Grid, Tables, and Filepicker. Later though. For now, it’s Friday and I don’t wanna do this any longer.

Eric Lamb's avatar
Eric Lamb

Builds things. Actively looking for clients.

Comments 0

Be the first to comment!