I'm a big fan of using joomla tags to classify items - articles, maps, films, books, people, reviews - and also using them to hold references and citations. Categories (in Joomla terminology) are also useful, but they have key limitations (which are advantages in some situations). Eventually the number of tags you are using across components gets unwieldy for the standard TagField form element to make it easy to remember and find the tag you want, whether using nested or Ajax mode.

 So you need a form field element able to restrict the tags to a useable subset of the whole lot. This would enable you to have separate fields on on the form for groups of tags like "Genre" that you might want to use across more than one component so you still want to use a tag and not a dedicated field for the particular database table..

The standard Joomla Form Tag Field type does not allow you to restrict the selection to only children of a specified parent tag. We need to define a new field type which I will call ChildTags in the admin/component/models/fields folder as childtags.php and it will be called in the xml file with type="childtags" .

Now we could simply start afresh and write a complete new field type, but that would involve simply copying a lot of code from the standard Joomla TagField. It turns out to be a fairly simple modification to allow a parent tag to be specified in the tag field xml file and extend the TagField class to limit the tags which can be selected to children of the specified parent

At it's simplest we can just add a parent="23" property to the new field type and specify the id of the desired parent tag (23 in this case).

But this won't enable us to reuse our ChildTag field type in different installations as we will have no idea what the desired id will be when we define the xml file. It might be better to specify it by the name or alias. (and here we hit a joomla quirk that tag titles do not have to be unique - only the alias does. Usually the alias is simply a lower case version of the title with spaces and punctuation replaced by hyphens - but not always.

So maybe use parent="hot-stuff" to specify the tag with id 23 and the alias hot-stuff would be more useful.

But that is still a bit inflexible. We have to know the desired alias when writing the xml, and it is not easy to change it without editing the file. We need a way of allowing the admin (at least) to specify what tag should be used as the parent for a tag group after the component has been installed and without having to edit a file.

One way forward is to add a component options parameter called, say, genre, which can be a standard TagField to select a parent tag under which all of the tags for Genre are grouped.

<field name="genre_parent" type="tag" 
		label="Genre tag group" 
		description="Show additional tag selector for Genre on item edit form limited to children of the tag specified here. Other tags can still be selected in the standard tags field."
		mode="nested" published="1"
		multiple="false" default=""
	>
		<option value="">- not used -</option>
	</field>

Note that we are setting multiple=false as only want a single value. We are also using mode=nested which prevents entering new values and lets us see the tree structure to make sure we select the correct branch.

In this example we are specifically setting a group for Genre. Since this is set in the component parameters we could make it more generic and call it taggroup1 and allow the admin to decide what to use it for.We could then use the title and description of the selected parent as the label and description for the ChildTags field when it gets displayed on the form. We can also allow a number of generic tag groups to be used by having further taggroup2, taggroup3 etc fields in the options. Leaving them unset would simple not use them.

Since we may be using the same ChildTag field type for various tag groups and across components we will need to tell the modified class where to find the option value which specifies the parent. It will need to know both the component where the option is found, and the name of the options field (the component parameter) which holds the value.

So our field specification xml for a ChildTag form field looks like this:

<field name="genre" type="childtags"
	label="dummy value replace by parent title" 
	description="dummy value replaced by parent description"
	parent="com_xbfilms.genre_parent"
	multiple="true"  published="1"
	mode="nested" custom="deny"
/>

 For the ChildTag field type itself as noted above we can make it a subclass of the standard TagField and then we only need to override the getOptions() function to fetch the desired parent tag id and only get children of that tag in the list.

Firstly to subclass the TagField we need this:

class JFormFieldChildTags extends Joomla\CMS\Form\Field\TagField 
{
	public $type = 'ChildTags';
	//...

