Skip to content

Instantly share code, notes, and snippets.

@biilmann
Created June 27, 2011 18:49
Show Gist options
  • Save biilmann/1049492 to your computer and use it in GitHub Desktop.
Save biilmann/1049492 to your computer and use it in GitHub Desktop.
Form extension for Webpop

Forms with the Contact extension

This contact extension for Webpop is based on writing HTML5 forms with all the validations the HTML5 spec introduces, and having Webpop carry out the validations server-side in case the browser doesn't support them (or has been sidestepped completely).

To use it, simply copy "contact.js" into the Extensions folder of your project.

Writing forms with the <pop:contact> extension is very straight forward, and while the extension can easily be used to output forms with a predefined markup, every part of the final html can be handcrafted.

Here's a simple contact form:

<pop:contact:form mailto="us@webpop.com" name="contact">
  <pop:success>
    <h2>Thank you for getting in touch</h2>
    <p>We'll get back yo you as soon as we can.</p>
  </pop:success>
  <pop:errors />
  <pop:fields>
    <pop:field label="Your email" title="Email" name="email" type="email" required="true" />
    <pop:field label="Your message" title="Message" name="message" required="true" type="textarea" />
    <input type="submit" value="Send" />
  </pop:fields>
</pop:contact>

Anything inside the <pop:success> tag will only be shown when the form has been successfully submitted, while anything within the <pop:fields> tag will be hidden on successful form processing. The <pop:errors> tag will list the fields that fails to validate server-side. If the form is processes successfully a mail with the data will be sent to the address in the mailto attribute. Multiple addresses can be separated by commas. If the form has a "mail_subject" attribute it will be used as the subject for the mail. If the form has a field with the name "subject" and no "mail_subject" attribute, the subject from the field will be used when sending the mail.

Each <pop:field> tag expands to a label a an input, select or textarea tag wrapped in a <p>. In this case the HTML for the form would end up looking like this:

<form method="post">
  <p class="field email required">
    <label for="contact_email">Your email</label>
    <input id="contact_email" name="contact[email]" type="email" required="required" />
  </p>
  <p class="field textarea required">
    <label for="contact_message">Your message</label>
    <textarea id="contact_message" name="contact[message]" required="required"></textarea>
  </p>
  <input type="submit" value="Send" />
</form>

The <pop:contact:form> tag can also take an "action" attribute if you don't want the form to submit to same page that it is shown on (eg. you want a contact form on the home page that submits to "/contact"). To take advantage of this you should put the form into an include and use the <pop:include> tag to place the form both in the template where you want the form displayed and in the template where you want the result displayed (and remember that the form will be shown there as well if the server-side validations fail).

A field can have the type text (default), email, url, number, password, range, search, textarea, checkbox, hidden, select or radio.

Select and radio fields both takes an "options" attribute and a "labels" attribute. Here's an example select field:

<pop:field type="select" name="country" options="us, es, uk" labels="USA, Spain, UK" />

This would output:

<p class="field select">
  <label for="contact_country">Country</label>
  <select id="contact_country" name="contact[country]">
    <option value=""></option>
    <option value="us">USA</option>
    <option value="es">Spain</option>
    <option value="uk">UK</option>
  </select>
</p>

Note that a blank option is included if the field is not marked as required. The select field can also take a "multiple" attribute to mark that more than one option can be selected.

The checkbox field takes optional "on" and "off" attributes in case you want to specify the values that should be sent in the result mail when using the field.

If you want more precise control over the HTML each field outputs, the <pop:field> tag can be opened up and you'll have access to a <pop:label/> and a <pop:input/> field inside. If you want even more fine grained control you can access <pop:name/> to get the name of the field and <pop:classes/> to get the classes ("field", "required", "invalid", etc...) for the field and completely handwrite the html. Within a select field or a radio field you also have access to <pop:options/> - an array of options with a <pop:label/> and a <pop:option/>

