Commit ac9a53fe authored by Mike Greiling's avatar Mike Greiling

Merge branch '2035-add-blocking-issues-to-frontend' into 'master'

Resolve "Blocking issues MVC: (add support for issue dependencies)"

See merge request gitlab-org/gitlab!21415
parents 2ce3b765 c2ca79c4
...@@ -55,6 +55,10 @@ ...@@ -55,6 +55,10 @@
background-color: $gray-light; background-color: $gray-light;
} }
.bg-white {
background-color: $white;
}
.bg-line-target-blue { .bg-line-target-blue {
background: $line-target-blue; background: $line-target-blue;
} }
......
...@@ -29,10 +29,13 @@ ...@@ -29,10 +29,13 @@
} }
.border-width-1px { border-width: 1px; } .border-width-1px { border-width: 1px; }
.border-bottom-width-1px { border-bottom-width: 1px; }
.border-style-dashed { border-style: dashed; } .border-style-dashed { border-style: dashed; }
.border-style-solid { border-style: solid; } .border-style-solid { border-style: solid; }
.border-bottom-style-solid { border-bottom-style: solid; }
.border-color-blue-300 { border-color: $blue-300; } .border-color-blue-300 { border-color: $blue-300; }
.border-color-default { border-color: $border-color; } .border-color-default { border-color: $border-color; }
.border-bottom-color-default { border-bottom-color: $border-color; }
.box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; } .box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; }
.mh-50vh { max-height: 50vh; } .mh-50vh { max-height: 50vh; }
......
...@@ -44,6 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -44,6 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action do before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, project.group) push_frontend_feature_flag(:vue_issuable_sidebar, project.group)
push_frontend_feature_flag(:issue_link_types, project)
end end
around_action :allow_gitaly_ref_name_caching, only: [:discussions] around_action :allow_gitaly_ref_name_caching, only: [:discussions]
......
<script> <script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { GlFormGroup, GlFormRadioGroup, GlLoadingIcon } from '@gitlab/ui';
import { GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import RelatedIssuableInput from './related_issuable_input.vue'; import RelatedIssuableInput from './related_issuable_input.vue';
import { import {
issuableTypesMap, issuableTypesMap,
itemAddFailureTypesMap, itemAddFailureTypesMap,
linkedIssueTypesMap,
addRelatedIssueErrorMap, addRelatedIssueErrorMap,
addRelatedItemErrorMap, addRelatedItemErrorMap,
} from '../constants'; } from '../constants';
...@@ -12,9 +14,12 @@ import { ...@@ -12,9 +14,12 @@ import {
export default { export default {
name: 'AddIssuableForm', name: 'AddIssuableForm',
components: { components: {
GlFormGroup,
GlFormRadioGroup,
GlLoadingIcon, GlLoadingIcon,
RelatedIssuableInput, RelatedIssuableInput,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
inputValue: { inputValue: {
type: String, type: String,
...@@ -55,6 +60,25 @@ export default { ...@@ -55,6 +60,25 @@ export default {
default: itemAddFailureTypesMap.NOT_FOUND, default: itemAddFailureTypesMap.NOT_FOUND,
}, },
}, },
data() {
return {
linkedIssueType: linkedIssueTypesMap.RELATES_TO,
linkedIssueTypes: [
{
text: __('relates to'),
value: linkedIssueTypesMap.RELATES_TO,
},
{
text: __('blocks'),
value: linkedIssueTypesMap.BLOCKS,
},
{
text: __('is blocked by'),
value: linkedIssueTypesMap.IS_BLOCKED_BY,
},
],
};
},
computed: { computed: {
isSubmitButtonDisabled() { isSubmitButtonDisabled() {
return ( return (
...@@ -74,7 +98,10 @@ export default { ...@@ -74,7 +98,10 @@ export default {
this.$emit('pendingIssuableRemoveRequest', params); this.$emit('pendingIssuableRemoveRequest', params);
}, },
onFormSubmit() { onFormSubmit() {
this.$emit('addIssuableFormSubmit', this.$refs.relatedIssuableInput.$refs.input.value); this.$emit('addIssuableFormSubmit', {
pendingReferences: this.$refs.relatedIssuableInput.$refs.input.value,
linkedIssueType: this.linkedIssueType,
});
}, },
onFormCancel() { onFormCancel() {
this.$emit('addIssuableFormCancel'); this.$emit('addIssuableFormCancel');
...@@ -91,6 +118,24 @@ export default { ...@@ -91,6 +118,24 @@ export default {
<template> <template>
<form @submit.prevent="onFormSubmit"> <form @submit.prevent="onFormSubmit">
<template v-if="glFeatures.issueLinkTypes">
<gl-form-group
:label="__('The current issue')"
label-for="linked-issue-type-radio"
label-class="label-bold"
class="mb-2"
>
<gl-form-radio-group
id="linked-issue-type-radio"
v-model="linkedIssueType"
:options="linkedIssueTypes"
:checked="linkedIssueType"
/>
</gl-form-group>
<p class="bold">
{{ __('the following issue(s)') }}
</p>
</template>
<related-issuable-input <related-issuable-input
ref="relatedIssuableInput" ref="relatedIssuableInput"
:focus-on-mount="true" :focus-on-mount="true"
...@@ -116,7 +161,7 @@ export default { ...@@ -116,7 +161,7 @@ export default {
class="js-add-issuable-form-add-button btn btn-success float-left qa-add-issue-button" class="js-add-issuable-form-add-button btn btn-success float-left qa-add-issue-button"
:class="{ disabled: isSubmitButtonDisabled }" :class="{ disabled: isSubmitButtonDisabled }"
> >
Add {{ __('Add') }}
<gl-loading-icon v-if="isSubmitting" ref="loadingIcon" :inline="true" /> <gl-loading-icon v-if="isSubmitting" ref="loadingIcon" :inline="true" />
</button> </button>
<button type="button" class="btn btn-default float-right" @click="onFormCancel"> <button type="button" class="btn btn-default float-right" @click="onFormCancel">
......
<script> <script>
import Sortable from 'sortablejs';
import IssueWeight from 'ee/boards/components/issue_card_weight.vue';
import sortableConfig from 'ee/sortable/sortable_config';
import { GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import AddIssuableForm from './add_issuable_form.vue'; import AddIssuableForm from './add_issuable_form.vue';
import { issuableIconMap, issuableQaClassMap } from '../constants'; import RelatedIssuesList from './related_issues_list.vue';
import {
issuableIconMap,
issuableQaClassMap,
linkedIssueTypesMap,
linkedIssueTypesTextMap,
} from '../constants';
export default { export default {
name: 'RelatedIssuesBlock', name: 'RelatedIssuesBlock',
directives: {
tooltip,
},
components: { components: {
Icon, Icon,
AddIssuableForm, AddIssuableForm,
RelatedIssuableItem, RelatedIssuesList,
GlLoadingIcon,
IssueWeight,
IssueDueDate,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
isFetching: { isFetching: {
type: Boolean, type: Boolean,
...@@ -79,11 +74,6 @@ export default { ...@@ -79,11 +74,6 @@ export default {
required: false, required: false,
default: () => ({}), default: () => ({}),
}, },
title: {
type: String,
required: false,
default: __('Related issues'),
},
issuableType: { issuableType: {
type: String, type: String,
required: true, required: true,
...@@ -93,6 +83,22 @@ export default { ...@@ -93,6 +83,22 @@ export default {
hasRelatedIssues() { hasRelatedIssues() {
return this.relatedIssues.length > 0; return this.relatedIssues.length > 0;
}, },
categorisedIssues() {
if (this.glFeatures.issueLinkTypes) {
return Object.values(linkedIssueTypesMap)
.map(linkType => ({
linkType,
issues: this.relatedIssues.filter(issue => issue.linkType === linkType),
}))
.filter(obj => obj.issues.length > 0);
}
return [
{
linkType: linkedIssueTypesMap.RELATES_TO,
issues: this.relatedIssues,
},
];
},
shouldShowTokenBody() { shouldShowTokenBody() {
return this.hasRelatedIssues || this.isFetching; return this.hasRelatedIssues || this.isFetching;
}, },
...@@ -111,57 +117,32 @@ export default { ...@@ -111,57 +117,32 @@ export default {
qaClass() { qaClass() {
return issuableQaClassMap[this.issuableType]; return issuableQaClassMap[this.issuableType];
}, },
validIssueWeight() { cardBodyCssClass() {
if (this.issue) { return this.glFeatures.issueLinkTypes
return this.issue.weight >= 0; ? {
'linked-issues-card-body': true,
'bg-gray-light': true,
'gl-p-3': this.isFormVisible || this.shouldShowTokenBody,
} }
: {};
return false;
},
}, },
mounted() { formCssClass() {
if (this.canReorder) { if (this.glFeatures.issueLinkTypes) {
this.sortable = Sortable.create( return ['bordered-box', 'bg-white'];
this.$refs.list,
Object.assign({}, sortableConfig, {
onStart: this.addDraggingCursor,
onEnd: this.reordered,
}),
);
} }
if (this.hasRelatedIssues) {
return [
'border-bottom-width-1px',
'border-bottom-style-solid',
'border-bottom-color-default',
];
}
return [];
}, },
methods: {
getBeforeAfterId(itemEl) {
const prevItemEl = itemEl.previousElementSibling;
const nextItemEl = itemEl.nextElementSibling;
return {
beforeId: prevItemEl && parseInt(prevItemEl.dataset.orderingId, 0),
afterId: nextItemEl && parseInt(nextItemEl.dataset.orderingId, 0),
};
},
reordered(event) {
this.removeDraggingCursor();
const { beforeId, afterId } = this.getBeforeAfterId(event.item);
const { oldIndex, newIndex } = event;
this.$emit('saveReorder', {
issueId: parseInt(event.item.dataset.key, 10),
oldIndex,
newIndex,
afterId,
beforeId,
});
},
addDraggingCursor() {
document.body.classList.add('is-dragging');
},
removeDraggingCursor() {
document.body.classList.remove('is-dragging');
},
issuableOrderingId({ epic_issue_id: epicIssueId, id }) {
return this.issuableType === 'issue' ? epicIssueId : id;
}, },
created() {
this.linkedIssueTypesTextMap = linkedIssueTypesTextMap;
this.title = this.glFeatures.issueLinkTypes ? __('Linked issues') : __('Related issues');
}, },
}; };
</script> </script>
...@@ -203,12 +184,11 @@ export default { ...@@ -203,12 +184,11 @@ export default {
</div> </div>
</h3> </h3>
</div> </div>
<div :class="cardBodyCssClass">
<div <div
v-if="isFormVisible" v-if="isFormVisible"
:class="{
'related-issues-add-related-issues-form-with-break': hasRelatedIssues,
}"
class="js-add-related-issues-form-area card-body" class="js-add-related-issues-form-area card-body"
:class="formCssClass"
> >
<add-issuable-form <add-issuable-form
:is-submitting="isSubmitting" :is-submitting="isSubmitting"
...@@ -224,67 +204,21 @@ export default { ...@@ -224,67 +204,21 @@ export default {
@addIssuableFormCancel="$emit('addIssuableFormCancel', $event)" @addIssuableFormCancel="$emit('addIssuableFormCancel', $event)"
/> />
</div> </div>
<div <template v-if="shouldShowTokenBody">
v-if="shouldShowTokenBody" <related-issues-list
:class="{ 'sortable-container': canReorder }" v-for="category in categorisedIssues"
class="related-issues-token-body" :key="category.linkType"
> :heading="linkedIssueTypesTextMap[category.linkType]"
<div v-if="isFetching" class="related-issues-loading-icon qa-related-issues-loading-icon"> :can-admin="canAdmin"
<gl-loading-icon
ref="loadingIcon"
label="Fetching related issues"
class="prepend-top-5"
/>
</div>
<ul ref="list" :class="{ 'content-list': !canReorder }" class="related-items-list">
<li
v-for="issue in relatedIssues"
:key="issue.id"
:class="{
'user-can-drag': canReorder,
'sortable-row': canReorder,
'card card-slim': canReorder,
}"
:data-key="issue.id"
:data-ordering-id="issuableOrderingId(issue)"
class="js-related-issues-token-list-item list-item pt-0 pb-0"
>
<related-issuable-item
:id-key="issue.id"
:display-reference="issue.reference"
:confidential="issue.confidential"
:title="issue.title"
:path="issue.path"
:state="issue.state"
:milestone="issue.milestone"
:assignees="issue.assignees"
:created-at="issue.created_at"
:closed-at="issue.closed_at"
:can-remove="canAdmin"
:can-reorder="canReorder" :can-reorder="canReorder"
:is-fetching="isFetching"
:issuable-type="issuableType"
:path-id-separator="pathIdSeparator" :path-id-separator="pathIdSeparator"
event-namespace="relatedIssue" :related-issues="category.issues"
class="qa-related-issuable-item"
@relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)" @relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)"
> @saveReorder="$emit('saveReorder', $event)"
<span v-if="validIssueWeight" slot="weight" class="order-md-1">
<issue-weight
:weight="issue.weight"
class="item-weight d-flex align-items-center"
tag-name="span"
/>
</span>
<span v-if="issue.due_date" slot="dueDate" class="order-md-1">
<issue-due-date
:date="issue.due_date"
tooltip-placement="top"
css-class="item-due-date d-flex align-items-center"
/> />
</span> </template>
</related-issuable-item>
</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
......
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import Sortable from 'sortablejs';
import IssueWeight from 'ee/boards/components/issue_card_weight.vue';
import sortableConfig from 'ee/sortable/sortable_config';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'RelatedIssuesList',
directives: {
tooltip,
},
components: {
GlLoadingIcon,
IssueDueDate,
IssueWeight,
RelatedIssuableItem,
},
mixins: [glFeatureFlagsMixin()],
props: {
canAdmin: {
type: Boolean,
required: false,
default: false,
},
canReorder: {
type: Boolean,
required: false,
default: false,
},
heading: {
type: String,
required: false,
default: '',
},
isFetching: {
type: Boolean,
required: false,
default: false,
},
issuableType: {
type: String,
required: true,
},
pathIdSeparator: {
type: String,
required: true,
},
relatedIssues: {
type: Array,
required: false,
default: () => [],
},
},
computed: {
validIssueWeight() {
return this.issue && this.issue.weight >= 0;
},
shouldRenderHeading() {
return this.glFeatures.issueLinkTypes && this.heading;
},
},
mounted() {
if (this.canReorder) {
this.sortable = Sortable.create(
this.$refs.list,
Object.assign({}, sortableConfig, {
onStart: this.addDraggingCursor,
onEnd: this.reordered,
}),
);
}
},
methods: {
getBeforeAfterId(itemEl) {
const prevItemEl = itemEl.previousElementSibling;
const nextItemEl = itemEl.nextElementSibling;
return {
beforeId: prevItemEl && parseInt(prevItemEl.dataset.orderingId, 0),
afterId: nextItemEl && parseInt(nextItemEl.dataset.orderingId, 0),
};
},
reordered(event) {
this.removeDraggingCursor();
const { beforeId, afterId } = this.getBeforeAfterId(event.item);
const { oldIndex, newIndex } = event;
this.$emit('saveReorder', {
issueId: parseInt(event.item.dataset.key, 10),
oldIndex,
newIndex,
afterId,
beforeId,
});
},
addDraggingCursor() {
document.body.classList.add('is-dragging');
},
removeDraggingCursor() {
document.body.classList.remove('is-dragging');
},
issuableOrderingId({ epicIssueId, id }) {
return this.issuableType === 'issue' ? epicIssueId : id;
},
},
};
</script>
<template>
<div>
<h4 v-if="shouldRenderHeading" class="gl-font-size-14 mt-0">{{ heading }}</h4>
<div
class="related-issues-token-body"
:class="{
'sortable-container': canReorder,
'bordered-box': glFeatures.issueLinkTypes,
'bg-white': glFeatures.issueLinkTypes,
}"
>
<div v-if="isFetching" class="related-issues-loading-icon qa-related-issues-loading-icon">
<gl-loading-icon ref="loadingIcon" label="Fetching linked issues" class="prepend-top-5" />
</div>
<ul ref="list" :class="{ 'content-list': !canReorder }" class="related-items-list">
<li
v-for="issue in relatedIssues"
:key="issue.id"
:class="{
'user-can-drag': canReorder,
'sortable-row': canReorder,
'card card-slim': canReorder,
}"
:data-key="issue.id"
:data-ordering-id="issuableOrderingId(issue)"
class="js-related-issues-token-list-item list-item pt-0 pb-0"
>
<related-issuable-item
:id-key="issue.id"
:display-reference="issue.reference"
:confidential="issue.confidential"
:title="issue.title"
:path="issue.path"
:state="issue.state"
:milestone="issue.milestone"
:assignees="issue.assignees"
:created-at="issue.createdAt"
:closed-at="issue.closedAt"
:can-remove="canAdmin"
:can-reorder="canReorder"
:path-id-separator="pathIdSeparator"
event-namespace="relatedIssue"
class="qa-related-issuable-item"
@relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)"
>
<span v-if="validIssueWeight" slot="weight" class="order-md-1">
<issue-weight
:weight="issue.weight"
class="item-weight d-flex align-items-center"
tag-name="span"
/>
</span>
<span v-if="issue.dueDate" slot="dueDate" class="order-md-1">
<issue-due-date
:date="issue.dueDate"
tooltip-placement="top"
css-class="item-due-date d-flex align-items-center"
/>
</span>
</related-issuable-item>
</li>
</ul>
</div>
</div>
</template>
...@@ -23,7 +23,6 @@ Your caret can stop touching a `rawReference` can happen in a variety of ways: ...@@ -23,7 +23,6 @@ Your caret can stop touching a `rawReference` can happen in a variety of ways:
and hide the `AddIssuableForm` area. and hide the `AddIssuableForm` area.
*/ */
import _ from 'underscore';
import Flash from '~/flash'; import Flash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import RelatedIssuesBlock from './related_issues_block.vue'; import RelatedIssuesBlock from './related_issues_block.vue';
...@@ -62,11 +61,6 @@ export default { ...@@ -62,11 +61,6 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
title: {
type: String,
required: false,
default: __('Related issues'),
},
issuableType: { issuableType: {
type: String, type: String,
required: false, required: false,
...@@ -110,11 +104,14 @@ export default { ...@@ -110,11 +104,14 @@ export default {
this.fetchRelatedIssues(); this.fetchRelatedIssues();
}, },
methods: { methods: {
findRelatedIssueById(id) {
return this.state.relatedIssues.find(issue => issue.id === id);
},
onRelatedIssueRemoveRequest(idToRemove) { onRelatedIssueRemoveRequest(idToRemove) {
const issueToRemove = _.find(this.state.relatedIssues, issue => issue.id === idToRemove); const issueToRemove = this.findRelatedIssueById(idToRemove);
if (issueToRemove) { if (issueToRemove) {
RelatedIssuesService.remove(issueToRemove.relation_path) RelatedIssuesService.remove(issueToRemove.relationPath)
.then(({ data }) => { .then(({ data }) => {
this.store.setRelatedIssues(data.issuables); this.store.setRelatedIssues(data.issuables);
}) })
...@@ -133,13 +130,13 @@ export default { ...@@ -133,13 +130,13 @@ export default {
onPendingIssueRemoveRequest(indexToRemove) { onPendingIssueRemoveRequest(indexToRemove) {
this.store.removePendingRelatedIssue(indexToRemove); this.store.removePendingRelatedIssue(indexToRemove);
}, },
onPendingFormSubmit(newValue) { onPendingFormSubmit(event) {
this.processAllReferences(newValue); this.processAllReferences(event.pendingReferences);
if (this.state.pendingReferences.length > 0) { if (this.state.pendingReferences.length > 0) {
this.isSubmitting = true; this.isSubmitting = true;
this.service this.service
.addRelatedIssues(this.state.pendingReferences) .addRelatedIssues(this.state.pendingReferences, event.linkedIssueType)
.then(({ data }) => { .then(({ data }) => {
// We could potentially lose some pending issues in the interim here // We could potentially lose some pending issues in the interim here
this.store.setPendingReferences([]); this.store.setPendingReferences([]);
...@@ -179,11 +176,11 @@ export default { ...@@ -179,11 +176,11 @@ export default {
}); });
}, },
saveIssueOrder({ issueId, beforeId, afterId, oldIndex, newIndex }) { saveIssueOrder({ issueId, beforeId, afterId, oldIndex, newIndex }) {
const issueToReorder = _.find(this.state.relatedIssues, issue => issue.id === issueId); const issueToReorder = this.findRelatedIssueById(issueId);
if (issueToReorder) { if (issueToReorder) {
RelatedIssuesService.saveOrder({ RelatedIssuesService.saveOrder({
endpoint: issueToReorder.relation_path, endpoint: issueToReorder.relationPath,
move_before_id: beforeId, move_before_id: beforeId,
move_after_id: afterId, move_after_id: afterId,
}) })
...@@ -227,7 +224,6 @@ export default { ...@@ -227,7 +224,6 @@ export default {
:is-form-visible="isFormVisible" :is-form-visible="isFormVisible"
:input-value="inputValue" :input-value="inputValue"
:auto-complete-sources="autoCompleteSources" :auto-complete-sources="autoCompleteSources"
:title="title"
:issuable-type="issuableType" :issuable-type="issuableType"
:path-id-separator="pathIdSeparator" :path-id-separator="pathIdSeparator"
@saveReorder="saveIssueOrder" @saveReorder="saveIssueOrder"
......
...@@ -6,6 +6,18 @@ export const issuableTypesMap = { ...@@ -6,6 +6,18 @@ export const issuableTypesMap = {
MERGE_REQUEST: 'merge_request', MERGE_REQUEST: 'merge_request',
}; };
export const linkedIssueTypesMap = {
BLOCKS: 'blocks',
IS_BLOCKED_BY: 'is_blocked_by',
RELATES_TO: 'relates_to',
};
export const linkedIssueTypesTextMap = {
[linkedIssueTypesMap.RELATES_TO]: __('Relates to'),
[linkedIssueTypesMap.BLOCKS]: __('Blocks'),
[linkedIssueTypesMap.IS_BLOCKED_BY]: __('Is blocked by'),
};
export const autoCompleteTextMap = { export const autoCompleteTextMap = {
true: { true: {
[issuableTypesMap.ISSUE]: __(' or <#issue id>'), [issuableTypesMap.ISSUE]: __(' or <#issue id>'),
......
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { linkedIssueTypesMap } from '../constants';
class RelatedIssuesService { class RelatedIssuesService {
constructor(endpoint) { constructor(endpoint) {
...@@ -9,9 +10,10 @@ class RelatedIssuesService { ...@@ -9,9 +10,10 @@ class RelatedIssuesService {
return axios.get(this.endpoint); return axios.get(this.endpoint);
} }
addRelatedIssues(newIssueReferences) { addRelatedIssues(newIssueReferences, linkType = linkedIssueTypesMap.RELATES_TO) {
return axios.post(this.endpoint, { return axios.post(this.endpoint, {
issuable_references: newIssueReferences, issuable_references: newIssueReferences,
link_type: linkType,
}); });
} }
......
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
class RelatedIssuesStore { class RelatedIssuesStore {
constructor() { constructor() {
this.state = { this.state = {
...@@ -9,7 +11,7 @@ class RelatedIssuesStore { ...@@ -9,7 +11,7 @@ class RelatedIssuesStore {
} }
setRelatedIssues(issues = []) { setRelatedIssues(issues = []) {
this.state.relatedIssues = issues; this.state.relatedIssues = convertObjectPropsToCamelCase(issues, { deep: true });
} }
removeRelatedIssue(idToRemove) { removeRelatedIssue(idToRemove) {
......
...@@ -118,8 +118,8 @@ export default { ...@@ -118,8 +118,8 @@ export default {
this.addPendingReferences(this.getRawRefs(newValue)); this.addPendingReferences(this.getRawRefs(newValue));
this.setItemInputValue(''); this.setItemInputValue('');
}, },
handleAddItemFormSubmit(newValue) { handleAddItemFormSubmit(event) {
this.handleAddItemFormBlur(newValue); this.handleAddItemFormBlur(event.pendingReferences);
if (this.pendingReferences.length > 0) { if (this.pendingReferences.length > 0) {
this.addItem(); this.addItem();
......
...@@ -13,10 +13,6 @@ $token-spacing-bottom: 0.5em; ...@@ -13,10 +13,6 @@ $token-spacing-bottom: 0.5em;
margin-left: 0.5em; margin-left: 0.5em;
} }
.related-issues-add-related-issues-form-with-break {
border-bottom: 1px solid $border-color;
}
.related-issues-token-body { .related-issues-token-body {
padding: 0; padding: 0;
...@@ -59,3 +55,7 @@ $token-spacing-bottom: 0.5em; ...@@ -59,3 +55,7 @@ $token-spacing-bottom: 0.5em;
.issue-token-end { .issue-token-end {
order: 1; order: 1;
} }
.linked-issues-card-body > * + * {
margin-top: $gl-padding;
}
...@@ -420,6 +420,136 @@ describe 'Related issues', :js do ...@@ -420,6 +420,136 @@ describe 'Related issues', :js do
expect(find('.js-related-issues-header-issue-count')).to have_content('2') expect(find('.js-related-issues-header-issue-count')).to have_content('2')
end end
end end
context 'with issue_link_types feature flag enabled' do
def add_linked_issue(issue, radio_input_value)
find('.js-issue-count-badge-add-button').click
find('.js-add-issuable-form-input').set "#{issue.to_reference(project)} "
find("input[name=\"linked-issue-type-radio\"][value=\"#{radio_input_value}\"]").click
find('.js-add-issuable-form-add-button').click
wait_for_requests
end
before do
stub_feature_flags(issue_link_types: true)
visit project_issue_path(project, issue_a)
wait_for_requests
end
context 'when adding a "relates_to" issue' do
before do
add_linked_issue(issue_b, "relates_to")
end
it 'shows "Relates to" heading' do
headings = all('.linked-issues-card-body h4')
expect(headings.count).to eq(1)
expect(headings[0].text).to eq("Relates to")
end
it 'shows the added issue' do
items = all('.item-title a')
expect(items[0].text).to eq(issue_b.title)
expect(find('.js-related-issues-header-issue-count')).to have_content('1')
end
end
context 'when adding a "blocks" issue' do
before do
add_linked_issue(issue_b, "blocks")
end
it 'shows "Blocks" heading' do
headings = all('.linked-issues-card-body h4')
expect(headings.count).to eq(1)
expect(headings[0].text).to eq("Blocks")
end
it 'shows the added issue' do
items = all('.item-title a')
expect(items[0].text).to eq(issue_b.title)
expect(find('.js-related-issues-header-issue-count')).to have_content('1')
end
end
context 'when adding an "is_blocked_by" issue' do
before do
add_linked_issue(issue_b, "is_blocked_by")
end
it 'shows "Is blocked by" heading' do
headings = all('.linked-issues-card-body h4')
expect(headings.count).to eq(1)
expect(headings[0].text).to eq("Is blocked by")
end
it 'shows the added issue' do
items = all('.item-title a')
expect(items[0].text).to eq(issue_b.title)
expect(find('.js-related-issues-header-issue-count')).to have_content('1')
end
end
context 'when adding "relates_to", "blocks", and "is_blocked_by" issues' do
before do
add_linked_issue(issue_b, "relates_to")
add_linked_issue(issue_c, "blocks")
add_linked_issue(issue_d, "is_blocked_by")
end
it 'shows "Blocks", "Is blocked by", and "Relates to" headings' do
headings = all('.linked-issues-card-body h4')
expect(headings.count).to eq(3)
expect(headings[0].text).to eq("Blocks")
expect(headings[1].text).to eq("Is blocked by")
expect(headings[2].text).to eq("Relates to")
end
it 'shows all added issues' do
items = all('.item-title a')
expect(items.count).to eq(3)
expect(find('.js-related-issues-header-issue-count')).to have_content('3')
end
end
end
context 'with issue_link_types feature flag disabled' do
before do
stub_feature_flags(issue_link_types: false)
visit project_issue_path(project, issue_b)
wait_for_requests
end
context 'when adding an issue' do
before do
find('.js-issue-count-badge-add-button').click
find('.js-add-issuable-form-input').set "#{issue_c.to_reference(project)} "
find('.js-add-issuable-form-add-button').click
wait_for_requests
end
it 'does not group it into "Blocks", "Is blocked by", or "Relates to" headings' do
headings = all('.linked-issues-card-body h4')
expect(headings.count).to eq(0)
end
it 'shows the added issue' do
items = all('.item-title a')
expect(items.count).to eq(1)
end
end
end
end end
end end
end end
...@@ -122,10 +122,12 @@ describe('RelatedItemsTreeApp', () => { ...@@ -122,10 +122,12 @@ describe('RelatedItemsTreeApp', () => {
describe('handleAddItemFormSubmit', () => { describe('handleAddItemFormSubmit', () => {
it('calls `addItem` action when `pendingReferences` prop in state is not empty', () => { it('calls `addItem` action when `pendingReferences` prop in state is not empty', () => {
const newValue = '&1 &2'; const emitObj = {
pendingReferences: '&1 &2',
};
jest.spyOn(wrapper.vm, 'addItem').mockImplementation(); jest.spyOn(wrapper.vm, 'addItem').mockImplementation();
wrapper.vm.handleAddItemFormSubmit(newValue); wrapper.vm.handleAddItemFormSubmit(emitObj);
expect(wrapper.vm.addItem).toHaveBeenCalled(); expect(wrapper.vm.addItem).toHaveBeenCalled();
}); });
......
import Vue from 'vue'; import { mount } from '@vue/test-utils';
import { PathIdSeparator } from 'ee/related_issues/constants'; import { linkedIssueTypesMap, PathIdSeparator } from 'ee/related_issues/constants';
import addIssuableForm from 'ee/related_issues/components/add_issuable_form.vue'; import AddIssuableForm from 'ee/related_issues/components/add_issuable_form.vue';
const issuable1 = { const issuable1 = {
id: 200, id: 200,
...@@ -22,56 +22,49 @@ const issuable2 = { ...@@ -22,56 +22,49 @@ const issuable2 = {
const pathIdSeparator = PathIdSeparator.Issue; const pathIdSeparator = PathIdSeparator.Issue;
describe('AddIssuableForm', () => { const findFormInput = wrapper => wrapper.find('.js-add-issuable-form-input').element;
let AddIssuableForm;
let vm;
beforeEach(() => { const findRadioInput = (inputs, value) => inputs.filter(input => input.element.value === value)[0];
AddIssuableForm = Vue.extend(addIssuableForm);
});
afterEach(() => { describe('AddIssuableForm', () => {
if (vm) { let wrapper;
// Avoid any NPE errors from `@blur` being called
// after `vm.$destroy` in tests, https://github.com/vuejs/vue/issues/5829
document.activeElement.blur();
vm.$destroy(); afterEach(() => {
} wrapper.destroy();
}); });
describe('with data', () => { describe('with data', () => {
describe('without references', () => { describe('without references', () => {
describe('without any input text', () => { describe('without any input text', () => {
beforeEach(() => { beforeEach(() => {
vm = new AddIssuableForm({ wrapper = mount(AddIssuableForm, {
propsData: { propsData: {
inputValue: '', inputValue: '',
pendingReferences: [], pendingReferences: [],
pathIdSeparator, pathIdSeparator,
}, },
}).$mount(); });
}); });
it('should have disabled submit button', () => { it('should have disabled submit button', () => {
expect(vm.$refs.addButton.disabled).toBe(true); expect(wrapper.vm.$refs.addButton.disabled).toBe(true);
expect(vm.$refs.loadingIcon).toBeUndefined(); expect(wrapper.vm.$refs.loadingIcon).toBeUndefined();
}); });
}); });
describe('with input text', () => { describe('with input text', () => {
beforeEach(() => { beforeEach(() => {
vm = new AddIssuableForm({ wrapper = mount(AddIssuableForm, {
propsData: { propsData: {
inputValue: 'foo', inputValue: 'foo',
pendingReferences: [], pendingReferences: [],
pathIdSeparator, pathIdSeparator,
}, },
}).$mount(); });
}); });
it('should not have disabled submit button', () => { it('should not have disabled submit button', () => {
expect(vm.$refs.addButton.disabled).toBe(false); expect(wrapper.vm.$refs.addButton.disabled).toBe(false);
}); });
}); });
}); });
...@@ -80,52 +73,142 @@ describe('AddIssuableForm', () => { ...@@ -80,52 +73,142 @@ describe('AddIssuableForm', () => {
const inputValue = 'foo #123'; const inputValue = 'foo #123';
beforeEach(() => { beforeEach(() => {
vm = new AddIssuableForm({ wrapper = mount(AddIssuableForm, {
propsData: { propsData: {
inputValue, inputValue,
pendingReferences: [issuable1.reference, issuable2.reference], pendingReferences: [issuable1.reference, issuable2.reference],
pathIdSeparator, pathIdSeparator,
}, },
}).$mount(); });
}); });
it('should put input value in place', () => { it('should put input value in place', () => {
expect(vm.$el.querySelector('.js-add-issuable-form-input').value).toEqual(inputValue); expect(findFormInput(wrapper).value).toEqual(inputValue);
}); });
it('should render pending issuables items', () => { it('should render pending issuables items', () => {
expect(vm.$el.querySelectorAll('.js-add-issuable-form-token-list-item').length).toEqual(2); expect(wrapper.findAll('.js-add-issuable-form-token-list-item').length).toEqual(2);
}); });
it('should not have disabled submit button', () => { it('should not have disabled submit button', () => {
expect(vm.$refs.addButton.disabled).toBe(false); expect(wrapper.vm.$refs.addButton.disabled).toBe(false);
}); });
}); });
it('when submitting pending issues', () => { it('should emit the `addIssuableFormSubmit` event when submitting pending issues', () => {
vm = new AddIssuableForm({ wrapper = mount(AddIssuableForm, {
propsData: { propsData: {
inputValue: 'foo #123', inputValue: 'foo #123',
pendingReferences: [issuable1.reference, issuable2.reference], pendingReferences: [issuable1.reference, issuable2.reference],
pathIdSeparator, pathIdSeparator,
}, },
}); });
vm.$mount();
spyOn(vm, '$emit'); spyOn(wrapper.vm, '$emit');
const newInputValue = 'filling in things'; const newInputValue = 'filling in things';
const inputEl = vm.$el.querySelector('.js-add-issuable-form-input'); const inputEl = findFormInput(wrapper);
inputEl.value = newInputValue; inputEl.value = newInputValue;
vm.onFormSubmit(); wrapper.vm.onFormSubmit();
expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormSubmit', {
pendingReferences: newInputValue,
linkedIssueType: linkedIssueTypesMap.RELATES_TO,
});
});
it('should emit the `addIssuableFormCancel` event when canceling form to collapse', () => {
spyOn(wrapper.vm, '$emit');
wrapper.vm.onFormCancel();
expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormCancel');
});
});
describe('with :issue_link_types feature flag on', () => {
beforeEach(() => {
wrapper = mount(AddIssuableForm, {
propsData: {
inputValue: '',
pendingReferences: [],
pathIdSeparator,
},
provide: {
glFeatures: {
issueLinkTypes: true,
},
},
});
});
expect(vm.$emit).toHaveBeenCalledWith('addIssuableFormSubmit', newInputValue); describe('radio buttons', () => {
let radioInputs;
beforeEach(() => {
radioInputs = wrapper.findAll('[name="linked-issue-type-radio"]');
});
it('shows "relates to" option', () => {
expect(findRadioInput(radioInputs, linkedIssueTypesMap.RELATES_TO)).not.toBeNull();
});
it('shows "blocks" option', () => {
expect(findRadioInput(radioInputs, linkedIssueTypesMap.BLOCKS)).not.toBeNull();
});
it('shows "is blocked by" option', () => {
expect(findRadioInput(radioInputs, linkedIssueTypesMap.IS_BLOCKED_BY)).not.toBeNull();
});
it('shows 3 options in total', () => {
expect(radioInputs.length).toBe(3);
});
}); });
it('when canceling form to collapse', () => { describe('when the form is submitted', () => {
spyOn(vm, '$emit'); it('emits an event with a "relates_to" link type when the "relates to" radio input selected', done => {
vm.onFormCancel(); spyOn(wrapper.vm, '$emit');
expect(vm.$emit).toHaveBeenCalledWith('addIssuableFormCancel'); wrapper.vm.linkedIssueType = linkedIssueTypesMap.RELATES_TO;
wrapper.vm.onFormSubmit();
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormSubmit', {
pendingReferences: '',
linkedIssueType: linkedIssueTypesMap.RELATES_TO,
});
done();
});
});
it('emits an event with a "blocks" link type when the "blocks" radio input selected', done => {
spyOn(wrapper.vm, '$emit');
wrapper.vm.linkedIssueType = linkedIssueTypesMap.BLOCKS;
wrapper.vm.onFormSubmit();
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormSubmit', {
pendingReferences: '',
linkedIssueType: linkedIssueTypesMap.BLOCKS,
});
done();
});
});
it('emits an event with a "is_blocked_by" link type when the "is blocked by" radio input selected', done => {
spyOn(wrapper.vm, '$emit');
wrapper.vm.linkedIssueType = linkedIssueTypesMap.IS_BLOCKED_BY;
wrapper.vm.onFormSubmit();
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormSubmit', {
pendingReferences: '',
linkedIssueType: linkedIssueTypesMap.IS_BLOCKED_BY,
});
done();
});
});
}); });
}); });
}); });
import Vue from 'vue'; import { mount } from '@vue/test-utils';
import relatedIssuesBlock from 'ee/related_issues/components/related_issues_block.vue'; import RelatedIssuesBlock from 'ee/related_issues/components/related_issues_block.vue';
import { import {
issuable1, issuable1,
issuable2, issuable2,
issuable3, issuable3,
issuable4,
issuable5,
} from 'spec/vue_shared/components/issue/related_issuable_mock_data'; } from 'spec/vue_shared/components/issue/related_issuable_mock_data';
import { PathIdSeparator } from 'ee/related_issues/constants'; import {
linkedIssueTypesMap,
linkedIssueTypesTextMap,
PathIdSeparator,
} from 'ee/related_issues/constants';
describe('RelatedIssuesBlock', () => { describe('RelatedIssuesBlock', () => {
let RelatedIssuesBlock; let wrapper;
let vm;
beforeEach(() => {
RelatedIssuesBlock = Vue.extend(relatedIssuesBlock);
});
afterEach(() => { afterEach(() => {
if (vm) { wrapper.destroy();
vm.$destroy();
}
}); });
describe('with defaults', () => { describe('with defaults', () => {
beforeEach(() => { beforeEach(() => {
vm = new RelatedIssuesBlock({ wrapper = mount(RelatedIssuesBlock, {
propsData: { propsData: {
pathIdSeparator: PathIdSeparator.Issue, pathIdSeparator: PathIdSeparator.Issue,
issuableType: 'issue', issuableType: 'issue',
}, },
}).$mount(); });
}); });
it('unable to add new related issues', () => { it('unable to add new related issues', () => {
expect(vm.$refs.issueCountBadgeAddButton).toBeUndefined(); expect(wrapper.vm.$refs.issueCountBadgeAddButton).toBeUndefined();
}); });
it('add related issues form is hidden', () => { it('add related issues form is hidden', () => {
expect(vm.$el.querySelector('.js-add-related-issues-form-area')).toBeNull(); expect(wrapper.contains('.js-add-related-issues-form-area')).toBe(false);
});
it('should not show loading icon', () => {
expect(vm.$refs.loadingIcon).toBeUndefined();
}); });
}); });
describe('with isFetching=true', () => { describe('with isFetching=true', () => {
beforeEach(() => { beforeEach(() => {
vm = new RelatedIssuesBlock({ wrapper = mount(RelatedIssuesBlock, {
propsData: { propsData: {
pathIdSeparator: PathIdSeparator.Issue, pathIdSeparator: PathIdSeparator.Issue,
isFetching: true, isFetching: true,
issuableType: 'issue', issuableType: 'issue',
}, },
}).$mount();
}); });
it('should show loading icon', () => {
expect(vm.$refs.loadingIcon).toBeDefined();
}); });
it('should show `...` badge count', () => { it('should show `...` badge count', () => {
expect(vm.badgeLabel).toBe('...'); expect(wrapper.vm.badgeLabel).toBe('...');
}); });
}); });
describe('with canAddRelatedIssues=true', () => { describe('with canAddRelatedIssues=true', () => {
beforeEach(() => { beforeEach(() => {
vm = new RelatedIssuesBlock({ wrapper = mount(RelatedIssuesBlock, {
propsData: { propsData: {
pathIdSeparator: PathIdSeparator.Issue, pathIdSeparator: PathIdSeparator.Issue,
canAdmin: true, canAdmin: true,
issuableType: 'issue', issuableType: 'issue',
}, },
}).$mount(); });
}); });
it('can add new related issues', () => { it('can add new related issues', () => {
expect(vm.$refs.issueCountBadgeAddButton).toBeDefined(); expect(wrapper.vm.$refs.issueCountBadgeAddButton).toBeDefined();
}); });
}); });
describe('with isFormVisible=true', () => { describe('with isFormVisible=true', () => {
beforeEach(() => { beforeEach(() => {
vm = new RelatedIssuesBlock({ wrapper = mount(RelatedIssuesBlock, {
propsData: { propsData: {
pathIdSeparator: PathIdSeparator.Issue, pathIdSeparator: PathIdSeparator.Issue,
isFormVisible: true, isFormVisible: true,
issuableType: 'issue', issuableType: 'issue',
}, },
}).$mount(); });
}); });
it('shows add related issues form', () => { it('shows add related issues form', () => {
expect(vm.$el.querySelector('.js-add-related-issues-form-area')).toBeDefined(); expect(wrapper.contains('.js-add-related-issues-form-area')).toBe(true);
}); });
}); });
describe('with relatedIssues', () => { describe('with relatedIssues', () => {
beforeEach(() => { beforeEach(() => {
vm = new RelatedIssuesBlock({ wrapper = mount(RelatedIssuesBlock, {
propsData: { propsData: {
pathIdSeparator: PathIdSeparator.Issue, pathIdSeparator: PathIdSeparator.Issue,
relatedIssues: [issuable1, issuable2], relatedIssues: [issuable1, issuable2],
issuableType: 'issue', issuableType: 'issue',
}, },
}).$mount();
}); });
it('should render issue tokens items', () => {
expect(vm.$el.querySelectorAll('.js-related-issues-token-list-item').length).toEqual(2);
});
});
describe('methods', () => {
beforeEach(() => {
vm = new RelatedIssuesBlock({
propsData: {
pathIdSeparator: PathIdSeparator.Issue,
relatedIssues: [issuable1, issuable2, issuable3, issuable4, issuable5],
issuableType: 'issue',
},
}).$mount();
});
it('reorder item correctly when an item is moved to the top', () => {
const beforeAfterIds = vm.getBeforeAfterId(vm.$el.querySelector('ul li:first-child'));
expect(beforeAfterIds.beforeId).toBeNull();
expect(beforeAfterIds.afterId).toBe(2);
});
it('reorder item correctly when an item is moved to the bottom', () => {
const beforeAfterIds = vm.getBeforeAfterId(vm.$el.querySelector('ul li:last-child'));
expect(beforeAfterIds.beforeId).toBe(4);
expect(beforeAfterIds.afterId).toBeNull();
});
it('reorder item correctly when an item is swapped with adjecent item', () => {
const beforeAfterIds = vm.getBeforeAfterId(vm.$el.querySelector('ul li:nth-child(3)'));
expect(beforeAfterIds.beforeId).toBe(2);
expect(beforeAfterIds.afterId).toBe(4);
}); });
it('reorder item correctly when an item is moved somewhere in the middle', () => { it('should render issue tokens items', () => {
const beforeAfterIds = vm.getBeforeAfterId(vm.$el.querySelector('ul li:nth-child(4)')); expect(wrapper.findAll('.js-related-issues-token-list-item').length).toEqual(2);
expect(beforeAfterIds.beforeId).toBe(3);
expect(beforeAfterIds.afterId).toBe(5);
}); });
}); });
...@@ -166,84 +113,61 @@ describe('RelatedIssuesBlock', () => { ...@@ -166,84 +113,61 @@ describe('RelatedIssuesBlock', () => {
}, },
].forEach(({ issuableType, icon }) => { ].forEach(({ issuableType, icon }) => {
it(`issuableType=${issuableType} is passed`, () => { it(`issuableType=${issuableType} is passed`, () => {
vm = new RelatedIssuesBlock({ wrapper = mount(RelatedIssuesBlock, {
propsData: { propsData: {
pathIdSeparator: PathIdSeparator.Issue, pathIdSeparator: PathIdSeparator.Issue,
issuableType, issuableType,
}, },
}).$mount(); });
const el = vm.$el.querySelector(`.issue-count-badge-count .ic-${icon}`);
expect(el).not.toBeNull(); expect(wrapper.contains(`.issue-count-badge-count .ic-${icon}`)).toBe(true);
}); });
}); });
}); });
describe('issuableOrderingId returns correct issuable order id when', () => { describe('with :issue_link_types feature flag on', () => {
it('issuableType is epic', () => { beforeEach(() => {
vm = new RelatedIssuesBlock({ wrapper = mount(RelatedIssuesBlock, {
propsData: { propsData: {
pathIdSeparator: PathIdSeparator.Issue, pathIdSeparator: PathIdSeparator.Issue,
relatedIssues: [issuable1, issuable2, issuable3],
issuableType: 'issue', issuableType: 'issue',
}, },
}).$mount(); provide: {
glFeatures: {
const orderId = vm.issuableOrderingId(issuable1); issueLinkTypes: true,
},
expect(orderId).toBe(issuable1.epic_issue_id); },
});
}); });
it('issuableType is issue', () => { it('displays "Linked issues" in the header', () => {
vm = new RelatedIssuesBlock({ expect(wrapper.find('h3').text()).toContain('Linked issues');
propsData: { });
pathIdSeparator: PathIdSeparator.Issue,
issuableType: 'epic',
},
}).$mount();
const orderId = vm.issuableOrderingId(issuable1); describe('categorized headings', () => {
let categorizedHeadings;
expect(orderId).toBe(issuable1.id); beforeEach(() => {
}); categorizedHeadings = wrapper.findAll('h4');
}); });
describe('renders correct ordering id when', () => { it('shows "Blocks" heading', () => {
let relatedIssues; const blocks = linkedIssueTypesTextMap[linkedIssueTypesMap.BLOCKS];
beforeAll(() => { expect(categorizedHeadings.at(0).text()).toBe(blocks);
relatedIssues = [issuable1, issuable2, issuable3, issuable4, issuable5];
}); });
it('issuableType is epic', () => { it('shows "Is blocked by" heading', () => {
vm = new RelatedIssuesBlock({ const isBlockedBy = linkedIssueTypesTextMap[linkedIssueTypesMap.IS_BLOCKED_BY];
propsData: {
pathIdSeparator: PathIdSeparator.Issue,
issuableType: 'epic',
relatedIssues,
},
}).$mount();
const listItems = vm.$el.querySelectorAll('.list-item');
Array.from(listItems).forEach((item, index) => { expect(categorizedHeadings.at(1).text()).toBe(isBlockedBy);
expect(Number(item.dataset.orderingId)).toBe(relatedIssues[index].id);
});
}); });
it('issuableType is issue', () => { it('shows "Relates to" heading', () => {
vm = new RelatedIssuesBlock({ const relatesTo = linkedIssueTypesTextMap[linkedIssueTypesMap.RELATES_TO];
propsData: {
pathIdSeparator: PathIdSeparator.Issue,
issuableType: 'issue',
relatedIssues,
},
}).$mount();
const listItems = vm.$el.querySelectorAll('.list-item');
Array.from(listItems).forEach((item, index) => { expect(categorizedHeadings.at(2).text()).toBe(relatesTo);
expect(Number(item.dataset.orderingId)).toBe(relatedIssues[index].epic_issue_id);
}); });
}); });
}); });
......
import { mount } from '@vue/test-utils';
import RelatedIssuesList from 'ee/related_issues/components/related_issues_list.vue';
import {
issuable1,
issuable2,
issuable3,
issuable4,
issuable5,
} from 'spec/vue_shared/components/issue/related_issuable_mock_data';
import { PathIdSeparator } from 'ee/related_issues/constants';
describe('RelatedIssuesList', () => {
let wrapper;
afterEach(() => {
wrapper.destroy();
});
describe('with defaults', () => {
beforeEach(() => {
wrapper = mount(RelatedIssuesList, {
propsData: {
pathIdSeparator: PathIdSeparator.Issue,
issuableType: 'issue',
},
});
});
it('should not show loading icon', () => {
expect(wrapper.vm.$refs.loadingIcon).toBeUndefined();
});
});
describe('with isFetching=true', () => {
beforeEach(() => {
wrapper = mount(RelatedIssuesList, {
propsData: {
pathIdSeparator: PathIdSeparator.Issue,
isFetching: true,
issuableType: 'issue',
},
});
});
it('should show loading icon', () => {
expect(wrapper.vm.$refs.loadingIcon).toBeDefined();
});
});
describe('methods', () => {
beforeEach(() => {
wrapper = mount(RelatedIssuesList, {
propsData: {
pathIdSeparator: PathIdSeparator.Issue,
relatedIssues: [issuable1, issuable2, issuable3, issuable4, issuable5],
issuableType: 'issue',
},
});
});
it('updates the order correctly when an item is moved to the top', () => {
const beforeAfterIds = wrapper.vm.getBeforeAfterId(
wrapper.vm.$el.querySelector('ul li:first-child'),
);
expect(beforeAfterIds.beforeId).toBeNull();
expect(beforeAfterIds.afterId).toBe(2);
});
it('updates the order correctly when an item is moved to the bottom', () => {
const beforeAfterIds = wrapper.vm.getBeforeAfterId(
wrapper.vm.$el.querySelector('ul li:last-child'),
);
expect(beforeAfterIds.beforeId).toBe(4);
expect(beforeAfterIds.afterId).toBeNull();
});
it('updates the order correctly when an item is swapped with adjacent item', () => {
const beforeAfterIds = wrapper.vm.getBeforeAfterId(
wrapper.vm.$el.querySelector('ul li:nth-child(3)'),
);
expect(beforeAfterIds.beforeId).toBe(2);
expect(beforeAfterIds.afterId).toBe(4);
});
it('updates the order correctly when an item is moved somewhere in the middle', () => {
const beforeAfterIds = wrapper.vm.getBeforeAfterId(
wrapper.vm.$el.querySelector('ul li:nth-child(4)'),
);
expect(beforeAfterIds.beforeId).toBe(3);
expect(beforeAfterIds.afterId).toBe(5);
});
});
describe('issuableOrderingId returns correct issuable order id when', () => {
it('issuableType is epic', () => {
wrapper = mount(RelatedIssuesList, {
propsData: {
pathIdSeparator: PathIdSeparator.Issue,
issuableType: 'issue',
},
});
expect(wrapper.vm.issuableOrderingId(issuable1)).toBe(issuable1.epicIssueId);
});
it('issuableType is issue', () => {
wrapper = mount(RelatedIssuesList, {
propsData: {
pathIdSeparator: PathIdSeparator.Issue,
issuableType: 'epic',
},
});
expect(wrapper.vm.issuableOrderingId(issuable1)).toBe(issuable1.id);
});
});
describe('renders correct ordering id when', () => {
let relatedIssues;
beforeAll(() => {
relatedIssues = [issuable1, issuable2, issuable3, issuable4, issuable5];
});
it('issuableType is epic', () => {
wrapper = mount(RelatedIssuesList, {
propsData: {
pathIdSeparator: PathIdSeparator.Issue,
issuableType: 'epic',
relatedIssues,
},
});
const listItems = wrapper.vm.$el.querySelectorAll('.list-item');
Array.from(listItems).forEach((item, index) => {
expect(Number(item.dataset.orderingId)).toBe(relatedIssues[index].id);
});
});
it('issuableType is issue', () => {
wrapper = mount(RelatedIssuesList, {
propsData: {
pathIdSeparator: PathIdSeparator.Issue,
issuableType: 'issue',
relatedIssues,
},
});
const listItems = wrapper.vm.$el.querySelectorAll('.list-item');
Array.from(listItems).forEach((item, index) => {
expect(Number(item.dataset.orderingId)).toBe(relatedIssues[index].epicIssueId);
});
});
});
describe('with :issue_link_types feature flag on', () => {
it('shows a heading', () => {
const heading = 'Related';
wrapper = mount(RelatedIssuesList, {
propsData: {
pathIdSeparator: PathIdSeparator.Issue,
issuableType: 'issue',
heading,
},
provide: {
glFeatures: {
issueLinkTypes: true,
},
},
});
expect(wrapper.find('h4').text()).toContain(heading);
});
});
});
...@@ -2,6 +2,7 @@ import Vue from 'vue'; ...@@ -2,6 +2,7 @@ import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import relatedIssuesRoot from 'ee/related_issues/components/related_issues_root.vue'; import relatedIssuesRoot from 'ee/related_issues/components/related_issues_root.vue';
import relatedIssuesService from 'ee/related_issues/services/related_issues_service'; import relatedIssuesService from 'ee/related_issues/services/related_issues_service';
import { linkedIssueTypesMap } from 'ee/related_issues/constants';
import { import {
defaultProps, defaultProps,
issuable1, issuable1,
...@@ -123,16 +124,21 @@ describe('RelatedIssuesRoot', () => { ...@@ -123,16 +124,21 @@ describe('RelatedIssuesRoot', () => {
it('processes references before submitting', () => { it('processes references before submitting', () => {
const input = '#123'; const input = '#123';
const linkedIssueType = linkedIssueTypesMap.RELATES_TO;
const emitObj = {
pendingReferences: input,
linkedIssueType,
};
vm.onPendingFormSubmit(input); vm.onPendingFormSubmit(emitObj);
expect(vm.processAllReferences).toHaveBeenCalledWith(input); expect(vm.processAllReferences).toHaveBeenCalledWith(input);
expect(vm.service.addRelatedIssues).toHaveBeenCalledWith([input]); expect(vm.service.addRelatedIssues).toHaveBeenCalledWith([input], linkedIssueType);
}); });
it('submit zero pending issue as related issue', done => { it('submit zero pending issue as related issue', done => {
vm.store.setPendingReferences([]); vm.store.setPendingReferences([]);
vm.onPendingFormSubmit(); vm.onPendingFormSubmit({});
setTimeout(() => { setTimeout(() => {
expect(vm.state.pendingReferences.length).toEqual(0); expect(vm.state.pendingReferences.length).toEqual(0);
...@@ -152,7 +158,7 @@ describe('RelatedIssuesRoot', () => { ...@@ -152,7 +158,7 @@ describe('RelatedIssuesRoot', () => {
}); });
vm.store.setPendingReferences([issuable1.reference]); vm.store.setPendingReferences([issuable1.reference]);
vm.onPendingFormSubmit(); vm.onPendingFormSubmit({});
setTimeout(() => { setTimeout(() => {
expect(vm.state.pendingReferences.length).toEqual(0); expect(vm.state.pendingReferences.length).toEqual(0);
...@@ -173,7 +179,7 @@ describe('RelatedIssuesRoot', () => { ...@@ -173,7 +179,7 @@ describe('RelatedIssuesRoot', () => {
}); });
vm.store.setPendingReferences([issuable1.reference, issuable2.reference]); vm.store.setPendingReferences([issuable1.reference, issuable2.reference]);
vm.onPendingFormSubmit(); vm.onPendingFormSubmit({});
setTimeout(() => { setTimeout(() => {
expect(vm.state.pendingReferences.length).toEqual(0); expect(vm.state.pendingReferences.length).toEqual(0);
......
...@@ -2690,6 +2690,9 @@ msgstr "" ...@@ -2690,6 +2690,9 @@ msgstr ""
msgid "Blocked" msgid "Blocked"
msgstr "" msgstr ""
msgid "Blocks"
msgstr ""
msgid "Blog" msgid "Blog"
msgstr "" msgstr ""
...@@ -10137,6 +10140,9 @@ msgstr "" ...@@ -10137,6 +10140,9 @@ msgstr ""
msgid "Is" msgid "Is"
msgstr "" msgstr ""
msgid "Is blocked by"
msgstr ""
msgid "Is not" msgid "Is not"
msgstr "" msgstr ""
...@@ -10941,6 +10947,9 @@ msgstr "" ...@@ -10941,6 +10947,9 @@ msgstr ""
msgid "Linked emails (%{email_count})" msgid "Linked emails (%{email_count})"
msgstr "" msgstr ""
msgid "Linked issues"
msgstr ""
msgid "LinkedIn" msgid "LinkedIn"
msgstr "" msgstr ""
...@@ -15104,6 +15113,9 @@ msgstr "" ...@@ -15104,6 +15113,9 @@ msgstr ""
msgid "Related merge requests" msgid "Related merge requests"
msgstr "" msgstr ""
msgid "Relates to"
msgstr ""
msgid "Release" msgid "Release"
msgid_plural "Releases" msgid_plural "Releases"
msgstr[0] "" msgstr[0] ""
...@@ -18180,6 +18192,9 @@ msgstr "" ...@@ -18180,6 +18192,9 @@ msgstr ""
msgid "The content of this page is not encoded in UTF-8. Edits can only be made via the Git repository." msgid "The content of this page is not encoded in UTF-8. Edits can only be made via the Git repository."
msgstr "" msgstr ""
msgid "The current issue"
msgstr ""
msgid "The data source is connected, but there is no data to display. %{documentationLink}" msgid "The data source is connected, but there is no data to display. %{documentationLink}"
msgstr "" msgstr ""
...@@ -21504,6 +21519,9 @@ msgstr "" ...@@ -21504,6 +21519,9 @@ msgstr ""
msgid "authored" msgid "authored"
msgstr "" msgstr ""
msgid "blocks"
msgstr ""
msgid "branch name" msgid "branch name"
msgstr "" msgstr ""
...@@ -21990,6 +22008,9 @@ msgstr "" ...@@ -21990,6 +22008,9 @@ msgstr ""
msgid "is an invalid IP address range" msgid "is an invalid IP address range"
msgstr "" msgstr ""
msgid "is blocked by"
msgstr ""
msgid "is enabled." msgid "is enabled."
msgstr "" msgstr ""
...@@ -22465,6 +22486,9 @@ msgstr "" ...@@ -22465,6 +22486,9 @@ msgstr ""
msgid "register" msgid "register"
msgstr "" msgstr ""
msgid "relates to"
msgstr ""
msgid "released %{time}" msgid "released %{time}"
msgstr "" msgstr ""
...@@ -22581,6 +22605,9 @@ msgstr "" ...@@ -22581,6 +22605,9 @@ msgstr ""
msgid "tag name" msgid "tag name"
msgstr "" msgstr ""
msgid "the following issue(s)"
msgstr ""
msgid "this document" msgid "this document"
msgstr "" msgstr ""
......
...@@ -19,9 +19,12 @@ module QA ...@@ -19,9 +19,12 @@ module QA
end end
view 'ee/app/assets/javascripts/related_issues/components/related_issues_block.vue' do view 'ee/app/assets/javascripts/related_issues/components/related_issues_block.vue' do
element :related_issues_plus_button
end
view 'ee/app/assets/javascripts/related_issues/components/related_issues_list.vue' do
element :related_issuable_item element :related_issuable_item
element :related_issues_loading_icon element :related_issues_loading_icon
element :related_issues_plus_button
end end
view 'ee/app/assets/javascripts/sidebar/components/weight/weight.vue' do view 'ee/app/assets/javascripts/sidebar/components/weight/weight.vue' do
......
...@@ -6,40 +6,43 @@ export const defaultProps = { ...@@ -6,40 +6,43 @@ export const defaultProps = {
export const issuable1 = { export const issuable1 = {
id: 200, id: 200,
epic_issue_id: 1, epicIssueId: 1,
confidential: false, confidential: false,
reference: 'foo/bar#123', reference: 'foo/bar#123',
displayReference: '#123', displayReference: '#123',
title: 'some title', title: 'some title',
path: '/foo/bar/issues/123', path: '/foo/bar/issues/123',
state: 'opened', state: 'opened',
linkType: 'relates_to',
}; };
export const issuable2 = { export const issuable2 = {
id: 201, id: 201,
epic_issue_id: 2, epicIssueId: 2,
confidential: false, confidential: false,
reference: 'foo/bar#124', reference: 'foo/bar#124',
displayReference: '#124', displayReference: '#124',
title: 'some other thing', title: 'some other thing',
path: '/foo/bar/issues/124', path: '/foo/bar/issues/124',
state: 'opened', state: 'opened',
linkType: 'blocks',
}; };
export const issuable3 = { export const issuable3 = {
id: 202, id: 202,
epic_issue_id: 3, epicIssueId: 3,
confidential: false, confidential: false,
reference: 'foo/bar#125', reference: 'foo/bar#125',
displayReference: '#125', displayReference: '#125',
title: 'some other other thing', title: 'some other other thing',
path: '/foo/bar/issues/125', path: '/foo/bar/issues/125',
state: 'opened', state: 'opened',
linkType: 'is_blocked_by',
}; };
export const issuable4 = { export const issuable4 = {
id: 203, id: 203,
epic_issue_id: 4, epicIssueId: 4,
confidential: false, confidential: false,
reference: 'foo/bar#126', reference: 'foo/bar#126',
displayReference: '#126', displayReference: '#126',
...@@ -50,7 +53,7 @@ export const issuable4 = { ...@@ -50,7 +53,7 @@ export const issuable4 = {
export const issuable5 = { export const issuable5 = {
id: 204, id: 204,
epic_issue_id: 5, epicIssueId: 5,
confidential: false, confidential: false,
reference: 'foo/bar#127', reference: 'foo/bar#127',
displayReference: '#127', displayReference: '#127',
......
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