Skip to content

Clean-room system menu administration#1666

Merged
mambax7 merged 58 commits intoXOOPS:masterfrom
mambax7:feature/system-menu-cleanroom
Mar 20, 2026
Merged

Clean-room system menu administration#1666
mambax7 merged 58 commits intoXOOPS:masterfrom
mambax7:feature/system-menu-cleanroom

Conversation

@mambax7
Copy link
Collaborator

@mambax7 mambax7 commented Mar 19, 2026

Summary

Clean-room implementation of the system menu admin UI, replacing the
disputed PR #1665 / Core27 PR #20 code with a fresh implementation
that preserves the same user-visible functionality without reusing
the original source.

Test plan

  • Category list: accordion expand/collapse, drag reorder, toggle active
  • Items in accordion: nested sortable reorder, toggle, add/edit/delete
  • Add/edit forms for categories and items render correctly
  • Back from edit returns to list with correct accordion open
  • AJAX toggle works repeatedly without token errors
  • Delete confirmation works for both categories and items
  • Frontend nav menus render correctly after admin changes

Summary by CodeRabbit

  • New Features

    • Admin Menu System: menu manager with categories/items, CRUD UI, drag‑and‑drop ordering, enable/disable toggles, and new site setting to enable menus; expanded English admin strings and notifications.
  • Improvements

    • Front‑end nav revamped for Bootstrap 4/5 with responsive nested submenus, per‑category anchors, refined CSS/JS, multi‑list drag/drop, hash‑driven auto‑expand, and improved unread count behavior.
  • Security / Bug Fixes

    • Blocks unsafe javascript: URLs, stronger affix sanitization, CSRF‑protected admin/AJAX actions, safer update/migration flow.
  • Tests

    • New unit and regression tests for menu objects, tree logic, migration and admin flows.

mambax7 and others added 30 commits March 18, 2026 22:56
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated 1 comment.

Comment on lines +117 to +121
$this->assertLessThan(
$stripPos,
$inboxPos,
'xoInboxCount expansion must happen BEFORE strip_tags sanitization'
);
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertion order here is reversed: to enforce “xoInboxCount expansion must happen BEFORE strip_tags”, the test should assert $inboxPos < $stripPos (or equivalent). As written, it expects strip_tags to occur first and will fail with the current theme.php implementation (both tokens even appear on the same comment line). Consider also scoping the search to the renderMenuAffix() method body to avoid brittle first-occurrence matches elsewhere in theme.php.

Copilot uses AI. Check for mistakes.
  - Category title is now a normal navigable link (no data-toggle/data-bs-toggle)
  - Separate dropdown-toggle-split caret opens the submenu
  - Fixes: categories with both a URL and children are now clickable
  - Applied to xswatch4 (Bootstrap 4) and xbootstrap5 (Bootstrap 5)
  - xbootstrap theme unchanged (uses hardcoded demo links, not dynamic menus)
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated 4 comments.

Comment on lines +411 to +419
redirect_header(MENUS_ADMIN_URL, 3, 'Failed to delete item: ' . $item->getVar('items_id'));
}
xoops_confirm([
'surdel' => true,
'item_id' => $item_id,
'category_id' => $objCid,
'op' => 'delitem'
], Request::getString('REQUEST_URI', '', 'SERVER'), sprintf(_AM_SYSTEM_MENUS_SUREDELITEM, htmlspecialchars((string)$obj->getVar('items_title'), ENT_QUOTES, 'UTF-8')) . $items);
}

// Delete category permissions and category
$permHelper->deletePermissionForItem('menus_category_view', $catId);
if (!menus_cat_handler()->delete($cat)) {
redirect_header(MENUS_ADMIN_URL, 3, 'Failed to delete category');
}
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These redirects use hard-coded English strings ('Failed to delete item/category...'), which won't be translated and are inconsistent with the rest of the menus admin UI strings. Prefer language constants (and optionally interpolate the item/category id) so admin-facing errors are localized.

Copilot uses AI. Check for mistakes.
Comment on lines +944 to +949
$db = \XoopsDatabaseFactory::getDatabaseConnection();
foreach (['menuscategory', 'menusitems'] as $tableName) {
$result = $db->query('SHOW TABLES LIKE ' . $db->quote($db->prefix($tableName)));
if (!$db->isResultSet($result) || !($result instanceof \mysqli_result) || 0 === $db->getRowsNum($result)) {
return false;
}
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasMenuTables() uses SHOW TABLES LIKE with an unescaped pattern. Since _ and % are wildcards in LIKE, a prefix such as xoops_menuscategory can match unintended tables (false positives). Escape _/% (or query INFORMATION_SCHEMA) before checking existence.

Copilot uses AI. Check for mistakes.
Comment on lines +1086 to +1101
private function normalizeMenuUrl(string $url): string
{
$url = trim($url);
if ($url === '') {
return '#';
}

if (preg_match('/^\s*javascript\s*:/i', $url)) {
return '#';
}

if (preg_match('~^(https?://|mailto:|tel:|ftp://|/|#)~i', $url)) {
return $url;
}

return XOOPS_URL . '/' . ltrim($url, '/');
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalizeMenuUrl() blocks only a literal javascript: prefix. A stored value like java&#115;cript:... (or other entity/whitespace obfuscations) can bypass this check and still execute once the template escapes/HTML-decodes the attribute. Decode entities first and enforce an allowlist of schemes (or reuse the admin-side URL sanitizer).

Copilot uses AI. Check for mistakes.
$cacheTime = 'xo_inbox_count_time_' . $uid;

$currentScript = basename($_SERVER['SCRIPT_NAME'] ?? '');
if ($currentScript === 'viewpmsg.php' || $currentScript === 'readpmsg.php') {
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getInboxUnreadCount() only invalidates the cached count on viewpmsg.php and readpmsg.php. The existing xoInboxCount plugin also clears cache on pmlite.php, so counts can remain stale when messages are read in that flow.

Suggested change
if ($currentScript === 'viewpmsg.php' || $currentScript === 'readpmsg.php') {
if ($currentScript === 'viewpmsg.php'
|| $currentScript === 'readpmsg.php'
|| $currentScript === 'pmlite.php'
) {

Copilot uses AI. Check for mistakes.
… hover highlight

  xswatch4:
  - Single link with dropdown-toggle, opens on hover via CSS (matches xbootstrap5 behavior)
  - Added dropdown-submenu CSS to my_xoops.css (right-pointing arrows for nested items)
  - Removed dropdown-toggle from submenu items (CSS ::after handles the caret)

  xbootstrap5:
  - Split link + toggle with div wrapper (link navigable, caret opens dropdown)
  - Removed dropdown-toggle from submenu items (multilevelmenu.css handles arrows)
  - Added blue hover highlight on dropdown items (matches xswatch4)
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 1 comment.

// Absolute URLs must use an allowed scheme
if (preg_match('#^([a-zA-Z][a-zA-Z0-9+.-]*):#', $url, $m)) {
$scheme = strtolower($m[1]);
if (!in_array($scheme, ['http', 'https', 'ftp', 'mailto'], true)) {
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allowed URL schemes here differ from the front-end normalizer in class/theme.php (which treats tel: as allowed). As-is, admins won’t be able to save tel: links even though the renderer supports them. Consider aligning the allowed-scheme list between admin sanitization and theme normalization (either allow tel here or remove it from the renderer).

Suggested change
if (!in_array($scheme, ['http', 'https', 'ftp', 'mailto'], true)) {
if (!in_array($scheme, ['http', 'https', 'ftp', 'mailto', 'tel'], true)) {

Copilot uses AI. Check for mistakes.
…igates

  - xswatch4: added JS that intercepts first tap on dropdown links to show
    the submenu, then allows the second tap to follow the href
  - Fixes touch/mobile regression where hover-only CSS made child items unreachable
  - Desktop hover behavior preserved unchanged
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 2 comments.

Comment on lines +8 to +10
<a class="dropdown-item"
href="<{if $subItem.url|default:'' neq ''}><{$subItem.url|escape}><{else}>#<{/if}>"
target="<{$subItem.target}>"
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nested submenu links with children are rendered without the dropdown-toggle class. The shared multilevelmenu.js click handler targets .dropdown-submenu > .dropdown-toggle, so nested submenus won’t be toggleable on touch/keyboard for this theme unless the class (and typically aria-expanded) is present.

Suggested change
<a class="dropdown-item"
href="<{if $subItem.url|default:'' neq ''}><{$subItem.url|escape}><{else}>#<{/if}>"
target="<{$subItem.target}>"
<a class="dropdown-item dropdown-toggle"
href="<{if $subItem.url|default:'' neq ''}><{$subItem.url|escape}><{else}>#<{/if}>"
target="<{$subItem.target}>"
aria-haspopup="true"
aria-expanded="false"

Copilot uses AI. Check for mistakes.
string $permName,
int $itemId,
array $groupIds
): void {
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PSR-12 brace placement: the opening brace for system_menu_seed_permissions() should be on the next line after the function signature (currently ): void {).

Suggested change
): void {
): void
{

Copilot uses AI. Check for mistakes.
  - Touch script now intercepts taps on both top-level dropdown-toggle and
    nested .dropdown-submenu > a elements
  - First tap opens the submenu at any level, second tap follows the href
  - Uses :scope > .dropdown-menu to target only the direct child menu
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 1 comment.

var opened = null;
document.addEventListener('click', function(e) {
var link = e.target.closest('.xo-hover-dropdown > a.dropdown-toggle, .dropdown-submenu > a');
if (!link) { opened = null; return; }
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The touch dropdown handler clears opened when clicking outside a toggle (if (!link) { opened = null; return; }) but does not remove the .show classes from the currently opened menu. This can leave dropdowns stuck open on touch devices. Consider closing the previously opened menu when clicks occur outside the dropdown/toggles (or delegate to Bootstrap’s dropdown events instead of manual class toggling).

Suggested change
if (!link) { opened = null; return; }
if (!link) {
if (opened) {
opened.classList.remove('show');
var prev = opened.querySelector('.dropdown-menu');
if (prev) {
prev.classList.remove('show');
}
}
opened = null;
return;
}

Copilot uses AI. Check for mistakes.
  Fix touch dropdown: track open state per depth, never close ancestors

  - Replace single global `opened` with `openedByDepth[depth]` map
  - Tapping a nested submenu closes only peer menus at same depth + deeper levels
  - Ancestor menus stay open so child submenus remain visible and reachable
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated no new comments.

  - Outside-click handler now removes .show from all tracked open nodes before clearing the map
  - Fixes dropdowns getting stuck open on touch devices after tapping outside
@sonarqubecloud
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
6.9% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 2 comments.

string $permName,
int $itemId,
array $groupIds
): void {
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

system_menu_seed_permissions() uses ): void { with the opening brace on the same line as the signature, while the surrounding file consistently places function opening braces on the next line (PSR-12). Update the function formatting to match the rest of the file for consistency.

Suggested change
): void {
): void
{

Copilot uses AI. Check for mistakes.
Comment on lines +146 to +149
li.classList.add('show');
var menu = li.querySelector(':scope > .dropdown-menu');
if (menu) menu.classList.add('show');
openedByDepth[depth] = li;
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The xswatch4 menu script toggles Bootstrap's show class on nested submenus, but the shared multilevelmenu.css added by Theme::loadMenus() hides .dropdown-submenu > .dropdown-menu by default and only shows it via .dropdown-submenu.is-open (or hover). To avoid submenu toggles failing depending on CSS load order, align this script with the shared convention (toggle is-open / aria-expanded) or update the shared CSS to also respect .show for nested submenus.

Copilot uses AI. Check for mistakes.
@mambax7 mambax7 merged commit 71d8a98 into XOOPS:master Mar 20, 2026
15 of 17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants