Add dynamic scroller to the widget
What does this MR do and why?
Adds dynamic scroller to the widget component for dynamically rendered content.
Screenshots or screen recordings
How to set up and validate locally
- You'll need an EE License
- You'll need to have runners enabled (See $2408961 for setting up a runner)
- Enable
:refactor_security_extension
to turn this feature on.
echo "Feature.enable(:refactor_security_extension)" | rails c
- Import https://gitlab.com/gitlab-examples/security/security-reports
- Create a new MR by editing a file
- Apply the patch and go to MR widget to see the Metrics widget being enabled with some dummy data:
diff --git a/ee/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue b/ee/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue
index 255f6ff546a1..0c86d4c7e0dd 100644
--- a/ee/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue
+++ b/ee/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue
@@ -7,15 +7,18 @@ export default {
import(
'ee/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue'
),
+ MrMetricsWidget: () =>
+ import('ee/vue_merge_request_widget/extensions/metrics/mr_widget_metrics.vue'),
},
extends: CEWidgetApp,
computed: {
widgets() {
- return [window.gon?.features?.refactorSecurityExtension && 'MrSecurityWidget'].filter(
- (w) => w,
- );
+ return [
+ window.gon?.features?.refactorSecurityExtension && 'MrSecurityWidget',
+ 'MrMetricsWidget',
+ ].filter((w) => w);
},
},
diff --git a/ee/app/assets/javascripts/vue_merge_request_widget/extensions/metrics/mr_widget_metrics.vue b/ee/app/assets/javascripts/vue_merge_request_widget/extensions/metrics/mr_widget_metrics.vue
new file mode 100644
index 000000000000..7cd0f47ceb85
--- /dev/null
+++ b/ee/app/assets/javascripts/vue_merge_request_widget/extensions/metrics/mr_widget_metrics.vue
@@ -0,0 +1,184 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import { __, n__, s__, sprintf } from '~/locale';
+import MrWidget from '~/vue_merge_request_widget/components/widget/widget.vue';
+import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
+
+export default {
+ name: 'WidgetMetrics',
+ components: {
+ MrWidget,
+ GlSprintf,
+ },
+ i18n: {
+ label: s__('Reports|metrics report'),
+ loading: s__('Reports|Metrics reports are loading'),
+ error: s__('Reports|Metrics reports failed to load results'),
+ detectedChanges: s__(
+ 'Reports|Metrics reports: %{strongStart}%{numberOfChanges}%{strongEnd} %{changes}',
+ ),
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isLoading: true,
+ metrics: {
+ collapsed: null,
+ expanded: null,
+ },
+ };
+ },
+ computed: {
+ numberOfChanges() {
+ const collapsedData = this.metrics.collapsed;
+ const changedMetrics =
+ collapsedData?.existing_metrics?.filter((metric) => metric?.previous_value) || [];
+ const newMetrics = collapsedData?.new_metrics || [];
+ const removedMetrics = collapsedData?.removed_metrics || [];
+
+ return changedMetrics.length + newMetrics.length + removedMetrics.length;
+ },
+ hasChanges() {
+ return this.numberOfChanges > 0;
+ },
+ statusIcon() {
+ return this.hasChanges ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
+ },
+ shouldCollapse() {
+ return this.hasChanges;
+ },
+ summary() {
+ const { hasChanges, numberOfChanges } = this;
+ const changesSummary = sprintf(
+ s__('Reports|Metrics reports: %{strong_start}%{numberOfChanges}%{strong_end} %{changes}'),
+ {
+ numberOfChanges,
+ changes: n__('change', 'changes', numberOfChanges),
+ },
+ );
+ const noChangesSummary = s__('Reports|Metrics report scanning detected no new changes');
+ return hasChanges ? changesSummary : noChangesSummary;
+ },
+ },
+ methods: {
+ fetchCollapsedData() {
+ return new Promise((resolve) => {
+ resolve({
+ data: {
+ new_metrics: [],
+ existing_metrics: [
+ { name: 'setup_db', value: '34', previous_value: '30' },
+ ...Array.from(Array(20).keys()).map(() => ({
+ name: 'total_memory_used_by_dependencies_on_boot_prod_env_mb',
+ value: '878.5',
+ previous_value: '897.2',
+ })),
+ ],
+ removed_metrics: [],
+ },
+ });
+ });
+ },
+ fetchExpandedData() {
+ return new Promise((resolve) => {
+ resolve({ data: this.prepareReports() });
+ });
+ },
+ formatMetricDelta(metric) {
+ // calculate metric delta for sorting if numeric
+ const delta = Math.abs(parseFloat(metric.value) - parseFloat(metric.previous_value));
+
+ // give non-numeric metrics high delta so they appear first
+ return Number.isNaN(delta) ? Infinity : delta;
+ },
+ handleIsLoading(val) {
+ this.isLoading = val;
+ },
+ prepareReports() {
+ const {
+ new_metrics: newMetrics = [],
+ existing_metrics: existingMetrics = [],
+ removed_metrics: removedMetrics = [],
+ } = this.metrics.collapsed;
+
+ return [
+ ...newMetrics.map((metric, index) => {
+ return {
+ header: index === 0 ? __('New') : undefined,
+ id: uniqueId('new-metric-'),
+ text: `${metric.name}: ${metric.value}`,
+ icon: { name: EXTENSION_ICONS.neutral },
+ };
+ }),
+ ...removedMetrics.map((metric, index) => {
+ return {
+ header: index === 0 ? __('Removed') : undefined,
+ id: uniqueId('resolved-metric-'),
+ text: `${metric.name}: ${metric.value}`,
+ icon: { name: EXTENSION_ICONS.neutral },
+ };
+ }),
+ ...existingMetrics
+ .filter((metric) => metric?.previous_value)
+ .map((metric) => {
+ return {
+ id: uniqueId('changed-metric-'),
+ text: `${metric.name}: ${metric.value} (${metric.previous_value})`,
+ icon: { name: EXTENSION_ICONS.neutral },
+ delta: this.formatMetricDelta(metric),
+ };
+ })
+ .sort((a, b) => b.delta - a.delta)
+ .map((metric, index) => {
+ return {
+ header: index === 0 ? __('Changed') : undefined,
+ ...metric,
+ };
+ }),
+ ...existingMetrics
+ .filter((metric) => !metric?.previous_value)
+ .map((metric, index) => {
+ return {
+ header: index === 0 ? __('No changes') : undefined,
+ id: uniqueId('unchanged-metric-'),
+ text: `${metric.name}: ${metric.value}`,
+ icon: { name: EXTENSION_ICONS.neutral },
+ };
+ }),
+ ];
+ },
+ },
+};
+</script>
+
+<template>
+ <mr-widget
+ v-model="metrics"
+ :error-text="$options.i18n.error"
+ :fetch-collapsed-data="fetchCollapsedData"
+ :fetch-expanded-data="fetchExpandedData"
+ :content="metrics.expanded"
+ :widget-name="$options.name"
+ :is-collapsible="shouldCollapse"
+ :is-loading="isLoading"
+ :status-icon-name="statusIcon"
+ @isLoading="handleIsLoading"
+ >
+ <template #summary>
+ <gl-sprintf v-if="hasChanges" :message="$options.i18n.detectedChanges">
+ <template #strong>
+ <strong>{{ numberOfChanges }}</strong>
+ </template>
+ <template #changes>
+ {{ n__('change', 'changes', numberOfChanges) }}
+ </template>
+ </gl-sprintf>
+ </template>
+ </mr-widget>
+</template>
MR acceptance checklist
This checklist encourages us to confirm any changes have been analyzed to reduce risks in quality, performance, reliability, security, and maintainability.
-
I have evaluated the MR acceptance checklist for this MR.
Related to #384924 (closed)
Edited by Savas Vedova