 Then we need to parse the parent value to  find the id specified in 

$parent_id = 0;
	$parent = (string) $this->element['parent'];
	//we're looking for a string in format "com_componentname.parentoptionname
	if ($parent && (substr($parent,0,4) == 'com_'))  { //for php8+ use str_starts_with($parent, string 'com_')
	//break string into two parts at the '.'
	$parent = explode('.',$parent);
	//get specified component parameters
	$params = ComponentHelper::getParams($parent[0]);
	//if we've got a set of params get the parentoptionname value or default it to 1 (the root) if missing
	if ($params) $parent_id = $params->get($parent[1],1);		    

Now we've got a valid id we can get just the children of that tag. Unfortunately the built-in TagHelper doesn't provide a function to just get children of a tag so we'll have to make our own. Simple enough with the tree structure, we just need to get all the tag id's between the lft and rgt value of the parent.

$db = Factory::getDbo();
		$query = $db->getQuery(true)
			->select('DISTINCT a.id AS value, a.path, a.title AS text, a.level, a.published, a.lft')
			->from('#__tags AS a')
			->join('LEFT', $db->qn('#__tags') . ' AS b ON a.lft > b.lft AND a.rgt < b.rgt');		
		// Limit options to only children of parent if the parent id is greater than 1 (root)
	    if ($parent_id > 1) {
	        $query->where('b.id = '. $parent_id);
	    }			
		$query->where($db->qn('a.lft') . ' > 0');

this actually means it would work just like TagField works with no parent id. So once J3 is end of life and no longer getting updates we can simply modify the core files for our own branch of xbOomla and stop using Joomla - actually I'll probably do this well before next August when the expiry is due  it would be nice to get rid of all the nag notices about "needing" to migrate to J4; I don't need to.  

There is two more quirks to iron out - firstly there is a problem with using Aax mode because although the option list is correctly limited to the subtree, if the (admin) user starts to type a tag name that is elsewhere in the tree, the ajax javascript will offer the unwanted tag and enable it to be selected. We could alo rewrite the ajax processing, but that's more hassle than I want, so for now we will limit theChildTag field to always operate in Nested mode. However the parent class (TagField) code will default to whatever is set in the Tag component options if the mode= property is not specified. To block this and force nested mode we have to rely on the person designing the form to always specify mode="nested", or better if we simply override the parent isNested() function to always return true..

public function isNested()
	{
	    return true;
	}

Secondly we have to merge our options with any that have been set directly in the form xml specification. The parent class simply uses $options = array_merge(parent::getOptions(), $options);, but if we do that then we will get the standard options (all of the tags) as well as any xml options added in. So we need to merge with the grandparent options. Thus:

$options = array_merge(get_parent_class(get_parent_class(get_class($this)))::getOptions(), $options);

Finally we can strip out the condition on the call to prepareOptionsNested() after we have merged the option, and we can the test if we are setting a tag as its own parent since that only applies if we are in the Tags component - which we won't be.

We need to include the stuff from the parent getOptions() to do with multi-language sites - you can see that in the Joomla source, I have stripped it out here cos this post is already way too long.

FormHelper::loadFieldClass('list');

class JFormFieldChildTags extends Joomla\CMS\Form\Field\TagField 
{
	public $type = 'ChildTags';

	protected function getOptions()
	{
        $published = (string) $this->element['published'] ?: array(0, 1);		
		
		$parent_id = 1;  //0 is not a legal value, 1 means the root is parent
		//get the value for the parent propertry and parse it to find the specified parent
		$parent = (string) $this->element['parent'];
		if ($parent && (substr($parent,0,4) == 'com_'))  { 
		    $parent = explode('.',$parent);
		    $params = ComponentHelper::getParams($parent[0]);
		    if ($params) $parent_id = $params->get($parent[1],1);		    
		}		    

		$db    = Factory::getDbo();
		$query = $db->getQuery(true)
			->select('DISTINCT a.id AS value, a.path, a.title AS text, a.level, a.published, a.lft')
			->from('#__tags AS a')
			->join('LEFT', $db->qn('#__tags') . ' AS b ON a.lft > b.lft AND a.rgt < b.rgt');
		
		// Limit options to only children of parent
	    if ($parent_id > 1) {
	        $query->where('b.id = '. $parent_id);
	    }
			
		$query->where($db->qn('a.lft') . ' > 0');

		// Filter on the published state
		if (is_numeric($published))
		{
			$query->where('a.published = ' . (int) $published);
		}
		elseif (is_array($published))
		{
			$published = ArrayHelper::toInteger($published);
			$query->where('a.published IN (' . implode(',', $published) . ')');
		}

		$query->order('a.lft ASC');

		// Get the options.
		$db->setQuery($query);

		try
		{
			$options = $db->loadObjectList();
		}
		catch (\RuntimeException $e)
		{
			return array();
		}
		// Merge any additional options in the XML definition.
          $grandparent = $this->get_grandparent_class($this);
          $options = ($grandparent) ? array_merge($grandparent::getOptions(), $options) : $options;
        // 
        //$options = array_merge(get_parent_class(get_parent_class(get_class($this)))::getOptions(), $options);   

		$this->prepareOptionsNested($options);
		return $options;
	}

	/**
	 * Override parent function to force always use nested mode
	 * {@inheritDoc}
	 * @see \Joomla\CMS\Form\Field\TagField::isNested()
	 */
	public function isNested()
	{
	    return true;
	}

}

 

So now we've got a ChildTags field type defined how do we use it in practice - how do we save them with the rest of the tags for the item, and how do we load the field data when editing an item. And then do we want to show them out as a separate group of tags when displaying tags on the front-end? All this in the next post.