jaspervdj / digestive-functors

A general way to consume input using applicative functors
149 stars 71 forks source link

Making the form's name accessible within the template (Heist) #91

Open cimmanon opened 10 years ago

cimmanon commented 10 years ago

I'd like to propose adding some hooks for JavaScript purposes when using Digestive Functors (not sure how much of this applies to non-Heist users). From a front-end perspective, the names of DF form fields have caused me a bit of trouble because I want to be able to write both my templates and my JavaScript to "just work" without having to hardcode things like form or subform names. Recently, I've been working on working with Digestive Aeson and would like to write an elegant solution that will go through the JSON response received from a submission made via XMLHttpRequest (AJAX) and match up the errors to the field they belong to. There's just one problem with that: Digestive Aeson doesn't tell you the form name.

Why this is a problem

Consider the following form:

data Foo = Foo
    { name :: Text
    , age :: Int
    , account :: Bar
    }

data Bar = Bar
    { username :: Text
    , password :: Text
    }

fooForm :: Monad m => Form Text m Foo
fooForm = Foo
    <$> "name" .: check "cannot be blank" (/= "") (text Nothing)
    <*> "age" .: stringRead "not a number" Nothing
    <*> "account" .: check "cannot have identical fields" (\ x -> username x /= password x) barForm

barForm :: Monad m => Form Text m Bar
barForm = Bar
    <$> "name" .: check "cannot be blank" (/= "") (text Nothing)
    <*> "password" .: check "cannot be blank" (/= "") (text Nothing)

Code to process the results (using Scotty):

(view, _) <- digestJSON fooForm =<< jsonData
json $ jsonErrors view

Submitting with an empty JSON object returns the following information:

{"name":"cannot be blank","account":{"password":"cannot be blank","name":"cannot be blank"},"age":"not a number"}

The problem becomes: how do I match the results to the fields via JavaScript? If I select all of the fields and split the names on the period character, I can traverse the JSON results and find the error:

var fields = el.querySelectorAll('input, textarea, select');
var prefix;
for (var i = 0, len = fields.length; i < len; i++) {
    var parent = fields[i].parentNode;
    var segments = fields[i].name.split(/\./);
    if (segments.length > 1) { prefix = segments.shift(); }
    var error = findNestedElement(segments, r);
    if (error) {
        var newErrorNode = errorNode.cloneNode(true);
        newErrorNode.innerHTML = error;
        parent.insertBefore(newErrorNode, fields[i].nextSibling);
    }
}

function findNestedElement(segments, collection) {
    var current = segments.shift();
    if (collection.hasOwnProperty(current)) {
        if (segments.length && typeof collection[current] === object) {
            return findNestedElement(segments, collection[current]);
        } else {
            return collection[current];
        }
    }
    return false;
}

However, it becomes inefficient to deal with results like this where the subform, which has no corresponding field, returns an error:

{"name":"cannot be blank","account":"cannot have identical fields","age":"not a number"}

It would be more efficient to traverse the JSON result and check the DOM for fields that match (especially since it would be more common for the JSON object to be smaller than the collection of form fields). If they don't exist, then they get pushed into my global error pile.

function unravelJSONObject(obj) {
    var collector = [];
    unraveler(obj, []);

    function unraveler(obj, keys) {
        for (var prop in obj) {
            if (obj.hasOwnProperty(prop)) {
                var keys_ = keys.slice(); // clone it
                keys_.push(prop);
                if (typeof obj[prop] === 'object') {
                    unraveler(obj[prop], keys_);
                } else {
                    collector.push({ error: obj[prop], path: keys_ });
                }
            }
        }
    }
    return collector;
}

var foo = {"name":"cannot be blank","account":{"password":"cannot be blank","name":"cannot be blank"},"age":"not a number"};

var errors = unravelJSONObject(foo);

for (var i = 0, len = errors.length; i < len; i++) {
    console.log(errors[i].path.join('.'));
}

The results look like this:

name
account.password
account.name
age

The most efficient way to lookup a DOM element when you only know the last part of the element's name is via document.querySelector([id$=a]), but it isn't very accurate when you have multiple fields with the same name (eg. name and account.name) because you could match multiple elements (note that querySelector returns a single item, so it could potentially get the right one if the elements are in the correct source order, but who wants to depend on that?). Ideally, I'd use getElementById instead of querySelector since it is a more efficient function. In order to do that, I need some way to discerning the form's name without selecting all of the fields and examining each one for a prefix (which can be error prone if the designer is adding form fields with periods in the name just to mess with us).

Proposal

I would like to see an attribute added to the form by the dfForm splice that contains the name of the form. If our template looks like this:

<dfForm>

</dfForm>

And our form's name is "name", then the generated markup would look like this:

<form method="POST" data-df-name="form">

</form>

For those not in the know, custom attributes are allowed in HTML5 by prefixing them with data-. I'm hesitant to suggest attaching it with the id, since I suspect many people are using it for other purposes and the name attribute was deprecated in HTML4.

From there, it would be a simple as making these modifications:

function unravelJSONObject(obj, prefix) {
    var collector = [];
    unraveler(obj, prefix ? [prefix] : []);

    function unraveler(obj, keys) {
        for (var prop in obj) {
            if (obj.hasOwnProperty(prop)) {
                var keys_ = keys.slice(); // clone it
                keys_.push(prop);
                if (typeof obj[prop] === 'object') {
                    unraveler(obj[prop], keys_);
                } else {
                    collector.push({ error: obj[prop], path: keys_ });
                }
            }
        }
    }
    return collector;
}

var foo = {"name":"cannot be blank","account":{"password":"cannot be blank","name":"cannot be blank"},"age":"not a number"};

// el is known in advance to be the form element
var errors = unravelJSONObject(foo, el.getAttribute('data-df-name'));

for (var i = 0, len = errors.length; i < len; i++) {
    console.log(errors[i].path.join('.'));
}

And now we have useful information to do an accurate check against the DOM:

form.name
form.account.password
form.account.name
form.age

If you've got a better way of attaching the form's name to the the form element, I'm all for it. It just needs to be on the form element for the best results. I realize this is something I could write a splice for myself, but it just seems like unnecessary boilerplate to write for every project. Plus, if there's an interest in this when I'm finished, I'd like to contribute it here -- so it has to be something I can depend on.

jaspervdj commented 10 years ago

Sounds good to me: I don't think older-browser-compatibility is a problem here. What do you think @mightybyte?

mightybyte commented 10 years ago

I don't see any problem with adding a data-df-name attribute to the form tag. Seems to have almost no downsides and clearly presented upsides.