Just listen to Alex

January 17, 2009

Easily repeating HTML form sections without (much) javascript

Filed under: programming, Uncategorized — Tags: , , — bosmeeuw @ 2:49 pm

If you’ve written more than a few data-driven web applications, you’ve surely encountered data models where a “master object” has multiple “child objects”.

In this example, we’ll use a book which can have many authors.
You could have the user enter the data for the book, and then add the authors one by one using multiple HTTP requests. Of course, this way the user needs to make many round trips to the server, which might slow him down considerably.

So you accomodate the user and write some javascript to enable him to add the X authors before posting anything to the server, yielding something like this:

Screenshot of expanding form (icons by famfamfam.com)

Screenshot of expanding form (icons by famfamfam.com)

There’s many approaches to programming this feature, the worst one being manually concatening HTML in a Javascript function and appending it to a container’s innerHTML attribute.

Below, you can find some code which allows you to create a repeating form like the one in the screenshot with just 3 lines of javascript. The idea is to create your repeating form element in plain HTML, and then attach some javascript behaviour to it which enables the user to repeat the form element. The Prototype Javascript library is required.

Here’s the code for the form:

<h1>Add a book</h1>

<form method="POST" action="/books/save_book">
   <table class="horizontal-layout">
      <tr>
         <td class="left">
            <h2>Data</h2>

            <table class="form">
               <tr>
                  <th>Title</th>
                  <td>
                     <input type="text" name="book&#91;title&#93;" />
                  </td>
               </tr>
               <tr>
                  <th>Description</th>
                  <td>
                     <textarea name="book&#91;description&#93;" class="medium"></textarea>
                  </td>
               </tr>
               <tr>
                  <th></th>
                  <td>
                     <input type="submit" class="submit" value="Save book &amp; authors" />
                  </td>
               </tr>
            </table>
         </td>
         <td>
            <h2>
               Authors
               <img src="/images/icons/page_white_add.png" id="add-author" class="link" />
            </h2>


            <div>
               <div
                  id="author-element"
                  class="expandable-form-entry"
                  style="line-height: 2em;"
               >
                  <input type="hidden" name="author&#91;#index&#93;&#91;id&#93;" />

                  <img src="/images/icons/page_white_delete.png" class="delete-entry link" />

                  <strong>Author name:</strong>
                  <br />
                  <input type="text" class="required" name="authors&#91;#index&#93;&#91;name&#93;" />

                  <br />

                  <strong>Author Remarks:</strong>
                  <br />
                  <textarea class="small" name="author&#91;#index&#93;&#91;remarks&#93;"></textarea>
               </div>
            </div>
         </td>
      </tr>
   </table>
</form>

<script type="text/javascript">
   var authorsExpander = new ExpandingFormElement({
      entryModel: 'author-element',
      addEntryLinkElement: 'add-author',
      deleteEntryElementClass: 'delete-entry',
      deletionConfirmText: "Are you sure you want to delete author?"
   })
</script>

Notice the input elements for the authors are named “author[#index][field_name]”. Each input name must contain the string “#index”, as the javascript code will replace this by the correct index of the element in the form. So if the user adds three authors, the third element will contain elements “author[2][name]” and “author[2][remarks]”, which you can easily save to your database server side.

The javascript at the bottom couples the expanding element behaviour and has these options:

  • entryModel: the id of the element you want to use as the model for the repeating element
  • addEntryLinkElement: the element the user will click to add a new entry
  • deleteEntryElementClass: the css class of the element the user can click to remove and added entry
  • deletionConfirmText: the text to confirm deletion of an entry (leave empty if you don’t want to ask for confirmation)

After you’ve saved the data to your database, the user might want to edit the data. This means you need to re-populate the data back to the form. This can be done using the addEntry() method of ExpandingFormElement, which can take a hash containing the data for the entry. The keys of the hash must correspond with the field name (the part after the [#index]). In a ruby on rails application, you could populate the data like this:

var authorsExpander = new ExpandingFormElement({
  entryModel: 'author-element',
  addEntryLinkElement: 'add-author',
  deleteEntryElementClass: 'delete-entry',
  deletionConfirmText: "Are you sure you want to delete author?"
})

<% @book.authors.each do |author| %>
	authorsExpander.addEntry(<%= author.attributes.to_json %>)
<% end %>

In PHP, you might use the json_encode() function on an associative array containing your author data.

Here’s the javascript code for the ExpandingFormElement class. The Prototype javascript library is required.

var ExpandingFormElement = Class.create({
    initialize: function(options) {
        this.options = options

        this.entryModel = $(options.entryModel)
        this.container = $(this.entryModel.parentNode)

        this.container.cleanWhitespace()

        if(this.container.childNodes.length > 1) {
            throw new Error("The container (parentNode) of the entryModel must contain only the entryModel, and no other nodes (put it in a <div> of its own). The container has " + this.container.childNodes.length + " elements after white space removal.")
        }

        this.entryModel.remove()

        $(options.addEntryLinkElement).observe('click',function() {
            this.addEntry()
        }.bind(this));
    } ,

    addEntry: function(values) {
        var copiedElement = this.entryModel.cloneNode(true)

        this.observeCopiedElement(copiedElement)

        var index = this.getNumberOfEntries()

        this.replaceInputNamesInElement(copiedElement, index)

        this.container.appendChild(copiedElement);

        if(values != null) {
            this.setEntryValues(copiedElement, values)
        }
    } ,

    setEntryValues: function(element, values) {
       $H(values).each(function(entry) {
          var input = this.getInputFromElementByName(element, entry.key)

          if(input) {
              input.value = entry.value;
          }
       }.bind(this));
    } ,

    getInputFromElementByName: function(element, name) {
        var matchedInput = null;

        var inputs = element.select('input','textarea','select')

        inputs.each(function(input) {
           if(input.name.indexOf("[" + name + "]") != -1) {
               matchedInput = input;

               return $break;
           }

           return null;
        });

        return matchedInput;
    } ,

    getNumberOfEntries: function() {
        return this.container.childNodes.length
    } ,

    observeCopiedElement: function(element) {
        var deleteEntryElement;

        if((deleteEntryElement = element.down('.' + this.options.deleteEntryElementClass))) {
            deleteEntryElement.observe('click',function() {
                if(this.options.deletionConfirmText) {
                    if(confirm(this.options.deletionConfirmText)) {
                        element.remove()
                    }
                }
                else {
                    element.remove()
                }
            }.bind(this))
        }
    } ,

    replaceInputNamesInElement: function(element, index) {
        $(element).select("input","textarea","select").each(function(input) {
            input.name = input.name.replace("#index",index)
        }.bind(this))
    }
});

Here’s the CSS I used for this example:

div.expandable-form-entry {
    position: relative;
    border-top: 1px dotted silver;
    padding-top: 1em;
    margin-bottom: 1em;
}

div.expandable-form-entry img.delete-entry {
    position: absolute;
    right: 0;
}
Advertisements

5 Comments »

  1. Great article!
    How about demo?

    Comment by LiMBiZ — March 6, 2009 @ 5:25 pm

  2. This is great! I spent a lot of hours looking for this. Can you provide an article to show how this will interact with a database? I am new with Javascript and need some help. thanks again

    Comment by Guy — March 18, 2009 @ 4:18 am

  3. But if there is a date text box with a calender the data is not being posted in IE…it works fine in firefox…can you tell me what is the problem in IE?…i tried in both IE 6 and IE 7….

    Comment by sweta — August 14, 2009 @ 7:59 am

  4. Thank you for your blog. It’s great!

    I really want to implement those repeating blocks but I’m totally new to this. For now, I just use a simple form that sent the result to an email address. For multiple request, the user have to send the form many times it’s not convenient at all.

    I’m not sure where to put those bits and pieces together and I don’t know what the Prototype javascript library is. Do you make your source code available? If yes, I would be enchanted if you can email it to me.

    Many thanks!

    Comment by Solange Richard — February 10, 2010 @ 3:56 pm

  5. .

    Comment by ripwheeleps3 — May 24, 2010 @ 4:38 am


RSS feed for comments on this post. TrackBack URI

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

Blog at WordPress.com.

%d bloggers like this: