Commit 7b821183 authored by Scott Hampton's avatar Scott Hampton Committed by Phil Hughes

Add accessibility report widget

Add the a11y artifact report widget to the
MR page.
parent 62127458
...@@ -45,10 +45,19 @@ export default { ...@@ -45,10 +45,19 @@ export default {
> >
{{ s__('AccessibilityReport|New') }} {{ s__('AccessibilityReport|New') }}
</div> </div>
{{ issue.name }} <div>
{{
sprintf(
s__(
'AccessibilityReport|The accessibility scanning found an error of the following type: %{code}',
),
{ code: issue.code },
)
}}
<gl-link ref="accessibility-issue-learn-more" :href="learnMoreUrl" target="_blank">{{ <gl-link ref="accessibility-issue-learn-more" :href="learnMoreUrl" target="_blank">{{
s__('AccessibilityReport|Learn More') s__('AccessibilityReport|Learn More')
}}</gl-link> }}</gl-link>
</div>
{{ sprintf(s__('AccessibilityReport|Message: %{message}'), { message: issue.message }) }} {{ sprintf(s__('AccessibilityReport|Message: %{message}'), { message: issue.message }) }}
</div> </div>
</div> </div>
......
<script>
import { mapActions, mapGetters } from 'vuex';
import { componentNames } from '~/reports/components/issue_body';
import ReportSection from '~/reports/components/report_section.vue';
import IssuesList from '~/reports/components/issues_list.vue';
import createStore from './store';
export default {
name: 'GroupedAccessibilityReportsApp',
store: createStore(),
components: {
ReportSection,
IssuesList,
},
props: {
baseEndpoint: {
type: String,
required: true,
},
headEndpoint: {
type: String,
required: true,
},
},
componentNames,
computed: {
...mapGetters([
'summaryStatus',
'groupedSummaryText',
'shouldRenderIssuesList',
'unresolvedIssues',
'resolvedIssues',
'newIssues',
]),
},
created() {
this.setEndpoints({
baseEndpoint: this.baseEndpoint,
headEndpoint: this.headEndpoint,
});
this.fetchReport();
},
methods: {
...mapActions(['fetchReport', 'setEndpoints']),
},
};
</script>
<template>
<report-section
:status="summaryStatus"
:success-text="groupedSummaryText"
:loading-text="groupedSummaryText"
:error-text="groupedSummaryText"
:has-issues="shouldRenderIssuesList"
class="mr-widget-section grouped-security-reports mr-report"
>
<template #body>
<div class="mr-widget-grouped-section report-block">
<issues-list
v-if="shouldRenderIssuesList"
:unresolved-issues="unresolvedIssues"
:new-issues="newIssues"
:resolved-issues="resolvedIssues"
:component="$options.componentNames.AccessibilityIssueBody"
class="report-block-group-list"
/>
</div>
</template>
</report-section>
</template>
...@@ -3,6 +3,9 @@ import * as types from './mutation_types'; ...@@ -3,6 +3,9 @@ import * as types from './mutation_types';
import { parseAccessibilityReport, compareAccessibilityReports } from './utils'; import { parseAccessibilityReport, compareAccessibilityReports } from './utils';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
export const setEndpoints = ({ commit }, { baseEndpoint, headEndpoint }) =>
commit(types.SET_ENDPOINTS, { baseEndpoint, headEndpoint });
export const fetchReport = ({ state, dispatch, commit }) => { export const fetchReport = ({ state, dispatch, commit }) => {
commit(types.REQUEST_REPORT); commit(types.REQUEST_REPORT);
......
import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '../../constants';
import { s__, n__ } from '~/locale';
export const groupedSummaryText = state => {
if (state.isLoading) {
return s__('Reports|Accessibility scanning results are being parsed');
}
if (state.hasError) {
return s__('Reports|Accessibility scanning failed loading results');
}
const numberOfResults =
(state.report?.summary?.errors || 0) + (state.report?.summary?.warnings || 0);
if (numberOfResults === 0) {
return s__('Reports|Accessibility scanning detected no issues for the source branch only');
}
return n__(
'Reports|Accessibility scanning detected %d issue for the source branch only',
'Reports|Accessibility scanning detected %d issues for the source branch only',
numberOfResults,
);
};
export const summaryStatus = state => {
if (state.isLoading) {
return LOADING;
}
if (state.hasError || state.status === STATUS_FAILED) {
return ERROR;
}
return SUCCESS;
};
export const shouldRenderIssuesList = state =>
Object.values(state.report).some(x => Array.isArray(x) && x.length > 0);
export const unresolvedIssues = state => [
...state.report.existing_errors,
...state.report.existing_warnings,
...state.report.existing_notes,
];
export const resolvedIssues = state => [
...state.report.resolved_errors,
...state.report.resolved_warnings,
...state.report.resolved_notes,
];
export const newIssues = state => [
...state.report.new_errors,
...state.report.new_warnings,
...state.report.new_notes,
];
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import * as actions from './actions'; import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations'; import mutations from './mutations';
import state from './state'; import state from './state';
...@@ -9,6 +10,7 @@ Vue.use(Vuex); ...@@ -9,6 +10,7 @@ Vue.use(Vuex);
export default initialState => export default initialState =>
new Vuex.Store({ new Vuex.Store({
actions, actions,
getters,
mutations, mutations,
state: state(initialState), state: state(initialState),
}); });
export const SET_ENDPOINTS = 'SET_ENDPOINTS';
export const REQUEST_REPORT = 'REQUEST_REPORT'; export const REQUEST_REPORT = 'REQUEST_REPORT';
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';
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
[types.SET_ENDPOINTS](state, { baseEndpoint, headEndpoint }) {
state.baseEndpoint = baseEndpoint;
state.headEndpoint = headEndpoint;
},
[types.REQUEST_REPORT](state) { [types.REQUEST_REPORT](state) {
state.isLoading = true; state.isLoading = true;
}, },
......
...@@ -167,7 +167,7 @@ export default { ...@@ -167,7 +167,7 @@ export default {
<div class="media"> <div class="media">
<status-icon :status="statusIconName" :size="24" class="align-self-center" /> <status-icon :status="statusIconName" :size="24" class="align-self-center" />
<div class="media-body d-flex flex-align-self-center align-items-center"> <div class="media-body d-flex flex-align-self-center align-items-center">
<div class="js-code-text code-text"> <div data-testid="report-section-code-text" class="js-code-text code-text">
<div> <div>
{{ headerText }} {{ headerText }}
<slot :name="slotName"></slot> <slot :name="slotName"></slot>
......
...@@ -39,6 +39,8 @@ import SourceBranchRemovalStatus from './components/source_branch_removal_status ...@@ -39,6 +39,8 @@ import SourceBranchRemovalStatus from './components/source_branch_removal_status
import TerraformPlan from './components/mr_widget_terraform_plan.vue'; import TerraformPlan from './components/mr_widget_terraform_plan.vue';
import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue'; import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue';
import { setFaviconOverlay } from '../lib/utils/common_utils'; import { setFaviconOverlay } from '../lib/utils/common_utils';
import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default { export default {
el: '#js-vue-mr-widget', el: '#js-vue-mr-widget',
...@@ -76,7 +78,9 @@ export default { ...@@ -76,7 +78,9 @@ export default {
SourceBranchRemovalStatus, SourceBranchRemovalStatus,
GroupedTestReportsApp, GroupedTestReportsApp,
TerraformPlan, TerraformPlan,
GroupedAccessibilityReportsApp,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
mrData: { mrData: {
type: Object, type: Object,
...@@ -138,6 +142,13 @@ export default { ...@@ -138,6 +142,13 @@ export default {
mergeError, mergeError,
}); });
}, },
shouldShowAccessibilityReport() {
return (
this.accessibilility?.base_path &&
this.accessibilility?.head_path &&
this.glFeatures.accessibilityMergeRequestWidget
);
},
}, },
watch: { watch: {
state(newVal, oldVal) { state(newVal, oldVal) {
...@@ -380,6 +391,12 @@ export default { ...@@ -380,6 +391,12 @@ export default {
<terraform-plan v-if="mr.terraformReportsPath" :endpoint="mr.terraformReportsPath" /> <terraform-plan v-if="mr.terraformReportsPath" :endpoint="mr.terraformReportsPath" />
<grouped-accessibility-reports-app
v-if="shouldShowAccessibilityReport"
:base-endpoint="mr.accessibility.base_path"
:head-endpoint="mr.accessibility.head_path"
/>
<div class="mr-widget-section"> <div class="mr-widget-section">
<component :is="componentName" :mr="mr" :service="service" /> <component :is="componentName" :mr="mr" :service="service" />
......
...@@ -103,6 +103,7 @@ export default class MergeRequestStore { ...@@ -103,6 +103,7 @@ export default class MergeRequestStore {
this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null; this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null;
this.terraformReportsPath = data.terraform_reports_path; this.terraformReportsPath = data.terraform_reports_path;
this.testResultsPath = data.test_reports_path; this.testResultsPath = data.test_reports_path;
this.accessibility = data.accessibility || {};
this.exposedArtifactsPath = data.exposed_artifacts_path; this.exposedArtifactsPath = data.exposed_artifacts_path;
this.cancelAutoMergePath = data.cancel_auto_merge_path; this.cancelAutoMergePath = data.cancel_auto_merge_path;
this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path); this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path);
......
...@@ -27,6 +27,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -27,6 +27,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true) push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true)
push_frontend_feature_flag(:merge_ref_head_comments, @project) push_frontend_feature_flag(:merge_ref_head_comments, @project)
push_frontend_feature_flag(:diff_compare_with_head, @project) push_frontend_feature_flag(:diff_compare_with_head, @project)
push_frontend_feature_flag(:accessibility_merge_request_widget, @project)
end end
before_action do before_action do
......
...@@ -350,6 +350,12 @@ export default { ...@@ -350,6 +350,12 @@ export default {
<terraform-plan v-if="mr.terraformReportsPath" :endpoint="mr.terraformReportsPath" /> <terraform-plan v-if="mr.terraformReportsPath" :endpoint="mr.terraformReportsPath" />
<grouped-accessibility-reports-app
v-if="shouldShowAccessibilityReport"
:base-endpoint="mr.accessibility.base_endpoint"
:head-endpoint="mr.accessibility.head_endpoint"
/>
<div class="mr-widget-section"> <div class="mr-widget-section">
<component :is="componentName" :mr="mr" :service="service" /> <component :is="componentName" :mr="mr" :service="service" />
......
...@@ -1067,6 +1067,9 @@ msgstr "" ...@@ -1067,6 +1067,9 @@ msgstr ""
msgid "AccessibilityReport|New" msgid "AccessibilityReport|New"
msgstr "" msgstr ""
msgid "AccessibilityReport|The accessibility scanning found an error of the following type: %{code}"
msgstr ""
msgid "Account" msgid "Account"
msgstr "" msgstr ""
...@@ -17303,6 +17306,20 @@ msgstr "" ...@@ -17303,6 +17306,20 @@ msgstr ""
msgid "Reports|%{combinedString} and %{resolvedString}" msgid "Reports|%{combinedString} and %{resolvedString}"
msgstr "" msgstr ""
msgid "Reports|Accessibility scanning detected %d issue for the source branch only"
msgid_plural "Reports|Accessibility scanning detected %d issues for the source branch only"
msgstr[0] ""
msgstr[1] ""
msgid "Reports|Accessibility scanning detected no issues for the source branch only"
msgstr ""
msgid "Reports|Accessibility scanning failed loading results"
msgstr ""
msgid "Reports|Accessibility scanning results are being parsed"
msgstr ""
msgid "Reports|Actions" msgid "Reports|Actions"
msgstr "" msgstr ""
......
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import GroupedAccessibilityReportsApp from '~/reports/accessibility_report/grouped_accessibility_reports_app.vue';
import AccessibilityIssueBody from '~/reports/accessibility_report/components/accessibility_issue_body.vue';
import store from '~/reports/accessibility_report/store';
import { comparedReportResult } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Grouped accessibility reports app', () => {
const Component = localVue.extend(GroupedAccessibilityReportsApp);
let wrapper;
let mockStore;
const mountComponent = () => {
wrapper = mount(Component, {
store: mockStore,
localVue,
propsData: {
baseEndpoint: 'base_endpoint.json',
headEndpoint: 'head_endpoint.json',
},
methods: {
fetchReport: () => {},
},
});
};
const findHeader = () => wrapper.find('[data-testid="report-section-code-text"]');
beforeEach(() => {
mockStore = store();
mountComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('while loading', () => {
beforeEach(() => {
mockStore.state.isLoading = true;
mountComponent();
});
it('renders loading state', () => {
expect(findHeader().text()).toEqual('Accessibility scanning results are being parsed');
});
});
describe('with error', () => {
beforeEach(() => {
mockStore.state.isLoading = false;
mockStore.state.hasError = true;
mountComponent();
});
it('renders error state', () => {
expect(findHeader().text()).toEqual('Accessibility scanning failed loading results');
});
});
describe('with a report', () => {
describe('with no issues', () => {
beforeEach(() => {
mockStore.state.report = {
summary: {
errors: 0,
warnings: 0,
},
};
});
it('renders no issues header', () => {
expect(findHeader().text()).toContain(
'Accessibility scanning detected no issues for the source branch only',
);
});
});
describe('with one issue', () => {
beforeEach(() => {
mockStore.state.report = {
summary: {
errors: 0,
warnings: 1,
},
};
});
it('renders one issue header', () => {
expect(findHeader().text()).toContain(
'Accessibility scanning detected 1 issue for the source branch only',
);
});
});
describe('with multiple issues', () => {
beforeEach(() => {
mockStore.state.report = {
summary: {
errors: 1,
warnings: 1,
},
};
});
it('renders multiple issues header', () => {
expect(findHeader().text()).toContain(
'Accessibility scanning detected 2 issues for the source branch only',
);
});
});
describe('with issues to show', () => {
beforeEach(() => {
mockStore.state.report = comparedReportResult;
});
it('renders custom accessibility issue body', () => {
const issueBody = wrapper.find(AccessibilityIssueBody);
expect(issueBody.props('issue').name).toEqual(comparedReportResult.new_errors[0].name);
expect(issueBody.props('issue').code).toEqual(comparedReportResult.new_errors[0].code);
expect(issueBody.props('issue').message).toEqual(
comparedReportResult.new_errors[0].message,
);
expect(issueBody.props('isNew')).toEqual(true);
});
});
});
});
...@@ -16,6 +16,22 @@ describe('Accessibility Reports actions', () => { ...@@ -16,6 +16,22 @@ describe('Accessibility Reports actions', () => {
localState = localStore.state; localState = localStore.state;
}); });
describe('setEndpoints', () => {
it('should commit SET_ENDPOINTS mutation', done => {
const baseEndpoint = 'base_endpoint.json';
const headEndpoint = 'head_endpoint.json';
testAction(
actions.setEndpoints,
{ baseEndpoint, headEndpoint },
localState,
[{ type: types.SET_ENDPOINTS, payload: { baseEndpoint, headEndpoint } }],
[],
done,
);
});
});
describe('fetchReport', () => { describe('fetchReport', () => {
let mock; let mock;
......
import * as getters from '~/reports/accessibility_report/store/getters';
import createStore from '~/reports/accessibility_report/store';
import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '~/reports/constants';
describe('Accessibility reports store getters', () => {
let localState;
let localStore;
beforeEach(() => {
localStore = createStore();
localState = localStore.state;
});
describe('summaryStatus', () => {
describe('when summary is loading', () => {
it('returns loading status', () => {
localState.isLoading = true;
expect(getters.summaryStatus(localState)).toEqual(LOADING);
});
});
describe('when summary has error', () => {
it('returns error status', () => {
localState.hasError = true;
expect(getters.summaryStatus(localState)).toEqual(ERROR);
});
});
describe('when summary has failed status', () => {
it('returns loading status', () => {
localState.status = STATUS_FAILED;
expect(getters.summaryStatus(localState)).toEqual(ERROR);
});
});
describe('when summary has successfully loaded', () => {
it('returns loading status', () => {
expect(getters.summaryStatus(localState)).toEqual(SUCCESS);
});
});
});
describe('groupedSummaryText', () => {
describe('when state is loading', () => {
it('returns the loading summary message', () => {
localState.isLoading = true;
const result = 'Accessibility scanning results are being parsed';
expect(getters.groupedSummaryText(localState)).toEqual(result);
});
});
describe('when state has error', () => {
it('returns the error summary message', () => {
localState.hasError = true;
const result = 'Accessibility scanning failed loading results';
expect(getters.groupedSummaryText(localState)).toEqual(result);
});
});
describe('when state has successfully loaded', () => {
describe('when report has errors', () => {
it('returns summary message containing number of errors', () => {
localState.report = {
summary: {
errors: 1,
warnings: 1,
},
};
const result = 'Accessibility scanning detected 2 issues for the source branch only';
expect(getters.groupedSummaryText(localState)).toEqual(result);
});
});
describe('when report has no errors', () => {
it('returns summary message containing no errors', () => {
localState.report = {
summary: {
errors: 0,
warnings: 0,
},
};
const result = 'Accessibility scanning detected no issues for the source branch only';
expect(getters.groupedSummaryText(localState)).toEqual(result);
});
});
});
});
describe('shouldRenderIssuesList', () => {
describe('when has issues to render', () => {
it('returns true', () => {
localState.report = {
existing_errors: [{ name: 'Issue' }],
};
expect(getters.shouldRenderIssuesList(localState)).toEqual(true);
});
});
describe('when does not have issues to render', () => {
it('returns false', () => {
localState.report = {
status: 'success',
summary: { errors: 0, warnings: 0 },
};
expect(getters.shouldRenderIssuesList(localState)).toEqual(false);
});
});
});
describe('unresolvedIssues', () => {
it('returns concatenated array of unresolved errors, warnings, and notes', () => {
localState.report = {
existing_errors: [1],
existing_warnings: [2],
existing_notes: [3],
};
const result = [1, 2, 3];
expect(getters.unresolvedIssues(localState)).toEqual(result);
});
});
describe('resolvedIssues', () => {
it('returns concatenated array of resolved errors, warnings, and notes', () => {
localState.report = {
resolved_errors: [1],
resolved_warnings: [2],
resolved_notes: [3],
};
const result = [1, 2, 3];
expect(getters.resolvedIssues(localState)).toEqual(result);
});
});
describe('newIssues', () => {
it('returns concatenated array of new errors, warnings, and notes', () => {
localState.report = {
new_errors: [1],
new_warnings: [2],
new_notes: [3],
};
const result = [1, 2, 3];
expect(getters.newIssues(localState)).toEqual(result);
});
});
});
...@@ -10,6 +10,20 @@ describe('Accessibility Reports mutations', () => { ...@@ -10,6 +10,20 @@ describe('Accessibility Reports mutations', () => {
localState = localStore.state; localState = localStore.state;
}); });
describe('SET_ENDPOINTS', () => {
it('sets base and head endpoints to give values', () => {
const baseEndpoint = 'base_endpoint.json';
const headEndpoint = 'head_endpoint.json';
mutations.SET_ENDPOINTS(localState, {
baseEndpoint,
headEndpoint,
});
expect(localState.baseEndpoint).toEqual(baseEndpoint);
expect(localState.headEndpoint).toEqual(headEndpoint);
});
});
describe('REQUEST_REPORT', () => { describe('REQUEST_REPORT', () => {
it('sets isLoading to true', () => { it('sets isLoading to true', () => {
mutations.REQUEST_REPORT(localState); mutations.REQUEST_REPORT(localState);
......
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