mirror of https://github.com/rancher/dashboard.git
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:
parent
5552e0df54
commit
c9744d1da5
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
}
|
||||
};
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue