feat: add auto-category menu module for knowledge base sections (#184)
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled

New module mod_mokowaas_categories auto-discovers article categories
from a configurable root and renders them as a collapsible sidebar tree.
Supports configurable depth, article counts, empty category filtering,
and ACL-aware access. Matches existing MokoWaaS sidebar styling.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-06-06 06:34:17 -05:00
parent d1ee2ef3f4
commit 082fa0798c
8 changed files with 446 additions and 0 deletions
@@ -0,0 +1,24 @@
MOD_MOKOWAAS_CATEGORIES="MokoWaaS Categories"
MOD_MOKOWAAS_CATEGORIES_DESC="Auto-discovers article categories and renders them as a collapsible tree menu. Ideal for knowledge base and help sections."
MOD_MOKOWAAS_CATEGORIES_ROOT_LABEL="Root Category"
MOD_MOKOWAAS_CATEGORIES_ROOT_DESC="Select a parent category. Only its children (and their subcategories) will be displayed. Leave as All to show the entire category tree."
MOD_MOKOWAAS_CATEGORIES_ALL_CATEGORIES="- All Categories -"
MOD_MOKOWAAS_CATEGORIES_DEPTH_LABEL="Maximum Depth"
MOD_MOKOWAAS_CATEGORIES_DEPTH_DESC="How many levels deep to display. 1 shows only top-level categories, 2 adds one level of subcategories, etc."
MOD_MOKOWAAS_CATEGORIES_COUNT_LABEL="Show Article Count"
MOD_MOKOWAAS_CATEGORIES_COUNT_DESC="Display the number of published articles next to each category name."
MOD_MOKOWAAS_CATEGORIES_EMPTY_LABEL="Show Empty Categories"
MOD_MOKOWAAS_CATEGORIES_EMPTY_DESC="Display categories that have no published articles. Only applies when Show Article Count is enabled."
MOD_MOKOWAAS_CATEGORIES_MENUITEM_LABEL="Target Menu Item"
MOD_MOKOWAAS_CATEGORIES_MENUITEM_DESC="The menu item to use as the base for category links. This sets the Itemid parameter for proper template and menu highlighting."
MOD_MOKOWAAS_CATEGORIES_ORDER_LABEL="Category Ordering"
MOD_MOKOWAAS_CATEGORIES_ORDER_DESC="How to sort categories within each level."
MOD_MOKOWAAS_CATEGORIES_ORDER_TREE="Tree Order (default)"
MOD_MOKOWAAS_CATEGORIES_ORDER_TITLE="Alphabetical"
MOD_MOKOWAAS_CATEGORIES_ORDER_CREATED="Date Created"
@@ -0,0 +1,2 @@
MOD_MOKOWAAS_CATEGORIES="MokoWaaS Categories"
MOD_MOKOWAAS_CATEGORIES_DESC="Auto-discovers article categories and renders them as a collapsible tree menu. Ideal for knowledge base and help sections."
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="module" client="administrator" method="upgrade">
<name>mod_mokowaas_categories</name>
<author>Moko Consulting</author>
<creationDate>2026-06-06</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.10-dev</version>
<description>MOD_MOKOWAAS_CATEGORIES_DESC</description>
<namespace path="src">Moko\Module\MokoWaaSCategories</namespace>
<files>
<folder module="mod_mokowaas_categories">services</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/mod_mokowaas_categories.ini</language>
<language tag="en-GB">en-GB/mod_mokowaas_categories.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic">
<field name="root_category" type="category"
extension="com_content"
label="MOD_MOKOWAAS_CATEGORIES_ROOT_LABEL"
description="MOD_MOKOWAAS_CATEGORIES_ROOT_DESC"
default="0"
show_root="true"
>
<option value="0">MOD_MOKOWAAS_CATEGORIES_ALL_CATEGORIES</option>
</field>
<field name="max_depth" type="number"
label="MOD_MOKOWAAS_CATEGORIES_DEPTH_LABEL"
description="MOD_MOKOWAAS_CATEGORIES_DEPTH_DESC"
default="3"
min="1"
max="10"
/>
<field name="show_article_count" type="radio"
label="MOD_MOKOWAAS_CATEGORIES_COUNT_LABEL"
description="MOD_MOKOWAAS_CATEGORIES_COUNT_DESC"
default="1"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="show_empty" type="radio"
label="MOD_MOKOWAAS_CATEGORIES_EMPTY_LABEL"
description="MOD_MOKOWAAS_CATEGORIES_EMPTY_DESC"
default="0"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="menu_item_id" type="menuitem"
label="MOD_MOKOWAAS_CATEGORIES_MENUITEM_LABEL"
description="MOD_MOKOWAAS_CATEGORIES_MENUITEM_DESC"
default=""
/>
<field name="ordering" type="list"
label="MOD_MOKOWAAS_CATEGORIES_ORDER_LABEL"
description="MOD_MOKOWAAS_CATEGORIES_ORDER_DESC"
default="lft">
<option value="lft">MOD_MOKOWAAS_CATEGORIES_ORDER_TREE</option>
<option value="title">MOD_MOKOWAAS_CATEGORIES_ORDER_TITLE</option>
<option value="created_time">MOD_MOKOWAAS_CATEGORIES_ORDER_CREATED</option>
</field>
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,25 @@
<?php
/**
* @package MokoWaaS
* @subpackage mod_mokowaas_categories
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\Service\Provider\HelperFactory;
use Joomla\CMS\Extension\Service\Provider\Module;
use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoWaaSCategories'));
$container->registerServiceProvider(new HelperFactory('\\Moko\\Module\\MokoWaaSCategories\\Administrator\\Helper'));
$container->registerServiceProvider(new Module());
}
};
@@ -0,0 +1,32 @@
<?php
/**
* @package MokoWaaS
* @subpackage mod_mokowaas_categories
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Module\MokoWaaSCategories\Administrator\Dispatcher;
defined('_JEXEC') or die;
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
use Joomla\CMS\Helper\HelperFactoryAwareInterface;
use Joomla\CMS\Helper\HelperFactoryAwareTrait;
class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareInterface
{
use HelperFactoryAwareTrait;
protected function getLayoutData()
{
$data = parent::getLayoutData();
$params = $data['params'];
$helper = $this->getHelperFactory()->getHelper('CategoriesHelper');
$data['categories'] = $helper->getCategories($params);
return $data;
}
}
@@ -0,0 +1,148 @@
<?php
/**
* @package MokoWaaS
* @subpackage mod_mokowaas_categories
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Module\MokoWaaSCategories\Administrator\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
use Joomla\Registry\Registry;
class CategoriesHelper
{
/**
* Get category tree from a root category with configurable depth.
*
* @param Registry $params Module parameters
*
* @return array Nested array of category objects
*/
public function getCategories(Registry $params): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$rootId = (int) $params->get('root_category', 0);
$maxDepth = (int) $params->get('max_depth', 3);
$showEmpty = (int) $params->get('show_empty', 0);
$showCount = (int) $params->get('show_article_count', 1);
$ordering = $params->get('ordering', 'lft');
$user = Factory::getApplication()->getIdentity();
$accessLevels = $user->getAuthorisedViewLevels();
// Build base query
$query = $db->getQuery(true)
->select([
$db->quoteName('c.id'),
$db->quoteName('c.title'),
$db->quoteName('c.alias'),
$db->quoteName('c.parent_id'),
$db->quoteName('c.level'),
$db->quoteName('c.lft'),
$db->quoteName('c.rgt'),
$db->quoteName('c.description'),
])
->from($db->quoteName('#__categories', 'c'))
->where($db->quoteName('c.extension') . ' = ' . $db->quote('com_content'))
->where($db->quoteName('c.published') . ' = 1')
->whereIn($db->quoteName('c.access'), $accessLevels);
// If a root category is set, constrain to its subtree
if ($rootId > 0)
{
$rootQuery = $db->getQuery(true)
->select([$db->quoteName('lft'), $db->quoteName('rgt'), $db->quoteName('level')])
->from($db->quoteName('#__categories'))
->where($db->quoteName('id') . ' = ' . $rootId);
$db->setQuery($rootQuery);
$root = $db->loadObject();
if (!$root)
{
return [];
}
$query->where($db->quoteName('c.lft') . ' > ' . (int) $root->lft)
->where($db->quoteName('c.rgt') . ' < ' . (int) $root->rgt)
->where($db->quoteName('c.level') . ' <= ' . ((int) $root->level + $maxDepth));
}
else
{
// No root — show from level 1 (skip the virtual root)
$query->where($db->quoteName('c.level') . ' >= 1')
->where($db->quoteName('c.level') . ' <= ' . $maxDepth);
}
// Article count subquery
if ($showCount)
{
$countSub = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__content', 'a'))
->where($db->quoteName('a.catid') . ' = ' . $db->quoteName('c.id'))
->where($db->quoteName('a.state') . ' = 1');
$query->select('(' . $countSub . ') AS ' . $db->quoteName('article_count'));
}
// Ordering
$validOrders = ['lft', 'title', 'created_time'];
$orderCol = \in_array($ordering, $validOrders, true) ? $ordering : 'lft';
$query->order($db->quoteName('c.' . $orderCol) . ' ASC');
$db->setQuery($query);
$categories = $db->loadObjectList() ?: [];
// Filter empty categories if configured
if (!$showEmpty && $showCount)
{
$categories = array_filter($categories, function ($cat) {
return (int) $cat->article_count > 0;
});
$categories = array_values($categories);
}
// Build nested tree
return $this->buildTree($categories, $rootId);
}
/**
* Build a nested tree from a flat list of categories.
*
* @param array $categories Flat list of category objects
* @param int $rootId Root category ID (0 for all)
*
* @return array Nested array with 'children' key on each node
*/
private function buildTree(array $categories, int $rootId): array
{
$map = [];
$tree = [];
foreach ($categories as $cat)
{
$cat->children = [];
$map[$cat->id] = $cat;
}
foreach ($categories as $cat)
{
$parentId = (int) $cat->parent_id;
if (isset($map[$parentId]))
{
$map[$parentId]->children[] = $cat;
}
else
{
$tree[] = $cat;
}
}
return $tree;
}
}
@@ -0,0 +1,138 @@
<?php
/**
* @package MokoWaaS
* @subpackage mod_mokowaas_categories
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Uri\Uri;
$categories = $categories ?? [];
$showCount = (int) ($params->get('show_article_count', 1));
$menuItemId = (int) $params->get('menu_item_id', 0);
if (empty($categories))
{
return;
}
// Detect active category from current URL
$app = \Joomla\CMS\Factory::getApplication();
$activeCatId = (int) $app->input->getInt('id', 0);
$currentView = $app->input->getCmd('view', '');
$isCatView = \in_array($currentView, ['category', 'categories'], true);
/**
* Build the link for a category.
*/
$buildLink = function (object $cat) use ($menuItemId): string {
$link = 'index.php?option=com_content&view=category&id=' . (int) $cat->id;
if ($menuItemId)
{
$link .= '&Itemid=' . $menuItemId;
}
return Route::_($link);
};
/**
* Check if a category or any of its descendants is the active category.
*/
$isActiveOrAncestor = function (object $cat) use ($activeCatId, $isCatView, &$isActiveOrAncestor): bool {
if (!$isCatView || !$activeCatId)
{
return false;
}
if ((int) $cat->id === $activeCatId)
{
return true;
}
foreach ($cat->children as $child)
{
if ($isActiveOrAncestor($child))
{
return true;
}
}
return false;
};
/**
* Render a category list recursively.
*/
$renderTree = function (array $categories, int $depth = 1) use (
&$renderTree, $buildLink, $isActiveOrAncestor, $showCount, $activeCatId, $isCatView
): void {
foreach ($categories as $cat):
$hasChildren = !empty($cat->children);
$isActive = $isCatView && (int) $cat->id === $activeCatId;
$isAncestor = $hasChildren && $isActiveOrAncestor($cat);
$liClass = 'item mokowaas-cat-item mokowaas-cat-level-' . $depth;
if ($isActive)
{
$liClass .= ' mm-active';
}
if ($hasChildren)
{
$liClass .= ' parent';
}
$aClass = ($hasChildren ? 'has-arrow' : 'no-dropdown');
if ($isActive)
{
$aClass .= ' mm-active';
}
$collapseClass = 'collapse-cat-level-' . ($depth + 1) . ' mm-collapse';
if ($isAncestor || $isActive)
{
$collapseClass .= ' mm-show';
}
$count = isset($cat->article_count) ? (int) $cat->article_count : 0;
?>
<li class="<?php echo $liClass; ?>">
<a class="<?php echo $aClass; ?>"
href="<?php echo $hasChildren ? '#' : $buildLink($cat); ?>"
<?php echo $isActive ? ' aria-current="page"' : ''; ?>>
<span class="icon-folder" aria-hidden="true"
style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
<span class="sidebar-item-title"><?php echo htmlspecialchars($cat->title); ?></span>
<?php if ($showCount): ?>
<span class="badge bg-secondary ms-auto"><?php echo $count; ?></span>
<?php endif; ?>
</a>
<?php if ($hasChildren): ?>
<ul class="<?php echo $collapseClass; ?>" style="padding-inline-start:0.75rem;">
<?php $renderTree($cat->children, $depth + 1); ?>
</ul>
<?php endif; ?>
</li>
<?php endforeach;
};
?>
<style>
.sidebar-wrapper .mokowaas-cat-item > a { padding-inline-start: 2rem; }
.sidebar-wrapper .mokowaas-cat-item > a .badge { font-size: 0.65em; padding: 0.15em 0.45em; }
.sidebar-wrapper .mokowaas-cat-level-2 > a { padding-inline-start: 2.5rem; }
.sidebar-wrapper .mokowaas-cat-level-3 > a { padding-inline-start: 3rem; }
.sidebar-wrapper .mokowaas-cat-level-4 > a { padding-inline-start: 3.5rem; }
</style>
<ul class="nav flex-column">
<?php $renderTree($categories); ?>
</ul>
+1
View File
@@ -24,6 +24,7 @@
<file type="module" id="mod_mokowaas_cpanel" client="administrator">mod_mokowaas_cpanel.zip</file>
<file type="module" id="mod_mokowaas_menu" client="administrator">mod_mokowaas_menu.zip</file>
<file type="module" id="mod_mokowaas_cache" client="administrator">mod_mokowaas_cache.zip</file>
<file type="module" id="mod_mokowaas_categories" client="administrator">mod_mokowaas_categories.zip</file>
<file type="plugin" id="plg_webservices_mokowaas" group="webservices">plg_webservices_mokowaas.zip</file>
<file type="plugin" id="plg_webservices_perfectpublisher" group="webservices">plg_webservices_perfectpublisher.zip</file>
<file type="plugin" id="plg_task_mokowaasdemo" group="task">plg_task_mokowaasdemo.zip</file>