From 02fa476b78a51a9b39276c7625c4172cba44ca12 Mon Sep 17 00:00:00 2001
From: Rudy Crespo <rcrespo@gitlab.com>
Date: Tue, 2 Apr 2024 17:59:40 -0400
Subject: [PATCH 1/2] Add top-level namespace to Usage Overview panel

Adds the top-level namespace's full name, type, visibility level icon
and avatar to the Usage Overview panel on the Value Stream Dashboard.

Changelog: added
EE: true
---
 .../visualizations/usage_overview.vue         | 45 ++++++++++-
 .../data_sources/usage_overview.js            | 23 +++++-
 .../queries/get_usage_overview.query.graphql  |  3 +
 .../data_sources/usage_overview_spec.js       | 17 +++-
 .../visualizations/usage_overview_spec.js     | 81 +++++++++++++++----
 .../analytics_dashboards/mock_data.js         | 19 +++++
 6 files changed, 163 insertions(+), 25 deletions(-)

diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/usage_overview.vue b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/usage_overview.vue
index f91494e8876c6f..f11c11be008d01 100644
--- a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/usage_overview.vue
+++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/usage_overview.vue
@@ -1,17 +1,23 @@
 <script>
 import { compact } from 'lodash';
+import { GlAvatar, GlIcon, GlTooltipDirective } from '@gitlab/ui';
 import { s__, sprintf } from '~/locale';
 import dateFormat, { masks } from '~/lib/dateformat';
 import SingleStat from './single_stat.vue';
 
 export default {
   name: 'UsageOverview',
+  directives: {
+    GlTooltip: GlTooltipDirective,
+  },
   components: {
+    GlAvatar,
+    GlIcon,
     SingleStat,
   },
   props: {
     data: {
-      type: Array,
+      type: Object,
       required: true,
     },
     options: {
@@ -22,7 +28,7 @@ export default {
   },
   computed: {
     recordedAt() {
-      const allRecordedAt = compact(this.data.map((metric) => metric.recordedAt));
+      const allRecordedAt = compact(this.data.metrics.map((metric) => metric.recordedAt));
       const [mostRecentRecordedAt] = allRecordedAt.sort().slice(-1);
 
       if (!mostRecentRecordedAt) return null;
@@ -47,7 +53,40 @@ export default {
 <template>
   <div class="gl-display-flex gl-md-flex-direction-column gl-flex-direction-row gl-font-size-sm">
     <div
-      v-for="metric in data"
+      v-if="data.namespace"
+      data-testid="usage-overview-namespace"
+      class="gl-display-flex gl-align-items-center gl-gap-3 gl-pr-9"
+    >
+      <gl-avatar
+        shape="rect"
+        :src="data.namespace.avatarUrl"
+        :size="48"
+        :entity-name="data.namespace.fullName"
+        :entity-id="data.namespace.id"
+        :fallback-on-error="true"
+      />
+
+      <div class="gl-line-height-20">
+        <span
+          class="gl-display-block gl-mb-1 gl-font-base gl-font-weight-normal gl-text-gray-700"
+          >{{ data.namespace.namespaceType }}</span
+        >
+        <div class="gl-display-flex gl-align-items-center gl-gap-2">
+          <span class="gl-font-size-h2 gl-font-weight-bold gl-text-gray-900 gl-truncate-end">{{
+            data.namespace.fullName
+          }}</span>
+          <gl-icon
+            v-gl-tooltip
+            class="gl-text-secondary"
+            :name="data.namespace.visibilityLevelIcon"
+            :title="data.namespace.visibilityLevelTooltip"
+          />
+        </div>
+      </div>
+    </div>
+
+    <div
+      v-for="metric in data.metrics"
       :key="metric.identifier"
       class="gl-pr-9"
       :data-testid="`usage-overview-metric-${metric.identifier}`"
diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/usage_overview.js b/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/usage_overview.js
index f4db43bac7ddb0..b6c2ea27f4f8b4 100644
--- a/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/usage_overview.js
+++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/usage_overview.js
@@ -11,6 +11,9 @@ import {
   USAGE_OVERVIEW_IDENTIFIER_PIPELINES,
 } from '~/analytics/shared/constants';
 import { toYmd } from '~/analytics/shared/utils';
+import { GROUP_VISIBILITY_TYPE, VISIBILITY_TYPE_ICON } from '~/visibility_level/constants';
+import { TYPENAME_GROUP } from '~/graphql_shared/constants';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
 import { defaultClient } from '../graphql/client';
 import getUsageOverviewQuery from '../graphql/queries/get_usage_overview.query.graphql';
 
@@ -23,6 +26,19 @@ const USAGE_OVERVIEW_IDENTIFIERS = [
   USAGE_OVERVIEW_IDENTIFIER_PIPELINES,
 ];
 
+/**
+ * Takes the usage overview query response, extracts information
+ * about the top-level namespace and formats it for rendering.
+ */
+export const extractUsageNamespaceData = (data) => ({
+  id: getIdFromGraphQLId(data?.id),
+  avatarUrl: data?.avatarUrl,
+  fullName: data?.fullName,
+  namespaceType: TYPENAME_GROUP,
+  visibilityLevelIcon: VISIBILITY_TYPE_ICON[data.visibility] ?? null,
+  visibilityLevelTooltip: GROUP_VISIBILITY_TYPE[data.visibility] ?? null,
+});
+
 /**
  * Takes a usage metrics query response, extracts the values and
  * adds the additional metadata we need for rendering.
@@ -106,10 +122,13 @@ export const fetch = async ({
     });
 
     if (!data.group) {
-      return usageOverviewNoData;
+      return { metrics: usageOverviewNoData };
     }
 
-    return extractUsageMetrics(data.group);
+    return {
+      namespace: extractUsageNamespaceData(data.group),
+      metrics: extractUsageMetrics(data.group),
+    };
   } catch {
     throw new Error(USAGE_OVERVIEW_NO_DATA_ERROR);
   }
diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/queries/get_usage_overview.query.graphql b/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/queries/get_usage_overview.query.graphql
index adbc5cf12a1a04..175bd10e497c2d 100644
--- a/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/queries/get_usage_overview.query.graphql
+++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/queries/get_usage_overview.query.graphql
@@ -11,6 +11,9 @@ query getUsageOverview(
 ) {
   group(fullPath: $fullPath) {
     id
+    avatarUrl
+    fullName
+    visibility
     groups: valueStreamDashboardUsageOverview(
       identifier: GROUPS
       timeframe: { start: $startDate, end: $endDate }
diff --git a/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/usage_overview_spec.js b/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/usage_overview_spec.js
index f6c6bff4c25c80..717aba907fd465 100644
--- a/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/usage_overview_spec.js
+++ b/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/usage_overview_spec.js
@@ -11,12 +11,15 @@ import {
   fetch,
   prepareQuery,
   extractUsageMetrics,
+  extractUsageNamespaceData,
 } from 'ee/analytics/analytics_dashboards/data_sources/usage_overview';
 import { defaultClient } from 'ee/analytics/analytics_dashboards/graphql/client';
 import {
   mockUsageMetricsQueryResponse,
+  mockUsageNamespaceData,
   mockUsageMetrics,
   mockUsageMetricsNoData,
+  mockUsageOverviewData,
 } from '../../mock_data';
 
 describe('Usage overview Data Source', () => {
@@ -52,6 +55,14 @@ describe('Usage overview Data Source', () => {
     });
   });
 
+  describe('extractUsageNamespaceData', () => {
+    it('returns the namespace data as expected', () => {
+      expect(extractUsageNamespaceData(mockGroupUsageMetricsQueryResponse)).toEqual(
+        mockUsageNamespaceData,
+      );
+    });
+  });
+
   describe('prepareQuery', () => {
     const queryIncludeKeys = [
       'includeGroups',
@@ -148,7 +159,7 @@ describe('Usage overview Data Source', () => {
 
       obj = await fetch(params);
 
-      expect(obj).toMatchObject(mockUsageMetricsNoData);
+      expect(obj).toMatchObject({ metrics: mockUsageMetricsNoData });
     });
 
     describe('with an error', () => {
@@ -172,8 +183,8 @@ describe('Usage overview Data Source', () => {
         obj = await fetch({ rootNamespace, queryOverrides: mockQuery });
       });
 
-      it('will fetch the usage metrics', () => {
-        expect(obj).toMatchObject(mockUsageMetrics);
+      it('will fetch the usage overview data', () => {
+        expect(obj).toMatchObject(mockUsageOverviewData);
       });
     });
   });
diff --git a/ee/spec/frontend/analytics/analytics_dashboards/components/visualizations/usage_overview_spec.js b/ee/spec/frontend/analytics/analytics_dashboards/components/visualizations/usage_overview_spec.js
index 3d0760f5bb9cc8..0c240a9711baa1 100644
--- a/ee/spec/frontend/analytics/analytics_dashboards/components/visualizations/usage_overview_spec.js
+++ b/ee/spec/frontend/analytics/analytics_dashboards/components/visualizations/usage_overview_spec.js
@@ -1,11 +1,17 @@
+import { GlAvatar, GlIcon } from '@gitlab/ui';
 import { GlSingleStat } from '@gitlab/ui/dist/charts';
 import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
 import UsageOverview from 'ee/analytics/analytics_dashboards/components/visualizations/usage_overview.vue';
-import { mockUsageMetrics, mockUsageMetricsNoData } from '../../mock_data';
+import { mockUsageOverviewData, mockUsageMetrics, mockUsageMetricsNoData } from '../../mock_data';
 
-describe('Single Stat Visualization', () => {
+describe('Usage Overview Visualization', () => {
   let wrapper;
-  const defaultProps = { data: mockUsageMetrics, options: {} };
+  const defaultProps = { data: mockUsageOverviewData, options: {} };
+
+  const findNamespaceTile = () => wrapper.findByTestId('usage-overview-namespace');
+  const findNamespaceAvatar = () => findNamespaceTile().findComponent(GlAvatar);
+  const findNamespaceVisibilityIcon = () => findNamespaceTile().findComponent(GlIcon);
 
   const findMetrics = () => wrapper.findAllComponents(GlSingleStat);
 
@@ -16,6 +22,9 @@ describe('Single Stat Visualization', () => {
 
   const createWrapper = (props = defaultProps) => {
     wrapper = mountExtended(UsageOverview, {
+      directives: {
+        GlTooltip: createMockDirective('gl-tooltip'),
+      },
       propsData: {
         data: props.data,
         options: props.options,
@@ -28,29 +37,67 @@ describe('Single Stat Visualization', () => {
       createWrapper();
     });
 
-    it('should render each metric', () => {
-      expect(findMetrics()).toHaveLength(mockUsageMetrics.length);
-    });
+    describe('namespace', () => {
+      it("should render namespace's full name", () => {
+        expect(wrapper.findByText('GitLab Org').exists()).toBe(true);
+      });
+
+      it('should render namespace type', () => {
+        expect(wrapper.findByText('Group').exists()).toBe(true);
+      });
+
+      it('should render avatar', () => {
+        expect(findNamespaceAvatar().props()).toMatchObject({
+          entityName: 'GitLab Org',
+          entityId: 225,
+          src: '/avatar.png',
+          shape: 'rect',
+          fallbackOnError: true,
+          size: 48,
+        });
+      });
+
+      it('should render visibility level icon', () => {
+        const tooltip = getBinding(findNamespaceVisibilityIcon().element, 'gl-tooltip');
 
-    it('should render each metric as a single stat', () => {
-      mockUsageMetrics.forEach(({ value, options }, idx) => {
-        expect(findMetricTitle(idx).text()).toBe(options.title);
-        expect(findMetricIcon(idx).props('name')).toBe(options.titleIcon);
-        expect(findMetricValue(idx).text()).toBe(String(value));
+        expect(findNamespaceVisibilityIcon().exists()).toBe(true);
+        expect(tooltip).toBeDefined();
+        expect(findNamespaceVisibilityIcon().props('name')).toBe('earth');
+        expect(findNamespaceVisibilityIcon().attributes('title')).toBe(
+          'Public - The group and any public projects can be viewed without any authentication.',
+        );
       });
     });
 
-    it('emits `showTooltip` with the latest metric.recordedAt as the last updated time', () => {
-      expect(wrapper.emitted('showTooltip')).toHaveLength(1);
-      expect(wrapper.emitted('showTooltip')[0][0]).toEqual(
-        'Statistics on top-level namespace usage. Usage data is a cumulative count, and updated monthly. Last updated: 2023-11-27 11:59 PM',
-      );
+    describe('metrics', () => {
+      it('should render each metric', () => {
+        expect(findMetrics()).toHaveLength(mockUsageMetrics.length);
+      });
+
+      it('should render each metric as a single stat', () => {
+        mockUsageMetrics.forEach(({ value, options }, idx) => {
+          expect(findMetricTitle(idx).text()).toBe(options.title);
+          expect(findMetricIcon(idx).props('name')).toBe(options.titleIcon);
+          expect(findMetricValue(idx).text()).toBe(String(value));
+        });
+      });
+
+      it('emits `showTooltip` with the latest metric.recordedAt as the last updated time', () => {
+        expect(wrapper.emitted('showTooltip')).toHaveLength(1);
+        expect(wrapper.emitted('showTooltip')[0][0]).toEqual(
+          'Statistics on top-level namespace usage. Usage data is a cumulative count, and updated monthly. Last updated: 2023-11-27 11:59 PM',
+        );
+      });
     });
   });
 
   describe('with no data', () => {
     beforeEach(() => {
-      createWrapper({ data: mockUsageMetricsNoData });
+      createWrapper({ data: { metrics: mockUsageMetricsNoData } });
+    });
+
+    it('should not render namespace tile', () => {
+      expect(findNamespaceTile().exists()).toBe(false);
     });
 
     it('should render each metric', () => {
diff --git a/ee/spec/frontend/analytics/analytics_dashboards/mock_data.js b/ee/spec/frontend/analytics/analytics_dashboards/mock_data.js
index fd24bb8157ca49..5e635bc714e827 100644
--- a/ee/spec/frontend/analytics/analytics_dashboards/mock_data.js
+++ b/ee/spec/frontend/analytics/analytics_dashboards/mock_data.js
@@ -591,6 +591,10 @@ export const mockMetaData = {
 
 export const mockUsageMetricsQueryResponse = {
   group: {
+    id: 'gid://gitlab/Group/225',
+    fullName: 'GitLab Org',
+    avatarUrl: '/avatar.png',
+    visibility: 'public',
     __typename: 'Group',
     groups: {
       __typename: 'ValueStreamDashboardCount',
@@ -631,6 +635,16 @@ export const mockUsageMetricsQueryResponse = {
   },
 };
 
+export const mockUsageNamespaceData = {
+  id: 225,
+  avatarUrl: '/avatar.png',
+  fullName: 'GitLab Org',
+  namespaceType: 'Group',
+  visibilityLevelIcon: 'earth',
+  visibilityLevelTooltip:
+    'Public - The group and any public projects can be viewed without any authentication.',
+};
+
 export const mockUsageMetrics = [
   {
     identifier: 'groups',
@@ -726,3 +740,8 @@ export const mockUsageMetricsNoData = [
     options: { title: 'Pipelines', titleIcon: 'pipeline' },
   },
 ];
+
+export const mockUsageOverviewData = {
+  namespace: mockUsageNamespaceData,
+  metrics: mockUsageMetrics,
+};
-- 
GitLab


From 138dfafb0ca69966b2d0f2458ad56f012e9f8252 Mon Sep 17 00:00:00 2001
From: Rudy Crespo <rcrespo@gitlab.com>
Date: Thu, 4 Apr 2024 17:18:35 -0400
Subject: [PATCH 2/2] Fix tooltip's positioning

---
 .../components/visualizations/usage_overview.vue                | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/usage_overview.vue b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/usage_overview.vue
index f11c11be008d01..a7e28a8c3d58be 100644
--- a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/usage_overview.vue
+++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/usage_overview.vue
@@ -76,7 +76,7 @@ export default {
             data.namespace.fullName
           }}</span>
           <gl-icon
-            v-gl-tooltip
+            v-gl-tooltip.viewport
             class="gl-text-secondary"
             :name="data.namespace.visibilityLevelIcon"
             :title="data.namespace.visibilityLevelTooltip"
-- 
GitLab