var mailer = require('mailer');
var humanize = function(string) {
var str=string.toLowerCase();
str=str.replace(/_/g,' ');
str=str.substring(0,1).toUpperCase()+str.substring(1);
return str;
};
var escapeHTML = function(html) {
return new String(html)
.replace(/&/gmi, '&amp;')
.replace(/'/gmi, '&#x27;')
.replace(/"/gmi, '&quot;')
.replace(/>/gmi, '&gt;')
.replace(/</gmi, '&lt;');
};
var Errors = function() {
this._errors = [];
};
Errors.prototype.isEmpty = function() {
return this._errors.length === 0;
};
Errors.prototype.add = function(error) {
this._errors.push(error);
};
Errors.prototype.__defineGetter__('length', function() { return this._errors.length; });
Errors.prototype.forEach = function(fn) {
return this._errors.forEach(function(error) {
fn(error);
});
};
var Error = function(field, name, message) {
this.field = field;
this.name = name;
this.message = message;
};
Error.prototype.html = function() {
return this.field.title + ": " + this.message;
};
var Field = function(formId, options, data) {
this.type = options.type || "text";
this.title = options.title || options.label || humanize(options.name);
this._label = options.label || this.title;
this.name = formId + "[" + options.name + "]";
this.id = formId + "_" + options.name;
this['class'] = options['class'];
this.value_options = options.options && options.options.split(',').map(function(v) { return v.trim(); });
this.labels = options.labels && options.labels.split(',').map(function(v) { return v.trim(); });
this.value = data ? this._cast(data[options.name]) : (options['default'] || "");
this.placeholder = options.placeholder || "";
this.required = {"true": true, "yes": true}[options.required] || false;
this.include_blank = options.include_blank ? true : false;
this.pattern = options.pattern || "";
this.on = options.on || "Checked";
this.off = options.off || "";
this.min = this._cast(options.min);
this.max = this._cast(options.max);
this.multiple = options.multiple || "";
this._options = options;
if (this.type == "range") {
this.min = this.min || 0;
this.max = this.max || 100;
}
this.validate = data ? true : false;
this._errors = [];
};
Field.prototype._cast = function(value) {
var self = this;
if (value === "" || typeof value === "undefined") return "";
try {
switch(this.type) {
case "number":
case "range":
return parseInt(value, 10);
case "select":
case "radio":
var val = value.map ? value.map(function(val) { return self._castOption.call(self, val); }) : this._castOption(value);
return value.map ? Array.prototype.slice.call(val) : val;
default:
return (value && value.trim()) || "";
}
} catch(e) {
this._typeMismatch = true;
return value;
}
};
Field.prototype._castOption = function(value) {
value = value.trim();
if (this.value_options) {
for (var i=0; i<this.value_options.length; i++) {
if (value === this.value_options[i]) return value;
}
} else {
return "";
}
return "";
};
Field.prototype.isRange = function() {
return this.type == "range" || (this.type == "number" && this.max);
};
Field.prototype.label = function(options) {
return {
html: "<label for='" + this.id + "'>" + this._label + "</label>"
};
};
Field.prototype.classes = function() {
return [
this.type,
this.validate && this.valid() ? "valid" : "",
this.validate && this.invalid() ? "invalid" : "",
this.required ? "required" : "optional",
this.validate && this.isRange() ? (this.rangeOverflow() || this.rangeUnderflow() ? "out-of-range" : "in-range") : ""
].filter(function(klass) { return klass; }).join(" ");
};
Field.prototype._attrFor = function(attr, enable, value) {
return enable ? " " + attr + "='" + (value || attr) + "'" : "";
};
Field.prototype._htmlForOption = function(option, label) {
if (this.type == "radio") {
var id = this.id + "_" + option;
return "<label for='" + id + "'>" + label + "</label>" +
"<input type='radio' name='" + this.name + "' " + this._attrFor("required", this.required) +
" value='" + option + "' id='" + id + "' " + this._attrFor("checked", this.value === option) + "/>";
} else {
var selected = this.value.indexOf ? this.value.indexOf(option) >= 0 : this.value === option;
return "<option value='" + option + "' " + this._attrFor("selected", selected) + ">" + label + "</option>";
}
};
Field.prototype.options = function() {
var self = this;
return (this.value_options || []).map(function(option, i) {
var label = self.labels && self.labels[i] || option;
return {
label: label,
option: option,
value: option,
html: function() { return self._htmlForOption(option, label); }
};
});
};
Field.prototype.input = function(options) {
var field = this;
var html;
switch(this.type) {
case "textarea":
html = "<textarea id='" + this.id + "' name='" + this.name + "'" + this._attrFor("required", this.required) +
this._attrFor("placeholder", this.placeholder, this.placeholder) + ">" + escapeHTML(this.value) + "</textarea>";
break;
case "checkbox":
html = (this.off ? "<input type='hidden' name='" + this.name +"' value='" + this.off + "' />" : "") +
"<input id='" + this.id + "' name='" + this.name + "' type='" + this.type +
"' value='" + this.on + "'" + (this.value === this.on && " checked='checked'") +
(this._attrFor("required", this.required)) + " />";
break;
case "radio":
html = this.options().map(function(option) { return option.html(); }).join("\n");
break;
case "select":
html = "<select id='" + this.id + "' name='" + this.name + (this.multiple && "[]") + "' " +
this._attrFor("required", this.required) + this._attrFor("multiple", this.multiple) + ">" +
(this.required && !this.include_blank ? "" : "<option value='' " + this._attrFor("selected", this.value === "") + "></option>") +
this.options().map(function(option) { return option.html(); }).join("\n") +
"</select>";
break;
default:
html = "<input id='" + this.id + "' name='" + this.name + "' type='" + this.type +
"' value='" + escapeHTML(this.value) + "'" + (this.placeholder ? " placeholder='" + this.placeholder + "'" : "") +
this._attrFor("multiple", this.multiple) + this._attrFor("required", this.required) +
this._attrFor("pattern", this.pattern, this.pattern) + "/>";
}
return {
name: this.name,
type: this.type,
value: this.value,
html: html
};
};
Field.prototype.html = function() {
if (this.type == "hidden") return this.input().html;
return "<p class='" + (this['class'] || "field " + this.classes()) + "'>" +
this.label().html + this.input().html +
"</p>";
};
Field.prototype.errors = function() {
if (this._errors.length) return this._errors;
var errors = {
valueMissing: "Please fill out this field",
tooLong: "Please enter a shorter value",
patternMismatch: "Please match the requested format",
typeMismatch: "Please match the requested format",
rangeUnderflow: "Please enter a higher value",
rangeOverflow: "Please enter a lower value",
stepMismatch: "Please choose an allowed value"
};
for (var error in errors) {
if (this[error]()) {
this["_" + error] = true;
this._errors.push(new Error(this, error, this._options[error] || errors[error]));
}
}
return this._errors;
};
Field.prototype.valid = function() { return this.validate ? this.errors().length == 0 : true; };
Field.prototype.invalid = function() { return !this.valid(); };
Field.prototype.valueMissing = function() {
return this._valueMissing || (this.required ? this.off === this.value : false);
};
Field.prototype.typeMismatch = function() {
if (this._typeMismatch) return this._typeMismatch;
if (this.pattern) return false;
if (this.type == "email") {
var regExp = /^[a-z0-9_.%+\-]+@[0-9a-z.\-]+\.[a-z.]{2,6}$/i;
if (this.multiple) {
return this.value && this.value.split(",").reduce(function(invalid, email) {
return invalid || !regExp.test(email.trim());
}, false);
} else {
return this.value && !regExp.test(this.value);
}
}
if (this.type == "url") return this.value && !(/[a-z][\-\.+a-z]*:\/\//i).test(this.value);
return false;
};
Field.prototype.patternMismatch = function() {
if (this._patternMismatch) return this._patternMismatch;
return this.pattern && this.value && !(new RegExp(this.pattern).test(this.value));
};
Field.prototype.tooLong = function() {
return this._tooLong || (this.maxlength ? (this.value && this.value.length > this.maxlength ? true : false) : false);
};
Field.prototype.rangeUnderflow = function() {
return this._rangeUnderflow || (this.min === "" ? false : this.value < this.min);
};
Field.prototype.rangeOverflow = function() {
return this._rangeOverflow || (this.max === "" ? false : this.value > this.max);
};
Field.prototype.stepMismatch = function() {
if (this._stepMismatch) return this._stepMismatch;
if (this.step) {
return ((this.value - this.min) % this.step) === 0;
}
return false;
};
exports.form = function(options, enclosed) {
var name = options.name || "form";
var success = false;
var errors = new Errors();
var title = options.title || humanize(name);
var data = request.params[name];
var submitted = request.request_method == "POST" && data;
var to = options.mailto;
var fields = [];
var curField = 0;
enclosed.tags(function(tag) { return tag.name == "fields";}).forEach(function(fieldsTag) {
fieldsTag.tags(function(tag) { return tag.name == "field";}).forEach(function(tag) {
var field = new Field(name, tag.options, data);
fields.push(field);
if (submitted) {
field.errors().forEach(function(error) {
errors.add(error);
});
}
});
});
if (request.request_method == "POST" && data) {
if (errors.isEmpty()) {
var body = [];
fields.forEach(function(field) {
body.push(field.title + ": " + (field.value.join ? field.value.join(", ") : field.value));
});
if (to) {
mailer.send(
to,
options.mail_subject || data.subject || "Form submission",
body.join("\n\n"),
{reply_to: data.email}
);
}
success = true;
}
}
return success ? {success: success, fields: null} :
"<form" + (options.name ? " name='" + options.name + "'" : "") + (options.id ? " id='" + options.id + "'" : "") +
" action='" + (options.action || "") + "' method='post' " + (options.novalidate ? "novalidate='novalidate'" : "") + ">" +
enclosed.render({
success: false,
fields: {
field: function(fieldOptions) {
var field = fields[curField];
curField++;
return field;
}
},
errors: errors.isEmpty() ? null : function(options, enclosing) {
return enclosing.tags().length ? errors : {html: errors._errors.map(function(error) { return error.html(); }).join("<br />")};
}
}) +
"</form>";
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment