More Cracked-Out Scaffolding: element.twisty

For anyone following this, I’m slowly building you up to a fully-featured sortable, filtered scaffold helper library.

You may recall we already built filtering with SELECT dropdowns. I’m going to add an input box and some sorting to that.

First, though, we need a little sumthin sumthin to simplify the UI: the twisty!

The twisty is the proper name for that little triangly ► which turns into a ▼ when you click it, revealing some more information (maybe a tree node, or more options).

We’re going to extend prototype’s Element object to create Element.twisty. It’ll all make sense later, I promise. First, here’s the functionality we’re heading for:

more info ►

explanation

The code simply toggles a hidden div, and depending on the visibility, changes the little arrow to match.

Things to look out for in this code: returns false, so you can return Element.twisty and it won’t jump to the top of the page; takes a string or a DOM node; uses unicode for the twisties; requires a SPAN element in your A.

<script type=’text/javascript’>
Object.extend(Element, {
twisty: function(element, _next) {
element = $(element); _next = $(_next);
var span = element.getElementsByTagName(‘SPAN’)[0];
span.innerHTML = (_next.style.display == ‘none’) ? ‘&#9660;’ : ‘&#9658;’
Element.toggle(_next);
return false;
}
});
</script>

<style type=’text/css’>
span.twisty { font-size: 70%; }
</style>

<div style=’width:200px;border:1px solid #ccc;’>
<a href=’#’ onclick=’return Element.twisty(this, $(“admin-info”));’>more info <span class=”twisty”>&#9658; </span></a>
<div id=’admin-info’ style=’display:none’>
Here’s your other box!
</div>
</div>

you know I can’t leave it alone

Why stop there?

Let’s set up a default box for the twisty to show/hide.. and while we’re at it, if the twisty <a> doesn’t have a SPAN in it, we’ll generate one automagically.

Object.extend(Element, {
twisty: function(element, _next) {
element = $(element);
var span = element.getElementsByTagName(‘SPAN’);

// automatically generate that SPAN if she doesn’t exist
if (span.length == 0) {
span = document.createElement(‘span’);
span.className = ‘twisty’;
element.appendChild(span);
}
else span = span[0];

// use a default action to find the node to hide/show
if (!_next) _next = findParent(element, ‘li’).nextSibling;
span.innerHTML = (_next.style.display == ‘none’) ? ‘&#9660;’ : ‘&#9658;’
Element.toggle(_next);
return false;
}
});

<ul>
<li><a href=’#’ onclick=’return Element.twisty(this);’>Toggle</a></li>
<li style=’display:none’>This is your content box</li>
</ul>

This way, it will apply automatically to a UL by hiding or showing the next LI, unless you override _next. so <a onclick=’return Element.twisty(this)’ href=’#’ > will work. Of course you can modify this yourself.

(FYI, this example won’t quite work as published, you need to Element.cleanWhitespace the parent UL in firefox, but I’m trying to keep it simple)

OK, so we have a twisty

Let’s add sorting to our filterable table header. To keep the UI nice and simple, we’re going to hide the filter boxes and make the TH clickable for sorting.

<table>
<thead>
<tr>
<th><a href=’/controller/list?filter=<%=params[:filter] %>&amp;order=monkey’>monkey</a></th>
<th><a href=’/controller/list?filter=<%= params[:filter] %>&amp;order=’foo’>foo</a></th>
<th>Non-sortable</th>
<th><a href=’#’ onclick=’return Element.twisty(this,$(“filter-row”));’>filter <span class=’twisty’></a></th>
</tr>
<tr style=’display:none’ id=’filter-row’>
<th>
<form action=’list’>
<input type=’hidden’ name=’filter’ value=” />
<input type=’text’ name=’filter’>
<input type=’submit’ value=’&raquo;’ />
</form>
</th>
</tr>
</thead>
<tbody>
<tr><td>monkey one</td>
<td>something</td>
<td>sample data</td>
<td></td>
</tr>
</tbody>
</table>

hint: click ‘filter’

monkey foo Non-sortable filter ►
monkey one something sample data
monkey two somethin else jaket sux

You’re probably going to want some signifier of order (ascending, descending) so we’ll use the unicode characters ▲ and ▼

I’m going to make this one a helper method, so our code looks nicer.
def table_sorting_header(fieldname, options = { :default => false })
params[:order] ||= “#{fieldname} asc” if options[:default] == true
if params[:order] == “#{fieldname} asc”
“<a href=’/controller/list?filter=#{params[:filter]}&amp;order=#{fieldname}+desc’>#{fieldname}” +
“<small>&#9650;</small></a>”
elsif params[:order] == “#{fieldname} desc”
“<a href=’/controller/list?filter=#{params[:filter]}&amp;order=#{fieldname}+asc’>#{fieldname}” +
“<small>&#9660</small></a>”
else
“<a href=’/controller/list?filter=#{params[:filter]}&amp;order=#{fieldname}+asc’>#{fieldname}</a>”
end
end

Call it like <%= table_sorting_header ‘monkey’ %> or if you sort one of the columns by default, <%= table_sorting_header ‘id’, { :default => true } %> on that column.

There’s the view done.. hook it up!

Open up the controller and fire away.. You’ll need the revised Hash#to_sql that converts ‘%’ queries to ‘LIKE’.

def list
@order = params[:order].gsub(/^(\w)\s/, “LOWER ($1) “) if params[:order] =~ /^(monkey|foo|id) (asc|desc)$/
@conditions[:monkey] = “%#{params[:monkey]}%” } if params[:monkey]
@conditions[:foo] = “%#{params[:foo]}%” if params[:foo]
@monkey_pages, @monkeys = paginate :monkey, :per_page => 20, :order => @order, :conditions => @conditions.to_sql
end