Skip to content

Add dynamic scroller to the widget

Savas Vedova requested to merge 384924-add-virtual-scroller into master

What does this MR do and why?

Adds dynamic scroller to the widget component for dynamically rendered content.

Screenshots or screen recordings

virtual-scroller

How to set up and validate locally

  1. You'll need an EE License
  2. You'll need to have runners enabled (See $2408961 for setting up a runner)
  3. Enable :refactor_security_extension to turn this feature on.
echo "Feature.enable(:refactor_security_extension)" | rails c
  1. Import https://gitlab.com/gitlab-examples/security/security-reports
  2. Create a new MR by editing a file
  3. 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.

Related to #384924 (closed)

Edited by Savas Vedova

Merge request reports

Loading