Commit 07757ca0 authored by Phil Hughes's avatar Phil Hughes

Merge branch '338275-refactor-accessibility-widget' into 'master'

Build accessibility extension

See merge request gitlab-org/gitlab!78138
parents 32f08f89 be5ec7eb
......@@ -139,7 +139,7 @@ export default {
fetchData: () => this.fetchCollapsedData(this.$props),
},
method: 'fetchData',
successCallback: (data) => {
successCallback: ({ data }) => {
if (Object.keys(data).length > 0) {
poll.stop();
this.setCollapsedData(data);
......@@ -317,9 +317,13 @@ export default {
<div v-if="data.link">
<gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
</div>
<div v-if="data.supportingText">
<p v-safe-html="generateText(data.supportingText)" class="gl-m-0"></p>
</div>
<gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
{{ data.badge.text }}
</gl-badge>
<actions
:widget="$options.label || $options.name"
:tertiary-buttons="data.actions"
......
import { uniqueId } from 'lodash';
import { __, n__, s__, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { EXTENSION_ICONS } from '../../constants';
export default {
name: 'WidgetAccessibility',
enablePolling: true,
i18n: {
loading: s__('Reports|Accessibility scanning results are being parsed'),
error: s__('Reports|Accessibility scanning failed loading results'),
},
props: ['accessibilityReportPath'],
computed: {
statusIcon() {
return this.collapsedData.status === 'failed'
? EXTENSION_ICONS.warning
: EXTENSION_ICONS.success;
},
},
methods: {
summary() {
const numOfResults = this.collapsedData?.summary?.errored || 0;
const successText = s__(
'Reports|Accessibility scanning detected no issues for the source branch only',
);
const warningText = sprintf(
n__(
'Reports|Accessibility scanning detected %{strong_start}%{number}%{strong_end} issue for the source branch only',
'Reports|Accessibility scanning detected %{strong_start}%{number}%{strong_end} issues for the source branch only',
numOfResults,
),
{
number: numOfResults,
},
false,
);
return numOfResults === 0 ? successText : warningText;
},
fetchCollapsedData() {
return axios.get(this.accessibilityReportPath);
},
fetchFullData() {
return Promise.resolve(this.prepareReports());
},
parsedTECHSCode(code) {
/*
* In issue code looks like "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail"
* or "WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent"
*
* The TECHS code is the "G18", "G168", "H91", etc. from the code which is used for the documentation.
* Here we simply split the string on `.` and get the code in the 5th position
*/
return code?.split('.')[4];
},
formatLearnMoreUrl(code) {
const parsed = this.parsedTECHSCode(code);
// eslint-disable-next-line @gitlab/require-i18n-strings
return `https://www.w3.org/TR/WCAG20-TECHS/${parsed || 'Overview'}.html`;
},
formatText(code) {
return sprintf(
s__(
'AccessibilityReport|The accessibility scanning found an error of the following type: %{code}',
),
{ code },
);
},
formatMessage(message) {
return sprintf(s__('AccessibilityReport|Message: %{message}'), { message });
},
prepareReports() {
const { new_errors, existing_errors, resolved_errors } = this.collapsedData;
const newErrors = new_errors.map((error) => {
return {
header: __('New'),
id: uniqueId('new-error-'),
text: this.formatText(error.code),
icon: { name: EXTENSION_ICONS.failed },
link: {
href: this.formatLearnMoreUrl(error.code),
text: __('Learn more'),
},
supportingText: this.formatMessage(error.message),
};
});
const existingErrors = existing_errors.map((error) => {
return {
id: uniqueId('existing-error-'),
text: this.formatText(error.code),
icon: { name: EXTENSION_ICONS.failed },
link: {
href: this.formatLearnMoreUrl(error.code),
text: __('Learn more'),
},
supportingText: this.formatMessage(error.message),
};
});
const resolvedErrors = resolved_errors.map((error) => {
return {
id: uniqueId('resolved-error-'),
text: this.formatText(error.code),
icon: { name: EXTENSION_ICONS.success },
link: {
href: this.formatLearnMoreUrl(error.code),
text: __('Learn more'),
},
supportingText: this.formatMessage(error.message),
};
});
return [...newErrors, ...existingErrors, ...resolvedErrors];
},
},
};
......@@ -73,26 +73,30 @@ export default {
return `${title}${subtitle}`;
},
fetchCollapsedData() {
return Promise.resolve(this.fetchPlans().then(this.prepareReports));
},
fetchFullData() {
const { valid, invalid } = this.collapsedData;
return Promise.resolve([...valid, ...invalid]);
},
// Custom methods
fetchPlans() {
return axios
.get(this.terraformReportsPath)
.then(({ data }) => {
return Object.keys(data).map((key) => {
return data[key];
.then((res) => {
const reports = Object.keys(res.data).map((key) => {
return res.data[key];
});
const formattedData = this.prepareReports(reports);
return {
...res,
data: formattedData,
};
})
.catch(() => {
const invalidData = { tf_report_error: 'api_error' };
return [invalidData];
const formattedData = this.prepareReports([{ tf_report_error: 'api_error' }]);
return { data: formattedData };
});
},
fetchFullData() {
const { valid, invalid } = this.collapsedData;
return Promise.resolve([...valid, ...invalid]);
},
createReportRow(report, iconName) {
const addNum = Number(report.create);
const changeNum = Number(report.update);
......
......@@ -45,6 +45,7 @@ import eventHub from './event_hub';
import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables';
import getStateQuery from './queries/get_state.query.graphql';
import terraformExtension from './extensions/terraform';
import accessibilityExtension from './extensions/accessibility';
export default {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
......@@ -205,7 +206,7 @@ export default {
);
},
shouldShowAccessibilityReport() {
return this.mr.accessibilityReportPath;
return Boolean(this.mr?.accessibilityReportPath);
},
formattedHumanAccess() {
return (this.mr.humanAccess || '').toLowerCase();
......@@ -240,6 +241,11 @@ export default {
this.registerTerraformPlans();
}
},
shouldShowAccessibilityReport(newVal) {
if (newVal) {
this.registerAccessibilityExtension();
}
},
},
mounted() {
MRWidgetService.fetchInitialData()
......@@ -478,6 +484,11 @@ export default {
registerExtension(terraformExtension);
}
},
registerAccessibilityExtension() {
if (this.shouldShowAccessibilityReport && this.shouldShowExtension) {
registerExtension(accessibilityExtension);
}
},
},
};
</script>
......
......@@ -29994,6 +29994,11 @@ msgid_plural "Reports|Accessibility scanning detected %d issues for the source b
msgstr[0] ""
msgstr[1] ""
msgid "Reports|Accessibility scanning detected %{strong_start}%{number}%{strong_end} issue for the source branch only"
msgid_plural "Reports|Accessibility scanning detected %{strong_start}%{number}%{strong_end} issues for the source branch only"
msgstr[0] ""
msgstr[1] ""
msgid "Reports|Accessibility scanning detected no issues for the source branch only"
msgstr ""
......
import MockAdapter from 'axios-mock-adapter';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { trimText } from 'helpers/text_helper';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container';
import { registerExtension } from '~/vue_merge_request_widget/components/extensions';
import accessibilityExtension from '~/vue_merge_request_widget/extensions/accessibility';
import httpStatusCodes from '~/lib/utils/http_status';
import { accessibilityReportResponseErrors, accessibilityReportResponseSuccess } from './mock_data';
describe('Accessibility extension', () => {
let wrapper;
let mock;
registerExtension(accessibilityExtension);
const endpoint = '/root/repo/-/merge_requests/4/accessibility_reports.json';
const mockApi = (statusCode, data) => {
mock.onGet(endpoint).reply(statusCode, data);
};
const findToggleCollapsedButton = () => wrapper.findByTestId('toggle-button');
const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item');
const createComponent = () => {
wrapper = mountExtended(extensionsContainer, {
propsData: {
mr: {
accessibilityReportPath: endpoint,
},
},
});
};
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
describe('summary', () => {
it('displays loading text', () => {
mockApi(httpStatusCodes.OK, accessibilityReportResponseErrors);
createComponent();
expect(wrapper.text()).toBe('Accessibility scanning results are being parsed');
});
it('displays failed loading text', async () => {
mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR);
createComponent();
await waitForPromises();
expect(wrapper.text()).toBe('Accessibility scanning failed loading results');
});
it('displays detected errors', async () => {
mockApi(httpStatusCodes.OK, accessibilityReportResponseErrors);
createComponent();
await waitForPromises();
expect(wrapper.text()).toBe(
'Accessibility scanning detected 8 issues for the source branch only',
);
});
it('displays no detected errors', async () => {
mockApi(httpStatusCodes.OK, accessibilityReportResponseSuccess);
createComponent();
await waitForPromises();
expect(wrapper.text()).toBe(
'Accessibility scanning detected no issues for the source branch only',
);
});
});
describe('expanded data', () => {
beforeEach(async () => {
mockApi(httpStatusCodes.OK, accessibilityReportResponseErrors);
createComponent();
await waitForPromises();
findToggleCollapsedButton().vm.$emit('click');
await waitForPromises();
});
it('displays all report list items', async () => {
expect(findAllExtensionListItems()).toHaveLength(10);
});
it('displays report list item formatted', () => {
const text = {
newError: trimText(findAllExtensionListItems().at(0).text()),
resolvedError: findAllExtensionListItems().at(3).text(),
existingError: trimText(findAllExtensionListItems().at(8).text()),
};
expect(text.newError).toBe(
'New The accessibility scanning found an error of the following type: WCAG2AA.Principle2.Guideline2_4.2_4_1.H64.1 Learn more Message: Iframe element requires a non-empty title attribute that identifies the frame.',
);
expect(text.resolvedError).toBe(
'The accessibility scanning found an error of the following type: WCAG2AA.Principle1.Guideline1_1.1_1_1.H30.2 Learn more Message: Img element is the only content of the link, but is missing alt text. The alt text should describe the purpose of the link.',
);
expect(text.existingError).toBe(
'The accessibility scanning found an error of the following type: WCAG2AA.Principle2.Guideline2_4.2_4_1.H64.1 Learn more Message: Iframe element requires a non-empty title attribute that identifies the frame.',
);
});
});
});
export const accessibilityReportResponseErrors = {
status: 'failed',
new_errors: [
{
code: 'WCAG2AA.Principle2.Guideline2_4.2_4_1.H64.1',
type: 'error',
type_code: 1,
message: 'Iframe element requires a non-empty title attribute that identifies the frame.',
context:
'<iframe height="0" width="0" style="display: none; visibility: hidden;" src="//10421980.fls.doubleclick.net/activityi;src=10421980;type=count0;cat=globa0;ord=6271888671448;gtm=2wg1c0;auiddc=40010797.1642181125;u1=undefined;u2=undefined;u3=undefined;u...',
selector: 'html > body > iframe:nth-child(42)',
runner: 'htmlcs',
runner_extras: {},
},
{
code: 'WCAG2AA.Principle3.Guideline3_2.3_2_2.H32.2',
type: 'error',
type_code: 1,
message:
'This form does not contain a submit button, which creates issues for those who cannot submit the form using the keyboard. Submit buttons are INPUT elements with type attribute "submit" or "image", or BUTTON elements with type "submit" or omitted/invalid.',
context:
'<form class="challenge-form" id="challenge-form" action="/users/sign_in?__cf_chl_jschl_tk__=xoagAHj9DXTTDveypAmMkakkNQgeWc6LmZA53YyDeSg-1642181129-0-gaNycGzNB1E" method="POST" enctype="application/x-www-form-urlencoded">\n <input type="hidden" name...',
selector: '#challenge-form',
runner: 'htmlcs',
runner_extras: {},
},
{
code: 'WCAG2AA.Principle2.Guideline2_4.2_4_1.H64.1',
type: 'error',
type_code: 1,
message: 'Iframe element requires a non-empty title attribute that identifies the frame.',
context: '<iframe style="display: none;"></iframe>',
selector: 'html > body > iframe',
runner: 'htmlcs',
runner_extras: {},
},
],
resolved_errors: [
{
code: 'WCAG2AA.Principle2.Guideline2_4.2_4_1.H64.1',
type: 'error',
type_code: 1,
message: 'Iframe element requires a non-empty title attribute that identifies the frame.',
context:
'<iframe height="0" width="0" style="display: none; visibility: hidden;" src="//10421980.fls.doubleclick.net/activityi;src=10421980;type=count0;cat=globa0;ord=6722452746146;gtm=2wg1a0;auiddc=716711306.1642082367;u1=undefined;u2=undefined;u3=undefined;...',
selector: 'html > body > iframe:nth-child(42)',
runner: 'htmlcs',
runner_extras: {},
},
{
code: 'WCAG2AA.Principle3.Guideline3_2.3_2_2.H32.2',
type: 'error',
type_code: 1,
message:
'This form does not contain a submit button, which creates issues for those who cannot submit the form using the keyboard. Submit buttons are INPUT elements with type attribute "submit" or "image", or BUTTON elements with type "submit" or omitted/invalid.',
context:
'<form class="challenge-form" id="challenge-form" action="/users/sign_in?__cf_chl_jschl_tk__=vDKZT2hjxWCstlWz2wtxsLdqLF79rM4IsoxzMgY6Lfw-1642082370-0-gaNycGzNB2U" method="POST" enctype="application/x-www-form-urlencoded">\n <input type="hidden" name...',
selector: '#challenge-form',
runner: 'htmlcs',
runner_extras: {},
},
],
existing_errors: [
{
code: 'WCAG2AA.Principle1.Guideline1_1.1_1_1.H30.2',
type: 'error',
type_code: 1,
message:
'Img element is the only content of the link, but is missing alt text. The alt text should describe the purpose of the link.',
context: '<a href="/" data-nav="logo">\n<img src="/images/icons/logos/...</a>',
selector: '#navigation-mobile > header > a',
runner: 'htmlcs',
runner_extras: {},
},
{
code: 'WCAG2AA.Principle1.Guideline1_1.1_1_1.H37',
type: 'error',
type_code: 1,
message:
'Img element missing an alt attribute. Use the alt attribute to specify a short text alternative.',
context: '<img src="/images/icons/slp-hamburger.svg" class="slp-inline-block slp-mr-8">',
selector: '#slpMobileNavActive > img',
runner: 'htmlcs',
runner_extras: {},
},
{
code: 'WCAG2AA.Principle1.Guideline1_1.1_1_1.H37',
type: 'error',
type_code: 1,
message:
'Img element missing an alt attribute. Use the alt attribute to specify a short text alternative.',
context: '<img src="/images/icons/slp-caret-down.svg">',
selector: '#navigation-mobile > div:nth-child(2) > div:nth-child(2) > button > div > img',
runner: 'htmlcs',
runner_extras: {},
},
{
code: 'WCAG2AA.Principle1.Guideline1_1.1_1_1.H37',
type: 'error',
type_code: 1,
message:
'Img element missing an alt attribute. Use the alt attribute to specify a short text alternative.',
context: '<img src="/images/icons/slp-caret-down.svg">',
selector: '#navigation-mobile > div:nth-child(2) > div:nth-child(3) > button > div > img',
runner: 'htmlcs',
runner_extras: {},
},
{
code: 'WCAG2AA.Principle1.Guideline1_1.1_1_1.H37',
type: 'error',
type_code: 1,
message:
'Img element missing an alt attribute. Use the alt attribute to specify a short text alternative.',
context: '<img src="/images/icons/slp-caret-down.svg">',
selector: '#navigation-mobile > div:nth-child(2) > div:nth-child(4) > button > div > img',
runner: 'htmlcs',
runner_extras: {},
},
],
summary: {
total: 8,
resolved: 2,
errored: 8,
},
};
export const accessibilityReportResponseSuccess = {
status: 'success',
new_errors: [],
resolved_errors: [],
existing_errors: [],
summary: {
total: 0,
resolved: 0,
errored: 0,
},
};
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