Drag/Drop Portal Interface With Scriptaculous And Drupal

Update: I posted some news on this here.

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 (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.

Portal Frontend

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 id="portal">
  <div class="portal-column" id="portal-column-0">
    <h2>Column 0</h2>
  </div>
  <div class="portal-column" id="portal-column-1">
    <h2>Column 1</h2>
  </div>
  <div class="portal-column" id="portal-column-2">
    <h2>Column 2</h2>
  </div>

  <div class="portal-column" id="portal-column-block-list" style="display: none;">
    <h2 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 class="block">
  <h3 class="handle">
    <a class="block-toggle"><span>toggle</span></a>
    <!-- Title -->
  </h3>
  <div 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.

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. The page is extracted from Drupal and newsportal theme.

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.

Portal Backend

Saving Settings

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(':', $_POST['container']);
$container = $query[0];
$blocks = array();

if (!in_array($container, _portal_column_list())) {
  return;
}

$available_blocks = _portal_block_list('list');
foreach (split(',', $query[1]) as $block) {
  if (array_key_exists($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(',', $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.

Loading Settings

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();
while ($row = db_fetch_object($result)) {
  $blocks = array();
  foreach (split(',', $row->blocks) as $block) {
    $blocks[] = "\"$block\"";
  }
  $blocks = join(', ', $blocks);
  $settings[] = "\"$row->container\": [$blocks]";
}
$settings = join(', ', $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));
});

As you see, the code is pretty self-explanatory. It iterates over settings, and appends each block to its container.

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.

AttachmentSize
portal-frontend.tar.gz51.8 KB
drupal-portal-module-0.1.tar.gz164.11 KB

Tags:
Submitted by Ayman on Mon, 2006/09/04 - 12:23am.

DaveNotik (not verified) | Nice. :) | Mon, 2006/09/04 - 2:40am

Thrilled. Been wanting to do this for quite some time. Just today, revisited this need, and hours later I see your post.

I'd like to get this working for my Workspace service (http://www.wovenlabs.com/workspace), allowing every user to have a personalized Workspace home page. Using Views, I'd like to provide all kinds of blocks that the user can place -- latest issues, all issues assigned to me, latest posts within this particular project, favorite pages, and so on. Really an excellent use case.

I'm also building a service called MyCommunity.org -- an uber Yahoo Groups, if you will, and can imagine many uses there.

Can this be written to take advantage of jQuery, the JavaScript library that's now in Drupal core, or will these third-party libraries be needed?

Maybe I missed this: does your module handle keeping track of the placement of the blocks server-side -- so the user can log in elsewhere and see the page they created?

Thanks for your work. I'll keep following.

Ayman | Cool :) Glad you found this | Mon, 2006/09/04 - 2:04pm

Cool :) Glad you found this post interesting, regarding your questions: I'm more familiar with Prototype so I chose it to implement this, porting to jQuery is a tempting option now that it's part of Core, however it will still require a 3rd party plugin for drag/drop (the one Jeff linked to).
The module does actually save user settings, I edited the fist paragraph to clearly mention this.

Thanks for your feedback.

Blue Cobalt (not verified) | saving user settings | Mon, 2006/09/04 - 7:03pm

Is the Drupal module supposed to automatically save user settings?

It isn't doing so for me. I come back to the portal page, and there are no blocks saved.

Drupal 4.7.3, Firefox 1.5.0.6 & Safari 2.0.4, Mac OS X.

This is an incredible module. Thanks so much!

Ayman | I investigated a bit, and | Mon, 2006/09/04 - 7:34pm

I investigated a bit, and found an issue that prevented save from working when clean URLs were disabled, fixed it now, would you please try again?

Thanks for the feedback.

Blue Cobalt (not verified) | works great! | Mon, 2006/09/04 - 7:50pm

Thanks Ayman. It works great now!

Jeff Eaton (not verified) | jQuery | Mon, 2006/09/04 - 4:28am

Dave, if you're looking to implement something like this in jQuery you should probably check out http://interface.eyecon.ro/ -- it's a library of interface plugins for jQuery designed to duplicated prototype's functionality.

