RichTranslation component for embedding components in translations and fix repositories causing refresh (#14736)

* add new component to support rich translation

* add test + minor refactor

* add whitelist for tags + fix test

* use new translation component for charts empty state

* fix docs message

* remove unused prop

* remove weak test

* fix tag

* fix tag

* revert removing root tag as prop

* fix type

* minor refactor

* sanitization

* replace escapeHtml with purifyHTML

* use rich translation for no charts docs message

---------

Co-authored-by: Mo Mesgin <mmesgin@Mos-M2-MacBook-Pro.local>
This commit is contained in:
momesgin 2025-07-10 12:02:06 -07:00 committed by GitHub
parent 5552e0df54
commit c9744d1da5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 283 additions and 39 deletions

View File

@ -1074,10 +1074,8 @@ catalog:
header: Charts
noCharts:
title: No charts to show
messagePart1: <b>Tips:</b> undo the last filter you applied or
messagePart2: clear all filters
messagePart3: ', and ensure you have the right <a tabindex="0" href={repositoriesUrl} rel="noopener noreferrer nofollow">repositories</a> in place.'
messagePart4: 'Want to learn more about Helm Charts and Apps? Read our <a tabindex="0" href="{docsBase}/how-to-guides/new-user-guides/helm-charts-in-rancher" target="_blank" class="secondary-text-link">documentation <i class="icon icon-external-link"></i></a>.'
message: '<b>Tips:</b> undo the last filter you applied or <resetAllFilters>clear all filters</resetAllFilters>, and ensure you have the right <repositoriesUrl>repositories</repositoriesUrl> in place.'
docsMessage: 'Want to learn more about Helm Charts and Apps? Read our <docsUrl>documentation</docsUrl>.'
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:

View File

@ -0,0 +1,106 @@
<script lang="ts">
import { defineComponent, h, VNode } from 'vue';
import { useStore } from 'vuex';
import { purifyHTML } from '@shell/plugins/clean-html';
const ALLOWED_TAGS = ['b', 'i', 'span', 'a']; // Add more as needed
/**
* A component for rendering translated strings with embedded HTML and custom Vue components.
*
* This component allows you to use a single translation key for a message that contains
* both standard HTML tags (like <b>, <i>, etc.) and custom Vue components (like <router-link>).
*
* @example
* // In your translation file (e.g., en-us.yaml):
* my:
* translation:
* key: 'This is a <b>bold</b> statement with a <customLink>link</customLink>.'
*
* // In your Vue component:
* <RichTranslation k="my.translation.key">
* <template #customLink="{ content }">
* <router-link to="{ name: 'some-path' }">{{ content }}</router-link>
* </template>
* </RichTranslation>
*/
export default defineComponent({
name: 'RichTranslation',
props: {
/**
* The translation key for the message.
*/
k: {
type: String,
required: true,
},
/**
* The HTML tag to use for the root element.
*/
tag: {
type: String,
default: 'span'
},
},
setup(props, { slots }) {
const store = useStore();
return () => {
// Get the raw translation string, without any processing.
const rawStr = store.getters['i18n/t'](props.k, {}, true);
if (!rawStr || typeof rawStr !== 'string') {
return h(props.tag, {}, [rawStr]);
}
// This regex splits the string by the custom tags, keeping the tags in the resulting array.
const regex = /<([a-zA-Z0-9]+)>(.*?)<\/\1>|<([a-zA-Z0-9]+)\/>/g;
const children: (VNode | string)[] = [];
let lastIndex = 0;
let match;
// Iterate over all matches of the regex.
while ((match = regex.exec(rawStr)) !== null) {
// Add the text before the current match as a plain text node.
if (match.index > lastIndex) {
children.push(h('span', { innerHTML: purifyHTML(rawStr.substring(lastIndex, match.index)) }));
}
const enclosingTagName = match[1]; // Captures the tag name for enclosing tags (e.g., 'customLink' from <customLink>...</customLink>)
const selfClosingTagName = match[3]; // Captures the tag name for self-closing tags (e.g., 'anotherTag' from <anotherTag/>)
const tagName = enclosingTagName || selfClosingTagName;
if (tagName) {
const content = enclosingTagName ? match[2] : '';
if (slots[tagName]) {
// If a slot is provided for this tag, render the slot with the content.
children.push(slots[tagName]({ content: purifyHTML(content) }));
} else if (ALLOWED_TAGS.includes(tagName.toLowerCase())) {
// If it's an allowed HTML tag, render it directly.
if (content) {
children.push(h(tagName, { innerHTML: purifyHTML(content, { ALLOWED_TAGS }) }));
} else {
children.push(h(tagName));
}
} else {
// Otherwise, render the tag and its content as plain HTML.
children.push(h('span', { innerHTML: purifyHTML(match[0]) }));
}
}
// Update the last index to continue searching after the current match
lastIndex = regex.lastIndex;
}
// Add any remaining text after the last match.
if (lastIndex < rawStr.length) {
children.push(h('span', { innerHTML: purifyHTML(rawStr.substring(lastIndex)) }));
}
// Render the root element with the processed children.
return h(props.tag, {}, children);
};
}
});
</script>

View File

@ -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<string, string> = {
'test.simple': 'Hello World',
'test.html': 'This is <b>bold</b> and <i>italic</i>.',
'test.custom': 'This has a <customLink>link</customLink> and <anotherTag/>.',
'test.mixed': 'Text before <tag1>content1</tag1> text in middle <tag2/> 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('<span>Hello World</span>');
});
it('renders HTML tags correctly', () => {
const wrapper = mount(RichTranslation, {
props: { k: 'test.html' },
global: { plugins: [mockI18nStore] },
});
expect(wrapper.html()).toContain('<span><span>This is </span><b>bold</b><span> and </span><i>italic</i><span>.</span></span>');
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('<a href="/test">link</a>');
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('<span class="self-closed">Self-closed content</span>');
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('<span>Text before </span><strong>content1</strong><span> text in middle </span><em>emphasized</em><span> text after.</span>');
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('<span>123</span>');
});
it('uses the specified root tag', () => {
const wrapper = mount(RichTranslation, {
props: {
k: 'test.simple',
tag: 'div',
},
global: { plugins: [mockI18nStore] },
});
expect(wrapper.html()).toContain('<div><span>Hello World</span></div>');
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 <customLink> and <anotherTag/>
// No slots provided
global: { plugins: [mockI18nStore] },
});
expect(wrapper.find('a').exists()).toBe(false); // Should not render as <a>
});
});

