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') }} -