Mark (not verified) | brilliant | Mon, 2006/09/04 - 9:02am

This is excellent Ayman, I really hope you do make a drupal project/module for this. And it makes a lot of sense that the workspace module uses this as a base, as suggested by Dave. Thanks for a Great article sharing this.

Ayman | Thanks for your reply, and | Mon, 2006/09/04 - 2:06pm

Thanks for your reply, and indeed Workspace and this module can greatly benefit from each other, I'll research porting the JavaScript code to jQuery and creating a Drupal project for this.

Joe Moraca (not verified) | Very Cool | Mon, 2006/09/04 - 8:50pm

Great demo. I am going to try it out. Thanks for sharing your work on this project.

FiReaNG3L (not verified) | awesome! | Tue, 2006/09/05 - 1:42am

I can't wait to see your jQuery version... I was interested in building such a thing (but didn't have a clue of how-to). Very helpful!

Sami Khan (not verified) | cookies, blocks and wordpress | Tue, 2006/09/05 - 6:17pm

I think one should use cookies to store the settings for users not logged in, I believe that's what Google and Netvibes does. Using this on the blocks would be a prudent step as well. Most of these types of applications however are completely based in javascript, since the whole purpose is simply to aggregate content from many different sources on the user's browser all you need is the XMLHTTPRequest object. It looks pretty but in terms of functionality and purpose, it's rather limited. Also you have a problem in terms of being able to actively update the content in those blocks as it's not ajax, if Drupal had a more powerful AJAX framework to support the backend and then you could make AJAX calls to get that content, then such a portal solution might be cool and useful -- barring that it's rather limited and clunky to implement for an actual web application. Would be cool however if your users could reposition your blocks and get rid of the ones that they didn't want using this method as is the case with Wordpress Canvas. Thanks for sharing though, it might be useful to some people not all that familiar with the domain that just want some additional cool functionality on their site.

alf (not verified) | This seems great, I'll be | Wed, 2006/09/06 - 2:00pm

This seems great, I'll be looking forward to a JQuery version as well.

Dries (not verified) | Hot | Thu, 2006/09/07 - 8:29am

In one word: hot. Rock on, Ayman. Oh, and aim for core!

Ayman | Thanks, I did some work on | Thu, 2006/09/07 - 6:55pm

Thanks, I did some work on porting the code to jQuery today, will post to drupal.org once I have something working.

Jerry (not verified) | Interesting! | Sat, 2006/09/09 - 5:50am

I've been working on implementing a similiar CMS component just over the past week, and your demo just gave me a whole bunch of new ideas. Thanks!

Kudos for sharing your work, too :)

Mark (not verified) | panles module | Mon, 2006/09/18 - 1:08pm

I just discovered earl miles (merlin of choas) wonderful panels module, and it struck me that your UI would be just great for panels module. I may be wrong but it may be fairly easy to integrate your interface for use with his panels module. looking forward to news on your jquery version..

Ayman | I'm sorry for the delay, I | Mon, 2006/09/18 - 2:38pm

I'm sorry for the delay, I have something almost working in jQuery, I'm just trying to find time to polish and release it, hopefully I'll do so in the next few days.

I've just checked the panels module and indeed my work is a logical extension to it, thanks for pointing this out, we can discuss how to integrate them once I have the jQuery port ready.

Christoph Cemper (not verified) | thanks for sharing! | Sat, 2006/09/23 - 10:21am

Wow... this is an amazin post... and the sample rock too... thank you very much for sharing... you are now on my rss reader :-)

christoph

bugz_nz (not verified) | Great module! | Sun, 2006/09/24 - 10:16pm

Hey Ayman,

This is a great module, I look forward to customising it and using it soon. There seems to be a script error in the portal.module file on line 143.

it reads:

$output .= "var settings = \{$settings};\n";

When I think it should read:

$output .= "var settings = {{$settings}};\n";

Ayman | Hi, Thanks everyone for the | Mon, 2006/09/25 - 4:06pm

Hi,

Thanks everyone for the feedback, the last two weeks were very busy for me and I haven't had time to finish the jQuery port yet :(

