Commit 38bc21e3 authored by Miguel Rincon's avatar Miguel Rincon

Merge branch '328257-try-to-improve-loading-and-caching' into 'master'

Improve graphql full code quality report UX

See merge request gitlab-org/gitlab!72036
parents 7304f86c f5b268ee
<script> <script>
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import { GlPagination, GlSkeletonLoader } from '@gitlab/ui';
import { componentNames } from 'ee/reports/components/issue_body'; import { componentNames } from 'ee/reports/components/issue_body';
import reportsMixin from 'ee/vue_shared/security_reports/mixins/reports_mixin'; import reportsMixin from 'ee/vue_shared/security_reports/mixins/reports_mixin';
import { n__, s__, sprintf } from '~/locale'; import { n__, s__, sprintf } from '~/locale';
import ReportSection from '~/reports/components/report_section.vue'; import ReportSection from '~/reports/components/report_section.vue';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default { export default {
components: { components: {
ReportSection, ReportSection,
PaginationLinks, PaginationLinks,
GlSkeletonLoader,
GlPagination,
}, },
mixins: [reportsMixin, glFeatureFlagsMixin()], mixins: [reportsMixin],
componentNames, componentNames,
computed: { computed: {
...mapState(['isLoadingCodequality', 'loadingCodequalityFailed', 'pageInfo']), ...mapState(['isLoadingCodequality', 'loadingCodequalityFailed', 'pageInfo']),
...mapGetters(['codequalityIssues', 'codequalityIssueTotal']), ...mapGetters(['codequalityIssues', 'codequalityIssueTotal']),
prevPage() {
return Math.max(this.pageInfo.currentPage - 1, 0);
},
nextPage() {
return this.pageInfo?.hasNextPage ? this.pageInfo.currentPage + 1 : null;
},
hasCodequalityIssues() { hasCodequalityIssues() {
return this.codequalityIssueTotal > 0; return this.codequalityIssueTotal > 0;
}, },
codequalityText() { codequalityText() {
const text = []; const text = [];
const count = this.codequalityIssueTotal; const { codequalityIssueTotal } = this;
if (count === 0) { if (codequalityIssueTotal === 0) {
return s__('ciReport|No code quality issues found'); return s__('ciReport|No code quality issues found');
} else if (count > 0) { } else if (codequalityIssueTotal > 0) {
return sprintf(s__('ciReport|Found %{issuesWithCount}'), { return sprintf(s__('ciReport|Found %{issuesWithCount}'), {
issuesWithCount: n__('%d code quality issue', '%d code quality issues', count), issuesWithCount: n__(
'%d code quality issue',
'%d code quality issues',
codequalityIssueTotal,
),
}); });
} }
...@@ -76,27 +70,13 @@ export default { ...@@ -76,27 +70,13 @@ export default {
:success-text="codequalityText" :success-text="codequalityText"
:unresolved-issues="codequalityIssues" :unresolved-issues="codequalityIssues"
:resolved-issues="[]" :resolved-issues="[]"
:has-issues="hasCodequalityIssues && !isLoadingCodequality" :has-issues="hasCodequalityIssues"
:component="$options.componentNames.CodequalityIssueBody" :component="$options.componentNames.CodequalityIssueBody"
class="codequality-report" class="codequality-report"
> >
<template v-if="hasCodequalityIssues" #sub-heading>{{ $options.i18n.subHeading }}</template> <template v-if="hasCodequalityIssues" #sub-heading>{{ $options.i18n.subHeading }}</template>
</report-section> </report-section>
<div v-if="isLoadingCodequality" class="gl-p-4">
<gl-skeleton-loader :lines="50" />
</div>
<gl-pagination
v-if="glFeatures.graphqlCodeQualityFullReport"
:disabled="isLoadingCodequality"
:value="pageInfo.currentPage"
:prev-page="prevPage"
:next-page="nextPage"
align="center"
class="gl-mt-3"
@input="setPage"
/>
<pagination-links <pagination-links
v-else
:change="setPage" :change="setPage"
:page-info="pageInfo" :page-info="pageInfo"
class="d-flex justify-content-center gl-mt-3" class="d-flex justify-content-center gl-mt-3"
......
<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: { nodes = [], pageInfo = {}, count = 0 } = {} } = {},
} = {},
}) {
return {
nodes,
parsedList: parseCodeclimateMetrics(nodes, this.blobPath),
count,
pageInfo,
};
},
error() {
this.errored = true;
},
watchLoading(isLoading) {
if (isLoading) {
this.trackViewEvent();
}
},
},
},
data() {
return {
codequalityViolations: {
nodes: [],
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.nodes = [
...previousResult.project.pipeline.codeQualityReports.nodes,
...draftData.project.pipeline.codeQualityReports.nodes,
];
});
},
})
.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) { query getCodeQualityViolations($projectPath: ID!, $iid: ID!, $first: Int, $after: String) {
project(fullPath: $projectPath) { project(fullPath: $projectPath) {
pipeline(iid: $iid) { pipeline(iid: $iid) {
codeQualityReports(first: $first, after: $after) { codeQualityReports(first: $first, after: $after) {
count count
edges { nodes {
node { line
line description
description path
path fingerprint
fingerprint severity
severity
}
} }
pageInfo { pageInfo {
startCursor ...PageInfo
endCursor
hasNextPage
} }
} }
} }
......
...@@ -4,30 +4,10 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -4,30 +4,10 @@ import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { parseCodeclimateMetrics } from '~/reports/codequality_report/store/utils/codequality_parser'; import { parseCodeclimateMetrics } from '~/reports/codequality_report/store/utils/codequality_parser';
import getCodeQualityViolations from '../graphql/queries/get_code_quality_violations.query.graphql';
import { VIEW_EVENT_NAME } from './constants'; import { VIEW_EVENT_NAME } from './constants';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { gqClient } from './utils';
export const setPage = ({ state, commit, dispatch }, page) => { export const setPage = ({ commit }, page) => commit(types.SET_PAGE, page);
if (gon.features?.graphqlCodeQualityFullReport) {
const { currentPage, startCursor, endCursor } = state.pageInfo;
if (page > currentPage) {
commit(types.SET_PAGE, {
after: endCursor,
currentPage: page,
});
} else {
commit(types.SET_PAGE, {
after: startCursor,
currentPage: page,
});
}
return dispatch('fetchReport');
}
return commit(types.SET_PAGE, { page });
};
export const requestReport = ({ commit }) => { export const requestReport = ({ commit }) => {
commit(types.REQUEST_REPORT); commit(types.REQUEST_REPORT);
...@@ -35,49 +15,24 @@ export const requestReport = ({ commit }) => { ...@@ -35,49 +15,24 @@ export const requestReport = ({ commit }) => {
Api.trackRedisHllUserEvent(VIEW_EVENT_NAME); Api.trackRedisHllUserEvent(VIEW_EVENT_NAME);
}; };
export const receiveReportSuccess = ({ state, commit }, data) => { export const receiveReportSuccess = ({ state, commit }, data) => {
if (gon.features?.graphqlCodeQualityFullReport) {
const parsedIssues = parseCodeclimateMetrics(
data.edges.map((edge) => edge.node),
state.blobPath,
);
return commit(types.RECEIVE_REPORT_SUCCESS_GRAPHQL, { data, parsedIssues });
}
const parsedIssues = parseCodeclimateMetrics(data, state.blobPath); const parsedIssues = parseCodeclimateMetrics(data, state.blobPath);
return commit(types.RECEIVE_REPORT_SUCCESS, parsedIssues); commit(types.RECEIVE_REPORT_SUCCESS, parsedIssues);
}; };
export const receiveReportError = ({ commit }, error) => commit(types.RECEIVE_REPORT_ERROR, error); export const receiveReportError = ({ commit }, error) => commit(types.RECEIVE_REPORT_ERROR, error);
export const fetchReport = async ({ state, dispatch }) => { export const fetchReport = ({ state, dispatch }) => {
try { dispatch('requestReport');
dispatch('requestReport');
if (!state.blobPath) throw new Error(); axios
.get(state.endpoint)
if (gon.features?.graphqlCodeQualityFullReport) { .then(({ data }) => {
const { projectPath, pipelineIid, pageInfo } = state; if (!state.blobPath) throw new Error();
const variables = { dispatch('receiveReportSuccess', data);
projectPath, })
iid: pipelineIid, .catch((error) => {
first: pageInfo.first, dispatch('receiveReportError', error);
after: pageInfo.after, createFlash({
}; message: s__('ciReport|There was an error fetching the codequality report.'),
await gqClient
.query({
query: getCodeQualityViolations,
variables,
})
.then(({ data }) => {
dispatch('receiveReportSuccess', data.project?.pipeline?.codeQualityReports);
});
} else {
await axios.get(state.endpoint).then(({ data }) => {
dispatch('receiveReportSuccess', data);
}); });
}
} catch (error) {
dispatch('receiveReportError', error);
createFlash({
message: s__('ciReport|There was an error fetching the codequality report.'),
}); });
}
}; };
export const codequalityIssues = (state) => { export const codequalityIssues = (state) => {
if (gon.features?.graphqlCodeQualityFullReport) {
return state.codequalityIssues;
}
const { page, perPage } = state.pageInfo; const { page, perPage } = state.pageInfo;
const start = (page - 1) * perPage; const start = (page - 1) * perPage;
return state.allCodequalityIssues.slice(start, start + perPage); return state.allCodequalityIssues.slice(start, start + perPage);
}; };
export const codequalityIssueTotal = (state) => { export const codequalityIssueTotal = (state) => state.allCodequalityIssues.length;
if (gon.features?.graphqlCodeQualityFullReport) {
return state.pageInfo.count;
}
return state.allCodequalityIssues.length;
};
export const SET_PAGE = 'SET_PAGE'; export const SET_PAGE = 'SET_PAGE';
export const REQUEST_REPORT = 'REQUEST_REPORT'; export const REQUEST_REPORT = 'REQUEST_REPORT';
export const RECEIVE_REPORT_SUCCESS_GRAPHQL = 'RECEIVE_REPORT_SUCCESS_GRAPHQL';
export const RECEIVE_REPORT_SUCCESS = 'RECEIVE_REPORT_SUCCESS'; export const RECEIVE_REPORT_SUCCESS = 'RECEIVE_REPORT_SUCCESS';
export const RECEIVE_REPORT_ERROR = 'RECEIVE_REPORT_ERROR'; export const RECEIVE_REPORT_ERROR = 'RECEIVE_REPORT_ERROR';
...@@ -2,25 +2,16 @@ import { SEVERITY_SORT_ORDER } from './constants'; ...@@ -2,25 +2,16 @@ import { SEVERITY_SORT_ORDER } from './constants';
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
[types.SET_PAGE](state, pageInfo) { [types.SET_PAGE](state, page) {
Object.assign(state, { Object.assign(state, {
pageInfo: Object.assign(state.pageInfo, pageInfo), pageInfo: Object.assign(state.pageInfo, {
page,
}),
}); });
}, },
[types.REQUEST_REPORT](state) { [types.REQUEST_REPORT](state) {
Object.assign(state, { isLoadingCodequality: true }); Object.assign(state, { isLoadingCodequality: true });
}, },
[types.RECEIVE_REPORT_SUCCESS_GRAPHQL](state, { data, parsedIssues }) {
Object.assign(state, {
isLoadingCodequality: false,
codequalityIssues: parsedIssues,
loadingCodequalityFailed: false,
pageInfo: Object.assign(state.pageInfo, {
count: data.count,
...data.pageInfo,
}),
});
},
[types.RECEIVE_REPORT_SUCCESS](state, allCodequalityIssues) { [types.RECEIVE_REPORT_SUCCESS](state, allCodequalityIssues) {
Object.assign(state, { Object.assign(state, {
isLoadingCodequality: false, isLoadingCodequality: false,
...@@ -38,12 +29,10 @@ export default { ...@@ -38,12 +29,10 @@ export default {
Object.assign(state, { Object.assign(state, {
isLoadingCodequality: false, isLoadingCodequality: false,
allCodequalityIssues: [], allCodequalityIssues: [],
codequalityIssues: [],
loadingCodequalityFailed: true, loadingCodequalityFailed: true,
codeQualityError, codeQualityError,
pageInfo: Object.assign(state.pageInfo, { pageInfo: Object.assign(state.pageInfo, {
total: 0, total: 0,
count: 0,
}), }),
}); });
}, },
......
import { PAGE_SIZE } from './constants'; import { PAGE_SIZE } from './constants';
export default () => ({ export default () => ({
projectPath: null,
pipelineIid: null,
endpoint: '', endpoint: '',
allCodequalityIssues: [], allCodequalityIssues: [],
codequalityIssues: [],
isLoadingCodequality: false, isLoadingCodequality: false,
loadingCodequalityFailed: false, loadingCodequalityFailed: false,
codeQualityError: null, codeQualityError: null,
...@@ -13,12 +10,5 @@ export default () => ({ ...@@ -13,12 +10,5 @@ export default () => ({
page: 1, page: 1,
perPage: PAGE_SIZE, perPage: PAGE_SIZE,
total: 0, total: 0,
count: 0,
currentPage: 1,
startCursor: '',
endCursor: '',
first: PAGE_SIZE,
after: '',
hasNextPage: false,
}, },
}); });
import createGqClient from '~/lib/graphql';
export const gqClient = createGqClient();
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import CodequalityReportApp from 'ee/codequality_report/codequality_report.vue'; 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 createStore from 'ee/codequality_report/store';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
Vue.use(Translate); Vue.use(Translate);
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export default () => { export default () => {
const tabsElement = document.querySelector('.pipelines-tabs'); const tabsElement = document.querySelector('.pipelines-tabs');
const codequalityTab = document.getElementById('js-pipeline-codequality-report'); const codequalityTab = document.getElementById('js-pipeline-codequality-report');
const isGraphqlFeatureFlagEnabled = gon.features?.graphqlCodeQualityFullReport;
if (tabsElement && codequalityTab) { if (tabsElement && codequalityTab) {
const fetchReportAction = 'fetchReport'; const fetchReportAction = 'fetchReport';
...@@ -17,38 +26,68 @@ export default () => { ...@@ -17,38 +26,68 @@ export default () => {
projectPath, projectPath,
pipelineIid, pipelineIid,
} = codequalityTab.dataset; } = codequalityTab.dataset;
const store = createStore({
endpoint: codequalityReportDownloadPath,
blobPath,
projectPath,
pipelineIid,
});
const isCodequalityTabActive = Boolean( const isCodequalityTabActive = Boolean(
document.querySelector('.pipelines-tabs > li > a.codequality-tab.active'), document.querySelector('.pipelines-tabs > li > a.codequality-tab.active'),
); );
if (isCodequalityTabActive) { if (isGraphqlFeatureFlagEnabled) {
store.dispatch(fetchReportAction); const vueOptions = {
} else { el: codequalityTab,
const tabClickHandler = (e) => { apolloProvider,
if (e.target.className === 'codequality-tab') { components: {
store.dispatch(fetchReportAction); CodequalityReportApp: CodequalityReportAppGraphQL,
tabsElement.removeEventListener('click', tabClickHandler); },
} 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 tabsElement.addEventListener('click', tabClickHandler);
new Vue({ }
el: codequalityTab,
components: { // eslint-disable-next-line no-new
CodequalityReportApp, new Vue({
}, el: codequalityTab,
store, components: {
render: (createElement) => createElement('codequality-report-app'), CodequalityReportApp,
}); },
store,
render: (createElement) => createElement('codequality-report-app'),
});
}
} }
}; };
...@@ -214,18 +214,33 @@ RSpec.describe 'Pipeline', :js do ...@@ -214,18 +214,33 @@ RSpec.describe 'Pipeline', :js do
context 'with code quality artifact' do context 'with code quality artifact' do
before do before do
create(:ee_ci_build, :codequality, pipeline: pipeline) create(:ee_ci_build, :codequality, pipeline: pipeline)
visit codequality_report_project_pipeline_path(project, pipeline)
end end
it 'shows code quality tab pane as active, quality issue with link to file, and events for data tracking' do context 'when navigating directly to the code quality tab' do
expect(page).to have_content('Code Quality') before do
expect(page).to have_css('#js-tab-codequality') visit codequality_report_project_pipeline_path(project, pipeline)
end
expect(page).to have_content('Method `new_array` has 12 arguments (exceeds 4 allowed). Consider refactoring.') it_behaves_like 'an active code quality tab'
expect(find_link('foo.rb:10')[:href]).to end_with(project_blob_path(project, File.join(pipeline.commit.id, 'foo.rb')) + '#L10') end
context 'when starting from the pipeline tab' do
before do
visit project_pipeline_path(project, pipeline)
end
it 'shows the code quality tab as inactive' do
expect(page).to have_content('Code Quality')
expect(page).not_to have_css('#js-tab-codequality')
end
context 'when the code quality tab is clicked' do
before do
click_link 'Code Quality'
end
expect(page).to have_selector('[data-track-action="click_button"]') it_behaves_like 'an active code quality tab'
expect(page).to have_selector('[data-track-label="get_codequality_report"]') end
end end
end end
...@@ -257,6 +272,19 @@ RSpec.describe 'Pipeline', :js do ...@@ -257,6 +272,19 @@ RSpec.describe 'Pipeline', :js do
end end
end end
shared_examples_for 'an active code quality tab' do
it 'shows code quality tab pane as active, quality issue with link to file, and events for data tracking' do
expect(page).to have_content('Code Quality')
expect(page).to have_css('#js-tab-codequality')
expect(page).to have_content('Method `new_array` has 12 arguments (exceeds 4 allowed). Consider refactoring.')
expect(find_link('foo.rb:10')[:href]).to end_with(project_blob_path(project, File.join(pipeline.commit.id, 'foo.rb')) + '#L10')
expect(page).to have_selector('[data-track-action="click_button"]')
expect(page).to have_selector('[data-track-label="get_codequality_report"]')
end
end
context 'for a branch pipeline' do context 'for a branch pipeline' do
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) } let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
...@@ -278,6 +306,14 @@ RSpec.describe 'Pipeline', :js do ...@@ -278,6 +306,14 @@ RSpec.describe 'Pipeline', :js do
it_behaves_like 'full codequality report' it_behaves_like 'full codequality report'
end end
context 'with graphql feature flag disabled' do
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
stub_feature_flags(graphql_code_quality_full_report: false)
it_behaves_like 'full codequality report'
end
end end
private private
......
import { GlInfiniteScroll } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
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';
import { mockGetCodeQualityViolationsResponse, codeQualityViolations } from './mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Codequality report app', () => {
let wrapper;
const createComponent = (
mockReturnValue = jest.fn().mockResolvedValue(mockGetCodeQualityViolationsResponse),
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(mockGetCodeQualityViolationsResponse));
});
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,
nodes: [],
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);
});
});
});
import { GlPagination, GlSkeletonLoader } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import CodequalityReportApp from 'ee/codequality_report/codequality_report.vue'; import CodequalityReportApp from 'ee/codequality_report/codequality_report.vue';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
...@@ -39,9 +38,7 @@ describe('Codequality report app', () => { ...@@ -39,9 +38,7 @@ describe('Codequality report app', () => {
const findStatus = () => wrapper.find('.js-code-text'); const findStatus = () => wrapper.find('.js-code-text');
const findSuccessIcon = () => wrapper.find('.js-ci-status-icon-success'); const findSuccessIcon = () => wrapper.find('.js-ci-status-icon-success');
const findWarningIcon = () => wrapper.find('.js-ci-status-icon-warning'); const findWarningIcon = () => wrapper.find('.js-ci-status-icon-warning');
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findPagination = () => wrapper.findComponent(PaginationLinks);
const findOldPagination = () => wrapper.findComponent(PaginationLinks);
const findPagination = () => wrapper.findComponent(GlPagination);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -54,7 +51,6 @@ describe('Codequality report app', () => { ...@@ -54,7 +51,6 @@ describe('Codequality report app', () => {
it('shows a loading state', () => { it('shows a loading state', () => {
expect(findStatus().text()).toBe('Loading Code Quality report'); expect(findStatus().text()).toBe('Loading Code Quality report');
expect(findSkeletonLoader().exists()).toBe(true);
}); });
}); });
...@@ -93,40 +89,8 @@ describe('Codequality report app', () => { ...@@ -93,40 +89,8 @@ describe('Codequality report app', () => {
'/root/test-codequality/blob/feature-branch/ee/spec/features/admin/geo/admin_geo_projects_spec.rb#L152', '/root/test-codequality/blob/feature-branch/ee/spec/features/admin/geo/admin_geo_projects_spec.rb#L152',
); );
}); });
});
describe('with graphql feature flag disabled', () => {
beforeEach(() => {
createComponent(
{},
parsedIssues,
{
graphqlCodeQualityFullReport: false,
},
shallowMount,
);
});
it('renders the old pagination component', () => {
expect(findOldPagination().exists()).toBe(true);
expect(findPagination().exists()).toBe(false);
});
});
describe('with graphql feature flag enabled', () => {
beforeEach(() => {
createComponent(
{},
parsedIssues,
{
graphqlCodeQualityFullReport: true,
},
shallowMount,
);
});
it('renders the pagination component', () => { it('renders the pagination component', () => {
expect(findOldPagination().exists()).toBe(false);
expect(findPagination().exists()).toBe(true); expect(findPagination().exists()).toBe(true);
}); });
}); });
......
import mockGetCodeQualityViolationsResponse from 'test_fixtures/graphql/codequality_report/graphql/queries/get_code_quality_violations.query.graphql.json';
export { mockGetCodeQualityViolationsResponse };
export const codeQualityViolations =
mockGetCodeQualityViolationsResponse.data.project.pipeline.codeQualityReports;
export const unparsedIssues = [ export const unparsedIssues = [
{ {
type: 'issue', type: 'issue',
...@@ -180,53 +187,3 @@ export const parsedIssues = [ ...@@ -180,53 +187,3 @@ export const parsedIssues = [
'/root/test-codequality/blob/feature-branch/ee/spec/finders/geo/lfs_object_registry_finder_spec.rb#L512', '/root/test-codequality/blob/feature-branch/ee/spec/finders/geo/lfs_object_registry_finder_spec.rb#L512',
}, },
]; ];
export const mockGraphqlResponse = {
data: {
project: {
pipeline: {
codeQualityReports: {
count: 3,
edges: [
{
node: {
line: 33,
description:
'Function `addToImport` has 54 lines of code (exceeds 25 allowed). Consider refactoring.',
path: 'app/assets/javascripts/importer_status.js',
fingerprint: 'f5c4a1a17a8903f8c6dd885142d0e5a7',
severity: 'MAJOR',
},
},
{
node: {
line: 170,
description: 'Avoid too many `return` statements within this function.',
path: 'app/assets/javascripts/ide/stores/utils.js',
fingerprint: '75e7e39f5b8ea0aadff2470a9b44ca68',
severity: 'MINOR',
},
},
{
node: {
line: 44,
description: 'Similar blocks of code found in 3 locations. Consider refactoring.',
path: 'ee/spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb',
fingerprint: '99c054a8b1a7270b193b0a03a6c69cfc',
severity: 'INFO',
},
},
],
},
},
},
},
};
export const mockGraphqlPagination = {
hasNextPage: true,
hasPreviousPage: true,
startCursor: 'abc123',
endCursor: 'abc124',
page: 1,
};
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import getCodeQualityViolations from 'ee/codequality_report/graphql/queries/get_code_quality_violations.query.graphql';
import * as actions from 'ee/codequality_report/store/actions'; import * as actions from 'ee/codequality_report/store/actions';
import { VIEW_EVENT_NAME } from 'ee/codequality_report/store/constants'; import { VIEW_EVENT_NAME } from 'ee/codequality_report/store/constants';
import * as types from 'ee/codequality_report/store/mutation_types'; import * as types from 'ee/codequality_report/store/mutation_types';
import initialState from 'ee/codequality_report/store/state'; import initialState from 'ee/codequality_report/store/state';
import { gqClient } from 'ee/codequality_report/store/utils';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import Api from '~/api'; import Api from '~/api';
import createFlash from '~/flash'; import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { import { unparsedIssues, parsedIssues } from '../mock_data';
unparsedIssues,
parsedIssues,
mockGraphqlResponse,
mockGraphqlPagination,
} from '../mock_data';
jest.mock('~/api.js'); jest.mock('~/api.js');
jest.mock('~/flash'); jest.mock('~/flash');
...@@ -41,60 +34,16 @@ describe('Codequality report actions', () => { ...@@ -41,60 +34,16 @@ describe('Codequality report actions', () => {
}); });
describe('setPage', () => { describe('setPage', () => {
it('sets the page number with feature flag disabled', (done) => { it('sets the page number', (done) => {
return testAction( return testAction(
actions.setPage, actions.setPage,
12, 12,
state, state,
[{ type: types.SET_PAGE, payload: { page: 12 } }], [{ type: types.SET_PAGE, payload: 12 }],
[], [],
done, done,
); );
}); });
describe('with the feature flag enabled', () => {
let mockPageInfo;
beforeEach(() => {
window.gon = { features: { graphqlCodeQualityFullReport: true } };
mockPageInfo = {
...mockGraphqlPagination,
currentPage: 11,
};
});
it('sets the next page number', (done) => {
return testAction(
actions.setPage,
12,
{ ...state, pageInfo: mockPageInfo },
[
{
type: types.SET_PAGE,
payload: { after: mockGraphqlPagination.endCursor, currentPage: 12 },
},
],
[{ type: 'fetchReport' }],
done,
);
});
it('sets the previous page number', (done) => {
return testAction(
actions.setPage,
10,
{ ...state, pageInfo: mockPageInfo },
[
{
type: types.SET_PAGE,
payload: { after: mockGraphqlPagination.startCursor, currentPage: 10 },
},
],
[{ type: 'fetchReport' }],
done,
);
});
});
}); });
describe('requestReport', () => { describe('requestReport', () => {
...@@ -110,7 +59,7 @@ describe('Codequality report actions', () => { ...@@ -110,7 +59,7 @@ describe('Codequality report actions', () => {
}); });
describe('receiveReportSuccess', () => { describe('receiveReportSuccess', () => {
it('parses the list of issues from the report with feature flag disabled', (done) => { it('parses the list of issues from the report', (done) => {
return testAction( return testAction(
actions.receiveReportSuccess, actions.receiveReportSuccess,
unparsedIssues, unparsedIssues,
...@@ -120,25 +69,6 @@ describe('Codequality report actions', () => { ...@@ -120,25 +69,6 @@ describe('Codequality report actions', () => {
done, done,
); );
}); });
it('parses the list of issues from the report with feature flag enabled', (done) => {
window.gon = { features: { graphqlCodeQualityFullReport: true } };
const data = {
edges: unparsedIssues.map((issue) => {
return { node: issue };
}),
};
return testAction(
actions.receiveReportSuccess,
data,
{ blobPath: '/root/test-codequality/blob/feature-branch', ...state },
[{ type: types.RECEIVE_REPORT_SUCCESS_GRAPHQL, payload: { data, parsedIssues } }],
[],
done,
);
});
}); });
describe('receiveReportError', () => { describe('receiveReportError', () => {
...@@ -155,83 +85,19 @@ describe('Codequality report actions', () => { ...@@ -155,83 +85,19 @@ describe('Codequality report actions', () => {
}); });
describe('fetchReport', () => { describe('fetchReport', () => {
describe('with graphql feature flag disabled', () => { beforeEach(() => {
beforeEach(() => { mock.onGet(endpoint).replyOnce(200, unparsedIssues);
mock.onGet(endpoint).replyOnce(200, unparsedIssues);
});
it('fetches the report', (done) => {
return testAction(
actions.fetchReport,
null,
{ blobPath: 'blah', ...state },
[],
[{ type: 'requestReport' }, { type: 'receiveReportSuccess', payload: unparsedIssues }],
done,
);
});
it('shows a flash message when there is an error', (done) => {
testAction(
actions.fetchReport,
'error',
state,
[],
[{ type: 'requestReport' }, { type: 'receiveReportError', payload: expect.any(Error) }],
() => {
expect(createFlash).toHaveBeenCalledWith({
message: 'There was an error fetching the codequality report.',
});
done();
},
);
});
it('shows an error when blob path is missing', (done) => {
testAction(
actions.fetchReport,
null,
state,
[],
[{ type: 'requestReport' }, { type: 'receiveReportError', payload: expect.any(Error) }],
() => {
expect(createFlash).toHaveBeenCalledWith({
message: 'There was an error fetching the codequality report.',
});
done();
},
);
});
}); });
describe('with graphql feature flag enabled', () => { it('fetches the report', (done) => {
beforeEach(() => { return testAction(
jest.spyOn(gqClient, 'query').mockResolvedValue(mockGraphqlResponse); actions.fetchReport,
state.paginationData = mockGraphqlPagination; null,
window.gon = { features: { graphqlCodeQualityFullReport: true } }; { blobPath: 'blah', ...state },
}); [],
[{ type: 'requestReport' }, { type: 'receiveReportSuccess', payload: unparsedIssues }],
it('fetches the report', () => { done,
return testAction( );
actions.fetchReport,
null,
{ blobPath: 'blah', ...state },
[],
[
{ type: 'requestReport' },
{
type: 'receiveReportSuccess',
payload: mockGraphqlResponse.data.project.pipeline.codeQualityReports,
},
],
() => {
expect(gqClient.query).toHaveBeenCalledWith({
query: getCodeQualityViolations,
variables: { after: '', first: 25, iid: null, projectPath: null },
});
},
);
});
}); });
it('shows a flash message when there is an error', (done) => { it('shows a flash message when there is an error', (done) => {
......
...@@ -15,7 +15,7 @@ describe('Codequality report mutations', () => { ...@@ -15,7 +15,7 @@ describe('Codequality report mutations', () => {
describe('set page', () => { describe('set page', () => {
it('should set page', () => { it('should set page', () => {
mutations[types.SET_PAGE](state, { page: 4 }); mutations[types.SET_PAGE](state, 4);
expect(state.pageInfo.page).toBe(4); expect(state.pageInfo.page).toBe(4);
}); });
}); });
...@@ -27,17 +27,7 @@ describe('Codequality report mutations', () => { ...@@ -27,17 +27,7 @@ describe('Codequality report mutations', () => {
}); });
}); });
describe('receive report success with graphql', () => { describe('receive report success', () => {
it('should set issue info and clear the loading flag', () => {
mutations[types.RECEIVE_REPORT_SUCCESS_GRAPHQL](state, { data: { count: 42 }, parsedIssues });
expect(state.isLoadingCodequality).toBe(false);
expect(state.codequalityIssues).toBe(parsedIssues);
expect(state.pageInfo.count).toBe(42);
});
});
describe('receive report success without graphql', () => {
it('should set issue info and clear the loading flag', () => { it('should set issue info and clear the loading flag', () => {
mutations[types.RECEIVE_REPORT_SUCCESS](state, parsedIssues); mutations[types.RECEIVE_REPORT_SUCCESS](state, parsedIssues);
...@@ -72,10 +62,8 @@ describe('Codequality report mutations', () => { ...@@ -72,10 +62,8 @@ describe('Codequality report mutations', () => {
expect(state.isLoadingCodequality).toBe(false); expect(state.isLoadingCodequality).toBe(false);
expect(state.loadingCodequalityFailed).toBe(true); expect(state.loadingCodequalityFailed).toBe(true);
expect(state.allCodequalityIssues).toEqual([]); expect(state.allCodequalityIssues).toEqual([]);
expect(state.codequalityIssues).toEqual([]);
expect(state.codeQualityError).toEqual(new Error()); expect(state.codeQualityError).toEqual(new Error());
expect(state.pageInfo.total).toBe(0); expect(state.pageInfo.total).toBe(0);
expect(state.pageInfo.count).toBe(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
...@@ -40307,6 +40307,9 @@ msgstr "" ...@@ -40307,6 +40307,9 @@ msgstr ""
msgid "ciReport|Failed to load %{reportName} report" msgid "ciReport|Failed to load %{reportName} report"
msgstr "" msgstr ""
msgid "ciReport|Failed to load Code Quality report"
msgstr ""
msgid "ciReport|Fixed" msgid "ciReport|Fixed"
msgstr "" msgstr ""
...@@ -40339,6 +40342,9 @@ msgstr "" ...@@ -40339,6 +40342,9 @@ msgstr ""
msgid "ciReport|Loading %{reportName} report" msgid "ciReport|Loading %{reportName} report"
msgstr "" msgstr ""
msgid "ciReport|Loading Code Quality report"
msgstr ""
msgid "ciReport|Manage licenses" msgid "ciReport|Manage licenses"
msgstr "" msgstr ""
...@@ -40375,6 +40381,9 @@ msgstr "" ...@@ -40375,6 +40381,9 @@ msgstr ""
msgid "ciReport|Security scanning failed loading any results" msgid "ciReport|Security scanning failed loading any results"
msgstr "" msgstr ""
msgid "ciReport|Showing %{fetchedItems} of %{totalItems} items"
msgstr ""
msgid "ciReport|Solution" msgid "ciReport|Solution"
msgstr "" 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