Add a support inbox icon to the topbar (sow-nodebb-plugin-support#10)
Mirrors the existing unread-drawer pattern: a new dropdown next to the notification bell, gated on templateData.support.visible (injected by the support plugin's filter:middleware.renderHeader hook, on every page render, for viewers with any support access at all). Opening it fetches GET /plugins/support/notifications via the api module, renders up to 10 entries by textContent (never innerHTML), and marks everything read via POST .../notifications/read, clearing the badge client-side. Also present in the mobile burger drawer, same gating. ponytail: no live socket push for this badge (core's own unread/notification counts get one); it only catches up on the next full page load after the box is opened. Noted in client.js as the upgrade path if that gap matters. Verified live against sow-nodebb-plugin-support's dev NodeBB stack (theme + plugin both mounted): icon hidden for a user with no support access, badge count and dropdown contents correct for a reviewer, badge clears on open. Full plain-node test suite passes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -208,6 +208,59 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSupportBadge() {
|
||||||
|
toArray(document.querySelectorAll('[component="support/count"]')).forEach((badge) => {
|
||||||
|
badge.textContent = '0';
|
||||||
|
badge.classList.add('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSupportMenu(menuEl, entries) {
|
||||||
|
if (!menuEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = (window.config && window.config.relative_path) || '';
|
||||||
|
|
||||||
|
// remove the loading row and any previously injected entries; the
|
||||||
|
// empty-state and footer/divider rows are server-rendered and stay put
|
||||||
|
const loading = menuEl.querySelector('[data-wg-support-loading]');
|
||||||
|
if (loading) {
|
||||||
|
loading.remove();
|
||||||
|
}
|
||||||
|
toArray(menuEl.querySelectorAll('.wg-support-item')).forEach((li) => {
|
||||||
|
li.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyEl = menuEl.querySelector('[data-wg-support-empty]');
|
||||||
|
const footerEl = menuEl.querySelector('[data-wg-support-footer]');
|
||||||
|
|
||||||
|
const list = (entries || []).slice(0, 10);
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.classList.toggle('hidden', list.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.forEach((entry) => {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.className = 'dropdown-item wg-support-item text-truncate';
|
||||||
|
link.href = relativePath + entry.path;
|
||||||
|
link.textContent = entry.bodyShort;
|
||||||
|
if (!entry.read) {
|
||||||
|
link.classList.add('wg-support-item--unread');
|
||||||
|
}
|
||||||
|
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'wg-support-item';
|
||||||
|
li.appendChild(link);
|
||||||
|
|
||||||
|
if (footerEl) {
|
||||||
|
menuEl.insertBefore(li, footerEl);
|
||||||
|
} else {
|
||||||
|
menuEl.appendChild(li);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getWestgateTopbar() {
|
function getWestgateTopbar() {
|
||||||
if (!document || typeof document.querySelector !== 'function') {
|
if (!document || typeof document.querySelector !== 'function') {
|
||||||
return null;
|
return null;
|
||||||
@@ -293,12 +346,36 @@
|
|||||||
renderUnreadMenu(menuEl, []);
|
renderUnreadMenu(menuEl, []);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$(document)
|
||||||
|
.off('show.bs.dropdown.westgateSupport')
|
||||||
|
.on('show.bs.dropdown.westgateSupport', '.wg-topbar [component="support"]', function () {
|
||||||
|
const menuEl = this.querySelector('[data-wg-support-menu]');
|
||||||
|
require(['api'], function (api) {
|
||||||
|
api.get('/plugins/support/notifications', {}, function (err, data) {
|
||||||
|
if (err) {
|
||||||
|
renderSupportMenu(menuEl, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderSupportMenu(menuEl, data && data.notifications);
|
||||||
|
// ponytail: unlike core's unread/notification badges, this one
|
||||||
|
// gets no live socket push, so it only catches up on the next
|
||||||
|
// full page load after the box is opened here. Add a socket
|
||||||
|
// event if that gap matters.
|
||||||
|
if (data && data.unreadCount) {
|
||||||
|
api.post('/plugins/support/notifications/read', {}, function () {});
|
||||||
|
clearSupportBadge();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.westgateTheme = window.westgateTheme || {};
|
window.westgateTheme = window.westgateTheme || {};
|
||||||
window.westgateTheme.wrapWikiTables = wrapWestgateWikiTables;
|
window.westgateTheme.wrapWikiTables = wrapWestgateWikiTables;
|
||||||
window.westgateTheme.initTopbar = initWestgateTopbar;
|
window.westgateTheme.initTopbar = initWestgateTopbar;
|
||||||
window.westgateTheme.renderUnreadMenu = renderUnreadMenu;
|
window.westgateTheme.renderUnreadMenu = renderUnreadMenu;
|
||||||
|
window.westgateTheme.renderSupportMenu = renderSupportMenu;
|
||||||
|
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
wrapWestgateWikiTables(document);
|
wrapWestgateWikiTables(document);
|
||||||
|
|||||||
@@ -173,7 +173,8 @@ $wg-topbar-panel-bg: rgba(21, 17, 26, 0.98);
|
|||||||
.wg-topbar .notifications-dropdown,
|
.wg-topbar .notifications-dropdown,
|
||||||
.wg-topbar .chats-dropdown,
|
.wg-topbar .chats-dropdown,
|
||||||
.wg-topbar .drafts-dropdown,
|
.wg-topbar .drafts-dropdown,
|
||||||
.wg-topbar .unread-dropdown {
|
.wg-topbar .unread-dropdown,
|
||||||
|
.wg-topbar .support-dropdown {
|
||||||
width: min(24rem, calc(100vw - 1.5rem));
|
width: min(24rem, calc(100vw - 1.5rem));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +190,10 @@ $wg-topbar-panel-bg: rgba(21, 17, 26, 0.98);
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wg-topbar .wg-support-item--unread {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.wg-topbar .dropdown-item,
|
.wg-topbar .dropdown-item,
|
||||||
.wg-topbar .btn.btn-ghost,
|
.wg-topbar .btn.btn-ghost,
|
||||||
.wg-topbar .btn.btn-light {
|
.wg-topbar .btn.btn-light {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<a class="nav-link dropdown-toggle d-flex gap-2 align-items-center" href="#" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" aria-label="Support inbox">
|
||||||
|
<i class="fa fa-fw fa-life-ring" aria-hidden="true"></i>
|
||||||
|
<span component="support/count" class="badge rounded-1 bg-primary {{{ if !support.unreadCount }}}hidden{{{ end }}}">{support.unreadCount}</span>
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu wg-topbar__dropdown support-dropdown p-1 shadow" role="menu" data-wg-support-menu>
|
||||||
|
<li data-wg-support-loading class="dropdown-item disabled"><i class="fa fa-fw fa-spinner fa-spin" aria-hidden="true"></i> Support inbox</li>
|
||||||
|
<li data-wg-support-empty class="hidden"><span class="dropdown-item disabled">Nothing new</span></li>
|
||||||
|
<li data-wg-support-footer><hr class="dropdown-divider"><a class="dropdown-item wg-support-item--all" href="{relative_path}/support">Go to Support</a></li>
|
||||||
|
</ul>
|
||||||
@@ -45,6 +45,12 @@
|
|||||||
<!-- IMPORT partials/sidebar/notifications.tpl -->
|
<!-- IMPORT partials/sidebar/notifications.tpl -->
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
{{{ if support.visible }}}
|
||||||
|
<li component="support" class="nav-item support dropdown" title="Support inbox">
|
||||||
|
<!-- IMPORT partials/header/support-drawer.tpl -->
|
||||||
|
</li>
|
||||||
|
{{{ end }}}
|
||||||
|
|
||||||
{{{ if canChat }}}
|
{{{ if canChat }}}
|
||||||
<li class="nav-item chats dropdown" title="[[global:header.chats]]">
|
<li class="nav-item chats dropdown" title="[[global:header.chats]]">
|
||||||
<!-- IMPORT partials/sidebar/chats.tpl -->
|
<!-- IMPORT partials/sidebar/chats.tpl -->
|
||||||
@@ -115,6 +121,9 @@
|
|||||||
<div class="wg-topbar__drawer-actions">
|
<div class="wg-topbar__drawer-actions">
|
||||||
<a href="{relative_path}/unread"><i class="fa fa-fw fa-inbox" aria-hidden="true"></i><span>[[unread:title]]</span><span component="unread/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.topic }}}hidden{{{ end }}}">{unreadCount.topic}</span></a>
|
<a href="{relative_path}/unread"><i class="fa fa-fw fa-inbox" aria-hidden="true"></i><span>[[unread:title]]</span><span component="unread/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.topic }}}hidden{{{ end }}}">{unreadCount.topic}</span></a>
|
||||||
<a href="{relative_path}/notifications"><i class="fa fa-fw fa-bell" aria-hidden="true"></i><span>[[global:header.notifications]]</span><span component="notifications/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.notification }}}hidden{{{ end }}}">{unreadCount.notification}</span></a>
|
<a href="{relative_path}/notifications"><i class="fa fa-fw fa-bell" aria-hidden="true"></i><span>[[global:header.notifications]]</span><span component="notifications/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.notification }}}hidden{{{ end }}}">{unreadCount.notification}</span></a>
|
||||||
|
{{{ if support.visible }}}
|
||||||
|
<a href="{relative_path}/support"><i class="fa fa-fw fa-life-ring" aria-hidden="true"></i><span>Support inbox</span><span component="support/count" class="badge rounded-1 bg-primary {{{ if !support.unreadCount }}}hidden{{{ end }}}">{support.unreadCount}</span></a>
|
||||||
|
{{{ end }}}
|
||||||
{{{ if canChat }}}
|
{{{ if canChat }}}
|
||||||
<a href="{relative_path}/user/{user.userslug}/chats{{{ if user.lastRoomId }}}/{user.lastRoomId}{{{ end }}}"><i class="fa fa-fw fa-comment" aria-hidden="true"></i><span>[[global:header.chats]]</span><span component="chat/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.chat }}}hidden{{{ end }}}">{unreadCount.chat}</span></a>
|
<a href="{relative_path}/user/{user.userslug}/chats{{{ if user.lastRoomId }}}/{user.lastRoomId}{{{ end }}}"><i class="fa fa-fw fa-comment" aria-hidden="true"></i><span>[[global:header.chats]]</span><span component="chat/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.chat }}}hidden{{{ end }}}">{unreadCount.chat}</span></a>
|
||||||
{{{ end }}}
|
{{{ end }}}
|
||||||
|
|||||||
@@ -180,3 +180,39 @@ assertIncludes(
|
|||||||
'.unread-dropdown',
|
'.unread-dropdown',
|
||||||
'Topbar styles should cover the unread dropdown'
|
'Topbar styles should cover the unread dropdown'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const supportDrawer = read('templates/partials/header/support-drawer.tpl');
|
||||||
|
assertIncludes(
|
||||||
|
topbar,
|
||||||
|
'<!-- IMPORT partials/header/support-drawer.tpl -->',
|
||||||
|
'Topbar should mount the support inbox drawer'
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
topbar,
|
||||||
|
'{{{ if support.visible }}}',
|
||||||
|
'Support inbox drawer should be gated on the viewer having any support access'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
topbar.indexOf('partials/sidebar/notifications.tpl') < topbar.indexOf('partials/header/support-drawer.tpl'),
|
||||||
|
'Support inbox should sit next to (after) the notifications bell'
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
supportDrawer,
|
||||||
|
'component="support/count"',
|
||||||
|
'Support toggle should carry its own unread count badge'
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
supportDrawer,
|
||||||
|
'data-wg-support-menu',
|
||||||
|
'Support dropdown menu should expose the JS mount hook'
|
||||||
|
);
|
||||||
|
assertMatches(
|
||||||
|
topbar,
|
||||||
|
/href="\{relative_path\}\/support"[^>]*>[\s\S]*?component="support\/count"/,
|
||||||
|
'Mobile drawer actions should include a Support inbox link with count badge'
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
stylesheet,
|
||||||
|
'.support-dropdown',
|
||||||
|
'Topbar styles should cover the support inbox dropdown'
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const client = fs.readFileSync(path.join(__dirname, '..', 'public', 'client.js'), 'utf8');
|
||||||
|
|
||||||
|
assert(
|
||||||
|
client.includes("'show.bs.dropdown.westgateSupport'"),
|
||||||
|
'client.js should fetch the support inbox when its dropdown opens'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
client.includes("'/plugins/support/notifications'"),
|
||||||
|
'client.js should load the support inbox from the plugin API via the api module (for CSRF-safe writes)'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
client.includes("'/plugins/support/notifications/read'"),
|
||||||
|
'client.js should mark the support inbox read when it is opened with unread entries'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
client.includes('renderSupportMenu'),
|
||||||
|
'client.js should expose the support inbox menu renderer'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
/textContent/.test(client) && !/innerHTML\s*=[^=]*bodyShort/.test(client),
|
||||||
|
'Support inbox entry text must be inserted via textContent, never innerHTML'
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user