bugz_nz, I double-checked, and looks like you are right, the syntax I used seems to work with versions prior to PHP 5.1(?), but for 5.1 it prints the backslash which is undesirable.

Ella (not verified) | Hey Ayman Thanks for the | Mon, 2006/09/25 - 6:32pm

Hey Ayman

Thanks for the great tutorial. I am having a bit of trouble setting the default blocks that I want to appear in the page when it first loads. I am using the following syntax but the line below:

var settings = { portal-column-0: [block1, block2], portal-column-1: [block4], portal-column-2: [block5] };

keeps throwing a javascript error:

Error: Expected ':'

Am I missing a colon somewhere?

Many thanks,

Ella

Ella (not verified) | The error was due to the | Mon, 2006/09/25 - 8:14pm

The error was due to the hypens in the column names, I renamed my div id's and the following code now works:

var settings = {portalcolumn0: [block1, block2], portalcolumn1: [block4], portalcolumn2: [block5]};

inanc (not verified) | one step further | Thu, 2006/09/28 - 3:30am

but, how to make a block not removed from block-list after dragged upon one of the three columns.

drag a to the middle column and drag a to rightmost column so we have got two a's, cloning and never disappears from the leftmost block list.

inanc (not verified) | two step further | Thu, 2006/09/28 - 3:31am

and... how to make a block removed from one of the columns (droppables) after clicked on a button on'em just like that toggle guy.

Hasan (not verified) | How to save moved block position within the same column | Thu, 2006/10/05 - 8:32am

Hi Ayman

This is the great module, thanks for that. I am having a bit problem. When I move a block within the same column then it does not post AJAX request. I am stuck on this, pls help.

Thanks,

Hasan

Anonymous (not verified) | Moving column in a same container | Sun, 2006/11/05 - 10:27am

Hi Hasan,

you can fix our problem by disabling the test of observer element in onEnd function : line 575 of dragdrop.js. For me it works fine now.

Thanks a lot for Ayman to this example.

Pat.

vik (not verified) | thanks | Tue, 2006/11/07 - 12:22am

Hi Ayman,
the tutorial is undoubtedly very good and i have been looking for something on these lines for quite a long time...many many thanks.

just 1 small query, instead of using DIV, can i use Table just by giving the appropriate elements the right id, class name.

i think that should be possible by changing the tag attribute in "sortables.create", i tried it but it did not work and throws an error 'addEventListener' is null or not an object. would be great if you can help.

Thanks vik

Jason (not verified) | onUpdate can't be invoked while draging in the same column | Wed, 2006/11/15 - 12:59pm

Hi Ayman, this is a really great demo
I have a question:
1) add two content to 'column0', the order is 'content0','content1'
2) if drag 'content0' to 'column1', the onUpdate is invoked, but while draging 'content0' in 'column0', the onUpdate will not be invoked

I don't know whether it is a bug, can you help me about this, ddrok@163.com
Thanks

Jason (not verified) | I think Pat's solution will | Thu, 2006/11/16 - 11:05am

I think Pat's solution will cause a performance issue, it will post all column's info to server

Jason (not verified) | Can you give me some help or | Sun, 2006/11/26 - 3:57pm

Can you give me some help or suggestions on this issue, with my great thanks

Post new comment

The content of this field is kept private and will not be shown publicly.
  • Allowed HTML tags: <a> <em> <strong> <cite> <strike> <code> <ul> <ol> <li> <dl> <dt> <dd> <blockquote> <sup> <sub> <h1> <h2> <h3> <b> <i> <u>
  • Lines and paragraphs break automatically.
  • You may post code using <code>...</code> (generic) or <?php ... ?> (highlighted PHP) tags.

More information about formatting options

CAPTCHA
This question is for testing whether you are a human visitor and to prevent automated spam submissions.
8 + 1 =
Solve this simple math problem and enter the result. E.g. for 1+3, enter 4.

Books

Learning Website Development with Django

Learning Website Development with Django
A beginner's tutorial to building web applications, quickly and cleanly, with the Django application framework.

My first book. Published by Packt Publishing in April 2008.

Icons

Entries Feed

Get Firefox!

Drupal.org

Linux

Gentoo


Creative Commons License