Adding WordPress-like tags to a Symfony 1.4 admin generator form

Update 2010-08-06: It looks like the sfDoctrineActAsTaggablePlugin team has incorporated much of this into the version they released today. I’ll post more when I get a chance to try it out.

This week I used the sfDoctrineActAsTaggablePlugin to add tag behavior to a Symfony project. My goal was to have a user interface similar to WordPress’s, which I like a lot:

Tag user interface in WordPress

Another goal was to use JQuery UI with the nice visual theme I built with their Theme Roller tool (which I had already installed and added to my symfony project).

The documentation was a little bit sparse and I’ll probably end up doing this again, so here are relevant instructions so Future Me and others may also benefit:

Set up

  1. get/install/checkout sfDoctrineActAsTaggablePlugin. I use this line in the `svn:externals` property on plugins/:
    sfDoctrineActAsTaggablePlugin/ http://svn.symfony-project.com/plugins/sfDoctrineActAsTaggablePlugin/tags/RELEASE_1_0_0
  2. enable the plugin in config/ProjectConfiguration.class.php.
        $this->enablePlugins(
                             'sfDoctrinePlugin', 
                             ...
    			 'sfDoctrineActAsTaggablePlugin'
    			 );
  3. Add the Taggable behavior to your model(s) in config/doctrine/schema.yml
        actAs:            { Timestampable: ~ , Taggable: ~ }
    (not templates: [Taggable] as the README says)
  4. Rebuild your model and forms and everything. My favorite way is to dump the old data to fixtures, then rebuild everything.
    ./symfony --color doctrine:data-dump
    ./symfony --color doctrine:build --all --and-load --no-confirmation

At this point you should have new database tables tag and tagging, and the proper model relations and everything.

Form fields for tags

UI for entering tags, displaying existing tags (with remove buttons)

I’m going to have

  1. a single text input field for adding tags,
  2. a link to display a tag cloud (see below) if the user wants to pick tags by clicking instead of typing
  3. a styled list of existing tags with delete icons
  4. a hidden field for storing tags to be removed after clicking on the delete icons.

The delete icons will trigger a Javascript function which populates the hidden field and hides the tag. Changes won’t be saved until the user submits the form, so we’ll also provide a little reminder.

  1. add the tag field(s) to the configure() method of your form, in lib/form/doctrine/ModelForm.class.php.
        // this text appears in gray until the user focuses on the field
        $default = 'Add tags with commas';
        $this->widgetSchema['new_tags'] = new sfWidgetFormInput
          (
           array('label'   =>  'Add Tags', 'default'   =>  $default),
           array(
                 'onclick' => "if (this.value=='$default') { 
                                    this.value = ''; this.style.color='black'; }", 
                 'size'   =>  '32',
    	     'id' => 'new_tags', 
                 // don't let the browser autocomplete.  We'll add typeahead, below
    	     'autocomplete' => "off",      
    	     'style' => 'color:#aaa'
    	     )
           );
        // allow the field to remain blank
        $this->setValidator('new_tags', new sfValidatorString(array('required' => false)));
     
        // this hidden field will be populated with JavaScript.
        $this->widgetSchema['remove_tags'] = new sfWidgetFormInputHidden();
        $this->setValidator('remove_tags', new sfValidatorString(array('required' => false)));
  2. Add a partial with the admin generator. In apps/frontend/modules/mymodule/config/generator.yml:
          form:
            display:
              # add another section after the other fields...
              Tags: [_tags]
  3. edit the partial, apps/frontend/modules/mymodule/templates/_tags.php
    <?php use_helper('JavascriptBase', 'Tags')  ?>
    <?php  
     
    // much of this I copied and adapted from a cached admin generator template.
    $name = 'new_tags'; $label = ''; $help = ''; 
    $class = 'sf_admin_form_row sf_admin_text sf_admin_form_field_tags';
     
     ?>
     
      <div class="<?php echo $class ?><?php $form[$name]->hasError() and print ' errors' ?>">
        <?php echo $form[$name]->renderError() ?>
        <div>
          <?php echo $form[$name]->renderLabel($label) ?>
     
          <div class="content"><?php echo $form[$name]->render($attributes instanceof sfOutputEscaper ? $attributes->getRawValue() : $attributes) ?>
     
     
    <?php // tag cloud will go here, see below ?>
     
     
    </div>
     
          <?php if ($help): ?>
            <div class="help"><?php echo __($help, array(), 'messages') ?></div>
          <?php elseif ($help = $form[$name]->renderHelp()): ?>
            <div class="help"><?php echo $help ?></div>
          <?php endif; ?>
        </div>
      </div>
     
    <?php // list of current tags, with remove buttons ?>
    <div class="sf_admin_form_row sf_admin_text sf_admin_form_field_tags">
    <div>
    <label>Current tags</label>
    <div class="content">
    <div class="taglist">   
       <?php foreach ( $form->getObject()->getTags() as $t):   ?>
        <span><nobr><?php 
     
    echo link_to_function("Remove '$t'", 
               "remove_tag(".json_encode($t).", this.parentElement)", 
               "class=removetag")
     
      ?>&nbsp;<?php echo $t  ?></nobr></span>
        <?php endforeach;  ?>
    </div>
    <span id="remove_tag_help" style="display:none;">Tag(s) removed. Remember to save the complaint.</span>
    </div>
    </div>
     
    </div>
  4. Add a Javascript function to support the “remove tag” buttons.
    $(function() {
                // add fancy jQuery UI button styles.  See additional in "CSS" below
    	    $(".taglist a").button({icons:{primary:'ui-icon-trash'}, text: false});
    });
     
    function remove_tag (tag, element) {
      remove_field = $("#complaint_remove_tags");
      if ( remove_field.val().length ) {
        remove_field.val( remove_field.val()  + "," + tag );
      }
      else {
        remove_field.val( tag );
      }
      $(element).hide();
      $("#remove_tag_help").show();
    }
  5. edit the `processForm` action to process the `new_tags` and `remove_tags` fields. I started by copying the `processForm` method from cache/frontend/dev/modules/autoMymodel/actions/actions.class.php into apps/frontend/modules/mymodule/actions/actions.class.php, then added code after validation:
      protected function processForm(sfWebRequest $request, sfForm $form)
      {
        $form->bind($request->getParameter($form->getName()), $request->getFiles($form->getName()));
     
        if ($form->isValid())
        {
          $notice = $form->getObject()->isNew() ? 'The item was created successfully.' : 'The item was updated successfully.';
     
          // NEW: deal with tags
          if ($form->getValue('remove_tags')) {
    	foreach (preg_split('/\s*,\s*/', $form->getValue('remove_tags')) as $tag) {
    	  $form->getObject()->removeTag($tag);
    	}
          }
          if ($form->getValue('new_tags')) {
    	foreach (preg_split('/\s*,\s*/', $form->getValue('new_tags')) as $tag) {
              // sorry, it would be better to not hard-code this string
    	  if ($tag == 'Add tags with commas') continue;
    	  $form->getObject()->addTag($tag);
    	}
          }
     
          try {
            $complaint = $form->save();
            // and the remainder is just pasted from the generated actions file

Yay! Now I can add, display, and remove tags. Now for the fancy parts.

Typeahead tag autocompletion

As detailed in the README, sfDoctrineActAsTaggablePlugin has typeahead support. I tried it, but I didn’t feel like the user interaction was quite smooth enough, and I wanted to use the excellent, beautiful, and user-expectation-meeting jQuery UI Autocomplete widget.

I ended up using the action provided by sfDoctrineActAsTaggablePlugin, but modified the view to return JSON instead of an HTML <ul>, and I used the multiple, remote demo code from the jQuery UI Autocomplete documentation.

  1. override the completeSuccess view for the taggableComplete/complete action
    mkdir -p apps/frontend/modules/taggableComplete/templates
    touch apps/frontend/modules/taggableComplete/templates/completeSuccess.php
    And here is my new template:
    <?php
     
    // we're rewriting the view for the taggable plugin to output JSON instead of HTML
    // see http://wiki.jqueryui.com/Autocomplete
     
    $tags_simple = array();
    foreach ( $tagSuggestions as $suggestion ) {
      $tags_simple[] =  $suggestion['suggested'];
    }
     
    echo json_encode($tags_simple);
    You can test your autocomplete action with a URL like http://myserver/myproject/taggableComplete/complete/current/p. In my case (because I have some data in my tag table) it returns this JSON-formatted array:
    ["PHP","plugins"]
  2. Add jQuery UI javascript. I used a <script> section in my _tags.php partial, but you could also use an external javascript file (but mind the line of PHP code using the url_for() helper).
    	$(function() {
     
    // for debug info, uncomment these lines and add a <div id="autocomplete_log">
    // 		function log(message) {
    // 			$("<div/>").text(message).prependTo("#autocomplete_log");
    // 			$("#autocomplete_log").attr("scrollTop", 0);
    // 		}
    		function split(val) {
    			return val.split(/\s*,\s*/);
    		}
    		function extractLast(term) {
    		        last = split(term).pop();
    // 			log ( "extracted last = "+last );
    			return last;
    		}
     
     
    		$("#new_tags").autocomplete({
     
    			source: function(request, response) {
    		      $.getJSON(<?php echo json_encode(url_for("taggableComplete/complete")) ?>, {
    					current: extractLast(request.term)
    				}, response);
    			},
    			search: function() {
    				// custom minLength
    				var term = extractLast(this.value);
    				if (term.length < 1) {
    					return false;
    				}
    			},
    		        focus: function(event, ui) {
    				// prevent value inserted on focus
    				return false;
    			},
    			select: function(event, ui) {
    				var terms = split( this.value );
    				// remove the current input
    				terms.pop();
    				// add the selected item
    				terms.push( ui.item.value );
    				// add placeholder to get the comma-and-space at the end
    				terms.push("");
    				this.value = terms.join(", ");
    				return false;
    			}
     
    		});
    	});

tag cloud

I like how WordPress has an option for picking tags from a tag cloud too. The sfDoctrineActAsTaggablePlugin made this nice and easy.

  1. Here’s the code for the view (in the _tags.php partial)
    <?php echo link_to_function("Choose from the most used tags &gt;&gt;", '$("#add_tag_from_cloud").show(); $(this).hide()' ) ?>
     
    <div id="add_tag_from_cloud" class="tag_cloud popular" style="display:none">
    <h3>Popular tags</h3>
    <?php 
    // gets the popular tags
    $tags = PluginTagTable::getPopulars();
     
    // Display the tags cloud, using link_to_function() instead of link_to()
    // The %s in the second arg will be substituted with the tag text.
    echo tag_cloud($tags, 'add_tag("%s")', array(
    				'link_function'   =>  'link_to_function',  
    				'link_options'   =>  array('class=addtag')
    					     )
    	       );
     
      ?>
    </div>
  2. And here’s the javascript to handle those links.
    function add_tag (tag) {
      add_field = $("#new_tags");
     
      if ( add_field.val() == "Add tags with commas" ) {
        add_field.val( tag );
        add_field.css("color", "black");
      }
      else if ( add_field.val().length ) {
        add_field.val( add_field.val()  + ", " + tag );
      }
      else {
        add_field.val( tag );
      }
      $(element).hide();
    }

CSS

I made lots of incremental changes to my CSS but here are all of the sections relevant to the tag stuff I’ve shown here, I think.

 
.taglist .removetag {
   vertical-align: middle;
   width: 18px;
   height: 18px;
}
 
.taglist span {
   margin-right: 1.4em;
}
 
.tag_cloud li {
   display: inline;
   list-style: none;
}
 
#sf_admin_container .tag_cloud li a {
   background: transparent;
   padding-left: 0px;
}

And I had this in my apps/frontend/templates/layout.php to apply JQuery UI button styling to all my form buttons and hyperlinks with the “button” CSS class:

<script type="text/javascript">
// use JQueryUI buttons
      $(function() {
       $("input:submit,  button, a.button").button();
      });
</script>

Yay! I’m pretty happy with it so far. I hacked at it for a couple days though and have been cloudy with fighting a cold, so if I’ve missed anything let me know. I think this is enough to get me there more quickly the next time.