Merge pull request 'feat: OG coverage dashboard as default admin view (#94)' (#112) from feat/dashboard-94 into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 16s

This commit was merged in pull request #112.
This commit is contained in:
2026-06-29 15:49:11 +00:00
9 changed files with 394 additions and 60 deletions
@@ -5,6 +5,13 @@
COM_MOKOOG="MokoSuiteOpenGraph"
COM_MOKOOG_TAGS_TITLE="MokoSuiteOpenGraph - Tag Manager"
COM_MOKOOG_SUBMENU_TAGS="Tags"
COM_MOKOOG_SUBMENU_DASHBOARD="Dashboard"
COM_MOKOOG_DASHBOARD_TITLE="MokoSuiteOpenGraph - Dashboard"
COM_MOKOOG_DASHBOARD_FIELD_GAPS="Field Coverage Gaps"
COM_MOKOOG_DASHBOARD_BY_TYPE="Coverage by Content Type"
COM_MOKOOG_DASHBOARD_MISSING="Articles Missing OG Tags"
COM_MOKOOG_DASHBOARD_ALL_COVERED="All published articles have OG tags."
COM_MOKOOG_DASHBOARD_MISSING_NOTE="Showing up to 20 most recent. Use Batch Generate to create OG tags for all articles at once."
COM_MOKOOG_NO_TAGS="No Open Graph tags have been created yet. Tags are created automatically when you edit articles or menu items."
COM_MOKOOG_TABLE_CAPTION="Table of Open Graph tags"
COM_MOKOOG_AUTO_GENERATED="auto-generated"
@@ -5,6 +5,13 @@
COM_MOKOOG="MokoSuiteOpenGraph"
COM_MOKOOG_TAGS_TITLE="MokoSuiteOpenGraph - Tag Manager"
COM_MOKOOG_SUBMENU_TAGS="Tags"
COM_MOKOOG_SUBMENU_DASHBOARD="Dashboard"
COM_MOKOOG_DASHBOARD_TITLE="MokoSuiteOpenGraph - Dashboard"
COM_MOKOOG_DASHBOARD_FIELD_GAPS="Field Coverage Gaps"
COM_MOKOOG_DASHBOARD_BY_TYPE="Coverage by Content Type"
COM_MOKOOG_DASHBOARD_MISSING="Articles Missing OG Tags"
COM_MOKOOG_DASHBOARD_ALL_COVERED="All published articles have OG tags."
COM_MOKOOG_DASHBOARD_MISSING_NOTE="Showing up to 20 most recent. Use Batch Generate to create OG tags for all articles at once."
COM_MOKOOG_NO_TAGS="No Open Graph tags have been created yet. Tags are created automatically when you edit articles or menu items."
COM_MOKOOG_TABLE_CAPTION="Table of Open Graph tags"
COM_MOKOOG_AUTO_GENERATED="auto-generated"
+2
View File
@@ -50,6 +50,7 @@
<folder>View</folder>
</files>
<files folder="tmpl">
<folder>dashboard</folder>
<folder>tag</folder>
<folder>tags</folder>
</files>
@@ -72,6 +73,7 @@
</files>
<menu img="class:bookmark">COM_MOKOOG</menu>
<submenu>
<menu link="option=com_mokoog&amp;view=dashboard">COM_MOKOOG_SUBMENU_DASHBOARD</menu>
<menu link="option=com_mokoog&amp;view=tags">COM_MOKOOG_SUBMENU_TAGS</menu>
</submenu>
</administration>
@@ -21,5 +21,5 @@ class DisplayController extends BaseController
*
* @var string
*/
protected $default_view = 'tags';
protected $default_view = 'dashboard';
}
@@ -0,0 +1,159 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage com_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoOG\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
/**
* Read-only model providing OG tag coverage metrics for the dashboard.
*/
class DashboardModel extends BaseDatabaseModel
{
/**
* Overall coverage statistics for com_content articles.
*
* @return array{total:int, with_og:int, coverage:int, missing_title:int, missing_description:int, missing_image:int}
*/
public function getStats(): array
{
$db = $this->getDatabase();
$total = $this->countContent();
$withOg = $this->countDistinct();
$missingTitle = $this->countEmptyField('og_title');
$missingDesc = $this->countEmptyField('og_description');
$missingImage = $this->countEmptyField('og_image');
return [
'total' => $total,
'with_og' => $withOg,
'coverage' => $total > 0 ? (int) round(($withOg / $total) * 100) : 0,
'missing_title' => $missingTitle,
'missing_description' => $missingDesc,
'missing_image' => $missingImage,
];
}
/**
* Coverage broken down by content_type.
*
* @return array Rows of {content_type, total, with_title, with_image}
*/
public function getCoverageByType(): array
{
$db = $this->getDatabase();
$empty = $db->quote('');
$query = $db->getQuery(true)
->select([
$db->quoteName('content_type'),
'COUNT(*) AS ' . $db->quoteName('total'),
'SUM(CASE WHEN ' . $db->quoteName('og_title') . ' <> ' . $empty . ' THEN 1 ELSE 0 END) AS ' . $db->quoteName('with_title'),
'SUM(CASE WHEN ' . $db->quoteName('og_image') . ' <> ' . $empty . ' THEN 1 ELSE 0 END) AS ' . $db->quoteName('with_image'),
])
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('published') . ' = 1')
->group($db->quoteName('content_type'))
->order($db->quoteName('content_type') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Published articles that have no OG tag yet.
*
* @param int $limit Maximum rows to return
*
* @return array Rows of {id, title}
*/
public function getMissingArticles(int $limit = 20): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([$db->quoteName('c.id'), $db->quoteName('c.title')])
->from($db->quoteName('#__content', 'c'))
->leftJoin(
$db->quoteName('#__mokoog_tags', 't')
. ' ON ' . $db->quoteName('t.content_type') . ' = ' . $db->quote('com_content')
. ' AND ' . $db->quoteName('t.content_id') . ' = ' . $db->quoteName('c.id')
)
->where($db->quoteName('c.state') . ' = 1')
->where($db->quoteName('t.id') . ' IS NULL')
->order($db->quoteName('c.id') . ' DESC');
$db->setQuery($query, 0, max(1, $limit));
return $db->loadObjectList() ?: [];
}
/**
* Count published com_content articles.
*/
private function countContent(): int
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__content'))
->where($db->quoteName('state') . ' = 1')
);
return (int) $db->loadResult();
}
/**
* Count distinct articles that have at least one published OG tag.
*/
private function countDistinct(): int
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('COUNT(DISTINCT ' . $db->quoteName('content_id') . ')')
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('content_type') . ' = ' . $db->quote('com_content'))
->where($db->quoteName('published') . ' = 1')
);
return (int) $db->loadResult();
}
/**
* Count published OG tag rows whose given field is empty.
*
* @param string $field One of og_title, og_description, og_image
*/
private function countEmptyField(string $field): int
{
// Whitelist the column name — it is never user input here, but keep it strict.
if (!\in_array($field, ['og_title', 'og_description', 'og_image'], true)) {
return 0;
}
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('content_type') . ' = ' . $db->quote('com_content'))
->where($db->quoteName('published') . ' = 1')
->where($db->quoteName($field) . ' = ' . $db->quote(''))
);
return (int) $db->loadResult();
}
}
@@ -0,0 +1,76 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage com_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoOG\Administrator\View\Dashboard;
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
/**
* Dashboard view — OG tag coverage metrics.
*/
class HtmlView extends BaseHtmlView
{
/**
* Overall coverage stats.
*
* @var array
*/
protected $stats = [];
/**
* Coverage broken down by content_type.
*
* @var array
*/
protected $byType = [];
/**
* Published articles missing an OG tag.
*
* @var array
*/
protected $missing = [];
/**
* Display the view.
*
* @param string $tpl Template name
*
* @return void
*/
public function display($tpl = null): void
{
/** @var \Joomla\Component\MokoOG\Administrator\Model\DashboardModel $model */
$model = $this->getModel();
$this->stats = $model->getStats();
$this->byType = $model->getCoverageByType();
$this->missing = $model->getMissingArticles(20);
$this->addToolbar();
parent::display($tpl);
}
/**
* Add the toolbar.
*
* @return void
*/
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOOG_DASHBOARD_TITLE'), 'bookmark');
ToolbarHelper::preferences('com_mokoog');
}
}
@@ -0,0 +1,142 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage com_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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;
/** @var \Joomla\Component\MokoOG\Administrator\View\Dashboard\HtmlView $this */
$s = $this->stats;
$coverage = (int) ($s['coverage'] ?? 0);
$total = (int) ($s['total'] ?? 0);
$withOg = (int) ($s['with_og'] ?? 0);
$colorClass = $coverage >= 80 ? 'text-success' : ($coverage >= 50 ? 'text-warning' : 'text-danger');
$stroke = $coverage >= 80 ? '#198754' : ($coverage >= 50 ? '#ffc107' : '#dc3545');
$r = 54.0;
$circ = 2 * M_PI * $r;
$dash = round($circ * $coverage / 100, 2);
$gap = round($circ - $dash, 2);
?>
<div class="p-3">
<div class="row">
<!-- Coverage donut -->
<div class="col-lg-4 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<h4 class="card-title"><?php echo Text::_('COM_MOKOOG_COVERAGE_PERCENT'); ?></h4>
<svg width="160" height="160" viewBox="0 0 140 140" role="img"
aria-label="<?php echo $coverage; ?>%" class="<?php echo $colorClass; ?>">
<circle cx="70" cy="70" r="54" fill="none" stroke="#e9ecef" stroke-width="14"></circle>
<circle cx="70" cy="70" r="54" fill="none" stroke="<?php echo $stroke; ?>" stroke-width="14"
stroke-dasharray="<?php echo $dash; ?> <?php echo $gap; ?>"
stroke-linecap="round" transform="rotate(-90 70 70)"></circle>
<text x="70" y="80" text-anchor="middle" font-size="30" font-weight="bold" fill="currentColor"><?php echo $coverage; ?>%</text>
</svg>
<p class="mt-2 mb-0"><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_ARTICLES', $withOg, $total); ?></p>
</div>
</div>
</div>
<!-- Missing fields -->
<div class="col-lg-8 mb-3">
<div class="card h-100">
<div class="card-body">
<h4 class="card-title"><?php echo Text::_('COM_MOKOOG_DASHBOARD_FIELD_GAPS'); ?></h4>
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between">
<span><?php echo Text::_('COM_MOKOOG_HEADING_OG_TITLE'); ?></span>
<span class="badge bg-<?php echo ($s['missing_title'] ?? 0) ? 'warning text-dark' : 'success'; ?>"><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_TITLE', (int) ($s['missing_title'] ?? 0)); ?></span>
</li>
<li class="list-group-item d-flex justify-content-between">
<span><?php echo Text::_('COM_MOKOOG_FIELD_OG_DESCRIPTION'); ?></span>
<span class="badge bg-<?php echo ($s['missing_description'] ?? 0) ? 'warning text-dark' : 'success'; ?>"><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_DESC', (int) ($s['missing_description'] ?? 0)); ?></span>
</li>
<li class="list-group-item d-flex justify-content-between">
<span><?php echo Text::_('COM_MOKOOG_HEADING_IMAGE'); ?></span>
<span class="badge bg-<?php echo ($s['missing_image'] ?? 0) ? 'warning text-dark' : 'success'; ?>"><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_IMAGE', (int) ($s['missing_image'] ?? 0)); ?></span>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Coverage by content type -->
<div class="col-lg-6 mb-3">
<div class="card h-100">
<div class="card-body">
<h4 class="card-title"><?php echo Text::_('COM_MOKOOG_DASHBOARD_BY_TYPE'); ?></h4>
<?php if (empty($this->byType)) : ?>
<p class="text-muted mb-0"><?php echo Text::_('COM_MOKOOG_NO_TAGS'); ?></p>
<?php else : ?>
<table class="table table-sm mb-0">
<thead>
<tr>
<th><?php echo Text::_('COM_MOKOOG_HEADING_CONTENT_TYPE'); ?></th>
<th class="text-end"><?php echo Text::_('JGRID_HEADING_ID'); ?></th>
<th class="text-end"><?php echo Text::_('COM_MOKOOG_HEADING_OG_TITLE'); ?></th>
<th class="text-end"><?php echo Text::_('COM_MOKOOG_HEADING_IMAGE'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($this->byType as $row) : ?>
<tr>
<td><?php echo $this->escape($row->content_type); ?></td>
<td class="text-end"><?php echo (int) $row->total; ?></td>
<td class="text-end"><?php echo (int) $row->with_title; ?></td>
<td class="text-end"><?php echo (int) $row->with_image; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
</div>
<!-- Articles missing OG tags -->
<div class="col-lg-6 mb-3">
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h4 class="card-title mb-0"><?php echo Text::_('COM_MOKOOG_DASHBOARD_MISSING'); ?></h4>
<a class="btn btn-sm btn-primary" href="<?php echo Route::_('index.php?option=com_mokoog&view=tags'); ?>">
<span class="icon-refresh" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOOG_TOOLBAR_BATCH_GENERATE'); ?>
</a>
</div>
<?php if (empty($this->missing)) : ?>
<p class="text-success mb-0">
<span class="icon-check" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOOG_DASHBOARD_ALL_COVERED'); ?>
</p>
<?php else : ?>
<ul class="list-group list-group-flush">
<?php foreach ($this->missing as $article) : ?>
<li class="list-group-item py-1">
<a href="<?php echo Route::_('index.php?option=com_content&task=article.edit&id=' . (int) $article->id); ?>">
<?php echo $this->escape($article->title); ?>
</a>
</li>
<?php endforeach; ?>
</ul>
<small class="text-muted d-block mt-2"><?php echo Text::_('COM_MOKOOG_DASHBOARD_MISSING_NOTE'); ?></small>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
@@ -1,58 +0,0 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage com_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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\Factory;
use Joomla\CMS\Language\Text;
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
// Total published articles
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__content')->where('state = 1'));
$totalArticles = (int) $db->loadResult();
// Articles with OG tags
$db->setQuery($db->getQuery(true)->select('COUNT(DISTINCT content_id)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where('published = 1'));
$articlesWithOg = (int) $db->loadResult();
// Articles missing OG data fields
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_title = ''")->where('published = 1'));
$missingTitle = (int) $db->loadResult();
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_description = ''")->where('published = 1'));
$missingDesc = (int) $db->loadResult();
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_image = ''")->where('published = 1'));
$missingImage = (int) $db->loadResult();
$coverage = $totalArticles > 0 ? round(($articlesWithOg / $totalArticles) * 100) : 0;
?>
<div class="mokoog-coverage card mb-3">
<div class="card-body">
<h4 class="card-title"><?php echo Text::_('COM_MOKOOG_COVERAGE_TITLE'); ?></h4>
<div class="row">
<div class="col-md-3 text-center">
<div class="display-4 <?php echo $coverage >= 80 ? 'text-success' : ($coverage >= 50 ? 'text-warning' : 'text-danger'); ?>">
<?php echo $coverage; ?>%
</div>
<small class="text-muted"><?php echo Text::_('COM_MOKOOG_COVERAGE_PERCENT'); ?></small>
</div>
<div class="col-md-9">
<ul class="list-unstyled">
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_ARTICLES', $articlesWithOg, $totalArticles); ?></li>
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_TITLE', $missingTitle); ?></li>
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_DESC', $missingDesc); ?></li>
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_IMAGE', $missingImage); ?></li>
</ul>
</div>
</div>
</div>
</div>
@@ -21,7 +21,6 @@ use Joomla\CMS\Session\Session;
$token = Session::getFormToken();
?>
<?php include __DIR__ . '/coverage.php'; ?>
<form action="<?php echo Route::_('index.php?option=com_mokoog&view=tags'); ?>" method="post" name="adminForm" id="adminForm">
<div class="row">
<div class="col-md-12">