Commit 17677c44 authored by Phil Hughes's avatar Phil Hughes

Added support for content level 2 in widget extensions

This creates a new `generateText` method that will correctly
generate the markup required to display each piece of text.
Using this method allows for the extensions to never pass any HTML
through to the base component.
They will instead pass this formatted string and the base
component will use the `generateText` method to create
the correct markup.

Closes https://gitlab.com/gitlab-org/gitlab/-/issues/341046
parent 39460c1a
......@@ -16,6 +16,7 @@ import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants';
import StatusIcon from './status_icon.vue';
import Actions from './actions.vue';
import { generateText } from './utils';
export const LOADING_STATES = {
collapsedLoading: 'collapsedLoading',
......@@ -147,6 +148,9 @@ export default {
Sentry.captureException(e);
});
},
isArray(arr) {
return Array.isArray(arr);
},
appear(index) {
if (index === this.fullData.length - 1) {
this.showFade = false;
......@@ -157,6 +161,7 @@ export default {
this.showFade = true;
}
},
generateText,
},
EXTENSION_ICON_CLASS,
};
......@@ -177,7 +182,7 @@ export default {
<div class="gl-flex-grow-1">
<template v-if="isLoadingSummary">{{ widgetLoadingText }}</template>
<template v-else-if="hasFetchError">{{ widgetErrorText }}</template>
<div v-else v-safe-html="summary(collapsedData)"></div>
<div v-else v-safe-html="generateText(summary(collapsedData))"></div>
</div>
<actions
:widget="$options.label || $options.name"
......@@ -224,32 +229,59 @@ export default {
:class="{
'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== fullData.length - 1,
}"
class="gl-display-flex gl-align-items-center gl-py-3 gl-pl-7"
class="gl-py-3 gl-pl-7"
data-testid="extension-list-item"
>
<status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" class="gl-pl-0" />
<gl-intersection-observer
:options="{ rootMargin: '100px', thresholds: 0.1 }"
class="gl-flex-wrap gl-display-flex gl-w-full"
@appear="appear(index)"
@disappear="disappear(index)"
>
<div
v-safe-html="data.text"
class="gl-mr-4 gl-display-flex gl-align-items-center"
></div>
<div v-if="data.link">
<gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
<div class="gl-w-full">
<div v-if="data.header" class="gl-mb-2">
<template v-if="isArray(data.header)">
<component
:is="headerI === 0 ? 'strong' : 'span'"
v-for="(header, headerI) in data.header"
:key="headerI"
v-safe-html="generateText(header)"
class="gl-display-block"
/>
</template>
<strong v-else v-safe-html="generateText(data.header)"></strong>
</div>
<div class="gl-display-flex">
<status-icon
v-if="data.icon"
:icon-name="data.icon.name"
:size="12"
class="gl-pl-0"
/>
<gl-intersection-observer
:options="{ rootMargin: '100px', thresholds: 0.1 }"
class="gl-w-full"
@appear="appear(index)"
@disappear="disappear(index)"
>
<div class="gl-flex-wrap gl-display-flex gl-w-full">
<div class="gl-mr-4 gl-display-flex gl-align-items-center">
<p v-safe-html="generateText(data.text)" class="gl-m-0"></p>
</div>
<div v-if="data.link">
<gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
</div>
<gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
{{ data.badge.text }}
</gl-badge>
<actions
:widget="$options.label || $options.name"
:tertiary-buttons="data.actions"
class="gl-ml-auto"
/>
</div>
<p
v-if="data.subtext"
v-safe-html="generateText(data.subtext)"
class="gl-m-0 gl-font-sm"
></p>
</gl-intersection-observer>
</div>
<gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
{{ data.badge.text }}
</gl-badge>
<actions
:widget="$options.label || $options.name"
:tertiary-buttons="data.actions"
class="gl-ml-auto"
/>
</gl-intersection-observer>
</div>
</li>
</smart-virtual-list>
<div
......
const TEXT_STYLES = {
success: {
start: '%{success_start}',
end: '%{success_end}',
},
danger: {
start: '%{danger_start}',
end: '%{danger_end}',
},
critical: {
start: '%{critical_start}',
end: '%{critical_end}',
},
same: {
start: '%{same_start}',
end: '%{same_end}',
},
strong: {
start: '%{strong_start}',
end: '%{strong_end}',
},
small: {
start: '%{small_start}',
end: '%{small_end}',
},
};
const getStartTag = (tag) => TEXT_STYLES[tag].start;
const textStyleTags = {
[getStartTag('success')]: '<span class="gl-font-weight-bold gl-text-green-500">',
[getStartTag('danger')]: '<span class="gl-font-weight-bold gl-text-red-500">',
[getStartTag('critical')]: '<span class="gl-font-weight-bold gl-text-red-800">',
[getStartTag('same')]: '<span class="gl-font-weight-bold gl-text-gray-700">',
[getStartTag('strong')]: '<span class="gl-font-weight-bold">',
[getStartTag('small')]: '<span class="gl-font-sm">',
};
export const generateText = (text) => {
if (typeof text !== 'string') return null;
return text
.replace(
new RegExp(
`(${Object.values(TEXT_STYLES)
.reduce((acc, i) => [...acc, ...Object.values(i)], [])
.join('|')})`,
'gi',
),
(replace) => {
const replacement = textStyleTags[replace];
// If the replacement tag ends with a `_end` then we can just return `</span>`
// unless we have a replacement, for cases were we want to change the HTML tag
if (!replacement && replace.endsWith('_end}')) {
return '</span>';
}
return replacement;
},
)
.replace(/%{([a-z]|_)+}/g, ''); // Filter out any tags we don't know about
};
......@@ -2,6 +2,7 @@
import { EXTENSION_ICONS } from '../constants';
import issuesCollapsedQuery from './issues_collapsed.query.graphql';
import issuesQuery from './issues.query.graphql';
import { n__, sprintf } from '~/locale';
export default {
// Give the extension a name
......@@ -20,7 +21,14 @@ export default {
// Small summary text to be displayed in the collapsed state
// Receives the collapsed data as an argument
summary(count) {
return 'Summary text<br/>Second line';
return sprintf(
n__(
'ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} change',
'ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} changes',
changesFound,
),
{ changesFound },
);
},
// Status icon to be used next to the summary text
// Receives the collapsed data as an argument
......@@ -57,9 +65,13 @@ export default {
.query({ query: issuesQuery, variables: { projectPath: targetProjectFullPath } })
.then(({ data }) => {
// Return some transformed data to be rendered in the expanded state
return data.project.issues.nodes.map((issue) => ({
return data.project.issues.nodes.map((issue, i) => ({
id: issue.id, // Required: The ID of the object
text: issue.title, // Required: The text to get used on each row
header: ['New', 'This is an %{strong_start}issue%{strong_end} row'],
text:
'%{critical_start}1 Critical%{critical_end}, %{danger_start}1 High%{danger_end}, and %{strong_start}1 Other%{strong_end}. %{small_start}Some smaller text%{small_end}', // Required: The text to get used on each row
subtext:
'Reported resource changes: %{strong_start}2%{strong_end} to add, 0 to change, 0 to delete', // Optional: The sub-text to get displayed below each rows main content
// Icon to get rendered on the side of each row
icon: {
// Required: Name maps to an icon in GitLabs SVG
......
......@@ -17,47 +17,30 @@ export default {
const changesFound = improved.length + degraded.length + same.length;
const text = sprintf(
n__(
'ciReport|Browser performance test metrics: %{strongStart}%{changesFound}%{strongEnd} change',
'ciReport|Browser performance test metrics: %{strongStart}%{changesFound}%{strongEnd} changes',
'ciReport|Browser performance test metrics: %{strong_start}%{changesFound}%{strong_end} change',
'ciReport|Browser performance test metrics: %{strong_start}%{changesFound}%{strong_end} changes',
changesFound,
),
{
changesFound,
strongStart: `<strong>`,
strongEnd: `</strong>`,
},
false,
);
const reportNumbers = [];
if (degraded.length > 0) {
reportNumbers.push(
`<strong class="gl-text-red-500">${sprintf(s__('ciReport|%{degradedNum} degraded'), {
degradedNum: degraded.length,
})}</strong>`,
);
}
if (same.length > 0) {
reportNumbers.push(
`<strong class="gl-text-gray-700">${sprintf(s__('ciReport|%{sameNum} same'), {
sameNum: same.length,
})}</strong>`,
);
}
if (improved.length > 0) {
reportNumbers.push(
`<strong class="gl-text-green-500">${sprintf(s__('ciReport|%{improvedNum} improved'), {
improvedNum: improved.length,
})}</strong>`,
);
}
const reportNumbersText = sprintf(
s__(
'ciReport|%{danger_start}%{degradedNum} degraded%{danger_end}, %{same_start}%{sameNum} same%{same_end}, and %{success_start}%{improvedNum} improved%{success_end}',
),
{
degradedNum: degraded.length,
sameNum: same.length,
improvedNum: improved.length,
},
);
return `${text}
<br>
${reportNumbers.join(', ')}
${reportNumbersText}
`;
},
statusIcon() {
......@@ -151,7 +134,7 @@ export default {
const text = sprintf(
s__(
'ciReport|%{prefix} %{strongStart}%{score}%{strongEnd} %{delta} %{deltaPercent} in %{path}',
'ciReport|%{prefix} %{strong_start}%{score}%{strong_end} %{delta} %{deltaPercent} in %{path}',
),
{
prefix,
......@@ -159,8 +142,6 @@ export default {
delta,
deltaPercent,
path,
strongStart: `<strong>`,
strongEnd: `</strong>`,
},
false,
);
......
......@@ -17,47 +17,27 @@ export default {
const changesFound = improved.length + degraded.length + same.length;
const text = sprintf(
n__(
'ciReport|Load performance test metrics detected %{strongStart}%{changesFound}%{strongEnd} change',
'ciReport|Load performance test metrics detected %{strongStart}%{changesFound}%{strongEnd} changes',
'ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} change',
'ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} changes',
changesFound,
),
{ changesFound },
);
const reportNumbersText = sprintf(
s__(
'ciReport|%{danger_start}%{degradedNum} degraded%{danger_end}, %{same_start}%{sameNum} same%{same_end}, and %{success_start}%{improvedNum} improved%{success_end}',
),
{
changesFound,
strongStart: `<strong>`,
strongEnd: `</strong>`,
degradedNum: degraded.length,
sameNum: same.length,
improvedNum: improved.length,
},
false,
);
const reportNumbers = [];
if (degraded.length > 0) {
reportNumbers.push(
`<strong class="gl-text-red-500">${sprintf(s__('ciReport|%{degradedNum} degraded'), {
degradedNum: degraded.length,
})}</strong>`,
);
}
if (same.length > 0) {
reportNumbers.push(
`<strong class="gl-text-gray-700">${sprintf(s__('ciReport|%{sameNum} same'), {
sameNum: same.length,
})}</strong>`,
);
}
if (improved.length > 0) {
reportNumbers.push(
`<strong class="gl-text-green-500">${sprintf(s__('ciReport|%{improvedNum} improved'), {
improvedNum: improved.length,
})}</strong>`,
);
}
return `${text}
<br>
${reportNumbers.join(', ')}
${reportNumbersText}
`;
},
statusIcon() {
......@@ -163,7 +143,7 @@ export default {
const prefix = metricData.score ? `${metricData.name}:` : metricData.name;
const score = metricData.score
? `<strong>${this.formatScore(metricData.score)}</strong>`
? `%{strong_start}${this.formatScore(metricData.score)}%{strong_end}`
: '';
const delta = metricData.delta ? `(${this.formatScore(metricData.delta)})` : '';
let deltaPercent = '';
......@@ -173,10 +153,8 @@ export default {
deltaPercent = `(${formattedChangeInPercent(oldScore, metricData.score)})`;
}
const text = `${prefix} ${score} ${delta} ${deltaPercent}`;
preparedMetricData.icon = icon;
preparedMetricData.text = text;
preparedMetricData.text = `${prefix} ${score} ${delta} ${deltaPercent}`;
return preparedMetricData;
},
......
......@@ -60,7 +60,7 @@ describe('Browser performance extension', () => {
await waitForPromises();
expect(wrapper.text()).toContain('Browser performance test metrics');
expect(wrapper.text()).toContain('2 degraded, 1 same, 1 improved');
expect(wrapper.text()).toContain('2 degraded, 1 same, and 1 improved');
});
it('should render info about fixed issues', async () => {
......
......@@ -60,7 +60,7 @@ describe('Load performance extension', () => {
await waitForPromises();
expect(wrapper.text()).toContain('Load performance test metrics detected 4 changes');
expect(wrapper.text()).toContain('1 degraded, 1 same, 2 improved');
expect(wrapper.text()).toContain('1 degraded, 1 same, and 2 improved');
});
it('should render info about fixed issues', async () => {
......
......@@ -40917,6 +40917,9 @@ msgstr ""
msgid "cannot merge"
msgstr ""
msgid "ciReport|%{danger_start}%{degradedNum} degraded%{danger_end}, %{same_start}%{sameNum} same%{same_end}, and %{success_start}%{improvedNum} improved%{success_end}"
msgstr ""
msgid "ciReport|%{degradedNum} degraded"
msgstr ""
......@@ -40947,7 +40950,7 @@ msgstr ""
msgid "ciReport|%{linkStartTag}Learn more about codequality reports %{linkEndTag}"
msgstr ""
msgid "ciReport|%{prefix} %{strongStart}%{score}%{strongEnd} %{delta} %{deltaPercent} in %{path}"
msgid "ciReport|%{prefix} %{strong_start}%{score}%{strong_end} %{delta} %{deltaPercent} in %{path}"
msgstr ""
msgid "ciReport|%{remainingPackagesCount} more"
......@@ -40995,8 +40998,8 @@ msgstr ""
msgid "ciReport|Browser performance test metrics: "
msgstr ""
msgid "ciReport|Browser performance test metrics: %{strongStart}%{changesFound}%{strongEnd} change"
msgid_plural "ciReport|Browser performance test metrics: %{strongStart}%{changesFound}%{strongEnd} changes"
msgid "ciReport|Browser performance test metrics: %{strong_start}%{changesFound}%{strong_end} change"
msgid_plural "ciReport|Browser performance test metrics: %{strong_start}%{changesFound}%{strong_end} changes"
msgstr[0] ""
msgstr[1] ""
......@@ -41090,8 +41093,8 @@ msgstr ""
msgid "ciReport|Load Performance"
msgstr ""
msgid "ciReport|Load performance test metrics detected %{strongStart}%{changesFound}%{strongEnd} change"
msgid_plural "ciReport|Load performance test metrics detected %{strongStart}%{changesFound}%{strongEnd} changes"
msgid "ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} change"
msgid_plural "ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} changes"
msgstr[0] ""
msgstr[1] ""
......
import { generateText } from '~/vue_merge_request_widget/components/extensions/utils';
describe('generateText', () => {
it.each`
text | expectedText
${'%{strong_start}Hello world%{strong_end}'} | ${'<span class="gl-font-weight-bold">Hello world</span>'}
${'%{success_start}Hello world%{success_end}'} | ${'<span class="gl-font-weight-bold gl-text-green-500">Hello world</span>'}
${'%{danger_start}Hello world%{danger_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-500">Hello world</span>'}
${'%{critical_start}Hello world%{critical_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-800">Hello world</span>'}
${'%{same_start}Hello world%{same_end}'} | ${'<span class="gl-font-weight-bold gl-text-gray-700">Hello world</span>'}
${'%{small_start}Hello world%{small_end}'} | ${'<span class="gl-font-sm">Hello world</span>'}
${'%{strong_start}%{danger_start}Hello world%{danger_end}%{strong_end}'} | ${'<span class="gl-font-weight-bold"><span class="gl-font-weight-bold gl-text-red-500">Hello world</span></span>'}
${'%{no_exist_start}Hello world%{no_exist_end}'} | ${'Hello world'}
${['array']} | ${null}
`('generates $expectedText from $text', ({ text, expectedText }) => {
expect(generateText(text)).toBe(expectedText);
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment