Update: I posted some news on this here [1].
Ever wondered how to create an interface like Google Personalized Home? In the first section of this article I'll demonstrate how to create a drag/drop portal in a few lines of JavaScript code, using the excellent Prototype and Scriptaculous JavaScript libraries. In the second section, I'll explain how to integrate this code into Drupal as a server backend for storing user settings. You may check the frontend here [2] (tested with Firefox 1.5, IE6, and Opera 8.5), and download a reusable JavaScript Portal class and Drupal module for the backend at the bottom of this post.
Let's start with the XHTML structure of our portal, the portal will provide three columns for users to arrange their portlets, a list of available portlets to choose from, and the actual portlets. Each column has .portal-column as its class, and each portlet's class is .block (I'll refer to portlets as blocks from now on). The block list is going to be a special portal column identified by #portal-column-block-list as an id.
<div [3] id="portal"> <div [3] class="portal-column" id="portal-column-0"> <h2> [4]Column 0</h2> </div> <div [3] class="portal-column" id="portal-column-1"> <h2> [4]Column 1</h2> </div> <div [3] class="portal-column" id="portal-column-2"> <h2> [4]Column 2</h2> </div> <div [3] class="portal-column" id="portal-column-block-list" style="display: none;"> <h2 [4] class="block-list-handle">Block List</h2> <!-- Blocks go here --> </div> </div>
The block list is not displayed until the user clicks on "Add content". Now let's take a look at each block's structure:
<div [3] class="block"> <h3 [5] class="handle"> <a [6] class="block-toggle"><span> [7]toggle</span></a> <!-- Title --> </h3> <div [3] class="content"> <!-- Content --> </div> </div>
Toggle is a link to hide and show block's content.
Let's take a quick look at the relevant parts of the CSS code that styles our portal elements (I'll exclude margin, padding, background and such):
#portal .portal-column { float: left; width: 30%; } #portal #portal-column-block-list { position: absolute; width: 200px; top: 180px; left: 10px; z-index: 10; } #portal .block .block-toggle { background-image: url(block-slide.png); float: right; cursor: pointer; } #portal .block .block-toggle span { display: none; } #portal .block-list-handle, #portal .handle { cursor: move; }
We will use a three-column fluid layout. The block list is absolutely positioned (and will become draggable as we will see shortly). The block toggle button is styled as an image of a triangle, and mouse cursor on block handles is set to move, providing visual feedback that blocks are draggable.
Now for the fun part, making our portal interactive! I'll use the Sortable component of the Script.aculo.us library. This component represents a container with a list of draggable elements inside it, which makes it ideal for our portal interface. The JavaScript code starts with iterating over all elements with .portal-column class in the #portal div, making them sortables:
var sortables = document.getElementsByClassName( 'portal-column', 'portal' ); sortables.each(function (sortable) { Sortable.create(sortable, { containment: sortables, constraint: false, tag: 'div', only: 'block', dropOnEmpty: true, handle: 'handle', hoverclass: 'block-hover', }) });
Sortable.create makes a sortable out of the element passed to it. It takes a list of options. tag and only specify the tag and class of draggable elements inside the sortable. Explanation of other options is available at Sortable.create documentation page [8].
Next step is activating the toggle button on blocks. To do this, we will iterate over elements with the .block class, and assign an event listener to each toggle:
var blocks = document.getElementsByClassName( 'block', 'portal' ); blocks.each( function (block) { var content = Element.childrenWithClassName( block, 'content', true ); var toggle = Element.childrenWithClassName( block, 'block-toggle', true ); Event.observe( toggle, 'click', function (e) { Effect.toggle(content, 'Slide'); }, false ); } );
For each block, we retrieve the content and toggle elements inside it using Element.childrenWithClassName, then create an event listener that hides/shows content whenever toggle is clicked. We pass 'Slide' to Effect.toggle for a nice drawer effect.
The final step in creating our portal frontend is creating the "Add content" button (#portal-block-list-link) that shows the block list, and making the block list draggable:
Event.observe( 'portal-block-list-link', 'click', displayBlockList, false ); new Draggable('portal-block-list', { handle: 'block-list-handle' } ); function displayBlockList(e) { Effect.toggle('portal-column-block-list'); Event.stop(e); }
And that's it! This is all what we need to make a portal with draggable portlets. You may see a the portal frontend with some sample portlets here [2]. The page is extracted from Drupal [9] and newsportal theme [10].
At the bottom of this post, there is a packaged version of this portal prototype. portal.js contains a reusable class for creating such portal interfaces. It has a constructor tht takes Prototype-like options object. The following options are available:
| Option | Default | Description |
|---|---|---|
| portal | portal | Id of the containing div for the portal. |
| column | portal-column | Class of portal columns. |
| block | block | Class of portal blocks (portlets). |
| content | content | Class of block's content div. |
| handle | handle | Class of block's handle. |
| hoverclass | block-hover | Hover class for blocks. |
| toggle | block-toggle | Class of block's toggle button. |
| blocklist | portal-column-block-list | Id of block list. |
| blocklistlink | portal-block-list-link | Id of block list toggle button. |
| blocklisthandle | block-list-handle | Class of block list handle. |
| saveurl | (none) | URL to submit column state to. |
In the next section, I'll outline how to save user settings to database by doing Ajax requests. Drupal will be the backend that handles data storage, integrating the code into any well-structured CMS that features the concept of blocks should be quite similar.
To monitor and submit user changes to server, we will use Sortable's onUpdate event handler. This function is called whenever a sortable's state is changed (by receiving or losing a block). This callback is defined in the options object passed to Sortable.create:
Sortable.create(sortable, { /* Previously mentioned options here */ onUpdate: function (container) { if (container.id == 'portal-column-block-list') { return; // no need to save block list state. } var url = 'http://www.example.com/portal/save'; var postBody = container.id + ':'; var blocks = document.getElementsByClassName( 'block', container ); postBody += blocks.pluck('id').join(','); postBody = 'value=' + escape(postBody); new Ajax.Request(url, { method: 'post', postBody: postBody } ); } }); };
The function creates a query of the form: value=container:block1,block2,block3 (...) and submits it to the server, this is done by iterating over the blocks of the changed container, it's worth noting that Sortable has a serialize method with similar functionality, however it requires special naming convention for element ids, I decided to write my own serialization function to make the code more flexible.
On the server side, Drupal will receive a POST request with the container state. Here is one way to handle it:
$query = split [11](':', $_POST['container']); $container = $query[0]; $blocks = array [12](); if (!in_array [13]($container, _portal_column_list())) { return; } $available_blocks = _portal_block_list('list'); foreach (split [11](',', $query[1]) as $block) { if (array_key_exists [14]($block, $available_blocks)) { $blocks[] = $block; } } db_query("DELETE_FROM {portal_user_settings} WHERE user = '%s' AND container = '%s'", $uid, $container); if ($blocks) { db_query("INSERT_INTO {portal_user_settings} (user, container, blocks) VALUES ('%s', '%s', '%s')", $uid, $container, join [15](',', $blocks)); }
The code extracts container and blocks ids into $container and $blocks, then verifies that submitted ids are actually available to portal users (to prevent malicious queries from submitting random strings into the database). _portal_column_list() and _portal_block_list('list') return arrays of available column and block ids respectively. If everything is correct, data is saved to the database.
To load the settings on the client side, Drupal generates a JavaScript associative array that encompasses the settings and embeds it into the page:
$result = db_query("SELECT container, blocks FROM {portal_user_settings} WHERE user = '%s'", $uid); $settings = array [12](); while ($row = db_fetch_object($result)) { $blocks = array [12](); foreach (split [11](',', $row->blocks) as $block) { $blocks[] = "\"$block\""; } $blocks = join [15](', ', $blocks); $settings[] = "\"$row->container\": [$blocks]"; } $settings = join [15](', ', $settings); $output = ''; $output .= "\n<script type=\"text/javascript\"><!--\n"; $output .= "var settings = \{$settings};\n"; /* More JavaScript code added here */ $output .= "--></script>\n"; return $output;
The resulting array will look something like:
var settings = { container1: [block1, block2], container2: [block3], ... };
settings variable is now available for our JavaScript code to rearrange blocks when the portal loads according to user preference:
for (var container in settings) { settings[container].each(function (block) { $(container).appendChild($(block)); });
This should cover the server side section. The code demonstrated here covers saving and loading user's portal settings. Attached below is the Drupal module from which code excerpts were taken. It's designed to nicely integrate with Drupal blocks. Any block can be used as a portlet, and developers need only implement hook_block to add portlets. Admins may specify what blocks are available as portlets, and both anonymous and registered users may maintain their own portals. The code is designed to gracefully degrade if JavaScript is disabled. If there is enough interest, I'll consider committing this module into Drupal's CVS and continue developing it.
Update: Drupal module was updated to fix an issue that prevented save from working when clean URLs were disabled.
| Attachment | Size |
|---|---|
| portal-frontend.tar.gz [16] | 51.8 KB |
| drupal-portal-module-0.1.tar.gz [17] | 164.11 KB |
Links:
[1] http://aymanh.com/update-on-drag-drop-portal-interface-for-drupal
[2] http://aymanh.com/files/portal/
[3] http://december.com/html/4/element/div.html
[4] http://december.com/html/4/element/h2.html
[5] http://december.com/html/4/element/h3.html
[6] http://december.com/html/4/element/a.html
[7] http://december.com/html/4/element/span.html
[8] http://wiki.script.aculo.us/scriptaculous/show/Sortable.create
[9] http://drupal.org/
[10] http://drupal.org/project/newsportal
[11] http://www.php.net/split
[12] http://www.php.net/array
[13] http://www.php.net/in_array
[14] http://www.php.net/array_key_exists
[15] http://www.php.net/join
[16] http://aymanh.com/files/portal-frontend.tar.gz
[17] http://aymanh.com/files/drupal-portal-module-0.1.tar.gz