Commit 22282d54 authored by mfluharty's avatar mfluharty

Load GraphQL-based component when flag enabled

When graphql_code_quality_full_report feature flag is enabled,
load GraphQL version of code quality report component
Add fixture for GraphQL query response data
Add tests for component functionality
parent 02f4d743
<script>
import { GlSkeletonLoader, GlInfiniteScroll, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { once } from 'lodash';
import produce from 'immer';
import api from '~/api';
import { componentNames } from 'ee/reports/components/issue_body';
import reportsMixin from 'ee/vue_shared/security_reports/mixins/reports_mixin';
import { n__, s__, sprintf } from '~/locale';
import ReportSection from '~/reports/components/report_section.vue';
import CodequalityIssueBody from '~/reports/codequality_report/components/codequality_issue_body.vue';
import { parseCodeclimateMetrics } from '~/reports/codequality_report/store/utils/codequality_parser';
import getCodeQualityViolations from './graphql/queries/get_code_quality_violations.query.graphql';
import { PAGE_SIZE, VIEW_EVENT_NAME } from './store/constants';
export default {
components: {
ReportSection,
CodequalityIssueBody,
GlSkeletonLoader,
GlInfiniteScroll,
GlLoadingIcon,
GlSprintf,
},
mixins: [reportsMixin],
componentNames,
inject: ['projectPath', 'pipelineIid', 'blobPath'],
apollo: {
codequalityViolations: {
query: getCodeQualityViolations,
variables() {
return {
projectPath: this.projectPath,
iid: this.pipelineIid,
first: PAGE_SIZE,
};
},
update({
project: {
pipeline: { codeQualityReports: { edges = [], pageInfo = {}, count = 0 } = {} } = {},
} = {},
}) {
return {
edges,
parsedList: parseCodeclimateMetrics(
edges.map((edge) => edge.node),
this.blobPath,
),
count,
pageInfo,
};
},
error() {
this.errored = true;
},
watchLoading(isLoading) {
if (isLoading) {
this.trackViewEvent();
}
},
},
},
data() {
return {
codequalityViolations: {
edges: [],
parsedList: [],
count: 0,
pageInfo: {},
},
errored: false,
};
},
computed: {
isLoading() {
return this.$apollo.queries.codequalityViolations.loading;
},
hasCodequalityViolations() {
return this.codequalityViolations.count > 0;
},
trackViewEvent() {
return once(() => {
api.trackRedisHllUserEvent(VIEW_EVENT_NAME);
});
},
codequalityText() {
const text = [];
const { count } = this.codequalityViolations;
if (count === 0) {
return s__('ciReport|No code quality issues found');
} else if (count > 0) {
return sprintf(s__('ciReport|Found %{issuesWithCount}'), {
issuesWithCount: n__('%d code quality issue', '%d code quality issues', count),
});
}
return text.join('');
},
codequalityStatus() {
return this.checkReportStatus(this.isLoading && !this.hasCodequalityViolations, this.errored);
},
},
i18n: {
subHeading: s__('ciReport|This report contains all Code Quality issues in the source branch.'),
loadingText: s__('ciReport|Loading Code Quality report'),
errorText: s__('ciReport|Failed to load Code Quality report'),
showingCount: s__('ciReport|Showing %{fetchedItems} of %{totalItems} items'),
},
methods: {
fetchMoreViolations() {
this.$apollo.queries.codequalityViolations
.fetchMore({
variables: {
first: PAGE_SIZE,
after: this.codequalityViolations.pageInfo.endCursor,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
return produce(fetchMoreResult, (draftData) => {
draftData.project.pipeline.codeQualityReports.edges = [
...previousResult.project.pipeline.codeQualityReports.edges,
...draftData.project.pipeline.codeQualityReports.edges,
];
});
},
})
.catch(() => {
this.errored = true;
});
},
},
};
</script>
<template>
<div>
<report-section
always-open
:status="codequalityStatus"
:loading-text="$options.i18n.loadingText"
:error-text="$options.i18n.errorText"
:success-text="codequalityText"
:unresolved-issues="codequalityViolations.parsedList"
:resolved-issues="[]"
:has-issues="hasCodequalityViolations"
:component="$options.componentNames.CodequalityIssueBody"
class="codequality-report"
>
<template v-if="hasCodequalityViolations" #sub-heading>{{
$options.i18n.subHeading
}}</template>
<template #body>
<gl-infinite-scroll
:max-list-height="500"
:fetched-items="codequalityViolations.parsedList.length"
:total-items="codequalityViolations.count"
@bottomReached="fetchMoreViolations"
>
<template #items>
<div class="report-block-container">
<template v-for="(issue, index) in codequalityViolations.parsedList">
<codequality-issue-body
:key="index"
:issue="issue"
class="report-block-list-issue"
/>
</template>
</div>
</template>
<template #default>
<div class="gl-mt-3">
<gl-loading-icon v-if="isLoading" />
<gl-sprintf v-else :message="$options.i18n.showingCount"
><template #fetchedItems>{{ codequalityViolations.parsedList.length }}</template
><template #totalItems>{{ codequalityViolations.count }}</template></gl-sprintf
>
</div>
</template>
</gl-infinite-scroll>
</template>
</report-section>
<div v-if="isLoading && !hasCodequalityViolations" class="report-block-container">
<gl-skeleton-loader :lines="36" />
</div>
</div>
</template>
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getCodeQualityViolations($projectPath: ID!, $iid: ID!, $first: Int, $after: String) {
project(fullPath: $projectPath) {
pipeline(iid: $iid) {
......@@ -13,9 +15,7 @@ query getCodeQualityViolations($projectPath: ID!, $iid: ID!, $first: Int, $after
}
}
pageInfo {
startCursor
endCursor
hasNextPage
...PageInfo
}
}
}
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import CodequalityReportApp from 'ee/codequality_report/codequality_report.vue';
import CodequalityReportAppGraphQL from 'ee/codequality_report/codequality_report_graphql.vue';
import createStore from 'ee/codequality_report/store';
import Translate from '~/vue_shared/translate';
Vue.use(Translate);
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export default () => {
const tabsElement = document.querySelector('.pipelines-tabs');
const codequalityTab = document.getElementById('js-pipeline-codequality-report');
const isGraphqlFeatureFlagEnabled = gon.features?.graphqlCodeQualityFullReport;
if (tabsElement && codequalityTab) {
const fetchReportAction = 'fetchReport';
......@@ -17,38 +26,68 @@ export default () => {
projectPath,
pipelineIid,
} = codequalityTab.dataset;
const store = createStore({
endpoint: codequalityReportDownloadPath,
blobPath,
projectPath,
pipelineIid,
});
const isCodequalityTabActive = Boolean(
document.querySelector('.pipelines-tabs > li > a.codequality-tab.active'),
);
if (isCodequalityTabActive) {
store.dispatch(fetchReportAction);
} else {
const tabClickHandler = (e) => {
if (e.target.className === 'codequality-tab') {
store.dispatch(fetchReportAction);
tabsElement.removeEventListener('click', tabClickHandler);
}
if (isGraphqlFeatureFlagEnabled) {
const vueOptions = {
el: codequalityTab,
apolloProvider,
components: {
CodequalityReportApp: CodequalityReportAppGraphQL,
},
provide: {
projectPath,
pipelineIid,
blobPath,
},
render: (createElement) => createElement('codequality-report-app'),
};
tabsElement.addEventListener('click', tabClickHandler);
}
if (isCodequalityTabActive) {
// eslint-disable-next-line no-new
new Vue(vueOptions);
} else {
const tabClickHandler = (e) => {
if (e.target.className === 'codequality-tab') {
// eslint-disable-next-line no-new
new Vue(vueOptions);
tabsElement.removeEventListener('click', tabClickHandler);
}
};
tabsElement.addEventListener('click', tabClickHandler);
}
} else {
const store = createStore({
endpoint: codequalityReportDownloadPath,
blobPath,
projectPath,
pipelineIid,
});
if (isCodequalityTabActive) {
store.dispatch(fetchReportAction);
} else {
const tabClickHandler = (e) => {
if (e.target.className === 'codequality-tab') {
store.dispatch(fetchReportAction);
tabsElement.removeEventListener('click', tabClickHandler);
}
};
// eslint-disable-next-line no-new
new Vue({
el: codequalityTab,
components: {
CodequalityReportApp,
},
store,
render: (createElement) => createElement('codequality-report-app'),
});
tabsElement.addEventListener('click', tabClickHandler);
}
// eslint-disable-next-line no-new
new Vue({
el: codequalityTab,
components: {
CodequalityReportApp,
},
store,
render: (createElement) => createElement('codequality-report-app'),
});
}
}
};
import { GlInfiniteScroll } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import codeQualityViolationsQueryResponse from 'test_fixtures/graphql/codequality_report/graphql/queries/get_code_quality_violations.query.graphql.json';
import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
import CodequalityReportApp from 'ee/codequality_report/codequality_report_graphql.vue';
import getCodeQualityViolations from 'ee/codequality_report/graphql/queries/get_code_quality_violations.query.graphql';
const codeQualityViolations =
codeQualityViolationsQueryResponse.data.project.pipeline.codeQualityReports;
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Codequality report app', () => {
let wrapper;
const createComponent = (
mockReturnValue = jest.fn().mockResolvedValue(codeQualityViolationsQueryResponse),
mountFn = mount,
) => {
const apolloProvider = createMockApollo([[getCodeQualityViolations, mockReturnValue]]);
wrapper = mountFn(CodequalityReportApp, {
localVue,
apolloProvider,
provide: {
projectPath: 'project-path',
pipelineIid: 'pipeline-iid',
blobPath: '/blob/path',
},
});
};
const findStatus = () => wrapper.find('.js-code-text');
const findSuccessIcon = () => wrapper.find('.js-ci-status-icon-success');
const findWarningIcon = () => wrapper.find('.js-ci-status-icon-warning');
const findInfiniteScroll = () => wrapper.find(GlInfiniteScroll);
afterEach(() => {
wrapper.destroy();
});
describe('when loading', () => {
beforeEach(() => {
createComponent(jest.fn().mockReturnValueOnce(new Promise(() => {})));
});
it('shows a loading state', () => {
expect(findStatus().text()).toBe('Loading Code Quality report');
});
});
describe('on error', () => {
beforeEach(() => {
createComponent(jest.fn().mockRejectedValueOnce(new Error('Error!')));
});
it('shows a warning icon and error message', () => {
expect(findWarningIcon().exists()).toBe(true);
expect(findStatus().text()).toBe('Failed to load Code Quality report');
});
});
describe('when there are codequality issues', () => {
beforeEach(() => {
createComponent(jest.fn().mockResolvedValue(codeQualityViolationsQueryResponse));
});
it('renders the codequality issues', () => {
const expectedIssueTotal = codeQualityViolations.count;
expect(findWarningIcon().exists()).toBe(true);
expect(findInfiniteScroll().exists()).toBe(true);
expect(findStatus().text()).toContain(`Found ${expectedIssueTotal} code quality issues`);
expect(findStatus().text()).toContain(
`This report contains all Code Quality issues in the source branch.`,
);
expect(wrapper.findAll('.report-block-list-issue')).toHaveLength(expectedIssueTotal);
});
it('renders a link to the line where the issue was found', () => {
const issueLink = wrapper.find('.report-block-list-issue a');
expect(issueLink.text()).toBe('foo.rb:10');
expect(issueLink.attributes('href')).toBe('/blob/path/foo.rb#L10');
});
it('loads the next page when the end of the list is reached', async () => {
jest
.spyOn(wrapper.vm.$apollo.queries.codequalityViolations, 'fetchMore')
.mockResolvedValue({});
findInfiniteScroll().vm.$emit('bottomReached');
await waitForPromises();
expect(wrapper.vm.$apollo.queries.codequalityViolations.fetchMore).toHaveBeenCalledWith(
expect.objectContaining({
variables: expect.objectContaining({
after: codeQualityViolations.pageInfo.endCursor,
}),
}),
);
});
});
describe('when there are no codequality issues', () => {
beforeEach(() => {
const emptyResponse = {
data: {
project: {
pipeline: {
codeQualityReports: {
...codeQualityViolations,
edges: [],
count: 0,
},
},
},
},
};
createComponent(jest.fn().mockResolvedValue(emptyResponse));
});
it('shows a message that no codequality issues were found', () => {
expect(findSuccessIcon().exists()).toBe(true);
expect(findStatus().text()).toBe('No code quality issues found');
expect(wrapper.findAll('.report-block-list-issue')).toHaveLength(0);
});
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Code Quality Report (GraphQL fixtures)' do
describe GraphQL::Query, type: :request do
include ApiHelpers
include GraphqlHelpers
include JavaScriptFixturesHelpers
let_it_be(:project) { create(:project, :repository) }
let_it_be(:current_user) { create(:user) }
let_it_be(:pipeline) { create(:ci_pipeline, :success, :with_codequality_reports, project: project) }
codequality_report_query_path = 'codequality_report/graphql/queries/get_code_quality_violations.query.graphql'
it "graphql/#{codequality_report_query_path}.json" do
project.add_developer(current_user)
query = get_graphql_query_as_string(codequality_report_query_path, ee: true)
post_graphql(query, current_user: current_user, variables: { projectPath: project.full_path, iid: pipeline.iid })
expect_graphql_errors_to_be_empty
end
end
end
......@@ -40214,6 +40214,9 @@ msgstr ""
msgid "ciReport|Failed to load %{reportName} report"
msgstr ""
msgid "ciReport|Failed to load Code Quality report"
msgstr ""
msgid "ciReport|Fixed"
msgstr ""
......@@ -40246,6 +40249,9 @@ msgstr ""
msgid "ciReport|Loading %{reportName} report"
msgstr ""
msgid "ciReport|Loading Code Quality report"
msgstr ""
msgid "ciReport|Manage licenses"
msgstr ""
......@@ -40282,6 +40288,9 @@ msgstr ""
msgid "ciReport|Security scanning failed loading any results"
msgstr ""
msgid "ciReport|Showing %{fetchedItems} of %{totalItems} items"
msgstr ""
msgid "ciReport|Solution"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment