Commit 529dc5a6 authored by Phil Hughes's avatar Phil Hughes

Merge branch '215516-show-requirements-test-status-badges' into 'master'

Show test report status badge on Requirements list page

Closes #215516

See merge request gitlab-org/gitlab!33848
parents 4f8ebc34 21b64cbe
...@@ -14,6 +14,7 @@ import { getTimeago } from '~/lib/utils/datetime_utility'; ...@@ -14,6 +14,7 @@ import { getTimeago } from '~/lib/utils/datetime_utility';
import timeagoMixin from '~/vue_shared/mixins/timeago'; import timeagoMixin from '~/vue_shared/mixins/timeago';
import RequirementForm from './requirement_form.vue'; import RequirementForm from './requirement_form.vue';
import RequirementStatusBadge from './requirement_status_badge.vue';
import { FilterState } from '../constants'; import { FilterState } from '../constants';
...@@ -26,6 +27,7 @@ export default { ...@@ -26,6 +27,7 @@ export default {
GlIcon, GlIcon,
GlLoadingIcon, GlLoadingIcon,
RequirementForm, RequirementForm,
RequirementStatusBadge,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -36,9 +38,16 @@ export default { ...@@ -36,9 +38,16 @@ export default {
type: Object, type: Object,
required: true, required: true,
validator: value => validator: value =>
['iid', 'state', 'userPermissions', 'title', 'createdAt', 'updatedAt', 'author'].every( [
prop => value[prop], 'iid',
), 'state',
'userPermissions',
'title',
'createdAt',
'updatedAt',
'author',
'testReports',
].every(prop => value[prop]),
}, },
showUpdateForm: { showUpdateForm: {
type: Boolean, type: Boolean,
...@@ -82,6 +91,12 @@ export default { ...@@ -82,6 +91,12 @@ export default {
author() { author() {
return this.requirement.author; return this.requirement.author;
}, },
testReport() {
return this.requirement.testReports.nodes[0];
},
showIssuableMetaActions() {
return Boolean(this.canUpdate || this.canArchive || this.testReport);
},
}, },
methods: { methods: {
/** /**
...@@ -131,8 +146,8 @@ export default { ...@@ -131,8 +146,8 @@ export default {
<div class="issue-title title"> <div class="issue-title title">
<span class="issue-title-text">{{ requirement.title }}</span> <span class="issue-title-text">{{ requirement.title }}</span>
</div> </div>
<div class="issuable-info"> <div class="issuable-info d-none d-sm-inline-block">
<span class="issuable-authored d-none d-sm-inline-block"> <span class="issuable-authored">
<span <span
v-gl-tooltip:tooltipcontainer.bottom v-gl-tooltip:tooltipcontainer.bottom
:title="tooltipTitle(requirement.createdAt)" :title="tooltipTitle(requirement.createdAt)"
...@@ -143,10 +158,27 @@ export default { ...@@ -143,10 +158,27 @@ export default {
<span class="author">{{ author.name }}</span> <span class="author">{{ author.name }}</span>
</gl-link> </gl-link>
</span> </span>
<span
v-gl-tooltip:tooltipcontainer.bottom
:title="tooltipTitle(requirement.updatedAt)"
class="issuable-updated-at"
>&middot; {{ updatedAt }}</span
>
</div> </div>
<requirement-status-badge
v-if="testReport"
:test-report="testReport"
class="d-block d-sm-none"
/>
</div> </div>
<div class="issuable-meta"> <div class="issuable-meta">
<ul v-if="canUpdate || canArchive" class="controls flex-column flex-sm-row"> <ul v-if="showIssuableMetaActions" class="controls flex-column flex-sm-row">
<requirement-status-badge
v-if="testReport"
:test-report="testReport"
element-type="li"
class="d-none d-sm-block"
/>
<li v-if="canUpdate && !isArchived" class="requirement-edit d-sm-block"> <li v-if="canUpdate && !isArchived" class="requirement-edit d-sm-block">
<gl-deprecated-button <gl-deprecated-button
v-gl-tooltip v-gl-tooltip
...@@ -180,13 +212,6 @@ export default { ...@@ -180,13 +212,6 @@ export default {
> >
</li> </li>
</ul> </ul>
<div class="float-right issuable-updated-at d-none d-sm-inline-block">
<span
v-gl-tooltip:tooltipcontainer.bottom
:title="tooltipTitle(requirement.updatedAt)"
>{{ updatedAt }}</span
>
</div>
</div> </div>
</div> </div>
</div> </div>
......
<script>
import { GlBadge, GlIcon, GlTooltip } from '@gitlab/ui';
import { __ } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { TestReportStatus } from '../constants';
export default {
components: {
GlBadge,
GlIcon,
GlTooltip,
},
mixins: [timeagoMixin],
props: {
testReport: {
type: Object,
required: true,
},
elementType: {
type: String,
required: false,
default: 'div',
},
},
computed: {
testReportBadge() {
if (this.testReport.state === TestReportStatus.Passed) {
return {
variant: 'success',
icon: 'status_success',
text: __('satisfied'),
tooltipTitle: __('Passed on'),
};
} else if (this.testReport.state === TestReportStatus.Failed) {
return {
variant: 'danger',
icon: 'status_failed',
text: __('failed'),
tooltipTitle: __('Failed on'),
};
}
return {
variant: 'warning',
icon: 'status_warning',
text: __('missing'),
tooltipTitle: '',
};
},
},
methods: {
getTestReportBadgeTarget() {
return this.$refs.testReportBadge?.$el || '';
},
},
};
</script>
<template>
<component :is="elementType" class="requirement-status-badge">
<gl-badge ref="testReportBadge" :variant="testReportBadge.variant">
<gl-icon :name="testReportBadge.icon" class="mr-1" />
{{ testReportBadge.text }}
</gl-badge>
<gl-tooltip
v-if="testReportBadge.tooltipTitle"
:target="getTestReportBadgeTarget"
custom-class="requirement-status-tooltip"
>
<b>{{ testReportBadge.tooltipTitle }}</b>
<div class="mt-1">{{ tooltipTitle(testReport.createdAt) }}</div>
</gl-tooltip>
</component>
</template>
...@@ -30,6 +30,11 @@ export const AvailableSortOptions = [ ...@@ -30,6 +30,11 @@ export const AvailableSortOptions = [
}, },
]; ];
export const TestReportStatus = {
Passed: 'PASSED',
Failed: 'FAILED',
};
export const DEFAULT_PAGE_SIZE = 20; export const DEFAULT_PAGE_SIZE = 20;
export const MAX_TITLE_LENGTH = 255; export const MAX_TITLE_LENGTH = 255;
...@@ -26,6 +26,13 @@ query projectRequirements( ...@@ -26,6 +26,13 @@ query projectRequirements(
createdAt createdAt
updatedAt updatedAt
state state
testReports(last: 1) {
nodes {
id
state
createdAt
}
}
userPermissions { userPermissions {
updateRequirement updateRequirement
adminRequirement adminRequirement
......
...@@ -65,3 +65,9 @@ ...@@ -65,3 +65,9 @@
} }
} }
} }
.requirement-status-tooltip {
.tooltip-inner {
max-width: 100%;
}
}
---
title: Show test report status badge on Requirements list
merge_request: 33848
author:
type: added
...@@ -3,8 +3,14 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -3,8 +3,14 @@ import { shallowMount } from '@vue/test-utils';
import { GlLink, GlDeprecatedButton, GlIcon, GlLoadingIcon } from '@gitlab/ui'; import { GlLink, GlDeprecatedButton, GlIcon, GlLoadingIcon } from '@gitlab/ui';
import RequirementItem from 'ee/requirements/components/requirement_item.vue'; import RequirementItem from 'ee/requirements/components/requirement_item.vue';
import RequirementForm from 'ee/requirements/components/requirement_form.vue'; import RequirementForm from 'ee/requirements/components/requirement_form.vue';
import RequirementStatusBadge from 'ee/requirements/components/requirement_status_badge.vue';
import { requirement1, requirementArchived, mockUserPermissions } from '../mock_data'; import {
requirement1,
requirementArchived,
mockUserPermissions,
mockTestReport,
} from '../mock_data';
const createComponent = (requirement = requirement1) => const createComponent = (requirement = requirement1) =>
shallowMount(RequirementItem, { shallowMount(RequirementItem, {
...@@ -77,6 +83,12 @@ describe('RequirementItem', () => { ...@@ -77,6 +83,12 @@ describe('RequirementItem', () => {
expect(wrapper.vm.author).toBe(requirement1.author); expect(wrapper.vm.author).toBe(requirement1.author);
}); });
}); });
describe('testReport', () => {
it('returns testReport object from reports array within `requirement`', () => {
expect(wrapper.vm.testReport).toBe(mockTestReport);
});
});
}); });
describe('methods', () => { describe('methods', () => {
...@@ -172,6 +184,25 @@ describe('RequirementItem', () => { ...@@ -172,6 +184,25 @@ describe('RequirementItem', () => {
expect(authorEl.find('.author').text()).toBe(requirement1.author.name); expect(authorEl.find('.author').text()).toBe(requirement1.author.name);
}); });
it('renders element containing requirement updated at', () => {
const updatedAtEl = wrapper.find('.issuable-info .issuable-updated-at');
expect(updatedAtEl.text()).toContain('updated');
expect(updatedAtEl.text()).toContain('ago');
expect(updatedAtEl.attributes('title')).toBe('Mar 20, 2020 8:09am GMT+0000');
});
it('renders requirement-status-badge component', () => {
const statusBadgeElSm = wrapper.find('.issuable-main-info').find(RequirementStatusBadge);
const statusBadgeElMd = wrapper.find('.issuable-meta').find(RequirementStatusBadge);
expect(statusBadgeElSm.exists()).toBe(true);
expect(statusBadgeElMd.exists()).toBe(true);
expect(statusBadgeElSm.props('testReport')).toBe(mockTestReport);
expect(statusBadgeElMd.props('testReport')).toBe(mockTestReport);
expect(statusBadgeElMd.props('elementType')).toBe('li');
});
it('renders element containing requirement `Edit` button when `requirement.userPermissions.updateRequirement` is true', () => { it('renders element containing requirement `Edit` button when `requirement.userPermissions.updateRequirement` is true', () => {
const editButtonEl = wrapper.find('.controls .requirement-edit').find(GlDeprecatedButton); const editButtonEl = wrapper.find('.controls .requirement-edit').find(GlDeprecatedButton);
...@@ -258,13 +289,5 @@ describe('RequirementItem', () => { ...@@ -258,13 +289,5 @@ describe('RequirementItem', () => {
expect(wrapperArchived.contains('.controls .requirement-reopen')).toBe(false); expect(wrapperArchived.contains('.controls .requirement-reopen')).toBe(false);
}); });
}); });
it('renders element containing requirement updated at', () => {
const updatedAtEl = wrapper.find('.issuable-meta .issuable-updated-at > span');
expect(updatedAtEl.text()).toContain('updated');
expect(updatedAtEl.text()).toContain('ago');
expect(updatedAtEl.attributes('title')).toBe('Mar 20, 2020 8:09am GMT+0000');
});
}); });
}); });
import { shallowMount } from '@vue/test-utils';
import { GlBadge, GlIcon, GlTooltip } from '@gitlab/ui';
import RequirementStatusBadge from 'ee/requirements/components/requirement_status_badge.vue';
import { mockTestReport, mockTestReportFailed, mockTestReportMissing } from '../mock_data';
const createComponent = (testReport = mockTestReport) =>
shallowMount(RequirementStatusBadge, {
propsData: {
testReport,
},
});
describe('RequirementStatusBadge', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('testReportBadge', () => {
it('returns object containing variant, icon, text and tooltipTitle when status is "PASSED"', () => {
expect(wrapper.vm.testReportBadge).toEqual({
variant: 'success',
icon: 'status_success',
text: 'satisfied',
tooltipTitle: 'Passed on',
});
});
it('returns object containing variant, icon, text and tooltipTitle when status is "FAILED"', () => {
wrapper.setProps({
testReport: mockTestReportFailed,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.testReportBadge).toEqual({
variant: 'danger',
icon: 'status_failed',
text: 'failed',
tooltipTitle: 'Failed on',
});
});
});
it('returns object containing variant, icon, text and tooltipTitle when status missing', () => {
wrapper.setProps({
testReport: mockTestReportMissing,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.testReportBadge).toEqual({
variant: 'warning',
icon: 'status_warning',
text: 'missing',
tooltipTitle: '',
});
});
});
});
});
describe('template', () => {
it('renders GlBadge component', () => {
const badgeEl = wrapper.find(GlBadge);
expect(badgeEl.exists()).toBe(true);
expect(badgeEl.props('variant')).toBe('success');
expect(badgeEl.text()).toBe('satisfied');
expect(badgeEl.contains(GlIcon)).toBe(true);
expect(badgeEl.find(GlIcon).props('name')).toBe('status_success');
});
it('renders GlTooltip component', () => {
const tooltipEl = wrapper.find(GlTooltip);
expect(tooltipEl.exists()).toBe(true);
expect(tooltipEl.find('b').text()).toBe('Passed on');
expect(tooltipEl.find('div').text()).toBe('Jun 4, 2020 10:55am GMT+0000');
});
});
});
...@@ -10,6 +10,27 @@ export const mockAuthor = { ...@@ -10,6 +10,27 @@ export const mockAuthor = {
webUrl: 'http://0.0.0.0:3000/root', webUrl: 'http://0.0.0.0:3000/root',
}; };
export const mockTestReport = {
id: 'gid://gitlab/RequirementsManagement::TestReport/1',
state: 'PASSED',
createdAt: '2020-06-04T10:55:48Z',
__typename: 'TestReport',
};
export const mockTestReportFailed = {
id: 'gid://gitlab/RequirementsManagement::TestReport/1',
state: 'FAILED',
createdAt: '2020-06-04T10:55:48Z',
__typename: 'TestReport',
};
export const mockTestReportMissing = {
id: 'gid://gitlab/RequirementsManagement::TestReport/1',
state: '',
createdAt: '2020-06-04T10:55:48Z',
__typename: 'TestReport',
};
export const requirement1 = { export const requirement1 = {
iid: '1', iid: '1',
title: 'Virtutis, magnitudinis animi, patientiae, fortitudinis fomentis dolor mitigari solet.', title: 'Virtutis, magnitudinis animi, patientiae, fortitudinis fomentis dolor mitigari solet.',
...@@ -18,6 +39,9 @@ export const requirement1 = { ...@@ -18,6 +39,9 @@ export const requirement1 = {
state: 'OPENED', state: 'OPENED',
userPermissions: mockUserPermissions, userPermissions: mockUserPermissions,
author: mockAuthor, author: mockAuthor,
testReports: {
nodes: [mockTestReport],
},
}; };
export const requirement2 = { export const requirement2 = {
...@@ -28,6 +52,9 @@ export const requirement2 = { ...@@ -28,6 +52,9 @@ export const requirement2 = {
state: 'OPENED', state: 'OPENED',
userPermissions: mockUserPermissions, userPermissions: mockUserPermissions,
author: mockAuthor, author: mockAuthor,
testReports: {
nodes: [mockTestReport],
},
}; };
export const requirement3 = { export const requirement3 = {
...@@ -38,6 +65,9 @@ export const requirement3 = { ...@@ -38,6 +65,9 @@ export const requirement3 = {
state: 'OPENED', state: 'OPENED',
userPermissions: mockUserPermissions, userPermissions: mockUserPermissions,
author: mockAuthor, author: mockAuthor,
testReports: {
nodes: [mockTestReport],
},
}; };
export const requirementArchived = { export const requirementArchived = {
...@@ -48,6 +78,9 @@ export const requirementArchived = { ...@@ -48,6 +78,9 @@ export const requirementArchived = {
state: 'ARCHIVED', state: 'ARCHIVED',
userPermissions: mockUserPermissions, userPermissions: mockUserPermissions,
author: mockAuthor, author: mockAuthor,
testReports: {
nodes: [mockTestReport],
},
}; };
export const mockRequirementsOpen = [requirement1, requirement2, requirement3]; export const mockRequirementsOpen = [requirement1, requirement2, requirement3];
......
...@@ -9281,6 +9281,9 @@ msgstr "" ...@@ -9281,6 +9281,9 @@ msgstr ""
msgid "Failed Jobs" msgid "Failed Jobs"
msgstr "" msgstr ""
msgid "Failed on"
msgstr ""
msgid "Failed to add a Zoom meeting" msgid "Failed to add a Zoom meeting"
msgstr "" msgstr ""
...@@ -15763,6 +15766,9 @@ msgstr "" ...@@ -15763,6 +15766,9 @@ msgstr ""
msgid "Passed" msgid "Passed"
msgstr "" msgstr ""
msgid "Passed on"
msgstr ""
msgid "Password" msgid "Password"
msgstr "" msgstr ""
...@@ -27252,6 +27258,9 @@ msgstr "" ...@@ -27252,6 +27258,9 @@ msgstr ""
msgid "revised" msgid "revised"
msgstr "" msgstr ""
msgid "satisfied"
msgstr ""
msgid "score" msgid "score"
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