If you're up to date with the last post, you should have a registration system that can show user info in a web browser, but needs information to be edited directly in the SQL database using some other tool.
This post aims to change that: instead of a read only view of a registration record, the fields in your web browser should become editable, with a save button to save your changes. Just like in 1993.
There's a lot of fiddly book keeping with forms: for example, if some entered value is invalid, the form should be presented again to the user for fixing, with suitable error messages, and without losing all the values they've just entered.
The digestive-functors
package provides some infrastructure to help with this, and that's what I'll use in this post.
It will need integrating with blaze, so that it can generate the right HTML to send to the user. This has already been done in the digestive-functors-blaze
package.
It will also need integrating with servant so that it can receive form data from the user. This needs a helper function which I'll show below.
The idea with digestive functors is that you have a Haskell datatype representing the values of your form. For this form, the existing Registration
type will suffice. Then you wire up the fields of this datatype to form fields in two places: once in the HTML to get an HTML <INPUT>
tag to appear so people can put in values; and once to get values back out of the form submission into a value of the Registration
datatype.
First, we have to prat around with stack: digestive-functors and digestive-functors-blaze is not available by default (for reasons, presumably) so when you add them to the dependencies
section of reg-exe
in package.yaml
you'll get a build error. At least if you're using the same stack resolver as me (lts-10.4).
The error message helpfully tells you how to modify stack.yaml
to make this work, though, so I can't complain too much: add this into stack.yaml
extra-deps: - digestive-functors-0.8.3.0 - digestive-functors-blaze-0.6.2.0
With those dependencies installed, let's write some code.
We're going to have two URL endpoints now: the same registration/1
endpoint from before, and a new one to receive form submissions. This new one will have the same URL, but will receive HTTP POST commands instead of the HTTP GET that everything so far has used. (That's a style of using HTTP where things which do not change the world should use GET, and things which do change the world should use POST: so GET is "pure" and POST is "like IO
")
This new POST endpoint is declared like this:
type RegistrationPostAPI = "registration" :> S.Capture "id" Integer :> S.ReqBody '[S.FormUrlEncoded] [(String,String)] :> S.Post '[HTML] B.Html handleRegistrationPost = error "We'll implement this later..." API = ... :<|> RegistrationPostAPI server = ... :<|> handleRegistrationPost
It is quite similar to the RegistrationAPI
: a bunch of pieces separated by :>
. There's a constant URL fragment ("registration"
") and then a captured variable for the registration ID, using Capture
.
But then there's another section to capture more: the request body (ReqBody
). This is something that doesn't exist in HTTP with regular GET requests. With POST requests, the browser can upload a big lump of data. In the case of an HTML form, that lump contains the values of all the form fields. The parameters to ReqBody
act a bit like the parameters to Get
and Post
: they describe the on-the-wire encoding (HTTP's x-www-form-urlencoded type) and a corresponding Haskell datatype to decode that encoding into - in this case, a list of String
key-value pairs.
At the end, Get
has been replaced with Post
to indicate that this piece of API uses HTTP POST. The type parameters are the same, indicating that HTML will be returned on the wire, and that this will come from blaze-html
HTML values on the Haskell side.
The GET endpoint needs to return an editable HTML view of the chosen Registration
always. The POST endpoint will have different behaviour though. The "expected" behaviour that you'd naturally go and implement right away is that it will update the database with the provided values. But there's another case that is also likely: that the POST data will contain invalid data, in which case we need to present the HTML form again, with suitable errors.
First let's factor out the HTML generation into a separate function, so that it can be used by both the GET and POST implementations. Move the HTML generating code at the end of handleRegistration
into its own function:
handleRegistration :: Integer -> S.Handler B.Html handleRegistration identifier = do -- ... return $ B.docTypeHtml $ do B.body $ do B.h1 $ do "Registration " B.toHtml (show identifier) htmlForRegistration registration htmlForRegistration :: Registration -> B.Html htmlForRegistration registration = do B.p $ do "First name: " B.toHtml (firstname registration) B.p $ do "Last name: " B.toHtml (lastname registration) B.p $ do "Date of Birth: " B.toHtml (dob registration)
Now we're ready to start making changes to use Digestive Functors.
digestive-functors
has a few types that wrap around your base content datatype (which is Registration
in this case).
First there is digestive-functors
's model of a form, DF.Form
. In our case, it will have this type: DF.Form B.Html m Registration
, saying its a form for providing a Registration
, and that if there are error messages, they will be represented by B.Html
- pieces of blaze-html
HTML. Never mind that m
for now.
Secondly there's a view, DF.View
, of a form: this stores the state of a form that is being filled out, no matter whether it is valid or not. If it turns out to be valid (i.e. completed correctly), you can extract out a Registration
from it; if not, you can extract out some error messages to go back into the (HTML) form to show to the user again.
It gets a bit tangly with multiple similar-but-different meanings for the word "form".
Define the DF.Form
that we're going to get users to fill out:
import qualified Text.Digestive as DF [...] registrationDigestiveForm :: Monad m => Registration -> DF.Form B.Html m Registration registrationDigestiveForm initial = do Registration <$> "firstname" .: DF.string (Just $ firstname initial) <*> "lastname" .: DF.string (Just $ lastname initial) <*> "dob" .: DF.string (Just $ dob initial)
This uses <$>
because it is the Functor bit of Digestive Functors. The form says that the three fields of Registration
will come from (in order) a form field called firstname
, a form field called lastname
and a form field called dob
. You better hope that these match up with the order of the fields in Registration
because there is no static checking of that. The code also declares that the three fields will be strings, and that their initial value will come from the supplied Registration
records. (There's not a lot written about getting default values into digestive functor forms, but this is one way.)
Given this form, we can get a DF.view
of a form populated with a value we've read from the database,
in handleRegistration
:
view <- DF.getForm "Registration" (registrationDigestiveForm registration)
... and now instead of passing a complete Registration
value to htmlForRegistration
to generate the HTML for the user, we instead pass that DF.view
. This will have all the fields of a Registration
but can accomodate invalid values too.
We'll need to make some serious changes to htmlForRegistration
to make it into a form:
import Data.Monoid ( (<>) ) import Text.Blaze.Html5 ( (!) ) import qualified Text.Blaze.Html5.Attributes as BA import Text.Digestive.Blaze.Html5 as DB htmlForRegistration :: DF.View B.Html -> B.Html htmlForRegistration view = B.form ! BA.method "post" $ do B.p $ do "First name: " DB.inputText "firstname" view B.p $ do "Last name: " DB.inputText "lastname" view B.p $ do "Date of Birth: " DB.inputText "dob" view
So, what should work now is if you go to a registration URL such as http://localhost:8080/registration/1 you should see an editable version of a Registration
record. But when you click the Submit button, you'll get a server error in the browser, and an error message on the console output of servant: because this is the point that we hit:
handleRegistrationPost = error "We'll implement this later..."
That's quite a lot of wiring stuff up just to, apparently, turn our data paragraphs into HTML input
fields. Next time, I'll implement the POST
side of this, which needs to handle saving the result back to the database, if the input is valid, and properly displaying an error to the user if not.
No comments:
Post a Comment