Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 72 additions & 9 deletions javascripts/discourse/initializers/init-nav-bar-additions.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,86 @@
import { withPluginApi } from "discourse/lib/plugin-api";
import I18n from "I18n";

/**
* Resolve a field value from a nav link, checking for a locale-specific
* translation first. Falls back to the base field value.
*/
function getLocalizedField(link, fieldName) {
const translations = link.translations;
if (!translations || translations.length === 0) {
return link[fieldName];
}

const locale = I18n.currentLocale();

// Try exact match first (e.g. "pt_BR"), then prefix match (e.g. "pt")
const match =
translations.find((t) => t.locale === locale) ||
translations.find((t) => locale.startsWith(t.locale));

return (match && match[fieldName]) || link[fieldName];
}

// Built-in Discourse filter routes that have associated topic counts
const COUNTED_FILTERS = {
"/new": "new",
"/unread": "unread",
};

function getCountForFilter(topicTrackingState, filterType) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Topic counts look reasonable. Would be good to add a test for these as @Grubba27 noted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Could you clarify what kind of tests you'd like?

@pmusaraj pmusaraj Jul 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A qunit or system test that uses the theme component, and validates that the New / Unread count is shown in the item.

if (!topicTrackingState) {
return 0;
}

if (filterType === "new") {
return topicTrackingState.countNew?.() ?? topicTrackingState.newCount ?? 0;
}

if (filterType === "unread") {
return (
topicTrackingState.countUnread?.() ?? topicTrackingState.unreadCount ?? 0
);
}

return 0;
}

export default {
name: "nav-links-component",

initialize() {
withPluginApi((api) => {
for (const {
display_name: displayName,
title,
url,
} of settings.nav_links) {
api.addNavigationBarItem({
const topicTrackingState = api.container.lookup(
"service:topic-tracking-state"
);

for (const link of settings.nav_links) {
const { display_name: displayName, url } = link;
const localizedDisplayName = getLocalizedField(link, "display_name");
const localizedTitle = getLocalizedField(link, "title");
const filterType = COUNTED_FILTERS[url];
const itemConfig = {
name: `custom_${displayName.replace(/\s+/g, "-").toLowerCase()}`,
displayName,
title,
title: localizedTitle,
href: url,
forceActive: (category, args, router) =>
router.currentURL?.split("?")[0] === url,
});
};

if (settings.Show_counts && filterType && topicTrackingState) {
// Use a function for displayName so Discourse re-evaluates it
// reactively, picking up count changes from TopicTrackingState
itemConfig.displayName = () => {
const count = getCountForFilter(topicTrackingState, filterType);
return count > 0
? `${localizedDisplayName} (${count})`
: localizedDisplayName;
};
} else {
itemConfig.displayName = localizedDisplayName;
}

api.addNavigationBarItem(itemConfig);
}
});
},
Expand Down
13 changes: 13 additions & 0 deletions locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,16 @@ en:
url:
label: URL
description: The link (e.g., /latest or https://discourse.org)
translations:
label: Translations
description: Per-language overrides for display name and title
properties:
locale:
label: Language
description: Select the language for this translation
display_name:
label: Display name
description: Translated display name for this language
title:
label: Title
description: Translated title (hover text) for this language
71 changes: 71 additions & 0 deletions settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,73 @@ nav_links:
min_length: 1
max_length: 2048
url: true
translations:
type: objects
schema:
name: translation
identifier: locale
properties:
locale:
type: enum
required: true
choices:
- ar
- be
- bg
- bs_BA
- ca
- cs
- da
- de
- el
- en
- en_GB
- es
- et
- fa_IR
- fi
- fr
- gl
- he
- hr
- hu
- hy
- id
- it
- ja
- ko
- lt
- lv
- nb_NO
- nl
- pl_PL
- pt
- pt_BR
- ro
- ru
- sk
- sl
- sq
- sr
- sv
- sw
- te
- th
- tr_TR
- ug
- uk
- ur
- vi
- zh_CN
- zh_TW
display_name:
type: string
validations:
max_length: 100
title:
type: string
validations:
max_length: 1000
Hide_dropdowns:
default: false
description:
Expand All @@ -40,3 +107,7 @@ Hide_default_links:
default: false
description:
en: "Hide the default links on both mobile and desktop."
Show_counts:
default: false
description:
en: "Show topic counts for links pointing to /new or /unread (e.g. New (24))."
52 changes: 52 additions & 0 deletions test/javascripts/acceptance/nav-bar-additions-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers";
import { visit } from "@ember/test-helpers";
import { test } from "qunit";

acceptance("Custom Top Navigation Links | Topic Counts", function (needs) {
needs.user();

let originalNavLinks;
let originalShowCounts;
Comment on lines +8 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Is it really necessary to have these variables? If you remove them, would it work?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

They're there to reset settings back to its original state after the test runs, since settings is a shared object that isn't automatically reset between tests. Without the afterEach restore, my mutations to nav_links and Show_counts would leak into other tests that run later, potentially causing unrelated failures that are hard to trace back. The test itself would still pass without them, but it's insurance against test pollution.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

But the honest answer is: it depends on how settings is initialized/reset in this specific test environment, and I haven't confirmed that.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

that is fine. All good

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

is there anything else I can do?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think all is good, you may merge it!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I can do the merging? Do I even have permission for that?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

hmm, I think not. It is fine, I'll merge it. Thanks a lot for the contribution!


needs.hooks.beforeEach(function () {
originalNavLinks = settings.nav_links;
originalShowCounts = settings.Show_counts;

settings.nav_links = [
{ display_name: "New Topics", title: "new topics", url: "/new" },
{ display_name: "Unread Topics", title: "unread topics", url: "/unread" },
];
settings.Show_counts = true;
});

needs.hooks.afterEach(function () {
settings.nav_links = originalNavLinks;
settings.Show_counts = originalShowCounts;
});

test("shows new and unread counts in navigation items", async function (assert) {
const topicTrackingState = this.owner.lookup(
"service:topic-tracking-state"
);
topicTrackingState.countNew = () => 42;
topicTrackingState.countUnread = () => 17;

await visit("/latest");

assert.ok(
exists(".nav-item_custom_new-topics"),
"custom navigation item for new topics is rendered"
);
assert
.dom(".nav-item_custom_new-topics a")
.hasText("New Topics (42)", "it shows the custom new topics count");

assert.ok(
exists(".nav-item_custom_unread-topics"),
"custom navigation item for unread topics is rendered"
);
assert
.dom(".nav-item_custom_unread-topics a")
.hasText("Unread Topics (17)", "it shows the custom unread topics count");
});
});