Formlets and defclass
Tue Aug 2, 2011Ok, I am officially off this fucking self-imposed thinking break.
By the by, in the post I linked above, I idly mused about why more people aren't making money tweaking PHP/CSS full time with WordPress. I'll save you the suspense; it's because the activity is mind-numbingly, eye-stabbingly boring. No one would do it for fun1. So yeah, the cash is good, but it's because anyone who's out of university runs as fast as their fresh degree can take them in the other direction. There's probably a solution somewhere in there that pays well, and doesn't induce a boredom-related coma. I'll look for it eventually, but this week, I finally sat down and forced myself through a [very nice CLOS tutorial](http://cl-cookbook.sourceforge.net/clos-tutorial/index.html) and a page or two of the spec.
Really, I've been meaning to do this ever since my feeble attempt at the 2011 Spring Lisp Game Jam taught me the hard way just how little I know about loop
, CLOS and object-orientation in general. The hard way because this isn't some theoretical exercise where a certain language feature might come in handy; there are parts of that project that could have been modeled much cleaner as objects. This weekend, I got to thinking that the formlet project I've been kicking around since getting a small taste from PLT Racket._formlet))) might be similarly object-appropriate. It's heavily inspired by their implementation, except that I go the extra step and automate validation at the same time. I think I had a semi-coherent rant about that lying around somewhere. The solution wasn't very clearly thought out, but I still think I was onto something. The pattern for form use is very consistent and simple; so why should I do something the computer can handle for me? I still need to add support for ajax, and a last handful of HTML form fields, but even in its half-assed, purely macro-driven form, it saved me a lot of typing at work and play. Trouble was that it was too difficult to add features. And hey, it looks like I could model it pretty well with objects, so I sat down with some documentation and copious amounts of green tea to see how far I could get in a weekend.
Pretty far, it turns out.
It's not quite a rewrite because stuff was kept, but that diff says I added/deleted 602 lines, and wc -l *.lisp *.asd *.md
is telling me that I've got 555 lines total.
So... yeah.
The declarations have been simplified. I did my best to carve out the annoyances, including
- There's no
show-[name]-formlet
function anymore, there's just ashow
method that handles all formlet and field output, as well as ashow-formlet
macro for ease-of-use purposes - That
show-formlet
macro doesn't need any magical values passed to it because the validating and sending pages are communicating via huncentoots'session-value
now - It is now easy to add additional field type handlers (just add a new
defclass
,show
, and potentiallyvalidate
method) - I've got the HTML output functions isolated enough that it actually wouldn't be very hard at all to port away from
cl-who
(I'm not going to, because it's the best of the lisp->html markup languages I've seen so far, but feel free to; it won't take you more than a few hours) - The hunchentoot-specific stuff is isolated in the
define-formlet
andshow-formlet
helper macros and a tiny bit in thepost-value
method (which means that the previous non-goal of portability across Lisp servers may also be attainable)
I've also added bunches of features that will come in handy in an ongoing work project. I think I've got a semi-handle on the CLOS stuff, having slogged through this. I don't imagine it's the greatest OO code in the world, but it's certainly a step up from defining tons of functions. The biggest difference in expressiveness actually came from the method system2.
Basically, it's possible to model the HTML fields as a series of subclasses. For starters, a regular field
(defclass formlet-field ()
((name :reader name :initarg :name)
(validation-functions :accessor validation-functions
:initarg :validation-functions :initform nil)
(default-value :reader default-value :initarg :default-value
:initform nil)
(error-messages :accessor error-messages :initarg :error-messages
:initform nil)))
is fairly self-explanatory. It has a name, a set of validation functions and associated error messages, and a default value (which I actually haven't implemented yet, but each field has the slot and it's properly assigned by define-formlet
). I realize that I could also model the different HTML outputs as a field, but I chose to do it as methods. The basic form fields are
(defclass text (formlet-field) ())
(defclass textarea (formlet-field) ())
(defclass password (formlet-field) ())
(defclass file (formlet-field) ())
(defclass checkbox (formlet-field) ())
These hold no surprises. They all have very slightly different show
methods, but it's trivial differences. The HTML representation is subtly different, but they all generate exactly one return value and don't need to be primed. As an example, here's the show
method for textarea
(defmethod show ((field textarea) &optional value error)
(html-to-str (:textarea :name (name field) (str value)) (str (show error))))
That first argument may look a bit odd if you're in the state I was at the beginning of the weekend. This is a method, not a function, so that's not a default value for field
, rather it's field
s expected type. Basically, if you call show
on a field
of type textarea
, you'll get that particular view function. Instead of, say, this one
(defmethod show ((field file) &optional value error)
(html-to-str (:input :name (name field) :type "file" :class "file")
(str (show error))))
which would only apply to a field
of type file
.
Moving on, the next set of fields introduces a bit of a twist.
(defclass formlet-field-set (formlet-field)
((value-set :reader value-set :initarg :value-set :initform nil))
(:documentation "This class is for fields that show the user a list of options"))
(defclass select (formlet-field-set) ())
(defclass radio-set (formlet-field-set) ())
Ok, yes, radio-set
isn't technically an HTML field, but I'm not sure that's a reasonable approach. I can't think of a situation where a single radio
button would be needed, but a lone checkbox
couldn't do the job. Anyway, the twist is that while these fields return a single value, they make the user choose from a set of different options rather than entering data (or just accepting/rejecting as with the single checkbox situation). The main difference is that you need to allow for a set of values to be specified in the field as options that the user can choose from, and you potentially need to apply the checked
or selected
property to the currently selected field. Here's what that looks like
(defmethod show ((field radio-set) &optional value error)
(html-to-str (loop for v in (value-set field)
do (htm (:span :class "input+label"
(:input :type "radio" :name (name field) :value v
:checked (when (string= v value) "checked"))
(str v))))
(str (show error))))
Note that this is all still defining a single method. Before I knew about this, I would have done something like defining separate show-textarea
, show-file
and show-radio-set
, or having a single cond
somewhere, dispatching and treating each element differently somewhere. In fact, that's how my formlet system worked for a fairly long time. I'm rather happy I took the time to learn this way.
The last set of fields proved to be most problematic, and only because of how Hunchentoot deals with post-parameter
.
(defclass formlet-field-return-set (formlet-field-set) ()
(:documentation "This class is specifically for fields that return multiple values from the user"))
(defclass multi-select (formlet-field-return-set) ())
(defclass checkbox-set (formlet-field-return-set) ())
We're not just specifying a set of potential choices here, we're now also getting a set back from the user to play around with. Which means that it's not enough to compare the current value against each option, we need to check whether each option is a member of the set of values.
(defmethod show ((field multi-select) &optional value error)
(html-to-str (:select :name (name field) :multiple "multiple" :size 5
(loop for v in (value-set field)
do (htm (:option :value v
:selected (when (member v value :test #'string=) "selected")
(str v)))))
(str (show error))))
But. We also need a way of getting those values in the first place. As I said, Hunchentoot fought me on this. The (post-parameters*)
are represented as an alist
, which is alright if a bit more verbose than I hoped, but at the same time, (post-parameter "field")
seems to be a very thin wrapper around (cdr (assoc "field" (post-parameters*))
. Which means that if I want to get all of the values of a particular field out of the posted data, I need to traverse that alist
and filter it myself. So, here's how I did that
(defmethod post-value ((field formlet-field) post-alist)
(cdr (assoc (name field) post-alist :test #'string=)))
(defmethod post-value ((field formlet-field-return-set) post-alist)
(loop for (k . v) in post-alist
if (string= k (name field)) collect v))
Now, bear in mind that I was (and still am) operating of very little sleep, so I can guarantee that this isn't the best solution, but it does what I need very simply. When I need the set of values posted, it's as easy as
(mapcar (lambda (field) (post-value field (post-parameters*))) (fields formlet))
Which doesn't look nearly as easy as it seemed in my mind, but it's still not too hairy to parse. I probably didn't need to go quite as crazy on the hierarchy my first time out. But it was my first time out. And I wanted to learn something. Next time, I'm hoping to finally have a little tutorialette that I've been kicking around fininished. Something related to CLOS and clsql.
Right, that's it. I've uploaded a fresh copy of the formlet system to my github, and to an asdf-able location (so (asdf-install:install 'formlets)
should work). Still no gpg key. I'm working on it, if you'll believe that. If you find any issues, feel free to note them (I actually check my github tracker more often than my email).
And now, if you'll excuse me, I'm going to go collapse into bed for about 12 hours.
EDIT: Ok, seriously, done dicking around with the formatting around now.
If it's any consolation at all, the actual project is properly indented.
Tue, 02 Aug, 2011