diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/dora_chart.vue b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/dora_chart.vue
index 8413db58c1f4a52e9cf46ed128219cf3f78efb58..6e88d9faa48125aabd7c54b4addcedec82d6c3d9 100644
--- a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/dora_chart.vue
+++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/dora_chart.vue
@@ -1,10 +1,14 @@
 <script>
+import { GlLoadingIcon } from '@gitlab/ui';
 import ComparisonChart from 'ee/analytics/dashboards/components/comparison_chart.vue';
+import GroupOrProjectProvider from 'ee/analytics/dashboards/components/group_or_project_provider.vue';
 
 export default {
   name: 'DoraChart',
   components: {
     ComparisonChart,
+    GlLoadingIcon,
+    GroupOrProjectProvider,
   },
   props: {
     data: {
@@ -22,10 +26,19 @@ export default {
 </script>
 
 <template>
-  <comparison-chart
-    :request-path="data.namespace.requestPath"
-    :is-project="Boolean(data.namespace.isProject)"
-    :exclude-metrics="data.excludeMetrics"
-    :filter-labels="data.filterLabels"
-  />
+  <group-or-project-provider
+    #default="{ isProject, isNamespaceLoading }"
+    :full-path="data.namespace"
+  >
+    <div v-if="isNamespaceLoading" class="gl--flex-center gl-h-full">
+      <gl-loading-icon size="lg" />
+    </div>
+    <comparison-chart
+      v-else
+      :request-path="data.namespace"
+      :is-project="isProject"
+      :exclude-metrics="data.excludeMetrics"
+      :filter-labels="data.filterLabels"
+    />
+  </group-or-project-provider>
 </template>
diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/dora_performers_score.vue b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/dora_performers_score.vue
index 26bc7f144e55f31f46da15a647e31e42bf63a63e..895160185bcc92cf8dd22da6c6a18670e5fce9d3 100644
--- a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/dora_performers_score.vue
+++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/dora_performers_score.vue
@@ -1,11 +1,15 @@
 <script>
+import { GlLoadingIcon } from '@gitlab/ui';
 import { DORA_PERFORMERS_SCORE_PROJECT_ERROR } from 'ee/analytics/dashboards/constants';
 import DoraPerformersScoreChart from 'ee/analytics/dashboards/components/dora_performers_score_chart.vue';
+import GroupOrProjectProvider from 'ee/analytics/dashboards/components/group_or_project_provider.vue';
 
 export default {
   name: 'DoraPerformersScoreVisualization',
   components: {
     DoraPerformersScoreChart,
+    GlLoadingIcon,
+    GroupOrProjectProvider,
   },
   props: {
     data: {
@@ -20,24 +24,33 @@ export default {
     },
   },
   computed: {
-    isProject() {
-      return this.data.namespace.isProject;
-    },
-    formattedData() {
-      return { namespace: this.data.namespace.requestPath };
+    fullPath() {
+      return this.data?.namespace;
     },
   },
-  mounted() {
-    if (this.isProject) {
-      this.$emit('error', { error: DORA_PERFORMERS_SCORE_PROJECT_ERROR, canRetry: false });
-    }
+  methods: {
+    handleResolveNamespace({ isProject = false }) {
+      if (isProject) {
+        this.$emit('error', { error: DORA_PERFORMERS_SCORE_PROJECT_ERROR, canRetry: false });
+      }
+    },
   },
 };
 </script>
 <template>
-  <dora-performers-score-chart
-    v-if="!isProject"
-    :data="formattedData"
-    @error="$emit('error', arguments[0])"
-  />
+  <group-or-project-provider
+    #default="{ isNamespaceLoading, isProject }"
+    :full-path="fullPath"
+    @done="handleResolveNamespace"
+    @error="(errorMsg) => $emit('error', errorMsg)"
+  >
+    <div v-if="isNamespaceLoading" class="gl--flex-center gl-h-full">
+      <gl-loading-icon size="lg" />
+    </div>
+    <dora-performers-score-chart
+      v-else-if="!isNamespaceLoading && !isProject"
+      :data="data"
+      @error="$emit('error', arguments[0])"
+    />
+  </group-or-project-provider>
 </template>
diff --git a/ee/app/assets/javascripts/analytics/dashboards/components/comparison_chart.vue b/ee/app/assets/javascripts/analytics/dashboards/components/comparison_chart.vue
index adc641e1acf642635de1b4d622fb02301c3759df..29a2140ff0a5b819b261af6257b535b88fb75fa3 100644
--- a/ee/app/assets/javascripts/analytics/dashboards/components/comparison_chart.vue
+++ b/ee/app/assets/javascripts/analytics/dashboards/components/comparison_chart.vue
@@ -2,7 +2,6 @@
 import { GlAlert } from '@gitlab/ui';
 import { uniq } from 'lodash';
 import * as Sentry from '~/sentry/sentry_browser_wrapper';
-import { joinPaths } from '~/lib/utils/url_utility';
 import { toYmd } from '~/analytics/shared/utils';
 import { CONTRIBUTOR_METRICS } from '~/analytics/shared/constants';
 import VulnerabilitiesQuery from '../graphql/vulnerabilities.query.graphql';
@@ -92,9 +91,6 @@ export default {
     };
   },
   computed: {
-    namespaceRequestPath() {
-      return this.isProject ? this.requestPath : joinPaths('groups', this.requestPath);
-    },
     filteredQueries() {
       return [
         { metrics: SUPPORTED_DORA_METRICS, queryFn: this.fetchDoraMetricsQuery },
@@ -303,7 +299,7 @@ export default {
     >
     <comparison-table
       :table-data="tableData"
-      :request-path="namespaceRequestPath"
+      :request-path="requestPath"
       :is-project="isProject"
       :now="$options.now"
       :filter-labels="filterLabels"
diff --git a/ee/app/assets/javascripts/analytics/dashboards/components/dora_visualization.vue b/ee/app/assets/javascripts/analytics/dashboards/components/dora_visualization.vue
index 15f40205b87806f728ef5a0e591d14cdf40ecbf8..f01b40c758b8defa6769458515c1ef62d89984d9 100644
--- a/ee/app/assets/javascripts/analytics/dashboards/components/dora_visualization.vue
+++ b/ee/app/assets/javascripts/analytics/dashboards/components/dora_visualization.vue
@@ -2,8 +2,7 @@
 import { uniq, flatten, uniqBy } from 'lodash';
 import { GlSkeletonLoader, GlAlert } from '@gitlab/ui';
 import { sprintf } from '~/locale';
-import { TYPENAME_PROJECT } from '~/graphql_shared/constants';
-import getGroupOrProject from '../graphql/get_group_or_project.query.graphql';
+import GroupOrProjectProvider from 'ee/analytics/dashboards/components/group_or_project_provider.vue';
 import filterLabelsQueryBuilder, { LABEL_PREFIX } from '../graphql/filter_labels_query_builder';
 import {
   DASHBOARD_DESCRIPTION_GROUP,
@@ -22,6 +21,7 @@ export default {
     ComparisonChartLabels,
     GlAlert,
     GlSkeletonLoader,
+    GroupOrProjectProvider,
   },
   props: {
     title: {
@@ -29,24 +29,16 @@ export default {
       required: false,
       default: '',
     },
+    fullPath: {
+      type: String,
+      required: true,
+    },
     data: {
       type: Object,
       required: true,
     },
   },
   apollo: {
-    groupOrProject: {
-      query: getGroupOrProject,
-      variables() {
-        return { fullPath: this.fullPath };
-      },
-      skip() {
-        return !this.fullPath;
-      },
-      update(data) {
-        return data;
-      },
-    },
     filterLabelsResults: {
       query() {
         return filterLabelsQueryBuilder(this.filterLabelsQuery, this.isProject);
@@ -72,19 +64,15 @@ export default {
   },
   data() {
     return {
-      groupOrProject: null,
+      namespace: null,
+      isProject: false,
+      hasNamespaceError: false,
       filterLabelsResults: [],
     };
   },
   computed: {
     loading() {
-      return (
-        this.$apollo.queries.groupOrProject.loading ||
-        this.$apollo.queries.filterLabelsResults.loading
-      );
-    },
-    fullPath() {
-      return this.data?.namespace;
+      return this.$apollo.queries.filterLabelsResults.loading;
     },
     filterLabelsQuery() {
       return this.data?.filter_labels || [];
@@ -102,21 +90,12 @@ export default {
       }
       return uniq(metrics);
     },
-    namespace() {
-      return this.groupOrProject?.group || this.groupOrProject?.project;
-    },
-    isProject() {
-      // eslint-disable-next-line no-underscore-dangle
-      return this.namespace?.__typename === TYPENAME_PROJECT;
-    },
     defaultTitle() {
       const name = this.namespace?.name;
       const text = this.isProject ? DASHBOARD_DESCRIPTION_PROJECT : DASHBOARD_DESCRIPTION_GROUP;
       return sprintf(text, { name });
     },
     loadNamespaceError() {
-      if (this.namespace) return '';
-
       const { fullPath } = this;
       return sprintf(DASHBOARD_NAMESPACE_LOAD_ERROR, { fullPath });
     },
@@ -127,45 +106,61 @@ export default {
       return sprintf(DASHBOARD_LABELS_LOAD_ERROR, { labels });
     },
   },
+  methods: {
+    handleNamespaceError() {
+      this.hasNamespaceError = true;
+    },
+    handleResolveNamespace({ group, project, isProject }) {
+      this.namespace = group ?? project;
+      this.isProject = isProject;
+    },
+  },
 };
 </script>
 <template>
-  <div v-if="loading">
-    <gl-skeleton-loader :lines="1" />
-  </div>
-  <gl-alert
-    v-else-if="loadNamespaceError"
-    class="gl-mt-5"
-    variant="danger"
-    :dismissible="false"
-    data-testid="load-namespace-error"
+  <group-or-project-provider
+    #default="{ isNamespaceLoading }"
+    :full-path="fullPath"
+    @done="handleResolveNamespace"
+    @error="handleNamespaceError"
   >
-    {{ loadNamespaceError }}
-  </gl-alert>
-  <div v-else>
-    <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
-      <h5 data-testid="comparison-chart-title">{{ title || defaultTitle }}</h5>
-      <comparison-chart-labels
-        v-if="hasFilterLabels"
-        :labels="filterLabelsResults"
-        :web-url="namespace.webUrl"
-      />
+    <div v-if="loading || isNamespaceLoading">
+      <gl-skeleton-loader :lines="1" />
     </div>
-
     <gl-alert
-      v-if="loadLabelsError"
+      v-else-if="hasNamespaceError"
+      class="gl-mt-5"
       variant="danger"
       :dismissible="false"
-      data-testid="load-labels-error"
+      data-testid="load-namespace-error"
     >
-      {{ loadLabelsError }}
+      {{ loadNamespaceError }}
     </gl-alert>
-    <comparison-chart
-      v-else
-      :request-path="fullPath"
-      :is-project="isProject"
-      :exclude-metrics="excludeMetrics"
-      :filter-labels="filterLabelNames"
-    />
-  </div>
+    <div v-else>
+      <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
+        <h5 data-testid="comparison-chart-title">{{ title || defaultTitle }}</h5>
+        <comparison-chart-labels
+          v-if="hasFilterLabels"
+          :labels="filterLabelsResults"
+          :web-url="namespace.webUrl"
+        />
+      </div>
+
+      <gl-alert
+        v-if="loadLabelsError"
+        variant="danger"
+        :dismissible="false"
+        data-testid="load-labels-error"
+      >
+        {{ loadLabelsError }}
+      </gl-alert>
+      <comparison-chart
+        v-else
+        :request-path="fullPath"
+        :is-project="isProject"
+        :exclude-metrics="excludeMetrics"
+        :filter-labels="filterLabelNames"
+      />
+    </div>
+  </group-or-project-provider>
 </template>
diff --git a/ee/app/assets/javascripts/analytics/dashboards/components/group_or_project_provider.vue b/ee/app/assets/javascripts/analytics/dashboards/components/group_or_project_provider.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2afaa0d19f42cab3949321786aa213fa67b0adc7
--- /dev/null
+++ b/ee/app/assets/javascripts/analytics/dashboards/components/group_or_project_provider.vue
@@ -0,0 +1,61 @@
+<script>
+import { __, sprintf } from '~/locale';
+import GetGroupOrProjectQuery from '../graphql/get_group_or_project.query.graphql';
+
+const NAMESPACE_LOAD_ERROR = __('Failed to fetch Namespace: %{fullPath}');
+
+/**
+ * Renderless component that resolves a namespace as a group or project
+ * given its path name as a property
+ */
+export default {
+  name: 'GroupOrProjectProvider',
+  props: {
+    fullPath: {
+      type: String,
+      required: true,
+    },
+  },
+  emits: ['done', 'error'],
+  data() {
+    return {
+      namespace: {},
+    };
+  },
+  apollo: {
+    namespace: {
+      query: GetGroupOrProjectQuery,
+      variables() {
+        return { fullPath: this.fullPath };
+      },
+      update(response) {
+        const { group = null, project = null } = response;
+        return { group, project, isProject: Boolean(project) };
+      },
+      error() {
+        this.$emit('error', sprintf(NAMESPACE_LOAD_ERROR, { fullPath: this.fullPath }));
+      },
+    },
+  },
+  computed: {
+    loading() {
+      return this.$apollo.queries.namespace.loading;
+    },
+  },
+  render() {
+    if (this.loading) {
+      return this.$scopedSlots.default({ isNamespaceLoading: this.loading });
+    }
+
+    const { group, project, isProject } = this.namespace;
+    this.$emit('done', { group, project, isProject });
+
+    return this.$scopedSlots.default({
+      group,
+      project,
+      isProject,
+      isNamespaceLoading: false,
+    });
+  },
+};
+</script>
diff --git a/ee/app/assets/javascripts/analytics/dashboards/value_streams_dashboard/components/app.vue b/ee/app/assets/javascripts/analytics/dashboards/value_streams_dashboard/components/app.vue
index e2be42306098ef521228bbcb177d92e942025062..eff6cbbdc3f51f495262436347e003d7ee509663 100644
--- a/ee/app/assets/javascripts/analytics/dashboards/value_streams_dashboard/components/app.vue
+++ b/ee/app/assets/javascripts/analytics/dashboards/value_streams_dashboard/components/app.vue
@@ -129,6 +129,7 @@ export default {
         :key="index"
         :title="title"
         :data="data"
+        :full-path="data.namespace"
         data-testid="panel-dora-chart"
       />
 
diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/panels_base.vue b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/panels_base.vue
index b578dc0a7859ebb863f77a9a8a30b07261988c7f..b39bc5cb856bd76c473f944d8c17cb7bb6f3fc58 100644
--- a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/panels_base.vue
+++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/panels_base.vue
@@ -135,11 +135,7 @@ export default {
       };
     },
     namespace() {
-      return {
-        name: this.namespaceName,
-        requestPath: this.namespaceFullPath,
-        isProject: this.isProject,
-      };
+      return this.namespaceFullPath;
     },
     panelTitle() {
       return sprintf(this.title, { namespaceName: this.rootNamespaceName });
diff --git a/ee/spec/frontend/analytics/analytics_dashboards/components/visualizations/dora_chart_spec.js b/ee/spec/frontend/analytics/analytics_dashboards/components/visualizations/dora_chart_spec.js
index ddfecbe2716cc4bcb063b4146029dc19d914ca37..98f864f554dca8fd91858bb7017ecdeb96821c7b 100644
--- a/ee/spec/frontend/analytics/analytics_dashboards/components/visualizations/dora_chart_spec.js
+++ b/ee/spec/frontend/analytics/analytics_dashboards/components/visualizations/dora_chart_spec.js
@@ -1,16 +1,22 @@
+import { GlLoadingIcon } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
 import DoraChart from 'ee/analytics/analytics_dashboards/components/visualizations/dora_chart.vue';
 import ComparisonChart from 'ee/analytics/dashboards/components/comparison_chart.vue';
+import GroupOrProjectProvider from 'ee/analytics/dashboards/components/group_or_project_provider.vue';
+import GetGroupOrProjectQuery from 'ee/analytics/dashboards/graphql/get_group_or_project.query.graphql';
+import { mockGroup } from 'ee_jest/analytics/dashboards/mock_data';
 
-describe('LineChart Visualization', () => {
+Vue.use(VueApollo);
+
+describe('DoraChart Visualization', () => {
   /** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */
   let wrapper;
+  let mockGroupOrProjectRequestHandler;
 
-  const namespace = {
-    title: 'Awesome Co. project',
-    requestPath: 'some/fake/path',
-    isProject: true,
-  };
+  const namespace = 'some/fake/path';
 
   const excludeMetrics = ['metric_one', 'metric_two'];
   const filterLabels = ['label_a'];
@@ -22,25 +28,63 @@ describe('LineChart Visualization', () => {
   };
 
   const findChart = () => wrapper.findComponent(ComparisonChart);
+  const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
 
   const createWrapper = (props = {}) => {
     wrapper = shallowMountExtended(DoraChart, {
+      apolloProvider: createMockApollo([
+        [GetGroupOrProjectQuery, mockGroupOrProjectRequestHandler],
+      ]),
       propsData: {
         data: defaultData,
         options: {},
         ...props,
       },
+      stubs: { GroupOrProjectProvider },
     });
   };
 
+  afterEach(() => {
+    mockGroupOrProjectRequestHandler = null;
+  });
+
+  describe('when loading', () => {
+    beforeEach(() => {
+      mockGroupOrProjectRequestHandler = jest.fn().mockImplementation(() => new Promise(() => {}));
+
+      createWrapper();
+    });
+
+    it('renders the loading icon', () => {
+      expect(findLoadingIcon().exists()).toBe(true);
+    });
+
+    it('does not render the comparison chart component', () => {
+      expect(findChart().exists()).toBe(false);
+    });
+  });
+
   describe('when mounted', () => {
-    it('renders the comparison chart component', () => {
+    beforeEach(() => {
+      mockGroupOrProjectRequestHandler = jest
+        .fn()
+        .mockReturnValueOnce({ data: { group: mockGroup, project: null } });
+
       createWrapper();
+    });
 
+    it('does not render the loading icon', () => {
+      expect(findLoadingIcon().exists()).toBe(false);
+    });
+
+    it('resolves the namespace', () => {
+      expect(mockGroupOrProjectRequestHandler).toHaveBeenCalledWith({ fullPath: namespace });
+    });
+
+    it('renders the comparison chart component', () => {
       expect(findChart().props()).toMatchObject({
         excludeMetrics,
         filterLabels,
-        isProject: true,
         requestPath: 'some/fake/path',
       });
     });
diff --git a/ee/spec/frontend/analytics/analytics_dashboards/components/visualizations/dora_performers_score_spec.js b/ee/spec/frontend/analytics/analytics_dashboards/components/visualizations/dora_performers_score_spec.js
index 7018450bce3c38c419599146686265ca9137bd12..d35a36057f951d44dcdde2addc45e0279eb86038 100644
--- a/ee/spec/frontend/analytics/analytics_dashboards/components/visualizations/dora_performers_score_spec.js
+++ b/ee/spec/frontend/analytics/analytics_dashboards/components/visualizations/dora_performers_score_spec.js
@@ -1,44 +1,90 @@
+import { GlLoadingIcon } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
 import DoraPerformersScore from 'ee/analytics/analytics_dashboards/components/visualizations/dora_performers_score.vue';
 import DoraChart from 'ee/analytics/dashboards/components/dora_performers_score_chart.vue';
+import GroupOrProjectProvider from 'ee/analytics/dashboards/components/group_or_project_provider.vue';
+import GetGroupOrProjectQuery from 'ee/analytics/dashboards/graphql/get_group_or_project.query.graphql';
+import { mockGroup, mockProject } from 'ee_jest/analytics/dashboards/mock_data';
+
+Vue.use(VueApollo);
 
 describe('DoraPerformersScore Visualization', () => {
   let wrapper;
+  let mockGroupOrProjectRequestHandler;
 
-  const namespace = {
-    title: 'Awesome Co. project',
-    requestPath: 'some/fake/path',
-    isProject: false,
-  };
+  const namespace = 'some/fake/path';
+
+  const mockNamespaceProvider = (args = {}) => ({
+    render() {
+      return this.$scopedSlots.default({
+        group: mockGroup,
+        project: null,
+        isProject: false,
+        isNamespaceLoading: false,
+        ...args,
+      });
+    },
+  });
+
+  const createWrapper = ({ props = {}, group = null, project = null, stubs } = {}) => {
+    mockGroupOrProjectRequestHandler = jest.fn().mockReturnValueOnce({ data: { group, project } });
 
-  const createWrapper = (props = {}) => {
     wrapper = shallowMountExtended(DoraPerformersScore, {
+      apolloProvider: createMockApollo([
+        [GetGroupOrProjectQuery, mockGroupOrProjectRequestHandler],
+      ]),
       propsData: {
         data: { namespace },
         options: {},
         ...props,
       },
+      stubs: {
+        GroupOrProjectProvider,
+        ...stubs,
+      },
     });
   };
 
   const findChart = () => wrapper.findComponent(DoraChart);
+  const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+
+  describe('isLoadingNamespace = true', () => {
+    it('displays a loading state', () => {
+      createWrapper({
+        group: mockGroup,
+        stubs: { GroupOrProjectProvider: mockNamespaceProvider({ isNamespaceLoading: true }) },
+      });
+
+      expect(findLoadingIcon().exists()).toBe(true);
+    });
+  });
 
   describe('for groups', () => {
     beforeEach(() => {
-      createWrapper();
+      createWrapper({ group: mockGroup });
+    });
+
+    it('does not display a loading state', () => {
+      expect(findLoadingIcon().exists()).toBe(false);
+    });
+
+    it('resolves the namespace', () => {
+      expect(mockGroupOrProjectRequestHandler).toHaveBeenCalled();
     });
 
     it('renders the panel', () => {
       expect(findChart().props().data).toMatchObject({
-        namespace: namespace.requestPath,
+        namespace,
       });
     });
   });
 
   describe('for projects', () => {
-    const projectNamespace = { ...namespace, isProject: true };
     beforeEach(() => {
-      createWrapper({ data: { namespace: projectNamespace } });
+      createWrapper({ project: mockProject });
     });
 
     it('does not render the panel', () => {
diff --git a/ee/spec/frontend/analytics/dashboards/components/comparison_chart_spec.js b/ee/spec/frontend/analytics/dashboards/components/comparison_chart_spec.js
index 6dab01ea83a9e06f4114c97eab020a0b21e2a985..4f79bcabb9f3e0ce5492d6600755c55caa11dc73 100644
--- a/ee/spec/frontend/analytics/dashboards/components/comparison_chart_spec.js
+++ b/ee/spec/frontend/analytics/dashboards/components/comparison_chart_spec.js
@@ -180,7 +180,8 @@ describe('Comparison chart', () => {
   describe('loading table and chart data', () => {
     beforeEach(() => {
       setGraphqlQueryHandlerResponses();
-      createWrapper();
+
+      createWrapper({ apolloProvider: createMockApolloProvider() });
     });
 
     it('will pass skeleton data to the comparison table', () => {
@@ -406,7 +407,7 @@ describe('Comparison chart', () => {
     });
   });
 
-  describe('isProject=true', () => {
+  describe('with a project namespace', () => {
     const fakeProjectPath = 'fake/project/path';
 
     beforeEach(async () => {
diff --git a/ee/spec/frontend/analytics/dashboards/components/dora_visualization_spec.js b/ee/spec/frontend/analytics/dashboards/components/dora_visualization_spec.js
index b5d892e2135b658d5593f1d279f8f82affb6f9ac..443e53f57ccb972b5587881774d3caa5bf966313 100644
--- a/ee/spec/frontend/analytics/dashboards/components/dora_visualization_spec.js
+++ b/ee/spec/frontend/analytics/dashboards/components/dora_visualization_spec.js
@@ -1,7 +1,6 @@
 import VueApollo from 'vue-apollo';
 import Vue from 'vue';
 import { GlSkeletonLoader } from '@gitlab/ui';
-import { TYPENAME_GROUP, TYPENAME_PROJECT } from '~/graphql_shared/constants';
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
 import createMockApollo from 'helpers/mock_apollo_helper';
 import waitForPromises from 'helpers/wait_for_promises';
@@ -9,8 +8,10 @@ import { METRICS_WITHOUT_LABEL_FILTERING } from 'ee/analytics/dashboards/constan
 import DoraVisualization from 'ee/analytics/dashboards/components/dora_visualization.vue';
 import ComparisonChartLabels from 'ee/analytics/dashboards/components/comparison_chart_labels.vue';
 import ComparisonChart from 'ee/analytics/dashboards/components/comparison_chart.vue';
-import getGroupOrProjectQuery from 'ee/analytics/dashboards/graphql/get_group_or_project.query.graphql';
+import GroupOrProjectProvider from 'ee/analytics/dashboards/components/group_or_project_provider.vue';
+import GetGroupOrProjectQuery from 'ee/analytics/dashboards/graphql/get_group_or_project.query.graphql';
 import filterLabelsQueryBuilder from 'ee/analytics/dashboards/graphql/filter_labels_query_builder';
+import { mockGroup, mockProject } from '../mock_data';
 import { mockFilterLabelsResponse } from '../helpers';
 
 Vue.use(VueApollo);
@@ -18,30 +19,34 @@ Vue.use(VueApollo);
 describe('DoraVisualization', () => {
   let wrapper;
 
-  const mockGroup = {
-    id: 'gid://gitlab/Group/10',
-    name: 'Group 10',
-    webUrl: 'gdk.test/groups/group-10',
-    __typename: TYPENAME_GROUP,
-  };
-  const mockProject = {
-    id: 'gid://gitlab/Project/20',
-    name: 'Project 20',
-    webUrl: 'gdk.test/group-10/project-20',
-    __typename: TYPENAME_PROJECT,
-  };
+  const mockNamespaceProvider = (args = {}) => ({
+    render() {
+      return this.$scopedSlots.default({
+        group: mockGroup,
+        project: null,
+        isProject: false,
+        isNamespaceLoading: false,
+        ...args,
+      });
+    },
+  });
 
   const createWrapper = async ({
     props = {},
-    group = null,
-    project = null,
     filterLabelsResolver = null,
+    groupOrProjectResolver = null,
+    isProject = false,
+    stubs = { GroupOrProjectProvider },
   } = {}) => {
     const filterLabels = props.data?.filter_labels || [];
     const apolloProvider = createMockApollo([
-      [getGroupOrProjectQuery, jest.fn().mockResolvedValue({ data: { group, project } })],
       [
-        filterLabelsQueryBuilder(filterLabels, !group),
+        GetGroupOrProjectQuery,
+        groupOrProjectResolver ||
+          jest.fn().mockResolvedValueOnce({ data: { group: mockGroup, project: null } }),
+      ],
+      [
+        filterLabelsQueryBuilder(filterLabels, isProject),
         filterLabelsResolver ||
           jest.fn().mockResolvedValue({ data: mockFilterLabelsResponse(filterLabels) }),
       ],
@@ -50,9 +55,11 @@ describe('DoraVisualization', () => {
     wrapper = shallowMountExtended(DoraVisualization, {
       apolloProvider,
       propsData: {
-        data: { namespace: 'test/one' },
+        fullPath: 'test/one',
+        data: {},
         ...props,
       },
+      stubs,
     });
 
     await waitForPromises();
@@ -71,12 +78,28 @@ describe('DoraVisualization', () => {
   const findTitle = () => wrapper.findByTestId('comparison-chart-title');
 
   it('shows a loading skeleton when fetching group/project details', () => {
-    createWrapper();
+    createWrapper({
+      stubs: { GroupOrProjectProvider: mockNamespaceProvider({ isNamespaceLoading: true }) },
+    });
+
     expect(findSkeletonLoader().exists()).toBe(true);
   });
 
+  it('requests the namespace data', async () => {
+    const handler = jest.fn().mockResolvedValueOnce();
+    await createWrapper({
+      groupOrProjectResolver: handler,
+    });
+
+    expect(handler).toHaveBeenCalledTimes(1);
+  });
+
   it('shows an error alert if it failed to fetch group/project', async () => {
-    await createWrapper();
+    await createWrapper({
+      fullPath: 'test/one',
+      groupOrProjectResolver: jest.fn().mockRejectedValueOnce(),
+    });
+
     expect(findNamespaceErrorAlert().exists()).toBe(true);
     expect(findNamespaceErrorAlert().text()).toBe(
       'Failed to load comparison chart for Namespace: test/one',
@@ -84,65 +107,71 @@ describe('DoraVisualization', () => {
   });
 
   it('passes data attributes to the comparison chart', async () => {
-    const requestPath = 'test';
+    const fullPath = 'test';
     const excludeMetrics = ['one', 'two'];
-    await createWrapper({
-      props: { data: { namespace: requestPath, exclude_metrics: excludeMetrics } },
-      group: mockGroup,
-    });
+
+    await createWrapper({ props: { fullPath, data: { exclude_metrics: excludeMetrics } } });
     expect(findComparisonChart().props()).toEqual(
-      expect.objectContaining({
-        requestPath,
-        excludeMetrics,
-      }),
+      expect.objectContaining({ requestPath: fullPath, excludeMetrics }),
     );
   });
 
   it('renders a group with the default title', async () => {
-    await createWrapper({ group: mockGroup });
+    await createWrapper();
+
     expect(findTitle().text()).toEqual(`Metrics comparison for ${mockGroup.name} group`);
-    expect(findComparisonChart().props('isProject')).toBe(false);
   });
 
   it('renders a project with the default title', async () => {
-    await createWrapper({ project: mockProject });
+    await createWrapper({
+      isProject: true,
+      groupOrProjectResolver: jest
+        .fn()
+        .mockResolvedValueOnce({ data: { group: null, project: mockProject } }),
+    });
     expect(findTitle().text()).toEqual(`Metrics comparison for ${mockProject.name} project`);
-    expect(findComparisonChart().props('isProject')).toBe(true);
   });
 
   it('renders the custom title from the `title` prop', async () => {
     const title = 'custom title';
-    await createWrapper({ props: { title }, group: mockGroup });
+
+    await createWrapper({ props: { title } });
     expect(findTitle().text()).toEqual(title);
   });
 
   describe('filter_labels', () => {
-    const namespace = 'test';
+    const fullPath = 'test';
 
     it('does not show labels when not defined', async () => {
-      await createWrapper({
-        props: { data: { namespace } },
-        group: mockGroup,
-      });
+      await createWrapper({ props: { fullPath } });
       expect(findComparisonChartLabels().exists()).toBe(false);
       expect(findComparisonChart().props('filterLabels')).toEqual([]);
     });
 
     it('does not show labels when empty', async () => {
-      await createWrapper({
-        props: { data: { namespace, filter_labels: [] } },
-        group: mockGroup,
-      });
+      await createWrapper({ props: { fullPath, data: { filter_labels: [] } } });
       expect(findComparisonChartLabels().exists()).toBe(false);
       expect(findComparisonChart().props('filterLabels')).toEqual([]);
     });
 
+    it('shows a loader when loading', async () => {
+      const testLabels = ['testA', 'testB'];
+
+      await createWrapper({
+        props: { fullPath, data: { filter_labels: testLabels } },
+        filterLabelsResolver: jest.fn().mockImplementation(() => new Promise(() => {})),
+      });
+
+      expect(findSkeletonLoader().exists()).toBe(true);
+      expect(findComparisonChart().exists()).toBe(false);
+    });
+
     it('shows an error alert if it failed to fetch labels', async () => {
       const testLabels = ['testA', 'testB'];
+
       await createWrapper({
-        props: { data: { namespace, filter_labels: testLabels } },
+        props: { fullPath, data: { filter_labels: testLabels } },
         filterLabelsResolver: jest.fn().mockRejectedValue(),
-        group: mockGroup,
       });
 
       expect(findComparisonChartLabels().exists()).toBe(false);
@@ -156,9 +185,9 @@ describe('DoraVisualization', () => {
     it('removes duplicate labels from the result', async () => {
       const dupLabel = 'testA';
       const testLabels = [dupLabel, dupLabel, dupLabel];
+
       await createWrapper({
-        props: { data: { namespace, filter_labels: testLabels } },
-        group: mockGroup,
+        props: { fullPath, data: { filter_labels: testLabels } },
       });
 
       expect(findComparisonChartLabels().exists()).toBe(true);
@@ -170,9 +199,9 @@ describe('DoraVisualization', () => {
     it('in addition to `exclude_metrics`, will exclude incompatible metrics', async () => {
       const testLabels = ['testA'];
       const excludeMetrics = ['cycle_time'];
+
       await createWrapper({
-        props: { data: { namespace, filter_labels: testLabels, exclude_metrics: excludeMetrics } },
-        group: mockGroup,
+        props: { fullPath, data: { filter_labels: testLabels, exclude_metrics: excludeMetrics } },
       });
 
       expect(findComparisonChart().props()).toEqual(
diff --git a/ee/spec/frontend/analytics/dashboards/components/group_or_project_provider_spec.js b/ee/spec/frontend/analytics/dashboards/components/group_or_project_provider_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..eb4bae31e45456393856930b05be498c7f36c39f
--- /dev/null
+++ b/ee/spec/frontend/analytics/dashboards/components/group_or_project_provider_spec.js
@@ -0,0 +1,116 @@
+import VueApollo from 'vue-apollo';
+import Vue from 'vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import GroupOrProjectProvider from 'ee/analytics/dashboards/components/group_or_project_provider.vue';
+import GetGroupOrProjectQuery from 'ee/analytics/dashboards/graphql/get_group_or_project.query.graphql';
+import { mockGroup, mockProject } from '../mock_data';
+
+Vue.use(VueApollo);
+
+describe('GroupOrProjectProvider', () => {
+  let wrapper;
+  let mockHandler;
+
+  const fullPath = 'fake/full/path';
+  const defaultScopedSlotSpy = jest.fn();
+  const scopedSlots = {
+    default: defaultScopedSlotSpy,
+  };
+
+  const createComponent = async ({ groupOrProjectResolver, group, project }) => {
+    const apolloProvider = createMockApollo([
+      [
+        GetGroupOrProjectQuery,
+        groupOrProjectResolver || jest.fn().mockResolvedValueOnce({ data: { group, project } }),
+      ],
+    ]);
+
+    wrapper = shallowMountExtended(GroupOrProjectProvider, {
+      apolloProvider,
+      propsData: {
+        fullPath,
+      },
+      scopedSlots,
+    });
+
+    await waitForPromises();
+  };
+
+  afterEach(() => {
+    defaultScopedSlotSpy.mockRestore();
+  });
+
+  describe('default', () => {
+    beforeEach(async () => {
+      mockHandler = jest.fn().mockResolvedValueOnce({ data: { group: mockGroup, project: null } });
+      await createComponent({ groupOrProjectResolver: mockHandler });
+    });
+
+    it('requests the group or project namespace', () => {
+      expect(mockHandler).toHaveBeenCalled();
+    });
+
+    it('emits `done` when the request completes', () => {
+      expect(wrapper.emitted('done')).toBeDefined();
+    });
+
+    it('sets isNamespaceLoading=false', () => {
+      expect(defaultScopedSlotSpy).toHaveBeenCalledWith(
+        expect.objectContaining({ isNamespaceLoading: false }),
+      );
+    });
+  });
+
+  describe('loading', () => {
+    beforeEach(async () => {
+      mockHandler = jest.fn().mockImplementation(() => new Promise(() => {}));
+      await createComponent({ groupOrProjectResolver: mockHandler });
+    });
+
+    it('sets isNamespaceLoading=true', () => {
+      expect(defaultScopedSlotSpy).toHaveBeenCalledWith(
+        expect.objectContaining({ isNamespaceLoading: true }),
+      );
+    });
+  });
+
+  describe('slot data', () => {
+    it.each`
+      type         | group        | project        | isProject
+      ${'group'}   | ${mockGroup} | ${null}        | ${false}
+      ${'project'} | ${null}      | ${mockProject} | ${true}
+    `(
+      'correctly sets the scope data given a $type namespace',
+      async ({ group, project, isProject }) => {
+        await createComponent({ group, project });
+
+        expect(defaultScopedSlotSpy).toHaveBeenCalledWith({
+          group,
+          project,
+          isProject,
+          isNamespaceLoading: false,
+        });
+      },
+    );
+  });
+
+  describe('with a failing request', () => {
+    beforeEach(async () => {
+      mockHandler = jest.fn().mockRejectedValue();
+
+      await createComponent({ groupOrProjectResolver: mockHandler });
+    });
+
+    it('emits `error` for a request failure', () => {
+      expect(wrapper.emitted('error')).toEqual([['Failed to fetch Namespace: fake/full/path']]);
+    });
+
+    it('sets isNamespaceLoading=false', () => {
+      expect(defaultScopedSlotSpy).toHaveBeenCalledWith(
+        expect.objectContaining({ isNamespaceLoading: false }),
+      );
+    });
+  });
+});
diff --git a/ee/spec/frontend/analytics/dashboards/mock_data.js b/ee/spec/frontend/analytics/dashboards/mock_data.js
index 9663a3b5c9d2b2607595e8f906d7ea8071963af5..41457a35ce0989693ba6a8770ddfc20b9d564895 100644
--- a/ee/spec/frontend/analytics/dashboards/mock_data.js
+++ b/ee/spec/frontend/analytics/dashboards/mock_data.js
@@ -1,4 +1,5 @@
 import { isUndefined } from 'lodash';
+import { TYPENAME_GROUP, TYPENAME_PROJECT } from '~/graphql_shared/constants';
 import { nMonthsBefore } from '~/lib/utils/datetime_utility';
 
 const METRIC_IDENTIFIERS = [
@@ -792,3 +793,17 @@ export const mockDoraPerformersScoreChartData = [
     data: [1, 1, 1, 1],
   },
 ];
+
+export const mockGroup = {
+  id: 'gid://gitlab/Group/10',
+  name: 'Group 10',
+  webUrl: 'gdk.test/groups/group-10',
+  __typename: TYPENAME_GROUP,
+};
+
+export const mockProject = {
+  id: 'gid://gitlab/Project/20',
+  name: 'Project 20',
+  webUrl: 'gdk.test/group-10/project-20',
+  __typename: TYPENAME_PROJECT,
+};
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 08d2f1dc6a42162b0929f0b0f41f794407d35e06..9e80cb7b23a758150f66301359459d90bc697fc2 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -20841,6 +20841,9 @@ msgstr ""
 msgid "Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later."
 msgstr ""
 
+msgid "Failed to fetch Namespace: %{fullPath}"
+msgstr ""
+
 msgid "Failed to fetch the iteration for this issue. Please try again."
 msgstr ""