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
|
header: Charts
|
||||||
noCharts:
|
noCharts:
|
||||||
title: No charts to show
|
title: No charts to show
|
||||||
messagePart1: <b>Tips:</b> undo the last filter you applied or
|
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.'
|
||||||
messagePart2: clear all filters
|
docsMessage: 'Want to learn more about Helm Charts and Apps? Read our <docsUrl>documentation</docsUrl>.'
|
||||||
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>.'
|
|
||||||
noWindows: Your repos do not contain any charts capable of being deployed on a cluster with Windows nodes.
|
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.
|
noWindowsAndLinux: Your repos do not contain any charts capable of being deployed on a cluster with both Windows and Linux worker nodes.
|
||||||
operatingSystems:
|
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')),
|
component: () => interopDefault(import('@shell/pages/c/_cluster/apps/charts/install.vue')),
|
||||||
name: 'c-cluster-apps-charts-install',
|
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 {
|
import {
|
||||||
REPO_TYPE, REPO, CHART, VERSION, SEARCH_QUERY, SORT_BY, _FLAGGED, CATEGORY, DEPRECATED, HIDDEN, TAG, STATUS
|
REPO_TYPE, REPO, CHART, VERSION, SEARCH_QUERY, SORT_BY, _FLAGGED, CATEGORY, DEPRECATED, HIDDEN, TAG, STATUS
|
||||||
} from '@shell/config/query-params';
|
} from '@shell/config/query-params';
|
||||||
|
import { DOCS_BASE } from '@shell/config/private-label';
|
||||||
import { APP_STATUS, compatibleVersionsFor, filterAndArrangeCharts, normalizeFilterQuery } from '@shell/store/catalog';
|
import { APP_STATUS, compatibleVersionsFor, filterAndArrangeCharts, normalizeFilterQuery } from '@shell/store/catalog';
|
||||||
import { lcFirst } from '@shell/utils/string';
|
import { lcFirst } from '@shell/utils/string';
|
||||||
import { sortBy } from '@shell/utils/sort';
|
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 AppChartCardFooter from '@shell/pages/c/_cluster/apps/charts/AppChartCardFooter';
|
||||||
import AddRepoLink from '@shell/pages/c/_cluster/apps/charts/AddRepoLink';
|
import AddRepoLink from '@shell/pages/c/_cluster/apps/charts/AddRepoLink';
|
||||||
import StatusLabel from '@shell/pages/c/_cluster/apps/charts/StatusLabel';
|
import StatusLabel from '@shell/pages/c/_cluster/apps/charts/StatusLabel';
|
||||||
|
import RichTranslation from '@shell/components/RichTranslation.vue';
|
||||||
import Select from '@shell/components/form/Select';
|
import Select from '@shell/components/form/Select';
|
||||||
|
|
||||||
const createInitialFilters = () => ({
|
const createInitialFilters = () => ({
|
||||||
|
|
@ -41,7 +43,8 @@ export default {
|
||||||
FilterPanel,
|
FilterPanel,
|
||||||
AppChartCardSubHeader,
|
AppChartCardSubHeader,
|
||||||
AppChartCardFooter,
|
AppChartCardFooter,
|
||||||
Select
|
Select,
|
||||||
|
RichTranslation
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetch() {
|
async fetch() {
|
||||||
|
|
@ -63,6 +66,7 @@ export default {
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
DOCS_BASE,
|
||||||
searchQuery: null,
|
searchQuery: null,
|
||||||
debouncedSearchQuery: null,
|
debouncedSearchQuery: null,
|
||||||
showDeprecated: null,
|
showDeprecated: null,
|
||||||
|
|
@ -502,25 +506,46 @@ export default {
|
||||||
{{ t('catalog.charts.noCharts.title') }}
|
{{ t('catalog.charts.noCharts.title') }}
|
||||||
</h1>
|
</h1>
|
||||||
<div class="empty-state-tips">
|
<div class="empty-state-tips">
|
||||||
<h4
|
<RichTranslation
|
||||||
v-clean-html="t('catalog.charts.noCharts.messagePart1', {}, true)"
|
k="catalog.charts.noCharts.message"
|
||||||
/>
|
:raw="true"
|
||||||
<a
|
|
||||||
tabindex="0"
|
|
||||||
role="button"
|
|
||||||
class="empty-state-reset-filters link"
|
|
||||||
data-testid="charts-empty-state-reset-filters"
|
|
||||||
@click="resetAllFilters"
|
|
||||||
>
|
>
|
||||||
{{ t('catalog.charts.noCharts.messagePart2') }}
|
<template #resetAllFilters="{ content }">
|
||||||
</a>
|
<a
|
||||||
<h4
|
tabindex="0"
|
||||||
v-clean-html="t('catalog.charts.noCharts.messagePart3', { repositoriesUrl: `/c/${clusterId}/apps/catalog.cattle.io.clusterrepo`}, true)"
|
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>
|
</div>
|
||||||
<h4
|
|
||||||
v-clean-html="t('catalog.charts.noCharts.messagePart4', {}, true)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
|
|
@ -696,7 +721,7 @@ export default {
|
||||||
|
|
||||||
.charts-empty-state {
|
.charts-empty-state {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 72px 0;
|
padding: 72px 120px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
.empty-state-title {
|
.empty-state-title {
|
||||||
|
|
@ -705,22 +730,8 @@ export default {
|
||||||
|
|
||||||
.empty-state-tips {
|
.empty-state-tips {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
font-size: 16px;
|
||||||
.empty-state-reset-filters {
|
line-height: 32px;
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(h4 .icon-external-link) {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(h4:hover .icon-external-link) {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue