Replacing Multi-select Lookup field with Checkboxes

The Situation

In SharePoint 2010, when you create a lookup field that allows for multiple selections, you end up with a very non-standard html widget that includes 2 boxes for moving “possible” values to “selected” values. Like so:

Out of the box Multi-select UI

Out of the box Multi-select UI

That’s all well and good if you have short values and a long list of them. But, if you happen to have long values and a short list of them, SharePoint’s UI choice is less than spectacular. As you can see in the image, most of the values are hidden in the select box. Marc Anderson has a function in his popular SpServices library to resize the select boxes to show the complete value. But, that’s not always preferable – as that can make your page very, very wide.

The Solution

Wouldn’t it be nice if you could just make a checkbox interface instead of the multi-select interface? Why, yes, yes it would. And now you can. There are a few moving parts here. I’ll try to break it down.

First, we need a way to select and unselect choices in the multi-select UI. It’s not as easy as just highlighting a value and using jQuery to “click” the add or remove button. Believe me, I tried that. Happily, I found someone else that grappled with this issue, and he got me most of the way there. I had to tweak a couple of things, because I had multiple multi-select fields on a single form, and because I know my users well enough to know that they’ll go crazy checking and unchecking things. So, here are my add choice and remove choice functions.

Add Choice

function addChoice(text, columnName) {

	// let's get the id from the column name
	var thisID = $("[title='" + columnName + " possible values']").prop('id');
	thisID = thisID.substr(0, thisID.indexOf('_SelectCandidate'));
	selector = "[id$=" + thisID + "_MultiLookupPicker]";
    $("[title='" + columnName + " possible values'] option").each(function () {   
        if ($(this).text() == text) {
            $(this).appendTo($("[title='" + columnName + " selected values']"));

            var multilookupPickerVal = $(selector).val();
            if ($(selector).val() == undefined || $(selector).val().length == 0) {
                $(selector).val($(this).val() + "|t" + $(this).text());
            }
            else {
                $(selector).val(multilookupPickerVal + "|t" + $(this).val() + "|t" + $(this).text());
            }
        }
    });
}

Remove Choice

function removeChoice(text, columnName) {
// let's get the id from the column name
	var thisID = $("[title='" + columnName + " selected values']").prop('id');
	thisID = thisID.substr(0, thisID.indexOf('_SelectResult'));
	selector = "[id$=" + thisID + "_MultiLookupPicker]";

	// loop through the selected options
    $("[title='" + columnName + " selected values'] option").each(function () {

        if ($(this).text() == text) {
            $(this).appendTo($("[title='" + columnName + " possible values']"));

            var multilookupPickerVal = $(selector).val();

            // call the internal function which creates a clean array out of the weird string
            var splitValue = GipSplit(multilookupPickerVal);

            // set the 2 array nodes we want to remove
            var valToRemove = $(this).val();
            var textToRemove = $(this).text();

            // Kill the value and text in the array
            splitValue = jQuery.grep(splitValue, function(value) {
        		return value != valToRemove;
      		});

      		splitValue = jQuery.grep(splitValue, function(value) {
        		return value != textToRemove;
      		});

      		var newValue = '';

      		// loop through the cleaned up array and rebuild the selector value
      		for (var i = 0; i < splitValue.length; i++) {
      			if (newValue.length == 0) {
      				newValue = splitValue[i];
      			}
      			else {
      				newValue = newValue + '|t' + splitValue[i];

      			}

			 } // end for loop

          	// set the new value to the selector
            $(selector).val(newValue);

        }
    });
}

Next, we need a function to draw the checkboxes to the screen. I wanted to make this as simple as possible, so this same function draws the checkboxes, hides the old UI, and attaches the remove and add choice functions to the click events of the checkboxes.

Draw Checkboxes

// function used to draw the checkboxes instead of a multi-select lookup
function drawCheckboxes(columnName) {

	// remove spaces from columnName
	var divName = columnName.split(' ').join('') + 'Checkboxes';

	// find the parent td, hide the span, clear the div
	$('div[id="' + divName + '"]').remove();
	$("[title='" + columnName + " possible values']").closest('span').after('<div id="' + divName + '">New div</div>');
	$("[title='" + columnName + " possible values']").closest('span').hide();
	var thisDiv = $('div[id="' + divName + '"]').html('');

	// loop through all the possible options and draw the checkboxes
    $("[title='" + columnName + " possible values'] option").each(function () {
        var thisText = $(this).text();
        var thisVal = $(this).val()
        var thisSnippet = "<input type='checkbox' name='" + columnName + "' value='" + thisText + "' id='" + columnName + "Checkbox" + thisVal + "'/><label for='" + columnName + "Checkbox" + thisVal + "'>" + thisText + "</label><br/>";

        thisDiv.append(thisSnippet);
    });

    // loop through all the selected options and draw the checkboxes
    $("[title='" + columnName + " selected values'] option").each(function () {
        var thisText = $(this).text();
        var thisVal = $(this).val()
        var thisSnippet = "<input type='checkbox' name='" + columnName + "' value='" + thisText + "' id='" + columnName + "Checkbox" + thisVal + "'/><label for='" + columnName + "Checkbox" + thisVal + "'>" + thisText + "</label><br/>";

        thisDiv.append(thisSnippet);
    });

    // Loop through the checklist for outcome statement lookup 

	var boxes = $('input[name="' + columnName + '"]');
	boxes.each(function(index) {

	$(this).click(function() { 
		// get the label

		if( $(this).is(':checked')){
			addChoice($(this).val(), columnName);
		}
		else {
			removeChoice($(this).val(), columnName);
		}
	});

	});  // End loop

} //end draw checkboxes

The basic solution

Great – now we have functions that will draw checkboxes and add and remove choices when the checkboxes are clicked. What do you do with it? First put all your scripts together in a text file and upload your text file to your site. (Note, there is one line you’ll need to edit in this text file.) (I typically use the site assets library.) On your page with your data form web part, you’ll need to include a content editor web part and link to your text file.  Run your page, and voila – checkboxes! (Note that in this image, I’ve shown both interfaces.)

Showing both the standard and checkbox UI.

Showing both the standard and checkbox UI.

Of course, around here, we’re integrating this with SPServices, to filter the multi-select. For that, you need a few more steps. First, you’ll need a copy of SPServices in your site, and you’ll need to include a reference to SPServices in your text file. Next, you’ll need to make a function that will work as a call-back to the SPServices.SPFilterDropdown or SPCascadeDropdowns function. Functions passed as callbacks can’t have parameters (at least, I haven’t figured out how to do it). So, you’ll need a wrapper function for drawCheckboxes that passes in the column you want. This is convenient because you can also do other stuff at the same time, if need be. Here’s my example, using a filter.

Wrapper Function

// need this function to pass a parameter-free function to the complete function of the dropdown filter
function drawProgramAreaCheckboxes() {
	drawCheckboxes("Collaborators Program Area");	
}
//end drawProgramAreaCheckboxes

Calling the SpServices Filter function with callback

//Filter and draw checkboxes for program areas
$().SPServices.SPFilterDropdown({
  relationshipList: "Program Area",
  relationshipListColumn: "Title",
  relationshipListSortColumn: "ID",
  columnName: "Collaborators Program Area",
  CAMLQuery: " 1",
  completefunc: drawProgramAreaCheckboxes,
  debug: true
});

//End program areas

Here’s the complete file for using checkboxes with SpServices. Note, there are multiple parts you’ll need to customize in here.

Editing Forms

Now that we have adding forms figured out, we need to do something about editing. It’s essentially the same, but you need to set the checkboxes that require setting. Again, for this, we’re using SPServices, and we need a few more function calls.

Step one – helper functions to set checkbox values

We need a couple of helper functions to set the checkboxes. The first sets a checkbox’s selected property to true based on the value of the checkbox.

function checkByValue(value) {

	$(":checkbox").filter(function() {
        return this.value == value;
    }).prop("checked", "true");

}

The second takes in a string as returned by spSpervices GetListItems for multi-select fields and the column name and loops through the string, setting each checkbox. The string returned is a series of ID’s and values, separated by semi-colons and hash tags. So, we’ll split the string on the ;# combo, and then check only when we have a value, not a number. Caveat – if your values ARE numbers, this won’t work for you, and you’d need to do check every other value, instead of the textual values.

function setMultiSelectValueCheckboxes(inString, column) {

 	var outString = inString.split(";#");

 	$.each(outString, function( index, value ) {

		  if (isNaN(value)) {

		  	checkByValue(value);
		  }
	});

} // end set MultiSelectValueCheckboxes