View File

@ -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',
}
};
},
},
]
},
{

View File

@ -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') }}
</h1>
<div class="empty-state-tips">
<h4
v-clean-html="t('catalog.charts.noCharts.messagePart1', {}, true)"
/>
<a
tabindex="0"
role="button"
class="empty-state-reset-filters link"
data-testid="charts-empty-state-reset-filters"
@click="resetAllFilters"
<RichTranslation
k="catalog.charts.noCharts.message"
:raw="true"
>
{{ t('catalog.charts.noCharts.messagePart2') }}
</a>
<h4
v-clean-html="t('catalog.charts.noCharts.messagePart3', { repositoriesUrl: `/c/${clusterId}/apps/catalog.cattle.io.clusterrepo`}, true)"
/>
<template #resetAllFilters="{ content }">
<a
tabindex="0"
role="button"
class="link"
data-testid="charts-empty-state-reset-filters"
@click="resetAllFilters"
@keyup.enter="resetAllFilters"
@keyup.space="resetAllFilters"
>{{ content }}</a>
</template>
<template #repositoriesUrl="{ content }">
<router-link :to="{ name: 'c-cluster-apps-catalog-repo'}">
{{ content }}
</router-link>
</template>
</RichTranslation>
<RichTranslation
k="catalog.charts.noCharts.docsMessage"
tag="div"
:raw="true"
>
<template #docsUrl="{ content }">
<a
:href="`${DOCS_BASE}/how-to-guides/new-user-guides/helm-charts-in-rancher`"
class="secondary-text-link"
tabindex="0"
target="_blank"
rel="noopener noreferrer nofollow"
>
<span class="sr-only">{{ t('generic.opensInNewTab') }}</span>
{{ content }} <i class="icon icon-external-link" />
</a>
</template>
</RichTranslation>
</div>
<h4
v-clean-html="t('catalog.charts.noCharts.messagePart4', {}, true)"
/>
</div>
<div
v-else
@ -696,7 +721,7 @@ export default {
.charts-empty-state {
width: 100%;
padding: 72px 0;
padding: 72px 120px;
text-align: center;
.empty-state-title {
@ -705,22 +730,8 @@ export default {
.empty-state-tips {
margin-bottom: 12px;
.empty-state-reset-filters {
font-size: 16px;
}
h4 {
display: inline;
}
}
:deep(h4 .icon-external-link) {
text-decoration: underline;
}
:deep(h4:hover .icon-external-link) {
text-decoration: none;
font-size: 16px;
line-height: 32px;
}
}