diff --git a/shell/assets/translations/en-us.yaml b/shell/assets/translations/en-us.yaml
index 6b65dfa6f3..7dfcf0f18b 100644
--- a/shell/assets/translations/en-us.yaml
+++ b/shell/assets/translations/en-us.yaml
@@ -1074,10 +1074,8 @@ catalog:
header: Charts
noCharts:
title: No charts to show
- messagePart1: Tips: undo the last filter you applied or
- messagePart2: clear all filters
- messagePart3: ', and ensure you have the right repositories in place.'
- messagePart4: 'Want to learn more about Helm Charts and Apps? Read our documentation .'
+ message: 'Tips: undo the last filter you applied or clear all filters, and ensure you have the right repositories in place.'
+ docsMessage: 'Want to learn more about Helm Charts and Apps? Read our documentation.'
noWindows: Your repos do not contain any charts capable of being deployed on a cluster with Windows nodes.
noWindowsAndLinux: Your repos do not contain any charts capable of being deployed on a cluster with both Windows and Linux worker nodes.
operatingSystems:
diff --git a/shell/components/RichTranslation.vue b/shell/components/RichTranslation.vue
new file mode 100644
index 0000000000..1f8984f42a
--- /dev/null
+++ b/shell/components/RichTranslation.vue
@@ -0,0 +1,106 @@
+
diff --git a/shell/components/__tests__/RichTranslation.test.ts b/shell/components/__tests__/RichTranslation.test.ts
new file mode 100644
index 0000000000..c495b487fa
--- /dev/null
+++ b/shell/components/__tests__/RichTranslation.test.ts
@@ -0,0 +1,115 @@
+import { mount } from '@vue/test-utils';
+import RichTranslation from '../RichTranslation.vue';
+import { createStore } from 'vuex';
+import { h } from 'vue';
+
+// Mock the i18n store getter
+const mockI18nStore = createStore({
+ getters: {
+ 'i18n/t': () => (key: string, args: any, noMarkup: boolean) => {
+ const translations: Record = {
+ 'test.simple': 'Hello World',
+ 'test.html': 'This is bold and italic.',
+ 'test.custom': 'This has a link and .',
+ 'test.mixed': 'Text before content1 text in middle text after.',
+ 'test.noString': 123,
+ };
+
+ return translations[key] || key;
+ },
+ },
+});
+
+describe('richTranslation', () => {
+ it('renders a simple translation correctly', () => {
+ const wrapper = mount(RichTranslation, {
+ props: { k: 'test.simple' },
+ global: { plugins: [mockI18nStore] },
+ });
+
+ expect(wrapper.text()).toBe('Hello World');
+ expect(wrapper.html()).toContain('Hello World');
+ });
+
+ it('renders HTML tags correctly', () => {
+ const wrapper = mount(RichTranslation, {
+ props: { k: 'test.html' },
+ global: { plugins: [mockI18nStore] },
+ });
+
+ expect(wrapper.html()).toContain('This is bold and italic.');
+ expect(wrapper.find('b').exists()).toBe(true);
+ expect(wrapper.find('i').exists()).toBe(true);
+ });
+
+ it('renders custom components via slots (enclosing tag)', () => {
+ const wrapper = mount(RichTranslation, {
+ props: { k: 'test.custom' },
+ slots: { customLink: ({ content }: { content: string }) => h('a', { href: '/test' }, content) },
+ global: { plugins: [mockI18nStore] },
+ });
+
+ expect(wrapper.html()).toContain('link');
+ expect(wrapper.find('a').text()).toBe('link');
+ });
+
+ it('renders custom components via slots (self-closing tag)', () => {
+ const wrapper = mount(RichTranslation, {
+ props: { k: 'test.custom' },
+ slots: { anotherTag: () => h('span', { class: 'self-closed' }, 'Self-closed content') },
+ global: { plugins: [mockI18nStore] },
+ });
+
+ expect(wrapper.html()).toContain('Self-closed content');
+ expect(wrapper.find('.self-closed').text()).toBe('Self-closed content');
+ });
+
+ it('handles mixed content with multiple custom components', () => {
+ const wrapper = mount(RichTranslation, {
+ props: { k: 'test.mixed' },
+ slots: {
+ tag1: ({ content }: { content: string }) => h('strong', {}, content),
+ tag2: () => h('em', {}, 'emphasized'),
+ },
+ global: { plugins: [mockI18nStore] },
+ });
+
+ expect(wrapper.html()).toContain('Text before content1 text in middle emphasized text after.');
+ expect(wrapper.find('strong').text()).toBe('content1');
+ expect(wrapper.find('em').text()).toBe('emphasized');
+ });
+
+ it('renders correctly when translation is not a string', () => {
+ const wrapper = mount(RichTranslation, {
+ props: { k: 'test.noString' },
+ global: { plugins: [mockI18nStore] },
+ });
+
+ expect(wrapper.text()).toBe('123');
+ expect(wrapper.html()).toContain('123');
+ });
+
+ it('uses the specified root tag', () => {
+ const wrapper = mount(RichTranslation, {
+ props: {
+ k: 'test.simple',
+ tag: 'div',
+ },
+ global: { plugins: [mockI18nStore] },
+ });
+
+ expect(wrapper.html()).toContain('Hello World
');
+ expect(wrapper.find('div').exists()).toBe(true);
+ expect(wrapper.find('span').exists()).toBe(true); // Inner span for content
+ });
+
+ it('falls back to raw tag content if slot is not provided for enclosing tag', () => {
+ const wrapper = mount(RichTranslation, {
+ props: { k: 'test.custom' }, // Contains and
+ // No slots provided
+ global: { plugins: [mockI18nStore] },
+ });
+
+ expect(wrapper.find('a').exists()).toBe(false); // Should not render as
+ });
+});
diff --git a/shell/config/router/routes.js b/shell/config/router/routes.js
index 534c4a281e..2424af32c5 100644
--- a/shell/config/router/routes.js
+++ b/shell/config/router/routes.js
@@ -313,6 +313,20 @@ export default [
component: () => interopDefault(import('@shell/pages/c/_cluster/apps/charts/install.vue')),
name: 'c-cluster-apps-charts-install',
},
+ {
+ path: '/c/:cluster/apps/catalog.cattle.io.clusterrepo',
+ name: 'c-cluster-apps-catalog-repo',
+ redirect(to) {
+ return {
+ name: 'c-cluster-product-resource',
+ params: {
+ ...to.params,
+ product: APPS,
+ resource: 'catalog.cattle.io.clusterrepo',
+ }
+ };
+ },
+ },
]
},
{
diff --git a/shell/pages/c/_cluster/apps/charts/index.vue b/shell/pages/c/_cluster/apps/charts/index.vue
index 196be9ff11..70ea98aa84 100644
--- a/shell/pages/c/_cluster/apps/charts/index.vue
+++ b/shell/pages/c/_cluster/apps/charts/index.vue
@@ -6,6 +6,7 @@ import { Banner } from '@components/Banner';
import {
REPO_TYPE, REPO, CHART, VERSION, SEARCH_QUERY, SORT_BY, _FLAGGED, CATEGORY, DEPRECATED, HIDDEN, TAG, STATUS
} from '@shell/config/query-params';
+import { DOCS_BASE } from '@shell/config/private-label';
import { APP_STATUS, compatibleVersionsFor, filterAndArrangeCharts, normalizeFilterQuery } from '@shell/store/catalog';
import { lcFirst } from '@shell/utils/string';
import { sortBy } from '@shell/utils/sort';
@@ -22,6 +23,7 @@ import AppChartCardSubHeader from '@shell/pages/c/_cluster/apps/charts/AppChartC
import AppChartCardFooter from '@shell/pages/c/_cluster/apps/charts/AppChartCardFooter';
import AddRepoLink from '@shell/pages/c/_cluster/apps/charts/AddRepoLink';
import StatusLabel from '@shell/pages/c/_cluster/apps/charts/StatusLabel';
+import RichTranslation from '@shell/components/RichTranslation.vue';
import Select from '@shell/components/form/Select';
const createInitialFilters = () => ({
@@ -41,7 +43,8 @@ export default {
FilterPanel,
AppChartCardSubHeader,
AppChartCardFooter,
- Select
+ Select,
+ RichTranslation
},
async fetch() {
@@ -63,6 +66,7 @@ export default {
data() {
return {
+ DOCS_BASE,
searchQuery: null,
debouncedSearchQuery: null,
showDeprecated: null,
@@ -502,25 +506,46 @@ export default {
{{ t('catalog.charts.noCharts.title') }}
-