Step two – get the ID from the query string.

SpServices has a helper function that lets you get parameters from the query string. SharePoint always passes an ID parameter to edit forms. So, you just need to grab it, like so:

// This function from SPServices gets all of the Query String parameters 
  	var queryStringVals = $().SPServices.SPGetQueryString();
  	var effortID = queryStringVals.ID;

Step three – build the checkboxes

Just like in the add scenario, you’ll need to build your checkboxes. You want to build them before you try to check them, or this won’t work. For brevity, you can refer to the completed text file for this section, since it’s no different than the add form.

Step four- use GetListItems to get the thing you’re editing

My test list is called “Educational Effort.” Here I’m passing in a CAML query to get just the item we’re editing. I’ve returned more viewfields than you’d really need. But, you get the idea. If we have a successful status, we then set the value of the checkboxes, using our helper function from above.

// Get the related effort
		$().SPServices({
		    operation: "GetListItems",
		    async: true,
		    listName: "Educational Effort",
		    CAMLQuery: "" + effortID + "",
		    CAMLViewFields: "",
		    completefunc: function (xData, Status) {

				if (Status == 'success') {

				$(xData.responseXML).SPFilterNode("z:row").each(function() {

 				// get outcome statement and program area values
		        var outcomeStatementLookup = $(this).attr("ows_OutcomeStatementLookup");
		        var collaboratorsProgramArea = $(this).attr("ows_CollaboratorsProgramArea");

		        // set checkboxes
				setMultiSelectValueCheckboxes(outcomeStatementLookup, 'Outcome Statement Lookup'); 
 				setMultiSelectValueCheckboxes(collaboratorsProgramArea , 'Collaborators Program Area'); 

		      });	// end each function	
		      } //end if status = success.		
		    } // end complete function
		  }); // end spservices
// End get the related effort

And, voila – automatically checked checkboxes.

Edit page with auto-checked checkboxes.

Edit page with auto-checked checkboxes.

Here’s the complete file for an edit form. Again, note that you’ll need to customize for your environment.

As usual, feel free to point out places where this could be optimized, tweaked, etc. I’ve testing in IE 10, Chrome and Firefox, and it seems to be working for me.

Advertisements

Joining the Task List with Related Content in a DVWP

The Situation

SharePoint 2010 lets you set up approval work flows on lists, and stores the approval tasks in the out of the box Task list. But, out-of-the-box, when you view the “My Tasks” view of the Tasks list, all you can see about the content you are supposed to approve is the title, which links to the view form for the item.

Out of the Box View of My Tasks

Out of the Box View of My Tasks

To be able to display more information about the item we are approving, we’re going to create a linked datasource and a DVWP with a joined sub-view. (More information on how to do this can be found in the posts “Display item counts in a Data View Web Part” and “Joined Subviews with Linked Datasources in SharePoint 2010.”)

Unlike in the examples in those other posts, we can’t control how SharePoint manages the look ups between the related content list and the task list. Unfortunately, it seems that the only “look up” provided is an ID at the end of the string in the “Related Content” field. No problem, we can work with that!

The Solution

Go ahead and create your dataview webpart and initially, join a subview based on the ID of the related content  and the WorkflowLink of the Task List. Find the dvt_2 template in your code view. You should see a select for rows that looks something like this:

<xsl:variable name="Rows" select="../../../YourListName/Rows/Row[@ID=$dvt_ParentRow/@WorkflowLink]" />

Change it to the following (replacing “YourListName” with your joined list).

<xsl:variable name="ChildID">
<xsl:value-of select="substring-after($dvt_ParentRow/@WorkflowLink, '=')" />
</xsl:variable>
<xsl:variable name="Rows" select="../../../YourListName/Rows/Row[@ID=$ChildID]" />

What does this code do? First, it creates a variable called “ChildID” and then it extracts the ID of the related content from the WorkflowLink column in the parent task list. Finally, it runs a select on your related content list matching your related content’s ID with the associated Task.

Doing so, you can output a view like so:

A View the Combines Task Information with Related Content

A View the Combines Task Information with Related Content

Voila. Easy way to connect a task list with its related content.

Blog Topics Plugin – Take Two

Quite some time ago, I launched the first version of the Blog Topics Plugin. I hadn’t updated it since way back in September of 2008. Originally, it was relying on URLs with query parameters for all the portal aspects of the plugin. For instance, if you wanted to link to a page of blogs from one topic, you’d have a URL like, “index.php?topic=1.” It worked, but was less than elegant. So, for our own work, we modified the plugin to use permalinks, and added some additional features. The publicly released version was treated like the forgotten stepchild. Lots of people have downloaded it and I think lots of people have used it. It continued to work, but it got no love.

Not anymore. I’ve now completed a long overdue overhaul of the plugin. Now, all the portal aspects can be switched on and off in the widgets. And, I’ve provided some sample theme code to show how to use the permalinks (which are written into the theme, not the plugin).

So, let’s run through how the plugin works now.

Where to put the files

The plugin comes with a bunch of files. So, let’s break down where they go.

  1. wp-content/mu-plugins
    This is where the main part of the plugin should be installed. You need to install the cets_blogtopics.php and the cets_blog_topics folder (and the files within) in the mu-plugins directory. The cets_blogtopics.php file should be in the root of the mu-plugins directory or the plugin will not run.
  2. wp-content/plugins
    All of the widgets should be installed in the plugins directory and only enabled on the blogs on which you want to use them. None of these are required widgets, and you should only install the ones you wish to actually use. (See below for an explanation of each of these.) The following are widget files:

    • cets_bt_featured_topic_with_posts_widget.php
    • cets_bt_related_blogs_widget.php
    • cets_bt_related_posts_widget.php
    • cets_bt_topicname_widget.php
    • cets_bt_topics_with_posts_widget.php
  3. wp-content/themes
    All of the code in the cets_blog_topics_sampletheme directory is, as the name suggests, sample theme code. To test it and play with it, install it in the wp-content/themes directory. You will need to enable it via Site Admin -> Themes, and then you will have to activate it on a selected test blog. You should install this AFTER installing the plugin and widgets. The theme utilizes the cets_bt_topics_with_posts widget and the cets_bt_featured_topics_with posts widget.

Now What?

Now you have the files where they belong, now what?

For a fresh install, the plugin will create a couple of tables in the database. The names will most likely be wp_blogs_cets_topic and wp_blogs_cets_topic_relationship.  (The first part may be different if you’ve customized your database prefix in your WPMU install.) In previous versions, the table names used plural syntax. Plural syntax of table names is just one of my many pet peeves about the database design in WPMU (don’t get me started on that).  There are a few new fields as well – description, slug, thumbnail, banner, and featured are all new in the topic table.  (Thumbnail and banner aren’t actually in use yet.)  If you already had the plugin installed, the upgrade script will attempt to alter the tables for you automatically.

By default, you must have at least one topic. The plugin installs with the default topic of “uncategorized.” If you’d like it to install with multiple topics, you can alter the code of the cets_blogtopics.php file on line 120 to add additional items to the array of default topics. Don’t worry – there’s a visual way to add and edit topics once the plugin is installed as well.

The Site Admin Features

Most of the site admin features of the plugin are found under Site Admin -> Blog Topics Management. The screen has three parts:

The first section is where site admins can add and edit topics, slugs, and descriptions. Slugs are what will be used for the portal permalinks. So, make sure that they contain no spaces, are short, and are human readable. The slugs are also used in the sample theme to create menu options. Think carefully about your slugs. The description is also used in the sample theme code, as well as in the blog topic name widget. Here’s what the administration screen looks like for this part:

Administrative View of Adding and Editing Blog Topics

The second part of the administrative interface allows you to set a featured topic. Again, the featured topic is utilized in the sample theme code and in the featured topic widget. A featured topic is not required to run the plugin, but is required for the featured topic widget.

Administrative View of Setting a Featured Topic or Uninstalling

Administrative View of Setting a Featured Topic or Uninstalling

The final part of the site admin interface is the uninstall feature. Clicking the uninstall link will remove the database and clean up the site options associated with the plugin. Manual deletion of files is still required.

There’s one other place where site admins can manage things. On Site Admin -> Blogs -> Edit, you can set the blog topic on a specific blog, and choose whether or not a blog is included in the aggregation bits (the widget & portal pieces). You’ll find these options in the Misc Blog Actions section of the blog editing page, and they look like so:

Blog Editing Features for Site Admins

Blog Editing Features for Site Admins

Blog Admin Options

That pretty much covers what the site admins can do to administer the plugin itself. What about the blog admins? When a user first creates a new blog, she will find a new option on the sign up page, just under the Privacy section:

User View of Sign Up Option

User View of Sign Up Option

It defaults to selecting the first item in the list, which is in alphabetical order. We’ve noticed that some people don’t really pay much attention here, and will just sign up with the default option, no matter what it is. Fortunately, there’s an easy way for blog admins to change the selected topic.  In Settings -> Blog Topic, blog admins can change the topic of their blog and decide whether or not their blog should be included in the aggregation bits of the plugin.

Blog Admin View of Setting Blog Topic

Blog Admin View of Setting Blog Topic

The Widgets

Okay, so we have the plugin installed, and we’ve categorized our blogs. Now what? What do we DO with that information. Here come the widgets. There are five of them. Some are incredibly simple – and could be used on any blog in the network. Others are really meant to be a part of your root blog, acting as portal pieces. Let’s start with the simplest one first.

Blog Topics Name

The blog topics name widget (cets_bt_topicname_widget.php) displays the name and, optionally, the description of the topic.  Like most of the widgets, it can also create a link to a portal page. In this case, if you follow the code in the sample theme, the portal page would display a list of all the other blogs that fall under the same topic. Here’s how it looks from the backend:

Back end view of blog topics name widget.

Back end view of blog topics name widget.

And, here’s how it displays data on the front end. If you elect to include the portal link, the topic name becomes the link:
Front end view of topic name widget.

Pretty simple, right? Yah, we don’t actually use this one, either. But, it was written, so I thought someone might want it.

Related Blogs Widget

The related blogs widget is a bit more interesting. It lists the top N blogs that are categorized under the same topic.  This one doesn’t have any portal parts, so it’s very safe to use, even if you’re not implementing any of the portal pieces. The list will simply link to each of the blogs directly. Here’s how it looks on the back end:

Back end view of the related blogs widget

Back end view of the related blogs widget

And here’s how it looks from the front end. (This example doesn’t list many blogs, since I was pulling these screen shots from my development environment. Clearly, you’d have a longer bulleted list in a production environment.)

Front end view of related blogs widget

Front end view of related blogs widget

Related Posts Widget

Here’s where things start to get fun. This widget will pull the N most recent posts from all the blogs that share the same topic affiliation. This widget can again include portal links. If you follow the sample theme code, the portal links would be to a page that lists more recent posts from that topic and a page that lists all sites in that topic. These are optional, of course. Here’s the back end view:

Back end view of related posts widget

Back end view of related posts widget

And, here’s how it displays on the front end:

Front end view of related posts widget

Front end view of related posts widget

Featured Topic With Posts Widget

The last two widgets are the workhorse widgets for the sample theme code. The first, the featured topic with posts widget, is fairly similar to the related posts widget, except for that instead of pulling the posts from the same topic as the current blog, it pulls the posts from whichever topic the site admin has set as featured. Again, you can determine how many posts to include and whether or not to include the portal links. Here’s what it looks like from the back end:

Back end view of featured topic with posts widget

Back end view of featured topic with posts widget

And, here’s what it looks like on the front end:

Front end view of featured topic with posts widgetFront end view of featured topic with posts widgetTopics with Posts Widget

Finally, the last widget allows you to pull all the topics and a selected number of posts from each topic. Use this one carefully – it’s fairly resource intensive. We’ve done some experimenting with different ways to cache this puppy. But, haven’t figured out what the best approach is yet.  You can also exclude certain topics. We use this widget in conjunction with the featured topic widget to show the featured topic’s posts in a separate area.  And, as always, the portal links are optional. Here’s how it looks on the back end:

Back end view of topics with posts widget

Back end view of topics with posts widget

And, here’s how it looks from the front end. That image in there? That’s part of the sample theme code. We’ll talk about that in a minute.

Front end view of topics with posts widget

Front end view of topics with posts widget

The Sample Theme

Whew – are you still with me? One last section – the sample theme. First I have to ask you a favor. Don’t just install this sample theme and run with it, okay? I tried to dumb it down enough so as to not be too tempting. It’s pretty bare bones. It’s really meant as a guide – a way to help you understand how to make all this portal stuff work. Okay, that said, how does it work? Where’s the magic?

Most of the magic is in the rewrites.php file. Here’s where you’ll find the code to tell wordpress to take any URL that includes topic= or sitelist= and turn into a URL that looks like this:
topic/mytopic/
or this:
sites/mytopic/

And, furthormore, this code lets wordpress know to send those URLS to  topic.php and sites.php, respectively. We get this code to run by including it in the functions.php file of the theme. The code for that is way down at the bottom of the rewrites.php file and looks like this:
//include the rewrites
include_once dirname(__FILE__) . '/rewrites.php';

Of course, you also need the topic.php and the sites.php files as well. These are the files that display all the recent posts from a topic or all the sites from a topic. I’ve tried to add commenting in each of these files so you know what’s going on there. Note that, as written, these files also make a custom feed link. If you want to use that, you’ll need to also use the topicfeed.php file.

The home page is really just a widgetized template that utilizes the Topics with Posts widget and the Featured Topic with Posts widget. The widgets are called in home.php using the nifty the_widget() function. But, they could also be set via the back end widget tools.

My cohort in crime, and designer/css guru, Kevin Graeme, has added some nifty css tricks to the sample theme. Remember that image in one of the widgets up above? Well, that’s just a little css trick based on the slug of the topic. If you check out style.css right about line 281, you’ll see a comment that tells you what to do if you want to implement that trick. You’ll need to create a style for each of your topics. Easy peasy.

Okay, I think that’s it. Hopefully, this will help you figure out how to use all this stuff.

Of course, you probably want to know where to get all this stuff, eh? It’s here: http://wordpress.org/extend/plugins/blog-topics/

Enjoy!

Google Maps Embed Plugin

I’ve released a new Google Maps Embed plugin for WPMU (and should work fine for WordPress as well). It uses the link generated by a Google map instead of the API key, for those users or those situations when using an API key is overkill.

Find it here: http://wpmudev.org/project/google-maps-embed

Two New Widgets Released

I threw a couple of new widgets out there for folks to have some fun with.

Featured Blog Tag Cloud and Shortcode
We use this one together with Donncha’s sitewide tags plugin, so we can have a tag cloud on your site’s home page that references the tags from the tags blog. The shortcode just allows you to pass in the ID of the blog you want, otherwise it acts pretty much just like the built-in shortcode, but with the defaults set a little differently – you can set the defaults directly in the code if you prefer different ones.

Featured Blog Widget
This widget lets you pick a featured blog and a featured author within that blog, and it will display the avatar of the featured author and a user-specified number of posts from the blog.

Both of these came out of the work that we were doing on an internal blog server – so they might be a tad bit customized in terms of layout. But, they’re good starting points if you need to do something similar.

Simple Dashboard Plugin Updated

1.3.2 release – It came to my attention that the core function get_blog_list does not include private blogs. So, I swapped out that code for a direct database call to get all blogs that aren’t spam, mature or deleted. I also streamlined the behind the scenes dashboard updates for a bit more efficiency.

Get the latest code over at http://wpmudev.org/project/simple-dashboard

New Blog Defaults Bug Fix – Yet Again

My coworker discovered another bug. (Thanks, Kevin.) Apparently, after a user created a new blog, they were being directed back to the main blog on the congratulations page.The latest version fixes that issue.

Get the code here: http://wpmudev.org/project/New-Blog-Defaults

New Blog Defaults – Updating for 2.7

I’m in the process of updating the new blog defaults plugin to incorporate the additional settings found in 2.7 (default avatar, large image size, a bunch of comments settings). If there are other defaults you’d like to see added, add a comment here. I may be able to work them in while I’m in updating mode.

Embed RSS Plugin

I know that some people have asked for some updates to some of the existing plugins I’ve got out there, and it’s on my list of things to do…really. But, I wanted to finish this up first. This is a plugin that integrates with the tinymce visual editor to allow someone to click an RSS icon, fill in some details, and embed a shortcode in a post or page that will be replaced with an RSS feed. Here’s what the pop up window from the page or post writing screen looks like

And, here’s a screen shot of a yahoo news feed:

You can find the code over at wpmudev.org.

I’ve not tested this with a stand-alone wordpress, but I think it would probably work there as well. It’s designed to go in the plugins folder, not the mu-plugins folder.

Shameless Promotion

The 2 plugins I’ve written are both in the plugin competition over at wmpu.org. So, if you like them, please head on over and vote. Thanks!

http://wpmu.org/2008-wpmu-plugin-competition/