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 ""