From 618b729140ad1c2fd3597b2852e3402260a082cd Mon Sep 17 00:00:00 2001
From: Brandon Labuschagne <blabuschagne@gitlab.com>
Date: Mon, 9 Nov 2020 15:25:16 +0200
Subject: [PATCH 1/3] Add devops adoption table

Add a table to the devops adoption feature which
displays the data for user defined segments.
---
 .../components/devops_adoption_app.vue        |  89 +++++++--
 .../admin/dev_ops_report/constants.js         |  18 +-
 .../devops_adoption_segments.query.graphql    |  17 ++
 .../components/devops_adoption_app_spec.js    | 169 +++++++++++++++++-
 .../admin/dev_ops_report/mock_data.js         |   5 +
 locale/gitlab.pot                             |   6 +
 6 files changed, 284 insertions(+), 20 deletions(-)
 create mode 100644 ee/app/assets/javascripts/admin/dev_ops_report/graphql/queries/devops_adoption_segments.query.graphql

diff --git a/ee/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_app.vue b/ee/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_app.vue
index 915a7aa8f5251c8e..123389fffc09bd70 100644
--- a/ee/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_app.vue
+++ b/ee/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_app.vue
@@ -1,10 +1,19 @@
 <script>
-import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import dateformat from 'dateformat';
+import { GlLoadingIcon, GlButton, GlSprintf, GlAlert, GlModalDirective } from '@gitlab/ui';
 import * as Sentry from '~/sentry/wrapper';
 import getGroupsQuery from '../graphql/queries/get_groups.query.graphql';
+import devopsAdoptionSegmentsQuery from '../graphql/queries/devops_adoption_segments.query.graphql';
 import DevopsAdoptionEmptyState from './devops_adoption_empty_state.vue';
-import { DEVOPS_ADOPTION_STRINGS, MAX_REQUEST_COUNT } from '../constants';
 import DevopsAdoptionSegmentModal from './devops_adoption_segment_modal.vue';
+import DevopsAdoptionTable from './devops_adoption_table.vue';
+import {
+  DEVOPS_ADOPTION_STRINGS,
+  DEVOPS_ADOPTION_ERROR_KEYS,
+  MAX_REQUEST_COUNT,
+  DATE_TIME_FORMAT,
+  DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
+} from '../constants';
 
 export default {
   name: 'DevopsAdoptionApp',
@@ -13,37 +22,71 @@ export default {
     GlLoadingIcon,
     DevopsAdoptionEmptyState,
     DevopsAdoptionSegmentModal,
+    DevopsAdoptionTable,
+    GlButton,
+    GlSprintf,
+  },
+  directives: {
+    GlModal: GlModalDirective,
   },
   i18n: {
     ...DEVOPS_ADOPTION_STRINGS.app,
   },
+  devopsSegmentModalId: DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
   data() {
     return {
+      isLoadingGroups: false,
       requestCount: 0,
-      loadingError: false,
-      isLoading: false,
       selectedSegmentId: null,
+      errors: {
+        [DEVOPS_ADOPTION_ERROR_KEYS.groups]: false,
+        [DEVOPS_ADOPTION_ERROR_KEYS.segments]: false,
+      },
       groups: {
         nodes: [],
         pageInfo: null,
       },
     };
   },
+  apollo: {
+    devopsAdoptionSegments: {
+      query: devopsAdoptionSegmentsQuery,
+      error(error) {
+        this.handleError(DEVOPS_ADOPTION_ERROR_KEYS.segments, error);
+        this.devopsAdoptionSegments = null;
+      },
+    },
+  },
   computed: {
     hasGroupData() {
       return Boolean(this.groups?.nodes?.length);
     },
+    hasSegmentsData() {
+      return Boolean(this.devopsAdoptionSegments?.nodes?.length);
+    },
+    hasLoadingError() {
+      return Object.values(this.errors).some(error => error === true);
+    },
+    timestamp() {
+      return dateformat(
+        this.devopsAdoptionSegments?.nodes[0]?.latestSnapshot?.recordedAt,
+        DATE_TIME_FORMAT,
+      );
+    },
+    isLoading() {
+      return this.isLoadingGroups || this.$apollo.queries.devopsAdoptionSegments.loading;
+    },
   },
   created() {
     this.fetchGroups();
   },
   methods: {
-    handleError(error) {
-      this.loadingError = true;
+    handleError(key, error) {
+      this.errors[key] = true;
       Sentry.captureException(error);
     },
     fetchGroups(nextPage) {
-      this.isLoading = true;
+      this.isLoadingGroups = true;
       this.$apollo
         .query({
           query: getGroupsQuery,
@@ -64,25 +107,45 @@ export default {
           if (this.requestCount < MAX_REQUEST_COUNT && pageInfo?.nextPage) {
             this.fetchGroups(pageInfo.nextPage);
           } else {
-            this.isLoading = false;
+            this.isLoadingGroups = false;
           }
         })
-        .catch(this.handleError);
+        .catch(error => this.handleError(DEVOPS_ADOPTION_ERROR_KEYS.groups, error));
     },
   },
 };
 </script>
 <template>
-  <gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">
-    {{ $options.i18n.groupsError }}
-  </gl-alert>
+  <div v-if="hasLoadingError">
+    <div v-for="(error, key) in errors" :key="key">
+      <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mt-3">
+        {{ $options.i18n[key] }}
+      </gl-alert>
+    </div>
+  </div>
   <gl-loading-icon v-else-if="isLoading" size="md" class="gl-my-5" />
   <div v-else>
-    <devops-adoption-empty-state :has-groups-data="hasGroupData" />
     <devops-adoption-segment-modal
       v-if="hasGroupData"
       :groups="groups.nodes"
       :segment-id="selectedSegmentId"
     />
+    <div v-if="hasSegmentsData" class="gl-mt-3">
+      <div
+        class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-my-3"
+        data-testid="tableHeader"
+      >
+        <span class="gl-text-gray-400">
+          <gl-sprintf :message="$options.i18n.tableHeader.text">
+            <template #timestamp>{{ timestamp }}</template>
+          </gl-sprintf>
+        </span>
+        <gl-button v-gl-modal="$options.devopsSegmentModalId">{{
+          $options.i18n.tableHeader.button
+        }}</gl-button>
+      </div>
+      <devops-adoption-table :segments="devopsAdoptionSegments.nodes" />
+    </div>
+    <devops-adoption-empty-state v-else :has-groups-data="hasGroupData" />
   </div>
 </template>
diff --git a/ee/app/assets/javascripts/admin/dev_ops_report/constants.js b/ee/app/assets/javascripts/admin/dev_ops_report/constants.js
index 7f47c0c593434ef6..404f143cd565d6f2 100644
--- a/ee/app/assets/javascripts/admin/dev_ops_report/constants.js
+++ b/ee/app/assets/javascripts/admin/dev_ops_report/constants.js
@@ -4,9 +4,25 @@ export const MAX_REQUEST_COUNT = 10;
 
 export const DEVOPS_ADOPTION_SEGMENT_MODAL_ID = 'devopsSegmentModal';
 
+export const DATE_TIME_FORMAT = 'yyyy-mm-dd HH:MM';
+
+export const DEVOPS_ADOPTION_ERROR_KEYS = {
+  groups: 'groupsError',
+  segments: 'segmentsError',
+};
+
 export const DEVOPS_ADOPTION_STRINGS = {
   app: {
-    groupsError: s__('DevopsAdoption|There was an error fetching Groups'),
+    [DEVOPS_ADOPTION_ERROR_KEYS.groups]: s__('DevopsAdoption|There was an error fetching Groups'),
+    [DEVOPS_ADOPTION_ERROR_KEYS.segments]: s__(
+      'DevopsAdoption|There was an error fetching Segments',
+    ),
+    tableHeader: {
+      text: s__(
+        'DevopsAdoption|Feature adoption is based on usage over the last 30 days. Last updated: %{timestamp}.',
+      ),
+      button: s__('DevopsAdoption|Add new segment'),
+    },
   },
   emptyState: {
     title: s__('DevopsAdoption|Add a segment to get started'),
diff --git a/ee/app/assets/javascripts/admin/dev_ops_report/graphql/queries/devops_adoption_segments.query.graphql b/ee/app/assets/javascripts/admin/dev_ops_report/graphql/queries/devops_adoption_segments.query.graphql
new file mode 100644
index 0000000000000000..52898e142d0c3d89
--- /dev/null
+++ b/ee/app/assets/javascripts/admin/dev_ops_report/graphql/queries/devops_adoption_segments.query.graphql
@@ -0,0 +1,17 @@
+query devopsAdoptionSegments {
+  devopsAdoptionSegments {
+    nodes {
+      name
+      latestSnapshot {
+        issueOpened
+        mergeRequestOpened
+        mergeRequestApproved
+        runnerConfigured
+        pipelineSucceeded
+        deploySucceeded
+        securityScanSucceeded
+        recordedAt
+      }
+    }
+  }
+}
diff --git a/ee/spec/frontend/admin/dev_ops_report/components/devops_adoption_app_spec.js b/ee/spec/frontend/admin/dev_ops_report/components/devops_adoption_app_spec.js
index 5aea5cec6f72b966..2b6958275913d0e1 100644
--- a/ee/spec/frontend/admin/dev_ops_report/components/devops_adoption_app_spec.js
+++ b/ee/spec/frontend/admin/dev_ops_report/components/devops_adoption_app_spec.js
@@ -1,16 +1,28 @@
 import Vue from 'vue';
 import VueApollo from 'vue-apollo';
 import { createLocalVue, shallowMount } from '@vue/test-utils';
-import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import { GlAlert, GlButton, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import { getByText } from '@testing-library/dom';
 import createMockApollo from 'jest/helpers/mock_apollo_helper';
 import waitForPromises from 'helpers/wait_for_promises';
 import getGroupsQuery from 'ee/admin/dev_ops_report/graphql/queries/get_groups.query.graphql';
+import devopsAdoptionSegments from 'ee/admin/dev_ops_report/graphql/queries/devops_adoption_segments.query.graphql';
 import DevopsAdoptionApp from 'ee/admin/dev_ops_report/components/devops_adoption_app.vue';
 import DevopsAdoptionEmptyState from 'ee/admin/dev_ops_report/components/devops_adoption_empty_state.vue';
+import DevopsAdoptionTable from 'ee/admin/dev_ops_report/components/devops_adoption_table.vue';
 import DevopsAdoptionSegmentModal from 'ee/admin/dev_ops_report/components/devops_adoption_segment_modal.vue';
-import { DEVOPS_ADOPTION_STRINGS } from 'ee/admin/dev_ops_report/constants';
+import {
+  DEVOPS_ADOPTION_STRINGS,
+  DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
+} from 'ee/admin/dev_ops_report/constants';
 import * as Sentry from '~/sentry/wrapper';
-import { groupNodes, nextGroupNode, groupPageInfo } from '../mock_data';
+import {
+  groupNodes,
+  nextGroupNode,
+  groupPageInfo,
+  devopsAdoptionSegmentsData,
+  devopsAdoptionSegmentsDataEmpty,
+} from '../mock_data';
 
 const localVue = createLocalVue();
 Vue.use(VueApollo);
@@ -24,9 +36,15 @@ const initialResponse = {
 describe('DevopsAdoptionApp', () => {
   let wrapper;
 
+  const groupsEmpty = jest.fn().mockResolvedValueOnce({ __typename: 'Groups', nodes: [] });
+  const segmentsEmpty = jest
+    .fn()
+    .mockResolvedValue({ data: { devopsAdoptionSegments: devopsAdoptionSegmentsDataEmpty } });
+
   function createMockApolloProvider(options = {}) {
-    const { groupsSpy } = options;
-    const mockApollo = createMockApollo([], {
+    const { groupsSpy = groupsEmpty, segmentsSpy = segmentsEmpty } = options;
+
+    const mockApollo = createMockApollo([[devopsAdoptionSegments, segmentsSpy]], {
       Query: {
         groups: groupsSpy,
       },
@@ -47,6 +65,9 @@ describe('DevopsAdoptionApp', () => {
     return shallowMount(DevopsAdoptionApp, {
       localVue,
       apolloProvider: mockApollo,
+      stubs: {
+        GlSprintf,
+      },
       data() {
         return data;
       },
@@ -163,7 +184,11 @@ describe('DevopsAdoptionApp', () => {
           .fn()
           .mockResolvedValueOnce(initialResponse)
           // `fetchMore` response
-          .mockResolvedValueOnce({ __typename: 'Groups', nodes: [nextGroupNode], nextPage: null });
+          .mockResolvedValueOnce({
+            __typename: 'Groups',
+            nodes: [nextGroupNode],
+            nextPage: null,
+          });
         const mockApollo = createMockApolloProvider({ groupsSpy });
         wrapper = createComponent({ mockApollo });
         await waitForPromises();
@@ -253,4 +278,136 @@ describe('DevopsAdoptionApp', () => {
       });
     });
   });
+
+  describe('segments data', () => {
+    describe('when loading', () => {
+      beforeEach(async () => {
+        const segmentsLoading = jest.fn().mockResolvedValue(new Promise(() => {}));
+        const mockApollo = createMockApolloProvider({ segmentsSpy: segmentsLoading });
+        wrapper = createComponent({ mockApollo });
+        await waitForPromises();
+      });
+
+      it('does not display the empty state', () => {
+        expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
+      });
+
+      it('displays the loader', () => {
+        expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+      });
+    });
+
+    describe('when there is no segment data', () => {
+      beforeEach(async () => {
+        const mockApollo = createMockApolloProvider();
+        wrapper = createComponent({ mockApollo });
+        await waitForPromises();
+      });
+
+      it('displays the empty state', () => {
+        expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(true);
+      });
+
+      it('does not display the table', () => {
+        expect(wrapper.find(DevopsAdoptionTable).exists()).toBe(false);
+      });
+    });
+
+    describe('when there is segment data', () => {
+      beforeEach(async () => {
+        const segmentsWithData = jest
+          .fn()
+          .mockResolvedValue({ data: { devopsAdoptionSegments: devopsAdoptionSegmentsData } });
+        const mockApollo = createMockApolloProvider({ segmentsSpy: segmentsWithData });
+        wrapper = createComponent({ mockApollo });
+        await waitForPromises();
+      });
+
+      it('does not display the empty state', () => {
+        expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
+      });
+
+      it('displays the table', () => {
+        expect(wrapper.find(DevopsAdoptionTable).exists()).toBe(true);
+      });
+
+      describe('table header', () => {
+        let tableHeader;
+
+        beforeEach(() => {
+          tableHeader = wrapper.find("[data-testid='tableHeader']");
+        });
+
+        afterEach(() => {
+          tableHeader = null;
+        });
+
+        it('displays the table header', () => {
+          expect(tableHeader.exists()).toBe(true);
+        });
+
+        it('displays the header text', () => {
+          const text =
+            'Feature adoption is based on usage over the last 30 days. Last updated: 2020-10-31 23:59.';
+          expect(getByText(wrapper.element, text)).not.toBeNull();
+        });
+
+        describe('segment modal button', () => {
+          let segmentButton;
+
+          beforeEach(() => {
+            segmentButton = tableHeader.find(GlButton);
+          });
+
+          afterEach(() => {
+            segmentButton = null;
+          });
+
+          it('displays the add segment button', () => {
+            expect(segmentButton.exists()).toBe(true);
+          });
+
+          it('calls the gl-modal show', async () => {
+            const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit');
+
+            segmentButton.trigger('click');
+
+            expect(rootEmit.mock.calls[0][0]).toContain('show');
+            expect(rootEmit.mock.calls[0][1]).toBe(DEVOPS_ADOPTION_SEGMENT_MODAL_ID);
+          });
+        });
+      });
+    });
+
+    describe('when there is an error', () => {
+      const segmentsErrorMessage = 'Error: bar!';
+
+      beforeEach(async () => {
+        jest.spyOn(Sentry, 'captureException');
+        const segmentsError = jest.fn().mockRejectedValue(segmentsErrorMessage);
+        const mockApollo = createMockApolloProvider({ segmentsSpy: segmentsError });
+        wrapper = createComponent({ mockApollo });
+        await waitForPromises();
+      });
+
+      it('does not display the loader', () => {
+        expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+      });
+
+      it('does not render the segment modal', () => {
+        expect(wrapper.find(DevopsAdoptionSegmentModal).exists()).toBe(false);
+      });
+
+      it('does not render the table', () => {
+        expect(wrapper.find(DevopsAdoptionTable).exists()).toBe(false);
+      });
+
+      it('displays the error message and calls Sentry', () => {
+        const alert = wrapper.find(GlAlert);
+        expect(alert.exists()).toBe(true);
+        expect(alert.text()).toBe(DEVOPS_ADOPTION_STRINGS.app.segmentsError);
+        expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(segmentsErrorMessage);
+      });
+    });
+  });
 });
diff --git a/ee/spec/frontend/admin/dev_ops_report/mock_data.js b/ee/spec/frontend/admin/dev_ops_report/mock_data.js
index d4baa6bb39d29a1e..2ad9f2d3c98385f2 100644
--- a/ee/spec/frontend/admin/dev_ops_report/mock_data.js
+++ b/ee/spec/frontend/admin/dev_ops_report/mock_data.js
@@ -48,6 +48,11 @@ export const devopsAdoptionSegmentsData = {
   __typename: 'devopsAdoptionSegments',
 };
 
+export const devopsAdoptionSegmentsDataEmpty = {
+  nodes: [],
+  __typename: 'devopsAdoptionSegments',
+};
+
 export const devopsAdoptionTableHeaders = [
   'Segment',
   'Issues',
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index d27d6f6604d386ad..138310175a9a2a8c 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -9552,6 +9552,9 @@ msgstr ""
 msgid "DevopsAdoption|DevOps adoption uses segments to track adoption across key features. Segments are a way to track multiple related projects and groups at once. For example, you could create a segment for the engineering department or a particular product team."
 msgstr ""
 
+msgid "DevopsAdoption|Feature adoption is based on usage over the last 30 days. Last updated: %{timestamp}."
+msgstr ""
+
 msgid "DevopsAdoption|Issues"
 msgstr ""
 
@@ -9582,6 +9585,9 @@ msgstr ""
 msgid "DevopsAdoption|There was an error fetching Groups"
 msgstr ""
 
+msgid "DevopsAdoption|There was an error fetching Segments"
+msgstr ""
+
 msgid "DevopsReport|Adoption"
 msgstr ""
 
-- 
GitLab


From 716a18e5b3b2dd93fb17115cc935b400a37437f2 Mon Sep 17 00:00:00 2001
From: Brandon Labuschagne <blabuschagne@gitlab.com>
Date: Tue, 24 Nov 2020 09:53:19 +0200
Subject: [PATCH 2/3] Apply reviewer feedback

In addition to the reviewer feedback I have
updated the error messages to include a remedy action.
---
 .../admin/dev_ops_report/components/devops_adoption_app.vue | 1 -
 ee/app/assets/javascripts/admin/dev_ops_report/constants.js | 6 ++++--
 .../dev_ops_report/components/devops_adoption_app_spec.js   | 5 ++++-
 locale/gitlab.pot                                           | 4 ++--
 4 files changed, 10 insertions(+), 6 deletions(-)

diff --git a/ee/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_app.vue b/ee/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_app.vue
index 123389fffc09bd70..2076107980df29b5 100644
--- a/ee/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_app.vue
+++ b/ee/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_app.vue
@@ -53,7 +53,6 @@ export default {
       query: devopsAdoptionSegmentsQuery,
       error(error) {
         this.handleError(DEVOPS_ADOPTION_ERROR_KEYS.segments, error);
-        this.devopsAdoptionSegments = null;
       },
     },
   },
diff --git a/ee/app/assets/javascripts/admin/dev_ops_report/constants.js b/ee/app/assets/javascripts/admin/dev_ops_report/constants.js
index 404f143cd565d6f2..22e8143bd0a787ee 100644
--- a/ee/app/assets/javascripts/admin/dev_ops_report/constants.js
+++ b/ee/app/assets/javascripts/admin/dev_ops_report/constants.js
@@ -13,9 +13,11 @@ export const DEVOPS_ADOPTION_ERROR_KEYS = {
 
 export const DEVOPS_ADOPTION_STRINGS = {
   app: {
-    [DEVOPS_ADOPTION_ERROR_KEYS.groups]: s__('DevopsAdoption|There was an error fetching Groups'),
+    [DEVOPS_ADOPTION_ERROR_KEYS.groups]: s__(
+      'DevopsAdoption|There was an error fetching Groups. Please refresh the page to try again.',
+    ),
     [DEVOPS_ADOPTION_ERROR_KEYS.segments]: s__(
-      'DevopsAdoption|There was an error fetching Segments',
+      'DevopsAdoption|There was an error fetching Segments. Please refresh the page to try again.',
     ),
     tableHeader: {
       text: s__(
diff --git a/ee/spec/frontend/admin/dev_ops_report/components/devops_adoption_app_spec.js b/ee/spec/frontend/admin/dev_ops_report/components/devops_adoption_app_spec.js
index 2b6958275913d0e1..26bae07178b0dbad 100644
--- a/ee/spec/frontend/admin/dev_ops_report/components/devops_adoption_app_spec.js
+++ b/ee/spec/frontend/admin/dev_ops_report/components/devops_adoption_app_spec.js
@@ -402,10 +402,13 @@ describe('DevopsAdoptionApp', () => {
         expect(wrapper.find(DevopsAdoptionTable).exists()).toBe(false);
       });
 
-      it('displays the error message and calls Sentry', () => {
+      it('displays the error message ', () => {
         const alert = wrapper.find(GlAlert);
         expect(alert.exists()).toBe(true);
         expect(alert.text()).toBe(DEVOPS_ADOPTION_STRINGS.app.segmentsError);
+      });
+
+      it('calls Sentry', () => {
         expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(segmentsErrorMessage);
       });
     });
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 138310175a9a2a8c..2896a88b7caa4e73 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -9582,10 +9582,10 @@ msgstr ""
 msgid "DevopsAdoption|Segment"
 msgstr ""
 
-msgid "DevopsAdoption|There was an error fetching Groups"
+msgid "DevopsAdoption|There was an error fetching Groups. Please refresh the page to try again."
 msgstr ""
 
-msgid "DevopsAdoption|There was an error fetching Segments"
+msgid "DevopsAdoption|There was an error fetching Segments. Please refresh the page to try again."
 msgstr ""
 
 msgid "DevopsReport|Adoption"
-- 
GitLab


From 3f3c5ed1f3f6c545786beceafb6eac8c69a151c5 Mon Sep 17 00:00:00 2001
From: Brandon Labuschagne <blabuschagne@gitlab.com>
Date: Wed, 25 Nov 2020 09:33:19 +0200
Subject: [PATCH 3/3] Apply maintainer suggestion

---
 .../dev_ops_report/components/devops_adoption_app.vue      | 6 +++---
 .../dev_ops_report/components/devops_adoption_table.vue    | 7 +++++++
 .../dev_ops_report/components/devops_adoption_app_spec.js  | 2 +-
 3 files changed, 11 insertions(+), 4 deletions(-)

diff --git a/ee/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_app.vue b/ee/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_app.vue
index 2076107980df29b5..96c93c6aabc52ae2 100644
--- a/ee/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_app.vue
+++ b/ee/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_app.vue
@@ -116,11 +116,11 @@ export default {
 </script>
 <template>
   <div v-if="hasLoadingError">
-    <div v-for="(error, key) in errors" :key="key">
-      <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mt-3">
+    <template v-for="(error, key) in errors">
+      <gl-alert v-if="error" :key="key" variant="danger" :dismissible="false" class="gl-mt-3">
         {{ $options.i18n[key] }}
       </gl-alert>
-    </div>
+    </template>
   </div>
   <gl-loading-icon v-else-if="isLoading" size="md" class="gl-my-5" />
   <div v-else>
diff --git a/ee/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_table.vue b/ee/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_table.vue
index 1da63f8a07069f8e..cc99fcd736611430 100644
--- a/ee/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_table.vue
+++ b/ee/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_table.vue
@@ -84,6 +84,7 @@ export default {
 
     <template #cell(issueOpened)="{ item }">
       <devops-adoption-table-cell-flag
+        v-if="item.latestSnapshot"
         :data-testid="$options.testids.ISSUES"
         :enabled="item.latestSnapshot.issueOpened"
       />
@@ -91,6 +92,7 @@ export default {
 
     <template #cell(mergeRequestOpened)="{ item }">
       <devops-adoption-table-cell-flag
+        v-if="item.latestSnapshot"
         :data-testid="$options.testids.MRS"
         :enabled="item.latestSnapshot.mergeRequestOpened"
       />
@@ -98,6 +100,7 @@ export default {
 
     <template #cell(mergeRequestApproved)="{ item }">
       <devops-adoption-table-cell-flag
+        v-if="item.latestSnapshot"
         :data-testid="$options.testids.APPROVALS"
         :enabled="item.latestSnapshot.mergeRequestApproved"
       />
@@ -105,6 +108,7 @@ export default {
 
     <template #cell(runnerConfigured)="{ item }">
       <devops-adoption-table-cell-flag
+        v-if="item.latestSnapshot"
         :data-testid="$options.testids.RUNNERS"
         :enabled="item.latestSnapshot.runnerConfigured"
       />
@@ -112,6 +116,7 @@ export default {
 
     <template #cell(pipelineSucceeded)="{ item }">
       <devops-adoption-table-cell-flag
+        v-if="item.latestSnapshot"
         :data-testid="$options.testids.PIPELINES"
         :enabled="item.latestSnapshot.pipelineSucceeded"
       />
@@ -119,6 +124,7 @@ export default {
 
     <template #cell(deploySucceeded)="{ item }">
       <devops-adoption-table-cell-flag
+        v-if="item.latestSnapshot"
         :data-testid="$options.testids.DEPLOYS"
         :enabled="item.latestSnapshot.deploySucceeded"
       />
@@ -126,6 +132,7 @@ export default {
 
     <template #cell(securityScanSucceeded)="{ item }">
       <devops-adoption-table-cell-flag
+        v-if="item.latestSnapshot"
         :data-testid="$options.testids.SCANNING"
         :enabled="item.latestSnapshot.securityScanSucceeded"
       />
diff --git a/ee/spec/frontend/admin/dev_ops_report/components/devops_adoption_app_spec.js b/ee/spec/frontend/admin/dev_ops_report/components/devops_adoption_app_spec.js
index 26bae07178b0dbad..8275e8ae3a7263ac 100644
--- a/ee/spec/frontend/admin/dev_ops_report/components/devops_adoption_app_spec.js
+++ b/ee/spec/frontend/admin/dev_ops_report/components/devops_adoption_app_spec.js
@@ -36,7 +36,7 @@ const initialResponse = {
 describe('DevopsAdoptionApp', () => {
   let wrapper;
 
-  const groupsEmpty = jest.fn().mockResolvedValueOnce({ __typename: 'Groups', nodes: [] });
+  const groupsEmpty = jest.fn().mockResolvedValue({ __typename: 'Groups', nodes: [] });
   const segmentsEmpty = jest
     .fn()
     .mockResolvedValue({ data: { devopsAdoptionSegments: devopsAdoptionSegmentsDataEmpty } });
-- 
GitLab