Commit be95dbcb authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '218607-provide-ability-to-mark-a-requirement-as-satisfied' into 'master'

Implement "Provide ability to mark a requirement as Satisfied"

See merge request gitlab-org/gitlab!43583
parents 5e442d9a 831e5f4d
......@@ -40,13 +40,15 @@ list is sorted by creation date in descending order.
## Edit a requirement
> - [Added](https://gitlab.com/gitlab-org/gitlab/-/issues/218607) ability to mark a requirement as Satisfied in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.5.
You can edit a requirement (if you have the necessary privileges) from the requirements
list page.
To edit a requirement:
1. From the requirements list, click **Edit** (**{pencil}**).
1. Update the title in text input field.
1. Update the title in text input field. You can also mark (and unmark) a requirement as satisfied in the edit form by using the checkbox labeled "Satisfied".
1. Click **Save changes**.
## Archive a requirement
......@@ -97,7 +99,7 @@ You can also sort the requirements list by:
GitLab supports [requirements test
reports](../../../ci/pipelines/job_artifacts.md#artifactsreportsrequirements) now.
You can add a job to your CI pipeline that, when triggered, marks all existing
requirements as Satisfied.
requirements as Satisfied (you may manually satisfy a requirement in the edit form [edit a requirement](#edit-a-requirement)).
### Add the manual job to CI
......
<script>
import { GlDrawer, GlFormGroup, GlFormTextarea, GlButton } from '@gitlab/ui';
import { GlDrawer, GlFormGroup, GlFormTextarea, GlFormCheckbox, GlButton } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { __, sprintf } from '~/locale';
import { MAX_TITLE_LENGTH } from '../constants';
import { MAX_TITLE_LENGTH, TestReportStatus } from '../constants';
export default {
titleInvalidMessage: sprintf(__('Requirement title cannot have more than %{limit} characters.'), {
......@@ -13,6 +13,7 @@ export default {
GlDrawer,
GlFormGroup,
GlFormTextarea,
GlFormCheckbox,
GlButton,
},
props: {
......@@ -33,6 +34,7 @@ export default {
data() {
return {
title: this.requirement?.title || '',
satisfied: this.requirement?.satisfied || false,
};
},
computed: {
......@@ -59,13 +61,15 @@ export default {
requirement: {
handler(value) {
this.title = value?.title || '';
this.satisfied = value?.satisfied || false;
},
deep: true,
},
drawerOpen(value) {
// Clear `title` value on drawer close.
// Clear `title` and `satisfied` value on drawer close.
if (!value) {
this.title = '';
this.satisfied = false;
}
},
},
......@@ -79,6 +83,23 @@ export default {
return '';
},
newLastTestReportState() {
// lastTestReportState determines whether a requirement is satisfied or not.
// Only create a new test report when manually marking/unmarking a requirement as satisfied:
// when 1) manually marking a requirement as satisfied for the first time.
const updateCondition1 = this.requirement.lastTestReportState === null && this.satisfied;
// or when 2) overriding the status in the latest test report.
const updateCondition2 =
this.requirement.lastTestReportState !== null &&
this.satisfied !== this.requirement.satisfied;
if (updateCondition1 || updateCondition2) {
return this.satisfied ? TestReportStatus.Passed : TestReportStatus.Failed;
}
return null;
},
handleSave() {
if (this.isCreate) {
this.$emit('save', this.title);
......@@ -86,6 +107,7 @@ export default {
this.$emit('save', {
iid: this.requirement.iid,
title: this.title,
lastTestReportState: this.newLastTestReportState(),
});
}
},
......@@ -96,12 +118,12 @@ export default {
<template>
<gl-drawer :open="drawerOpen" :header-height="getDrawerHeaderHeight()" @close="$emit('cancel')">
<template #header>
<h4 class="m-0">{{ fieldLabel }}</h4>
<h4 class="gl-m-0">{{ fieldLabel }}</h4>
</template>
<template>
<div class="requirement-form">
<span v-if="!isCreate" class="text-muted">{{ reference }}</span>
<div class="requirement-form-container" :class="{ 'flex-grow-1 mt-1': !isCreate }">
<div class="requirement-form-container" :class="{ 'gl-flex-grow-1 gl-mt-2': !isCreate }">
<gl-form-group
:label="__('Title')"
:invalid-feedback="$options.titleInvalidMessage"
......@@ -121,14 +143,17 @@ export default {
:class="{ 'gl-field-error-outline': titleInvalid }"
@keyup.escape.exact="$emit('cancel')"
/>
<gl-form-checkbox v-if="!isCreate" v-model="satisfied" class="gl-mt-6">{{
__('Satisfied')
}}</gl-form-checkbox>
</gl-form-group>
<div class="d-flex requirement-form-actions">
<div class="gl-display-flex requirement-form-actions gl-mt-6">
<gl-button
:disabled="disableSaveButton"
:loading="requirementRequestActive"
variant="success"
category="primary"
class="mr-auto js-requirement-save"
class="gl-mr-auto js-requirement-save"
@click="handleSave"
>
{{ saveButtonLabel }}
......
......@@ -136,6 +136,7 @@ export default {
<requirement-status-badge
v-if="testReport"
:test-report="testReport"
:last-test-report-manually-created="requirement.lastTestReportManuallyCreated"
class="d-block d-sm-none"
/>
</div>
......@@ -144,6 +145,7 @@ export default {
<requirement-status-badge
v-if="testReport"
:test-report="testReport"
:last-test-report-manually-created="requirement.lastTestReportManuallyCreated"
element-type="li"
class="d-none d-sm-block"
/>
......
......@@ -17,6 +17,10 @@ export default {
type: Object,
required: true,
},
lastTestReportManuallyCreated: {
type: Boolean,
required: true,
},
elementType: {
type: String,
required: false,
......@@ -47,6 +51,15 @@ export default {
tooltipTitle: '',
};
},
hideTestReportBadge() {
// User can manually indicate that a requirement has not been satisfied
// Internally, we create a test report with FAILED state with null build_id
// (this type of test report with null build id is said to be manually created).
// In this case, we do not show 'failed' badge.
return (
this.testReport.state === TestReportStatus.Failed && this.lastTestReportManuallyCreated
);
},
},
methods: {
getTestReportBadgeTarget() {
......@@ -57,7 +70,7 @@ export default {
</script>
<template>
<component :is="elementType" class="requirement-status-badge">
<component :is="elementType" v-if="!hideTestReportBadge" class="requirement-status-badge">
<gl-badge ref="testReportBadge" :variant="testReportBadge.variant">
<gl-icon :name="testReportBadge.icon" class="mr-1" />
{{ testReportBadge.text }}
......
......@@ -22,7 +22,12 @@ import projectRequirementsCount from '../queries/projectRequirementsCount.query.
import createRequirement from '../queries/createRequirement.mutation.graphql';
import updateRequirement from '../queries/updateRequirement.mutation.graphql';
import { FilterState, AvailableSortOptions, DEFAULT_PAGE_SIZE } from '../constants';
import {
FilterState,
AvailableSortOptions,
TestReportStatus,
DEFAULT_PAGE_SIZE,
} from '../constants';
export default {
DEFAULT_PAGE_SIZE,
......@@ -136,8 +141,15 @@ export default {
update(data) {
const requirementsRoot = data.project?.requirements;
const list = requirementsRoot?.nodes.map(node => {
return {
...node,
satisfied: node.lastTestReportState === TestReportStatus.Passed,
};
});
return {
list: requirementsRoot?.nodes || [],
list: list || [],
pageInfo: requirementsRoot?.pageInfo || {},
};
},
......@@ -325,7 +337,7 @@ export default {
replace: true,
});
},
updateRequirement({ iid, title, state, errorFlashMessage }) {
updateRequirement({ iid, title, state, lastTestReportState, errorFlashMessage }) {
const updateRequirementInput = {
projectPath: this.projectPath,
iid,
......@@ -337,6 +349,9 @@ export default {
if (state) {
updateRequirementInput.state = state;
}
if (lastTestReportState) {
updateRequirementInput.lastTestReportState = lastTestReportState;
}
return this.$apollo
.mutate({
......
......@@ -26,6 +26,8 @@ query projectRequirementsEE(
createdAt
updatedAt
state
lastTestReportState
lastTestReportManuallyCreated
testReports(first: 1, sort: created_desc) {
nodes {
id
......
......@@ -7,6 +7,14 @@ mutation updateRequirement($updateRequirementInput: UpdateRequirementInput!) {
title
state
updatedAt
lastTestReportState
testReports(first: 1, sort: created_desc) {
nodes {
id
state
createdAt
}
}
}
}
}
---
title: Provide ability to mark a requirement as Satisfied
merge_request: 43583
author:
type: added
import { shallowMount } from '@vue/test-utils';
import { GlDrawer, GlFormGroup, GlFormTextarea } from '@gitlab/ui';
import { GlDrawer, GlFormCheckbox, GlFormGroup, GlFormTextarea } from '@gitlab/ui';
import RequirementForm from 'ee/requirements/components/requirement_form.vue';
import { MAX_TITLE_LENGTH } from 'ee/requirements/constants';
import { TestReportStatus, MAX_TITLE_LENGTH } from 'ee/requirements/constants';
import { mockRequirementsOpen } from '../mock_data';
......@@ -19,6 +19,9 @@ const createComponent = ({
},
});
const findGlFormTextArea = wrapper => wrapper.find(GlFormTextarea);
const findGlFormCheckbox = wrapper => wrapper.find(GlFormCheckbox);
describe('RequirementForm', () => {
let wrapper;
let wrapperWithRequirement;
......@@ -94,24 +97,48 @@ describe('RequirementForm', () => {
describe('watchers', () => {
describe('requirement', () => {
it('sets `title` to the value of `requirement.title` when requirement is not null', async () => {
wrapper.setProps({
requirement: mockRequirementsOpen[0],
});
describe('when requirement is not null', () => {
it('renders the value of `requirement.title` as title', async () => {
wrapper.setProps({
requirement: mockRequirementsOpen[0],
});
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(findGlFormTextArea(wrapper).attributes('value')).toBe(
mockRequirementsOpen[0].title,
);
});
expect(wrapper.vm.title).toBe(mockRequirementsOpen[0].title);
it.each`
requirement | satisfied
${mockRequirementsOpen[0]} | ${true}
${mockRequirementsOpen[1]} | ${false}
`(
`renders the satisfied checkbox according to the value of \`requirement.satisfied\`=$satisfied`,
async ({ requirement, satisfied }) => {
wrapper = createComponent();
wrapper.setProps({ requirement });
await wrapper.vm.$nextTick();
expect(findGlFormCheckbox(wrapper).vm.$attrs.checked).toBe(satisfied);
},
);
});
it('sets `title` to empty string when requirement is null', async () => {
wrapperWithRequirement.setProps({
requirement: null,
describe('when requirement is null', () => {
beforeEach(() => {
wrapperWithRequirement.setProps({
requirement: null,
});
});
await wrapperWithRequirement.vm.$nextTick();
it('renders empty string as title', async () => {
await wrapperWithRequirement.vm.$nextTick();
expect(wrapperWithRequirement.vm.title).toBe('');
expect(findGlFormTextArea(wrapperWithRequirement).attributes('value')).toBe('');
});
});
});
......@@ -133,6 +160,33 @@ describe('RequirementForm', () => {
});
describe('methods', () => {
describe.each`
lastTestReportState | requirement | newLastTestReportState
${TestReportStatus.Passed} | ${mockRequirementsOpen[0]} | ${TestReportStatus.Failed}
${TestReportStatus.Failed} | ${mockRequirementsOpen[1]} | ${TestReportStatus.Passed}
${'null'} | ${mockRequirementsOpen[2]} | ${TestReportStatus.Passed}
`('newLastTestReportState', ({ lastTestReportState, requirement, newLastTestReportState }) => {
describe(`when \`lastTestReportState\` is ${lastTestReportState}`, () => {
beforeEach(() => {
wrapperWithRequirement = createComponent({ requirement });
});
it("returns null when `satisfied` hasn't changed", () => {
expect(wrapperWithRequirement.vm.newLastTestReportState()).toBe(null);
});
it(`returns ${newLastTestReportState} when \`satisfied\` has changed from ${
requirement.satisfied
} to ${!requirement.satisfied}`, () => {
wrapperWithRequirement.setData({
satisfied: !requirement.satisfied,
});
expect(wrapperWithRequirement.vm.newLastTestReportState()).toBe(newLastTestReportState);
});
});
});
describe('handleSave', () => {
it('emits `save` event on component with `title` as param when form is in create mode', () => {
wrapper.setData({
......@@ -147,7 +201,7 @@ describe('RequirementForm', () => {
});
});
it('emits `save` event on component with object as param containing `iid` & `title` when form is in update mode', () => {
it('emits `save` event on component with object as param containing `iid` & `title` & `lastTestReportState` when form is in update mode', () => {
wrapperWithRequirement.vm.handleSave();
return wrapperWithRequirement.vm.$nextTick(() => {
......@@ -156,6 +210,7 @@ describe('RequirementForm', () => {
{
iid: mockRequirementsOpen[0].iid,
title: mockRequirementsOpen[0].title,
lastTestReportState: wrapperWithRequirement.vm.newLastTestReportState(),
},
]);
});
......@@ -172,6 +227,14 @@ describe('RequirementForm', () => {
expect(wrapperWithRequirement.find('span').text()).toBe(`REQ-${mockRequirementsOpen[0].iid}`);
});
it('does not render gl-form-checkbox when form is in create mode', () => {
expect(findGlFormCheckbox(wrapper).exists()).toBe(false);
});
it('renders gl-form-checkbox when form is in edit mode', () => {
expect(findGlFormCheckbox(wrapperWithRequirement).exists()).toBe(true);
});
it('renders gl-form-group component', () => {
const glFormGroup = wrapper.find(GlFormGroup);
......@@ -185,7 +248,7 @@ describe('RequirementForm', () => {
});
it('renders gl-form-textarea component', () => {
const glFormTextarea = wrapper.find(GlFormTextarea);
const glFormTextarea = findGlFormTextArea(wrapper);
expect(glFormTextarea.exists()).toBe(true);
expect(glFormTextarea.attributes('id')).toBe('requirementTitle');
......@@ -194,7 +257,7 @@ describe('RequirementForm', () => {
});
it('renders gl-form-textarea component populated with `requirement.title` when `requirement` prop is defined', () => {
expect(wrapperWithRequirement.find(GlFormTextarea).attributes('value')).toBe(
expect(findGlFormTextArea(wrapperWithRequirement).attributes('value')).toBe(
mockRequirementsOpen[0].title,
);
});
......
......@@ -2,16 +2,36 @@ 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) =>
const createComponent = ({
testReport = mockTestReport,
lastTestReportManuallyCreated = false,
} = {}) =>
shallowMount(RequirementStatusBadge, {
propsData: {
testReport,
lastTestReportManuallyCreated,
},
});
const findGlBadge = wrapper => wrapper.find(GlBadge);
const findGlTooltip = wrapper => wrapper.find(GlTooltip);
const successBadgeProps = {
variant: 'success',
icon: 'status_success',
text: 'satisfied',
tooltipTitle: 'Passed on',
};
const failedBadgeProps = {
variant: 'danger',
icon: 'status_failed',
text: 'failed',
tooltipTitle: 'Failed on',
};
describe('RequirementStatusBadge', () => {
let wrapper;
......@@ -26,12 +46,7 @@ describe('RequirementStatusBadge', () => {
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',
});
expect(wrapper.vm.testReportBadge).toEqual(successBadgeProps);
});
it('returns object containing variant, icon, text and tooltipTitle when status is "FAILED"', () => {
......@@ -40,12 +55,7 @@ describe('RequirementStatusBadge', () => {
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.testReportBadge).toEqual({
variant: 'danger',
icon: 'status_failed',
text: 'failed',
tooltipTitle: 'Failed on',
});
expect(wrapper.vm.testReportBadge).toEqual(failedBadgeProps);
});
});
......@@ -67,22 +77,55 @@ describe('RequirementStatusBadge', () => {
});
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.find(GlIcon).exists()).toBe(true);
expect(badgeEl.find(GlIcon).props('name')).toBe('status_success');
describe.each`
testReport | badgeProps
${mockTestReport} | ${successBadgeProps}
${mockTestReportFailed} | ${failedBadgeProps}
`(`when the last test report's been automatically created`, ({ testReport, badgeProps }) => {
beforeEach(() => {
wrapper = createComponent({
testReport,
lastTestReportManuallyCreated: false,
});
});
describe(`when test report status is ${testReport.state}`, () => {
it(`renders GlBadge component`, () => {
const badgeEl = findGlBadge(wrapper);
expect(badgeEl.exists()).toBe(true);
expect(badgeEl.props('variant')).toBe(badgeProps.variant);
expect(badgeEl.text()).toBe(badgeProps.text);
expect(badgeEl.find(GlIcon).exists()).toBe(true);
expect(badgeEl.find(GlIcon).props('name')).toBe(badgeProps.icon);
});
it('renders GlTooltip component', () => {
const tooltipEl = findGlTooltip(wrapper);
expect(tooltipEl.exists()).toBe(true);
expect(tooltipEl.find('b').text()).toBe(badgeProps.tooltipTitle);
expect(tooltipEl.find('div').text()).toBe('Jun 4, 2020 10:55am GMT+0000');
});
});
});
it('renders GlTooltip component', () => {
const tooltipEl = wrapper.find(GlTooltip);
describe(`when the last test report's been manually created`, () => {
it('renders GlBadge component when status is "PASSED"', () => {
wrapper = createComponent({ lastTestReportManuallyCreated: true });
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');
expect(findGlBadge(wrapper).exists()).toBe(true);
expect(findGlBadge(wrapper).text()).toBe('satisfied');
});
it('does not render GlBadge component when status is "FAILED"', () => {
wrapper = createComponent({
testReport: mockTestReportFailed,
lastTestReportManuallyCreated: true,
});
expect(findGlBadge(wrapper).exists()).toBe(false);
});
});
});
});
......@@ -351,6 +351,51 @@ describe('RequirementsRoot', () => {
);
});
describe('when `lastTestReportState` is included in object param', () => {
beforeEach(() => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdateMutationResult);
});
it('calls `$apollo.mutate` with `lastTestReportState` when it is not null', () => {
wrapper.vm.updateRequirement({
iid: '1',
lastTestReportState: 'PASSED',
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
mutation: updateRequirement,
variables: {
updateRequirementInput: {
projectPath: 'gitlab-org/gitlab-shell',
iid: '1',
lastTestReportState: 'PASSED',
},
},
}),
);
});
it('calls `$apollo.mutate` without `lastTestReportState` when it is null', () => {
wrapper.vm.updateRequirement({
iid: '1',
lastTestReportState: null,
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
mutation: updateRequirement,
variables: {
updateRequirementInput: {
projectPath: 'gitlab-org/gitlab-shell',
iid: '1',
},
},
}),
);
});
});
it('calls `createFlash` with provided `errorFlashMessage` param and `Sentry.captureException` when request fails', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(new Error());
jest.spyOn(Sentry, 'captureException').mockImplementation();
......
......@@ -39,6 +39,9 @@ export const requirement1 = {
state: 'OPENED',
userPermissions: mockUserPermissions,
author: mockAuthor,
lastTestReportState: 'PASSED',
lastTestReportManuallyCreated: false,
satisfied: true,
testReports: {
nodes: [mockTestReport],
},
......@@ -52,6 +55,9 @@ export const requirement2 = {
state: 'OPENED',
userPermissions: mockUserPermissions,
author: mockAuthor,
lastTestReportState: 'FAILED',
lastTestReportManuallyCreated: true,
satisfied: false,
testReports: {
nodes: [mockTestReport],
},
......@@ -65,6 +71,9 @@ export const requirement3 = {
state: 'OPENED',
userPermissions: mockUserPermissions,
author: mockAuthor,
lastTestReportState: null,
lastTestReportManuallyCreated: true,
satisfied: false,
testReports: {
nodes: [mockTestReport],
},
......@@ -78,6 +87,9 @@ export const requirementArchived = {
state: 'ARCHIVED',
userPermissions: mockUserPermissions,
author: mockAuthor,
lastTestReportState: null,
lastTestReportManuallyCreated: true,
satisfied: false,
testReports: {
nodes: [mockTestReport],
},
......
......@@ -22606,6 +22606,9 @@ msgstr ""
msgid "SSL Verification:"
msgstr ""
msgid "Satisfied"
msgstr ""
msgid "Saturday"
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