Commit 8f0c9efc authored by Nathan Friend's avatar Nathan Friend

Merge branch '224622-requirement-description-support' into 'master'

Add Requirement description support

See merge request gitlab-org/gitlab!44902
parents 1fb57db4 52a83481
<script> <script>
import { GlDrawer, GlFormGroup, GlFormTextarea, GlFormCheckbox, GlButton } from '@gitlab/ui'; import '~/behaviors/markdown/render_gfm';
import $ from 'jquery';
import {
GlDrawer,
GlFormGroup,
GlFormTextarea,
GlButton,
GlFormCheckbox,
GlTooltipDirective,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import ZenMode from '~/zen_mode';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import RequirementStatusBadge from './requirement_status_badge.vue';
import RequirementMeta from '../mixins/requirement_meta';
import { MAX_TITLE_LENGTH, TestReportStatus } from '../constants'; import { MAX_TITLE_LENGTH, TestReportStatus } from '../constants';
export default { export default {
events: {
drawerClose: 'drawer-close',
disableEdit: 'disable-edit',
enableEdit: 'enable-edit',
},
titleInvalidMessage: sprintf(__('Requirement title cannot have more than %{limit} characters.'), { titleInvalidMessage: sprintf(__('Requirement title cannot have more than %{limit} characters.'), {
limit: MAX_TITLE_LENGTH, limit: MAX_TITLE_LENGTH,
}), }),
...@@ -15,7 +35,15 @@ export default { ...@@ -15,7 +35,15 @@ export default {
GlFormTextarea, GlFormTextarea,
GlFormCheckbox, GlFormCheckbox,
GlButton, GlButton,
MarkdownField,
RequirementStatusBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml,
}, },
mixins: [RequirementMeta],
inject: ['descriptionPreviewPath', 'descriptionHelpPath'],
props: { props: {
drawerOpen: { drawerOpen: {
type: Boolean, type: Boolean,
...@@ -26,6 +54,11 @@ export default { ...@@ -26,6 +54,11 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
enableRequirementEdit: {
type: Boolean,
required: false,
default: false,
},
requirementRequestActive: { requirementRequestActive: {
type: Boolean, type: Boolean,
required: true, required: true,
...@@ -33,8 +66,10 @@ export default { ...@@ -33,8 +66,10 @@ export default {
}, },
data() { data() {
return { return {
zenModeEnabled: false,
title: this.requirement?.title || '', title: this.requirement?.title || '',
satisfied: this.requirement?.satisfied || false, satisfied: this.requirement?.satisfied || false,
description: this.requirement?.description || '',
}; };
}, },
computed: { computed: {
...@@ -48,19 +83,17 @@ export default { ...@@ -48,19 +83,17 @@ export default {
return this.isCreate ? __('Create requirement') : __('Save changes'); return this.isCreate ? __('Create requirement') : __('Save changes');
}, },
titleInvalid() { titleInvalid() {
return this.title.length > MAX_TITLE_LENGTH; return this.title?.length > MAX_TITLE_LENGTH;
}, },
disableSaveButton() { disableSaveButton() {
return this.title === '' || this.titleInvalid || this.requirementRequestActive; return this.title === '' || this.titleInvalid || this.requirementRequestActive;
}, },
reference() {
return `REQ-${this.requirement?.iid}`;
},
}, },
watch: { watch: {
requirement: { requirement: {
handler(value) { handler(value) {
this.title = value?.title || ''; this.title = value?.title || '';
this.description = value?.description || '';
this.satisfied = value?.satisfied || false; this.satisfied = value?.satisfied || false;
}, },
deep: true, deep: true,
...@@ -69,10 +102,25 @@ export default { ...@@ -69,10 +102,25 @@ export default {
// Clear `title` and `satisfied` value on drawer close. // Clear `title` and `satisfied` value on drawer close.
if (!value) { if (!value) {
this.title = ''; this.title = '';
this.description = '';
this.satisfied = false; this.satisfied = false;
} }
}, },
}, },
mounted() {
this.zenMode = new ZenMode();
$(this.$refs.gfmContainer).renderGFM();
$(document).on('zen_mode:enter', () => {
this.zenModeEnabled = true;
});
$(document).on('zen_mode:leave', () => {
this.zenModeEnabled = false;
});
},
beforeDestroy() {
$(document).off('zen_mode:enter');
$(document).off('zen_mode:leave');
},
methods: { methods: {
getDrawerHeaderHeight() { getDrawerHeaderHeight() {
const wrapperEl = document.querySelector('.js-requirements-container-wrapper'); const wrapperEl = document.querySelector('.js-requirements-container-wrapper');
...@@ -100,31 +148,83 @@ export default { ...@@ -100,31 +148,83 @@ export default {
return null; return null;
}, },
handleSave() { handleFormInputKeyDown() {
if (this.isCreate) { if (this.zenModeEnabled) {
this.$emit('save', this.title); // Exit Zen mode, don't close the drawer.
this.zenModeEnabled = false;
this.zenMode.exit();
} else { } else {
this.$emit('save', { this.$emit(this.$options.events.disableEdit);
iid: this.requirement.iid,
title: this.title,
lastTestReportState: this.newLastTestReportState(),
});
} }
}, },
handleSave() {
const { title, description } = this;
const eventParams = {
title,
description,
};
if (!this.isCreate) {
eventParams.iid = this.requirement.iid;
eventParams.lastTestReportState = this.newLastTestReportState();
}
this.$emit('save', eventParams);
},
handleCancel() {
this.$emit(
this.isCreate ? this.$options.events.drawerClose : this.$options.events.disableEdit,
);
},
}, },
}; };
</script> </script>
<template> <template>
<gl-drawer :open="drawerOpen" :header-height="getDrawerHeaderHeight()" @close="$emit('cancel')"> <gl-drawer
:open="drawerOpen"
:header-height="getDrawerHeaderHeight()"
:class="{ 'zen-mode gl-absolute': zenModeEnabled }"
class="requirement-form-drawer"
@close="$emit($options.events.drawerClose)"
>
<template #header> <template #header>
<h4 class="gl-m-0">{{ fieldLabel }}</h4> <h4 v-if="isCreate" class="gl-m-0">{{ __('New Requirement') }}</h4>
<div v-else class="gl-display-flex gl-align-items-center">
<strong class="gl-text-gray-500">{{ reference }}</strong>
<requirement-status-badge
v-if="testReport"
:test-report="testReport"
:last-test-report-manually-created="requirement.lastTestReportManuallyCreated"
class="gl-ml-3"
/>
</div>
</template> </template>
<template> <template>
<div class="requirement-form"> <div v-if="!enableRequirementEdit && !isCreate" class="requirement-details">
<span v-if="!isCreate" class="text-muted">{{ reference }}</span> <div
class="title-container gl-display-flex gl-border-b-1 gl-border-b-solid gl-border-gray-100"
>
<h3 v-safe-html="titleHtml" class="title qa-title gl-flex-grow-1 gl-m-0 gl-mb-3"></h3>
<gl-button
v-if="canUpdate && !isArchived"
v-gl-tooltip.bottom
data-testid="edit"
:title="__('Edit title and description')"
icon="pencil"
class="btn-edit gl-align-self-start"
@click="$emit($options.events.enableEdit, $event)"
/>
</div>
<div data-testid="descriptionContainer" class="description-container gl-mt-3">
<div ref="gfmContainer" v-safe-html="descriptionHtml" class="md"></div>
</div>
</div>
<div v-else class="requirement-form">
<div class="requirement-form-container" :class="{ 'gl-flex-grow-1 gl-mt-2': !isCreate }"> <div class="requirement-form-container" :class="{ 'gl-flex-grow-1 gl-mt-2': !isCreate }">
<div data-testid="form-error-container" class="flash-container"></div>
<gl-form-group <gl-form-group
data-testid="title"
:label="__('Title')" :label="__('Title')"
:invalid-feedback="$options.titleInvalidMessage" :invalid-feedback="$options.titleInvalidMessage"
:state="!titleInvalid" :state="!titleInvalid"
...@@ -137,12 +237,39 @@ export default { ...@@ -137,12 +237,39 @@ export default {
autofocus autofocus
resize resize
:disabled="requirementRequestActive" :disabled="requirementRequestActive"
:placeholder="__('Describe the requirement here')" :placeholder="__('Requirement title')"
max-rows="25" max-rows="25"
class="requirement-form-textarea" class="requirement-form-textarea"
:class="{ 'gl-field-error-outline': titleInvalid }" :class="{ 'gl-field-error-outline': titleInvalid }"
@keyup.escape.exact="$emit('cancel')" @keydown.escape.exact.stop="handleFormInputKeyDown"
@keydown.meta.enter="handleSave"
@keydown.ctrl.enter="handleSave"
/> />
</gl-form-group>
<gl-form-group data-testid="description" class="common-note-form">
<label for="requirementDescription" class="d-block col-form-label gl-pb-0!">
{{ __('Description') }}
</label>
<markdown-field
:markdown-preview-path="descriptionPreviewPath"
:markdown-docs-path="descriptionHelpPath"
:enable-autocomplete="false"
:textarea-value="description"
>
<template #textarea>
<textarea
id="requirementDescription"
v-model="description"
:data-supports-quick-actions="false"
:aria-label="__('Description')"
:placeholder="__('Describe the requirement here')"
class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea"
@keydown.escape.exact.stop="handleFormInputKeyDown"
@keydown.meta.enter="handleSave"
@keydown.ctrl.enter="handleSave"
></textarea>
</template>
</markdown-field>
<gl-form-checkbox v-if="!isCreate" v-model="satisfied" class="gl-mt-6">{{ <gl-form-checkbox v-if="!isCreate" v-model="satisfied" class="gl-mt-6">{{
__('Satisfied') __('Satisfied')
}}</gl-form-checkbox> }}</gl-form-checkbox>
...@@ -162,7 +289,7 @@ export default { ...@@ -162,7 +289,7 @@ export default {
variant="default" variant="default"
category="primary" category="primary"
class="js-requirement-cancel" class="js-requirement-cancel"
@click="$emit('cancel')" @click="handleCancel"
> >
{{ __('Cancel') }} {{ __('Cancel') }}
</gl-button> </gl-button>
......
<script> <script>
import { escape } from 'lodash';
import { GlPopover, GlLink, GlAvatar, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { GlPopover, GlLink, GlAvatar, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { getTimeago } from '~/lib/utils/datetime_utility';
import timeagoMixin from '~/vue_shared/mixins/timeago'; import timeagoMixin from '~/vue_shared/mixins/timeago';
import RequirementStatusBadge from './requirement_status_badge.vue'; import RequirementStatusBadge from './requirement_status_badge.vue';
import RequirementMeta from '../mixins/requirement_meta';
import { FilterState } from '../constants'; import { FilterState } from '../constants';
export default { export default {
...@@ -20,7 +18,7 @@ export default { ...@@ -20,7 +18,7 @@ export default {
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
mixins: [timeagoMixin], mixins: [RequirementMeta, timeagoMixin],
props: { props: {
requirement: { requirement: {
type: Object, type: Object,
...@@ -42,36 +40,13 @@ export default { ...@@ -42,36 +40,13 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
active: {
type: Boolean,
required: false,
default: false,
}, },
computed: {
reference() {
return `REQ-${this.requirement.iid}`;
},
canUpdate() {
return this.requirement.userPermissions.updateRequirement;
},
canArchive() {
return this.requirement.userPermissions.adminRequirement;
},
createdAt() {
return sprintf(__('created %{timeAgo}'), {
timeAgo: escape(getTimeago().format(this.requirement.createdAt)),
});
},
updatedAt() {
return sprintf(__('updated %{timeAgo}'), {
timeAgo: escape(getTimeago().format(this.requirement.updatedAt)),
});
},
isArchived() {
return this.requirement?.state === FilterState.archived;
},
author() {
return this.requirement.author;
},
testReport() {
return this.requirement.testReports.nodes[0];
}, },
computed: {
showIssuableMetaActions() { showIssuableMetaActions() {
return Boolean(this.canUpdate || this.canArchive || this.testReport); return Boolean(this.canUpdate || this.canArchive || this.testReport);
}, },
...@@ -105,7 +80,11 @@ export default { ...@@ -105,7 +80,11 @@ export default {
</script> </script>
<template> <template>
<li class="issue requirement" :class="{ 'disabled-content': stateChangeRequestActive }"> <li
class="issue requirement gl-cursor-pointer"
:class="{ 'disabled-content': stateChangeRequestActive, 'gl-bg-blue-50': active }"
@click="$emit('show-click', requirement)"
>
<div class="issue-box"> <div class="issue-box">
<div class="issuable-info-container"> <div class="issuable-info-container">
<span class="issuable-reference text-muted d-none d-sm-block mr-2">{{ reference }}</span> <span class="issuable-reference text-muted d-none d-sm-block mr-2">{{ reference }}</span>
...@@ -119,7 +98,7 @@ export default { ...@@ -119,7 +98,7 @@ export default {
<span <span
v-gl-tooltip:tooltipcontainer.bottom v-gl-tooltip:tooltipcontainer.bottom
:title="tooltipTitle(requirement.createdAt)" :title="tooltipTitle(requirement.createdAt)"
>{{ createdAt }}</span >{{ createdAtFormatted }}</span
> >
{{ __('by') }} {{ __('by') }}
<gl-link ref="authorLink" class="author-link js-user-link" :href="author.webUrl"> <gl-link ref="authorLink" class="author-link js-user-link" :href="author.webUrl">
...@@ -130,7 +109,7 @@ export default { ...@@ -130,7 +109,7 @@ export default {
v-gl-tooltip:tooltipcontainer.bottom v-gl-tooltip:tooltipcontainer.bottom
:title="tooltipTitle(requirement.updatedAt)" :title="tooltipTitle(requirement.updatedAt)"
class="issuable-updated-at" class="issuable-updated-at"
>&middot; {{ updatedAt }}</span >&middot; {{ updatedAtFormatted }}</span
> >
</div> </div>
<requirement-status-badge <requirement-status-badge
...@@ -154,7 +133,7 @@ export default { ...@@ -154,7 +133,7 @@ export default {
v-gl-tooltip v-gl-tooltip
icon="pencil" icon="pencil"
:title="__('Edit')" :title="__('Edit')"
@click="$emit('editClick', requirement)" @click="$emit('edit-click', requirement)"
/> />
</li> </li>
<li v-if="canArchive && !isArchived" class="requirement-archive d-sm-block"> <li v-if="canArchive && !isArchived" class="requirement-archive d-sm-block">
...@@ -164,7 +143,7 @@ export default { ...@@ -164,7 +143,7 @@ export default {
icon="archive" icon="archive"
:loading="stateChangeRequestActive" :loading="stateChangeRequestActive"
:title="__('Archive')" :title="__('Archive')"
@click="handleArchiveClick" @click.stop="handleArchiveClick"
/> />
</li> </li>
<li v-if="canArchive && isArchived" class="requirement-reopen d-sm-block"> <li v-if="canArchive && isArchived" class="requirement-reopen d-sm-block">
......
...@@ -53,7 +53,7 @@ export default { ...@@ -53,7 +53,7 @@ export default {
:description="emptyStateDescription" :description="emptyStateDescription"
> >
<template v-if="emptyStateDescription && canCreateRequirement" #actions> <template v-if="emptyStateDescription && canCreateRequirement" #actions>
<gl-button category="primary" variant="success" @click="$emit('clickNewRequirement')">{{ <gl-button category="primary" variant="success" @click="$emit('click-new-requirement')">{{
__('New requirement') __('New requirement')
}}</gl-button> }}</gl-button>
</template> </template>
......
...@@ -50,7 +50,7 @@ export default { ...@@ -50,7 +50,7 @@ export default {
id="state-opened" id="state-opened"
data-state="opened" data-state="opened"
:title="__('Filter by requirements that are currently opened.')" :title="__('Filter by requirements that are currently opened.')"
@click="$emit('clickTab', { filterBy: $options.FilterState.opened })" @click="$emit('click-tab', { filterBy: $options.FilterState.opened })"
> >
{{ __('Open') }} {{ __('Open') }}
<gl-badge class="badge-pill">{{ requirementsCount.OPENED }}</gl-badge> <gl-badge class="badge-pill">{{ requirementsCount.OPENED }}</gl-badge>
...@@ -61,7 +61,7 @@ export default { ...@@ -61,7 +61,7 @@ export default {
id="state-archived" id="state-archived"
data-state="archived" data-state="archived"
:title="__('Filter by requirements that are currently archived.')" :title="__('Filter by requirements that are currently archived.')"
@click="$emit('clickTab', { filterBy: $options.FilterState.archived })" @click="$emit('click-tab', { filterBy: $options.FilterState.archived })"
> >
{{ __('Archived') }} {{ __('Archived') }}
<gl-badge class="badge-pill">{{ requirementsCount.ARCHIVED }}</gl-badge> <gl-badge class="badge-pill">{{ requirementsCount.ARCHIVED }}</gl-badge>
...@@ -72,7 +72,7 @@ export default { ...@@ -72,7 +72,7 @@ export default {
id="state-all" id="state-all"
data-state="all" data-state="all"
:title="__('Show all requirements.')" :title="__('Show all requirements.')"
@click="$emit('clickTab', { filterBy: $options.FilterState.all })" @click="$emit('click-tab', { filterBy: $options.FilterState.all })"
> >
{{ __('All') }} {{ __('All') }}
<gl-badge class="badge-pill">{{ requirementsCount.ALL }}</gl-badge> <gl-badge class="badge-pill">{{ requirementsCount.ALL }}</gl-badge>
...@@ -85,7 +85,7 @@ export default { ...@@ -85,7 +85,7 @@ export default {
variant="success" variant="success"
class="js-new-requirement qa-new-requirement-button" class="js-new-requirement qa-new-requirement-button"
:disabled="showCreateForm" :disabled="showCreateForm"
@click="$emit('clickNewRequirement')" @click="$emit('click-new-requirement')"
>{{ __('New requirement') }}</gl-button >{{ __('New requirement') }}</gl-button
> >
</div> </div>
......
import { __, sprintf } from '~/locale';
import { getTimeago } from '~/lib/utils/datetime_utility';
import { FilterState } from '../constants';
export default {
computed: {
reference() {
return `REQ-${this.requirement?.iid}`;
},
titleHtml() {
return this.requirement?.titleHtml;
},
descriptionHtml() {
return this.requirement?.descriptionHtml;
},
isArchived() {
return this.requirement?.state === FilterState.archived;
},
author() {
return this.requirement?.author;
},
createdAtFormatted() {
return sprintf(__('created %{timeAgo}'), {
timeAgo: getTimeago().format(this.requirement?.createdAt),
});
},
updatedAtFormatted() {
return sprintf(__('updated %{timeAgo}'), {
timeAgo: getTimeago().format(this.requirement?.updatedAt),
});
},
testReport() {
return this.requirement?.testReports.nodes[0];
},
canUpdate() {
return this.requirement?.userPermissions.updateRequirement;
},
canArchive() {
return this.requirement?.userPermissions.adminRequirement;
},
},
};
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "./requirement.fragment.graphql"
query projectRequirementsEE( query projectRequirementsEE(
$projectPath: ID! $projectPath: ID!
$state: RequirementState $state: RequirementState
...@@ -7,7 +10,7 @@ query projectRequirementsEE( ...@@ -7,7 +10,7 @@ query projectRequirementsEE(
$nextPageCursor: String = "" $nextPageCursor: String = ""
$authorUsernames: [String!] = [] $authorUsernames: [String!] = []
$search: String = "" $search: String = ""
$sortBy: Sort = created_desc $sortBy: Sort = CREATED_DESC
) { ) {
project(fullPath: $projectPath) { project(fullPath: $projectPath) {
requirements( requirements(
...@@ -21,36 +24,10 @@ query projectRequirementsEE( ...@@ -21,36 +24,10 @@ query projectRequirementsEE(
sort: $sortBy sort: $sortBy
) { ) {
nodes { nodes {
iid ...Requirement
title
createdAt
updatedAt
state
lastTestReportState
lastTestReportManuallyCreated
testReports(first: 1, sort: created_desc) {
nodes {
id
state
createdAt
}
}
userPermissions {
updateRequirement
adminRequirement
}
author {
name
username
avatarUrl
webUrl
}
} }
pageInfo { pageInfo {
hasPreviousPage ...PageInfo
hasNextPage
startCursor
endCursor
} }
} }
} }
......
#import "~/graphql_shared/fragments/author.fragment.graphql"
fragment Requirement on Requirement {
iid
title
titleHtml
description
descriptionHtml
createdAt
updatedAt
state
lastTestReportState
lastTestReportManuallyCreated
testReports(first: 1, sort: CREATED_DESC) {
nodes {
id
state
createdAt
}
}
userPermissions {
updateRequirement
adminRequirement
}
author {
...Author
}
}
#import "./requirement.fragment.graphql"
mutation updateRequirement($updateRequirementInput: UpdateRequirementInput!) { mutation updateRequirement($updateRequirementInput: UpdateRequirementInput!) {
updateRequirement(input: $updateRequirementInput) { updateRequirement(input: $updateRequirementInput) {
clientMutationId clientMutationId
errors errors
requirement { requirement {
iid ...Requirement
title
state
updatedAt
lastTestReportState
testReports(first: 1, sort: created_desc) {
nodes {
id
state
createdAt
}
}
} }
} }
} }
...@@ -38,6 +38,10 @@ export default () => { ...@@ -38,6 +38,10 @@ export default () => {
components: { components: {
RequirementsRoot, RequirementsRoot,
}, },
provide: {
descriptionPreviewPath: el.dataset.descriptionPreviewPath,
descriptionHelpPath: el.dataset.descriptionHelpPath,
},
data() { data() {
const { const {
filterBy, filterBy,
......
...@@ -20,6 +20,12 @@ ...@@ -20,6 +20,12 @@
overflow-y: auto !important; overflow-y: auto !important;
} }
} }
.requirement-form-drawer.zen-mode {
// We need to override `z-index` provided to GlDrawer
// in Zen mode to enable full-screen editing.
z-index: auto !important;
}
} }
.requirements-list-container { .requirements-list-container {
...@@ -73,7 +79,11 @@ ...@@ -73,7 +79,11 @@
} }
.gl-drawer { .gl-drawer {
width: 480px; // Both width & min-width
// are defined as per Pajamas
// See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44902#note_429056182
width: 28%;
min-width: 400px;
padding-left: $gl-padding; padding-left: $gl-padding;
padding-right: $gl-padding; padding-right: $gl-padding;
box-shadow: none; box-shadow: none;
......
...@@ -31,6 +31,8 @@ ...@@ -31,6 +31,8 @@
all: total_requirements, all: total_requirements,
requirements_web_url: project_requirements_management_requirements_path(@project), requirements_web_url: project_requirements_management_requirements_path(@project),
can_create_requirement: "#{can?(current_user, :create_requirement, @project)}", can_create_requirement: "#{can?(current_user, :create_requirement, @project)}",
description_preview_path: preview_markdown_path(@project),
description_help_path: help_page_path('user/markdown'),
empty_state_path: image_path('illustrations/empty-state/empty-requirements-lg.svg') } } empty_state_path: image_path('illustrations/empty-state/empty-requirements-lg.svg') } }
- if current_tab_count == 0 - if current_tab_count == 0
-# Show regular spinner only when there will be no -# Show regular spinner only when there will be no
......
---
title: Add support for providing requirement description.
merge_request: 44902
author:
type: added
...@@ -6,10 +6,10 @@ RSpec.describe 'Requirements list', :js do ...@@ -6,10 +6,10 @@ RSpec.describe 'Requirements list', :js do
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:user_guest) { create(:user) } let_it_be(:user_guest) { create(:user) }
let_it_be(:project) { create(:project, :repository) } let_it_be(:project) { create(:project, :repository) }
let_it_be(:requirement1) { create(:requirement, project: project, title: 'Some requirement-1', author: user, created_at: 5.days.ago, updated_at: 2.days.ago) } let_it_be(:requirement1) { create(:requirement, project: project, title: 'Some requirement-1', description: 'Sample description', author: user, created_at: 5.days.ago, updated_at: 2.days.ago) }
let_it_be(:requirement2) { create(:requirement, project: project, title: 'Some requirement-2', author: user, created_at: 6.days.ago, updated_at: 2.days.ago) } let_it_be(:requirement2) { create(:requirement, project: project, title: 'Some requirement-2', description: 'Sample description', author: user, created_at: 6.days.ago, updated_at: 2.days.ago) }
let_it_be(:requirement3) { create(:requirement, project: project, title: 'Some requirement-3', author: user, created_at: 7.days.ago, updated_at: 2.days.ago) } let_it_be(:requirement3) { create(:requirement, project: project, title: 'Some requirement-3', description: 'Sample description', author: user, created_at: 7.days.ago, updated_at: 2.days.ago) }
let_it_be(:requirement_archived) { create(:requirement, project: project, title: 'Some requirement-3', state: :archived, author: user, created_at: 8.days.ago, updated_at: 2.days.ago) } let_it_be(:requirement_archived) { create(:requirement, project: project, title: 'Some requirement-3', description: 'Sample description', state: :archived, author: user, created_at: 8.days.ago, updated_at: 2.days.ago) }
def create_requirement(title) def create_requirement(title)
page.within('.nav-controls') do page.within('.nav-controls') do
...@@ -131,34 +131,51 @@ RSpec.describe 'Requirements list', :js do ...@@ -131,34 +131,51 @@ RSpec.describe 'Requirements list', :js do
end end
end end
it 'shows title and description along with edit button in drawer' do
find('.requirements-list li.requirement', match: :first).click
page.within('.requirement-form-drawer') do
expect(page.find('.title-container')).to have_content(requirement1.title)
expect(page.find('.title-container')).to have_selector('button.btn-edit')
expect(page.find('.description-container')).to have_content(requirement1.description)
end
end
it 'shows edit form when edit button is clicked for a requirement' do it 'shows edit form when edit button is clicked for a requirement' do
page.within('.requirements-list li.requirement', match: :first) do
find('li.requirement-edit button[title="Edit"]').click
end
page.within('.requirement-form-drawer') do
expect(page.find('.gl-drawer-header span', match: :first)).to have_content("REQ-#{requirement1.iid}")
expect(page.find('textarea#requirementTitle')['value']).to have_content("#{requirement1.title}")
expect(page.find('textarea#requirementDescription')['value']).to have_content("#{requirement1.description}")
expect(page.find('input[type="checkbox"]')['checked']).to eq(requirement1.last_test_report_state)
expect(page.find('.js-requirement-save')).to have_content('Save changes')
end
end
it 'updates requirement using edit form' do
requirement_title = 'Foobar' requirement_title = 'Foobar'
requirement_description = 'Baz'
page.within('.requirements-list li.requirement', match: :first) do page.within('.requirements-list li.requirement', match: :first) do
find('li.requirement-edit button[title="Edit"]').click find('li.requirement-edit button[title="Edit"]').click
end end
page.within('.requirement-form') do page.within('.requirement-form-drawer') do
find('textarea#requirementTitle').native.send_keys requirement_title find('textarea#requirementTitle').native.send_keys requirement_title
find('button.js-requirement-save').click find('textarea#requirementDescription').native.send_keys requirement_description
find('input[type="checkbox"]').click
click_button 'Save changes'
wait_for_all_requests wait_for_all_requests
end end
page.within('.requirements-list li.requirement', match: :first) do page.within('.requirements-list li.requirement', match: :first) do
expect(page.find('.issue-title-text')).to have_content(requirement_title) expect(page.find('.issue-title-text')).to have_content(requirement_title)
end expect(page.find('.requirement-status-badge')).to have_content('satisfied')
end
it 'saves updated title for requirement using edit form' do
page.within('.requirements-list li.requirement', match: :first) do
find('li.requirement-edit button[title="Edit"]').click
end
page.within('.requirement-form') do
expect(page.find('span', match: :first)).to have_content("REQ-#{requirement1.iid}")
expect(page.find('textarea#requirementTitle')['value']).to have_content("#{requirement1.title}")
expect(page.find('.js-requirement-save')).to have_content('Save changes')
end end
end end
......
...@@ -32,64 +32,6 @@ describe('RequirementItem', () => { ...@@ -32,64 +32,6 @@ describe('RequirementItem', () => {
wrapperArchived.destroy(); wrapperArchived.destroy();
}); });
describe('computed', () => {
describe('reference', () => {
it('returns string containing `requirement.iid` prefixed with "REQ-"', () => {
expect(wrapper.vm.reference).toBe(`REQ-${requirement1.iid}`);
});
});
describe('canUpdate', () => {
it('returns value of `requirement.userPermissions.updateRequirement`', () => {
expect(wrapper.vm.canUpdate).toBe(requirement1.userPermissions.updateRequirement);
});
});
describe('canArchive', () => {
it('returns value of `requirement.userPermissions.updateRequirement`', () => {
expect(wrapper.vm.canArchive).toBe(requirement1.userPermissions.adminRequirement);
});
});
describe('createdAt', () => {
it('returns timeago-style string representing `requirement.createdAt`', () => {
// We don't have to be accurate here as it is already covered in rspecs
expect(wrapper.vm.createdAt).toContain('created');
expect(wrapper.vm.createdAt).toContain('ago');
});
});
describe('updatedAt', () => {
it('returns timeago-style string representing `requirement.updatedAt`', () => {
// We don't have to be accurate here as it is already covered in rspecs
expect(wrapper.vm.updatedAt).toContain('updated');
expect(wrapper.vm.updatedAt).toContain('ago');
});
});
describe('isArchived', () => {
it('returns `true` when current requirement is archived', () => {
expect(wrapperArchived.vm.isArchived).toBe(true);
});
it('returns `false` when current requirement is archived', () => {
expect(wrapper.vm.isArchived).toBe(false);
});
});
describe('author', () => {
it('returns value of `requirement.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', () => {
describe('handleArchiveClick', () => { describe('handleArchiveClick', () => {
it('emits `archiveClick` event on component with object containing `requirement.iid` & `state` as "ARCHIVED" as param', () => { it('emits `archiveClick` event on component with object containing `requirement.iid` & `state` as "ARCHIVED" as param', () => {
...@@ -139,6 +81,13 @@ describe('RequirementItem', () => { ...@@ -139,6 +81,13 @@ describe('RequirementItem', () => {
}); });
}); });
it('emits `show-click` event with requirement as param', () => {
wrapper.trigger('click');
expect(wrapper.emitted('show-click')).toBeTruthy();
expect(wrapper.emitted('show-click')[0]).toEqual([requirement1]);
});
it('renders element containing requirement reference', () => { it('renders element containing requirement reference', () => {
expect(wrapper.find('.issuable-reference').text()).toBe(`REQ-${requirement1.iid}`); expect(wrapper.find('.issuable-reference').text()).toBe(`REQ-${requirement1.iid}`);
}); });
...@@ -186,6 +135,11 @@ describe('RequirementItem', () => { ...@@ -186,6 +135,11 @@ describe('RequirementItem', () => {
expect(editButtonEl.exists()).toBe(true); expect(editButtonEl.exists()).toBe(true);
expect(editButtonEl.attributes('title')).toBe('Edit'); expect(editButtonEl.attributes('title')).toBe('Edit');
editButtonEl.vm.$emit('click');
expect(wrapper.emitted('edit-click')).toBeTruthy();
expect(wrapper.emitted('edit-click')[0]).toEqual([wrapper.vm.requirement]);
}); });
it('does not render element containing requirement `Edit` button when `requirement.userPermissions.updateRequirement` is false', () => { it('does not render element containing requirement `Edit` button when `requirement.userPermissions.updateRequirement` is false', () => {
......
import { shallowMount } from '@vue/test-utils';
import RequirementItem from 'ee/requirements/components/requirement_item.vue';
import { FilterState } from 'ee/requirements/constants';
import { mockAuthor, mockTestReport, requirement1 as mockRequirement } from '../mock_data';
const createComponent = (requirement = mockRequirement) =>
shallowMount(RequirementItem, {
propsData: {
requirement,
},
});
describe('RequirementMeta Mixin', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('reference', () => {
it('returns string containing `requirement.iid` prefixed with `REQ-`', () => {
expect(wrapper.vm.reference).toBe(`REQ-${mockRequirement.iid}`);
});
});
describe('titleHtml', () => {
it('returns value of `requirement.titleHtml`', () => {
expect(wrapper.vm.titleHtml).toBe(mockRequirement.titleHtml);
});
});
describe('descriptionHtml', () => {
it('returns value of `requirement.descriptionHtml`', () => {
expect(wrapper.vm.descriptionHtml).toBe(mockRequirement.descriptionHtml);
});
});
describe('isArchived', () => {
it('returns true when `requirement.state` is "ARCHIVED"', async () => {
wrapper.setProps({
requirement: {
...mockRequirement,
state: FilterState.archived,
},
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.isArchived).toBe(true);
});
it('returns false when `requirement.state` is "OPENED"', () => {
expect(wrapper.vm.isArchived).toBe(false);
});
});
describe('author', () => {
it('returns value of `requirement.author`', () => {
expect(wrapper.vm.author).toBe(mockAuthor);
});
});
describe('createdAtFormatted', () => {
it('returns timeago-style string representing `requirement.createdAtFormatted`', () => {
// We don't have to be accurate here as it is already covered in rspecs
expect(wrapper.vm.createdAtFormatted).toContain('created');
expect(wrapper.vm.createdAtFormatted).toContain('ago');
});
});
describe('updatedAtFormatted', () => {
it('returns timeago-style string representing `requirement.updatedAtFormatted`', () => {
// We don't have to be accurate here as it is already covered in rspecs
expect(wrapper.vm.updatedAtFormatted).toContain('updated');
expect(wrapper.vm.updatedAtFormatted).toContain('ago');
});
});
describe('testReport', () => {
it('returns testReport object from reports array within `requirement`', () => {
expect(wrapper.vm.testReport).toBe(mockTestReport);
});
});
describe('canUpdate', () => {
it('returns value of `requirement.userPermissions.updateRequirement`', () => {
expect(wrapper.vm.canUpdate).toBe(mockRequirement.userPermissions.updateRequirement);
});
});
describe('canArchive', () => {
it('returns value of `requirement.userPermissions.updateRequirement`', () => {
expect(wrapper.vm.canArchive).toBe(mockRequirement.userPermissions.adminRequirement);
});
});
});
});
...@@ -34,6 +34,10 @@ export const mockTestReportMissing = { ...@@ -34,6 +34,10 @@ export const mockTestReportMissing = {
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.',
titleHtml:
'Virtutis, magnitudinis animi, patientiae, fortitudinis fomentis dolor mitigari solet.',
description: 'fortitudinis _fomentis_ dolor mitigari solet.',
descriptionHtml: 'fortitudinis <i>fomentis</i> dolor mitigari solet.',
createdAt: '2020-03-19T08:09:09Z', createdAt: '2020-03-19T08:09:09Z',
updatedAt: '2020-03-20T08:09:09Z', updatedAt: '2020-03-20T08:09:09Z',
state: 'OPENED', state: 'OPENED',
...@@ -50,6 +54,10 @@ export const requirement1 = { ...@@ -50,6 +54,10 @@ export const requirement1 = {
export const requirement2 = { export const requirement2 = {
iid: '2', iid: '2',
title: 'Est autem officium, quod ita factum est, ut eius facti probabilis ratio reddi possit.', title: 'Est autem officium, quod ita factum est, ut eius facti probabilis ratio reddi possit.',
titleHtml:
'Est autem officium, quod ita factum est, ut eius facti probabilis ratio reddi possit.',
description: 'ut eius facti _probabilis_ ratio reddi possit.',
descriptionHtml: 'ut eius facti <i>probabilis</i> ratio reddi possit.',
createdAt: '2020-03-19T08:08:14Z', createdAt: '2020-03-19T08:08:14Z',
updatedAt: '2020-03-20T08:08:14Z', updatedAt: '2020-03-20T08:08:14Z',
state: 'OPENED', state: 'OPENED',
...@@ -66,6 +74,9 @@ export const requirement2 = { ...@@ -66,6 +74,9 @@ export const requirement2 = {
export const requirement3 = { export const requirement3 = {
iid: '3', iid: '3',
title: 'Non modo carum sibi quemque, verum etiam vehementer carum esse', title: 'Non modo carum sibi quemque, verum etiam vehementer carum esse',
titleHtml: 'Non modo carum sibi quemque, verum etiam vehementer carum esse',
description: 'verum etiam _vehementer_ carum esse.',
descriptionHtml: 'verum etiam <i>vehementer</i> carum esse.',
createdAt: '2020-03-19T08:08:25Z', createdAt: '2020-03-19T08:08:25Z',
updatedAt: '2020-03-20T08:08:25Z', updatedAt: '2020-03-20T08:08:25Z',
state: 'OPENED', state: 'OPENED',
...@@ -82,6 +93,9 @@ export const requirement3 = { ...@@ -82,6 +93,9 @@ export const requirement3 = {
export const requirementArchived = { export const requirementArchived = {
iid: '23', iid: '23',
title: 'Cuius quidem, quoniam Stoicus fuit', title: 'Cuius quidem, quoniam Stoicus fuit',
titleHtml: 'Cuius quidem, quoniam Stoicus fuit',
description: 'quoniam _Stoicus_ fuit.',
descriptionHtml: 'quoniam <i>Stoicus</i> fuit.',
createdAt: '2020-03-31T13:31:40Z', createdAt: '2020-03-31T13:31:40Z',
updatedAt: '2020-03-31T13:31:40Z', updatedAt: '2020-03-31T13:31:40Z',
state: 'ARCHIVED', state: 'ARCHIVED',
......
...@@ -22312,6 +22312,9 @@ msgstr "" ...@@ -22312,6 +22312,9 @@ msgstr ""
msgid "Requirement %{reference} has been updated" msgid "Requirement %{reference} has been updated"
msgstr "" msgstr ""
msgid "Requirement title"
msgstr ""
msgid "Requirement title cannot have more than %{limit} characters." msgid "Requirement title cannot have more than %{limit} characters."
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