Commit 033de334 authored by Eric Eastwood's avatar Eric Eastwood

Refactor related issues and add input processing methods

See the previous version of this MR,
https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/1816

Also includes refactoring updates
parent 516e70ae
...@@ -13,8 +13,6 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -13,8 +13,6 @@ document.addEventListener('DOMContentLoaded', () => {
render: createElement => createElement('related-issues-root', { render: createElement => createElement('related-issues-root', {
props: { props: {
endpoint: relatedIssuesRootElement.dataset.endpoint, endpoint: relatedIssuesRootElement.dataset.endpoint,
currentNamespacePath: relatedIssuesRootElement.dataset.namespace,
currentProjectPath: relatedIssuesRootElement.dataset.project,
canAddRelatedIssues: gl.utils.convertPermissionToBoolean( canAddRelatedIssues: gl.utils.convertPermissionToBoolean(
relatedIssuesRootElement.dataset.canAddRelatedIssues, relatedIssuesRootElement.dataset.canAddRelatedIssues,
), ),
......
<script> <script>
import GfmAutoComplete from '~/gfm_auto_complete';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import IssueToken from './issue_token.vue'; import IssueToken from './issue_token.vue';
...@@ -14,16 +15,22 @@ export default { ...@@ -14,16 +15,22 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
pendingIssuables: { pendingReferences: {
type: Array, type: Array,
required: false, required: false,
default: () => [], default: () => [],
}, },
autoCompleteSources: {
type: Object,
required: false,
default: () => ({}),
},
}, },
data() { data() {
return { return {
isInputFocused: false, isInputFocused: false,
isAutoCompleteOpen: false,
}; };
}, },
...@@ -31,6 +38,12 @@ export default { ...@@ -31,6 +38,12 @@ export default {
issueToken: IssueToken, issueToken: IssueToken,
}, },
computed: {
isSubmitButtonDisabled() {
return this.pendingReferences.length === 0;
},
},
methods: { methods: {
onInput() { onInput() {
const value = this.$refs.input.value; const value = this.$refs.input.value;
...@@ -42,8 +55,14 @@ export default { ...@@ -42,8 +55,14 @@ export default {
onBlur() { onBlur() {
this.isInputFocused = false; this.isInputFocused = false;
const value = this.$refs.input.value; // Avoid tokenizing partial input when clicking an autocomplete item
eventHub.$emit('addIssuableFormBlur', value); if (!this.isAutoCompleteOpen) {
const value = this.$refs.input.value;
eventHub.$emit('addIssuableFormBlur', value);
}
},
onAutoCompleteToggled(isOpen) {
this.isAutoCompleteOpen = isOpen;
}, },
onInputWrapperClick() { onInputWrapperClick() {
this.$refs.input.focus(); this.$refs.input.focus();
...@@ -58,14 +77,18 @@ export default { ...@@ -58,14 +77,18 @@ export default {
mounted() { mounted() {
const $input = $(this.$refs.input); const $input = $(this.$refs.input);
gl.GfmAutoComplete.setup($input, { new GfmAutoComplete(this.autoCompleteSources).setup($input, {
issues: true, issues: true,
}); });
$input.on('shown-issues.atwho', this.onAutoCompleteToggled.bind(this, true));
$input.on('hidden-issues.atwho', this.onAutoCompleteToggled.bind(this, false));
$input.on('inserted-issues.atwho', this.onInput); $input.on('inserted-issues.atwho', this.onInput);
}, },
beforeDestroy() { beforeDestroy() {
const $input = $(this.$refs.input); const $input = $(this.$refs.input);
$input.off('shown-issues.atwho');
$input.off('hidden-issues.atwho');
$input.off('inserted-issues.atwho', this.onInput); $input.off('inserted-issues.atwho', this.onInput);
}, },
}; };
...@@ -81,24 +104,20 @@ export default { ...@@ -81,24 +104,20 @@ export default {
@click="onInputWrapperClick"> @click="onInputWrapperClick">
<ul class="add-issuable-form-input-token-list"> <ul class="add-issuable-form-input-token-list">
<li <li
:key="issuable.reference" :key="reference"
v-for="issuable in pendingIssuables" v-for="(reference, index) in pendingReferences"
class="js-add-issuable-form-token-list-item add-issuable-form-token-list-item"> class="js-add-issuable-form-token-list-item add-issuable-form-token-list-item">
<issue-token <issue-token
event-namespace="pendingIssuable" event-namespace="pendingIssuable"
:reference="issuable.reference" :id-key="index"
:display-reference="issuable.displayReference" :display-reference="reference"
:title="issuable.title"
:path="issuable.path"
:state="issuable.state"
:fetch-status="issuable.fetchStatus"
:can-remove="true" /> :can-remove="true" />
</li> </li>
<li class="add-issuable-form-input-list-item"> <li class="add-issuable-form-input-list-item">
<input <input
ref="input" ref="input"
type="text" type="text"
class="add-issuable-form-input" class="js-add-issuable-form-input add-issuable-form-input"
:value="inputValue" :value="inputValue"
placeholder="Search issues..." placeholder="Search issues..."
@input="onInput" @input="onInput"
...@@ -111,8 +130,9 @@ export default { ...@@ -111,8 +130,9 @@ export default {
<button <button
ref="addButton" ref="addButton"
type="button" type="button"
class="btn btn-new pull-left" class="js-add-issuable-form-add-button btn btn-new pull-left"
@click="onFormSubmit"> @click="onFormSubmit"
:disabled="isSubmitButtonDisabled">
{{ addButtonLabel }} {{ addButtonLabel }}
</button> </button>
<button <button
......
<script> <script>
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import {
FETCHING_STATUS,
FETCH_SUCCESS_STATUS,
FETCH_ERROR_STATUS,
} from '../constants';
export default { export default {
name: 'IssueToken', name: 'IssueToken',
props: { props: {
reference: { idKey: {
type: String, type: Number,
required: true, required: true,
}, },
displayReference: { displayReference: {
...@@ -38,11 +33,6 @@ export default { ...@@ -38,11 +33,6 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
fetchStatus: {
type: String,
required: false,
default: FETCH_SUCCESS_STATUS,
},
canRemove: { canRemove: {
type: Boolean, type: Boolean,
required: false, required: false,
...@@ -51,20 +41,14 @@ export default { ...@@ -51,20 +41,14 @@ export default {
}, },
computed: { computed: {
isFetching() {
return this.fetchStatus === FETCHING_STATUS;
},
hasFetchingError() {
return this.fetchStatus === FETCH_ERROR_STATUS;
},
removeButtonLabel() { removeButtonLabel() {
return `Remove related issue ${this.reference}`; return `Remove related issue ${this.displayReference}`;
}, },
hasState() { hasState() {
return this.state && this.state.length > 0; return this.state && this.state.length > 0;
}, },
hasTitle() { hasTitle() {
return this.title.length > 0 || this.isFetching; return this.title.length > 0;
}, },
}, },
...@@ -75,26 +59,33 @@ export default { ...@@ -75,26 +59,33 @@ export default {
namespacePrefix = `${this.eventNamespace}-`; namespacePrefix = `${this.eventNamespace}-`;
} }
eventHub.$emit(`${namespacePrefix}removeRequest`, this.reference); eventHub.$emit(`${namespacePrefix}removeRequest`, this.idKey);
}, },
}, },
updated() { updated() {
const link = this.$refs.link;
const removeButton = this.$refs.removeButton; const removeButton = this.$refs.removeButton;
if (link) {
$(link).tooltip('fixTitle');
}
if (removeButton) { if (removeButton) {
$(this.$refs.removeButton).tooltip('fixTitle'); $(removeButton).tooltip('fixTitle');
} }
}, },
}; };
</script> </script>
<template> <template>
<div <div class="issue-token">
class="issue-token"
:class="{ 'issue-token-error': hasFetchingError }">
<a <a
ref="link" ref="link"
class="issue-token-link" class="issue-token-link"
:href="path"> :href="path"
:title="title"
data-toggle="tooltip"
data-placement="top">
<span <span
ref="reference" ref="reference"
class="issue-token-reference"> class="issue-token-reference">
...@@ -113,22 +104,18 @@ export default { ...@@ -113,22 +104,18 @@ export default {
<span <span
v-if="hasTitle" v-if="hasTitle"
ref="title" ref="title"
class="issue-token-title"> class="js-issue-token-title issue-token-title"
<i :class="{ 'issue-token-title-standalone': !canRemove }">
ref="fetchStatusIcon" <span class="issue-token-title-text">
v-if="isFetching" {{ title }}
class="fa fa-spinner fa-spin" </span>
aria-label="Fetching info">
</i>
{{ title }}
</span> </span>
</a> </a>
<button <button
ref="removeButton" ref="removeButton"
v-if="canRemove" v-if="canRemove"
type="button" type="button"
class="issue-token-remove-button" class="js-issue-token-remove-button issue-token-remove-button"
:class="{ 'issue-token-remove-button-standalone': !hasTitle }"
:title="removeButtonLabel" :title="removeButtonLabel"
data-toggle="tooltip" data-toggle="tooltip"
@click="onRemoveRequest"> @click="onRemoveRequest">
......
<script> <script>
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
import issueToken from './issue_token.vue'; import issueToken from './issue_token.vue';
import addIssuableForm from './add_issuable_form.vue'; import addIssuableForm from './add_issuable_form.vue';
...@@ -7,6 +8,11 @@ export default { ...@@ -7,6 +8,11 @@ export default {
name: 'RelatedIssuesBlock', name: 'RelatedIssuesBlock',
props: { props: {
isFetching: {
type: Boolean,
required: false,
default: false,
},
relatedIssues: { relatedIssues: {
type: Array, type: Array,
required: false, required: false,
...@@ -22,7 +28,7 @@ export default { ...@@ -22,7 +28,7 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
pendingRelatedIssues: { pendingReferences: {
type: Array, type: Array,
required: false, required: false,
default: () => [], default: () => [],
...@@ -37,9 +43,15 @@ export default { ...@@ -37,9 +43,15 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
autoCompleteSources: {
type: Object,
required: false,
default: () => ({}),
},
}, },
components: { components: {
loadingIcon,
addIssuableForm, addIssuableForm,
issueToken, issueToken,
}, },
...@@ -57,8 +69,8 @@ export default { ...@@ -57,8 +69,8 @@ export default {
}, },
methods: { methods: {
showAddRelatedIssuesForm() { toggleAddRelatedIssuesForm() {
eventHub.$emit('showAddRelatedIssuesForm'); eventHub.$emit('toggleAddRelatedIssuesForm');
}, },
}, },
...@@ -78,47 +90,59 @@ export default { ...@@ -78,47 +90,59 @@ export default {
<div <div
class="panel-heading" class="panel-heading"
:class="{ 'panel-empty-heading': !this.hasRelatedIssues }"> :class="{ 'panel-empty-heading': !this.hasRelatedIssues }">
<h3 class="panel-title"> <h3 class="panel-title related-issues-panel-title">
Related issues <div>
<a Related issues
v-if="hasHelpPath" <a
:href="helpPath"> v-if="hasHelpPath"
<i :href="helpPath">
class="related-issues-header-help-icon fa fa-question-circle"
aria-label="Read more about related issues">
</i>
</a>
<div class="related-issues-header-issue-count issue-count-badge">
<span
class="issue-count-badge-count"
:class="{ 'has-btn': this.canAddRelatedIssues }">
{{ relatedIssueCount }}
</span>
<button
ref="issueCountBadgeAddButton"
v-if="canAddRelatedIssues"
type="button"
class="issue-count-badge-add-button btn btn-small btn-default"
title="Add an issue"
aria-label="Add an issue"
data-toggle="tooltip"
data-placement="top"
@click="showAddRelatedIssuesForm">
<i <i
class="fa fa-plus" class="related-issues-header-help-icon fa fa-question-circle"
aria-hidden="true"> aria-label="Read more about related issues">
</i> </i>
</button> </a>
<div class="js-related-issues-header-issue-count related-issues-header-issue-count issue-count-badge">
<span
class="issue-count-badge-count"
:class="{ 'has-btn': this.canAddRelatedIssues }">
{{ relatedIssueCount }}
</span>
<button
ref="issueCountBadgeAddButton"
v-if="canAddRelatedIssues"
type="button"
class="js-issue-count-badge-add-button issue-count-badge-add-button btn btn-small btn-default"
title="Add an issue"
aria-label="Add an issue"
data-toggle="tooltip"
data-placement="top"
@click="toggleAddRelatedIssuesForm">
<i
class="fa fa-plus"
aria-hidden="true">
</i>
</button>
</div>
</div>
<div>
<loadingIcon
ref="loadingIcon"
v-if="isFetching"
label="Fetching related issues" />
</div> </div>
</h3> </h3>
</div> </div>
<div <div
v-if="isFormVisible" v-if="isFormVisible"
class="js-add-related-issues-form-area related-issues-add-related-issues-form panel-body"> class="js-add-related-issues-form-area panel-body"
:class="{
'related-issues-add-related-issues-form-with-break': hasRelatedIssues
}">
<add-issuable-form <add-issuable-form
:input-value="inputValue" :input-value="inputValue"
:pending-issuables="pendingRelatedIssues" :pending-references="pendingReferences"
add-button-label="Add related issues" /> add-button-label="Add related issues"
:auto-complete-sources="autoCompleteSources" />
</div> </div>
<div <div
v-if="hasRelatedIssues" v-if="hasRelatedIssues"
...@@ -126,18 +150,17 @@ export default { ...@@ -126,18 +150,17 @@ export default {
<ul <ul
class="related-issues-token-body"> class="related-issues-token-body">
<li <li
:key="issue.reference" :key="issue.id"
v-for="issue in relatedIssues" v-for="issue in relatedIssues"
class="js-related-issues-token-list-item related-issues-token-list-item"> class="js-related-issues-token-list-item related-issues-token-list-item">
<issue-token <issue-token
event-namespace="relatedIssue" event-namespace="relatedIssue"
:reference="issue.reference" :id-key="issue.id"
:display-reference="issue.displayReference" :display-reference="issue.reference"
:title="issue.title" :title="issue.title"
:path="issue.path" :path="issue.path"
:state="issue.state" :state="issue.state"
:fetch-status="issue.fetchStatus" :can-remove="true" />
:can-remove="issue.canRemove" />
</li> </li>
</ul> </ul>
</div> </div>
......
export const FETCHING_STATUS = 'FETCHING';
export const FETCH_SUCCESS_STATUS = 'FETCH_SUCCESS';
export const FETCH_ERROR_STATUS = 'FETCH_ERROR';
...@@ -8,11 +8,6 @@ class RelatedIssuesService { ...@@ -8,11 +8,6 @@ class RelatedIssuesService {
this.relatedIssuesResource = Vue.resource(endpoint); this.relatedIssuesResource = Vue.resource(endpoint);
} }
// eslint-disable-next-line class-methods-use-this
fetchIssueInfo(endpoint) {
return Vue.http.get(endpoint);
}
fetchRelatedIssues() { fetchRelatedIssues() {
return this.relatedIssuesResource.get(); return this.relatedIssuesResource.get();
} }
......
import {
FETCH_SUCCESS_STATUS,
FETCH_ERROR_STATUS,
} from '../constants';
import { assembleDisplayIssuableReference } from '../../../lib/utils/issuable_reference_utils';
class RelatedIssuesStore { class RelatedIssuesStore {
constructor() { constructor() {
this.state = { this.state = {
// Stores issue objects that we can lookup by reference // Stores issue objects of the known related issues
issueMap: {},
// Stores references to the actual known related issues
relatedIssues: [], relatedIssues: [],
// Stores references to the "staging area" related issues that are planned to be added // Stores references of the "staging area" related issues that are planned to be added
pendingRelatedIssues: [], pendingReferences: [],
}; };
} }
getIssueFromReference(reference, namespacePath, projectPath) { setRelatedIssues(issues) {
const issue = this.state.issueMap[reference]; this.state.relatedIssues = issues;
let displayReference = reference;
if (issue && issue.fetchStatus === FETCH_SUCCESS_STATUS) {
displayReference = assembleDisplayIssuableReference(
issue,
namespacePath,
projectPath,
);
}
const fetchStatus = issue ? issue.fetchStatus : FETCH_ERROR_STATUS;
return {
reference,
displayReference,
path: issue && issue.path,
title: issue && issue && issue.title,
state: issue && issue.state,
fetchStatus,
canRemove: issue && issue.destroy_relation_path && issue.destroy_relation_path.length > 0,
};
} }
getIssuesFromReferences(references, namespacePath, projectPath) { removeRelatedIssue(idToRemove) {
return references.map(reference => this.state.relatedIssues = this.state.relatedIssues.filter(issue => issue.id !== idToRemove);
this.getIssueFromReference(reference, namespacePath, projectPath));
} }
addToIssueMap(reference, issue) { setPendingReferences(issues) {
this.state.issueMap = { this.state.pendingReferences = issues;
...this.state.issueMap,
[reference]: issue,
};
} }
setRelatedIssues(value) { removePendingRelatedIssue(indexToRemove) {
this.state.relatedIssues = value; this.state.pendingReferences =
this.state.pendingReferences.filter((reference, index) => index !== indexToRemove);
} }
setPendingRelatedIssues(issues) {
this.state.pendingRelatedIssues = issues;
}
} }
export default RelatedIssuesStore; export default RelatedIssuesStore;
const ISSUABLE_REFERENCE_RE = /^((?:[^\s/]+(?:\/(?!#))?)*)#(\d+)$/i;
function getReferencePieces(partialReference, namespacePath, projectPath) {
const [
,
fullNamespace = '',
resultantIssue,
] = partialReference.match(ISSUABLE_REFERENCE_RE);
const namespacePieces = fullNamespace.split('/');
const resultantNamespace = namespacePieces.length > 1 ? namespacePieces.slice(0, -1).join('/') : namespacePath;
const resultantProject = namespacePieces.slice(-1)[0] || projectPath;
return {
namespace: resultantNamespace,
project: resultantProject,
issue: resultantIssue,
};
}
// Transform `foo/bar#123` into `#123` given
// `currentNamespacePath = 'foo'` and `currentProjectPath = 'bar'`
function assembleDisplayIssuableReference(issue, currentNamespacePath, currentProjectPath) {
let necessaryReference = `#${issue.iid}`;
if (issue.project_path && currentProjectPath !== issue.project_path) {
necessaryReference = issue.project_path + necessaryReference;
}
if (issue.namespace_full_path && currentNamespacePath !== issue.namespace_full_path) {
necessaryReference = `${issue.namespace_full_path}/${necessaryReference}`;
}
return necessaryReference;
}
function assembleFullIssuableReference(partialReference, currentNamespacePath, currentProjectPath) {
const {
namespace,
project,
issue,
} = getReferencePieces(partialReference, currentNamespacePath, currentProjectPath);
return `${namespace}/${project}#${issue}`;
}
export {
ISSUABLE_REFERENCE_RE,
getReferencePieces,
assembleDisplayIssuableReference,
assembleFullIssuableReference,
};
...@@ -742,12 +742,14 @@ ...@@ -742,12 +742,14 @@
.add-issuable-form-input-token-list { .add-issuable-form-input-token-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: baseline;
list-style: none; list-style: none;
margin-bottom: 0; margin-bottom: 0;
padding-left: 0; padding-left: 0;
} }
.add-issuable-form-token-list-item { .add-issuable-form-token-list-item {
max-width: 100%;
margin-bottom: $gl-vert-padding; margin-bottom: $gl-vert-padding;
margin-right: 1em; margin-right: 1em;
} }
......
...@@ -283,12 +283,14 @@ ul.related-merge-requests > li { ...@@ -283,12 +283,14 @@ ul.related-merge-requests > li {
.issue-token { .issue-token {
display: inline-flex; display: inline-flex;
align-items: stretch; align-items: stretch;
max-width: 100%;
line-height: 1.75; line-height: 1.75;
white-space: nowrap; white-space: nowrap;
} }
.issue-token-link { .issue-token-link {
display: inline-flex; display: inline-flex;
min-width: 0;
&:hover, &:hover,
&:focus { &:focus {
...@@ -299,28 +301,21 @@ ul.related-merge-requests > li { ...@@ -299,28 +301,21 @@ ul.related-merge-requests > li {
.issue-token-reference { .issue-token-reference {
display: flex; display: flex;
align-items: baseline; align-items: center;
margin-right: 1px; margin-right: 1px;
padding-left: 0.5em; padding-left: 0.5em;
padding-right: 0.5em; padding-right: 0.5em;
background-color: $gray-lighter; background-color: $gray-lighter;
border-top-left-radius: 2px;
border-bottom-left-radius: 2px;
transition: background $general-hover-transition-duration $general-hover-transition-curve, color $general-hover-transition-duration $general-hover-transition-curve; transition: background $general-hover-transition-duration $general-hover-transition-curve, color $general-hover-transition-duration $general-hover-transition-curve;
.issue-token-error & {
background-color: $red-50;
}
.issue-token:hover &, .issue-token:hover &,
.issue-token-link:focus > & { .issue-token-link:focus > & {
background-color: issue-token-reference-hover-background-color; background-color: $gray-normal;
color: $gl-link-hover-color; color: $gl-link-hover-color;
text-decoration: none; text-decoration: none;
} }
.issue-token-error:hover &,
.issue-token-link:focus > & {
background-color: $red-75;
}
} }
@mixin issue-token-state-icon { @mixin issue-token-state-icon {
...@@ -339,60 +334,56 @@ ul.related-merge-requests > li { ...@@ -339,60 +334,56 @@ ul.related-merge-requests > li {
} }
.issue-token-title { .issue-token-title {
overflow: hidden;
display: flex; display: flex;
align-items: baseline; align-items: baseline;
padding-left: 0.5em; padding-left: 0.5em;
padding-right: 0.5em;
background-color: $gray-normal; background-color: $gray-normal;
color: $gl-text-color-secondary; color: $gl-text-color-secondary;
transition: background $general-hover-transition-duration $general-hover-transition-curve; transition: background $general-hover-transition-duration $general-hover-transition-curve;
.issue-token-error & {
background-color: $red-75;
}
.issue-token:hover &, .issue-token:hover &,
.issue-token-link:focus > & { .issue-token-link:focus > & {
background-color: $border-gray-normal; background-color: $border-gray-normal;
} }
.issue-token-error:hover &,
.issue-token-link:focus > & {
background-color: $red-100;
}
& > .fa { & > .fa {
line-height: inherit; line-height: inherit;
} }
} }
.issue-token-title-standalone {
padding-right: 0.5em;
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
}
.issue-token-title-text {
overflow: hidden;
max-width: 264px;
text-overflow: ellipsis;
}
.issue-token-remove-button { .issue-token-remove-button {
padding: 0 0.5em 0 0; display: flex;
align-items: center;
padding: 0 0.5em;
background-color: $gray-normal; background-color: $gray-normal;
border: 0; border: 0;
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
color: $gl-text-color-secondary; color: $gl-text-color-secondary;
transition: background $general-hover-transition-duration $general-hover-transition-curve; transition: background $general-hover-transition-duration $general-hover-transition-curve;
.issue-token-error & { &:hover,
background-color: $red-75; &:focus,
}
.issue-token:hover &, .issue-token:hover &,
.issue-token-link:focus + & { .issue-token-link:focus + & {
background-color: $border-gray-normal; background-color: $border-gray-normal;
} outline: none;
.issue-token-error:hover &,
.issue-token-link:focus > & {
background-color: $red-100;
} }
& > .fa { & > .fa {
font-size: 0.9em; font-size: 0.9em;
} }
} }
// When there isn't a title
.issue-token-remove-button-standalone {
padding-left: 0.5em;
}
.related-issues-block { .related-issues-block {
margin-top: $gl-vert-padding; margin-top: 3 * $gl-vert-padding;
}
.related-issues-panel-title {
display: flex;
justify-content: space-between;
} }
.related-issues-header-help-icon { .related-issues-header-help-icon {
...@@ -11,7 +16,7 @@ ...@@ -11,7 +16,7 @@
margin-left: 0.5em; margin-left: 0.5em;
} }
.related-issues-add-related-issues-form { .related-issues-add-related-issues-form-with-break {
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;
} }
...@@ -24,6 +29,7 @@ ...@@ -24,6 +29,7 @@
} }
.related-issues-token-list-item { .related-issues-token-list-item {
max-width: 100%;
margin-bottom: 0.5em; margin-bottom: 0.5em;
margin-right: 1em; margin-right: 1em;
} }
...@@ -20,7 +20,9 @@ module SystemNoteHelper ...@@ -20,7 +20,9 @@ module SystemNoteHelper
'moved' => 'icon_arrow_circle_o_right', 'moved' => 'icon_arrow_circle_o_right',
'outdated' => 'icon_edit', 'outdated' => 'icon_edit',
'approved' => 'icon_check', 'approved' => 'icon_check',
'unapproved' => 'icon_fa_close' 'unapproved' => 'icon_fa_close',
'relate' => 'icon_anchor',
'unrelate' => 'icon_anchor_broken'
}.freeze }.freeze
def icon_for_system_note(note) def icon_for_system_note(note)
......
...@@ -68,11 +68,10 @@ ...@@ -68,11 +68,10 @@
= edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago') = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago')
.js-related-issues-root{ data: { endpoint: namespace_project_issue_links_path(@project.namespace, @project, @issue), - if can?(current_user, :read_issue_link, @project)
namespace: @project.namespace.path, .js-related-issues-root{ data: { endpoint: namespace_project_issue_links_path(@project.namespace, @project, @issue),
project: @project.path, can_add_related_issues: "#{can?(current_user, :update_issue, @issue)}",
can_add_related_issues: "#{can?(current_user, :update_issue, @issue)}", help_path: help_page_path('user/project/issues/related_issues') } }
help_path: help_page_path('user/project/issues/related_issues') } }
#merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } } #merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } }
// This element is filled in using JavaScript. // This element is filled in using JavaScript.
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" d="m8.419 7.99l-.002.002c-.023-.026-.046-.051-.071-.075-.642-.642-1.678-.651-2.312-.018l-2.432 2.432c-.635.635-.626 1.668.018 2.312.642.642 1.678.651 2.312.018l1.028-1.028c.719.366 1.481.518 2.176.444l-1.753 1.753c-1.344 1.344-3.542 1.326-4.909-.041-1.367-1.367-1.383-3.566-.041-4.909l2.292-2.292c1.344-1.344 3.542-1.326 4.909.041.016.016.032.032.048.049.009.008.017.016.025.024.362.362.367.944.011 1.3-.356.356-.938.351-1.3-.011m-.575.284l.002-.002c.023.026.046.051.071.075.642.642 1.678.651 2.312.018l2.432-2.432c.635-.635.626-1.668-.018-2.312-.642-.642-1.678-.651-2.312-.018l-1.028 1.028c-.719-.366-1.481-.518-2.176-.444l1.753-1.753c1.344-1.344 3.542-1.326 4.909.041 1.367 1.367 1.383 3.566.041 4.909l-2.292 2.292c-1.344 1.344-3.542 1.326-4.909-.041-.016-.016-.032-.032-.048-.049-.009-.008-.017-.016-.025-.024-.362-.362-.367-.944-.011-1.3.356-.356.938-.351 1.3.011"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill-rule="evenodd"><path fill-rule="nonzero" d="M11.3 8.85c.2-.15.4-.3.6-.5l1.4-1.4c1.37-1.38 1.53-3.44.36-4.6-1.17-1.18-3.23-1.02-4.6.35l-1.4 1.4c-.2.2-.36.4-.5.6l1.66.66.04-.04 1.4-1.4c.6-.6 1.48-.66 1.98-.16s.44 1.38-.15 1.97l-1.44 1.4-.04.05.66 1.67zM8.85 11.3c-.15.2-.3.4-.5.6l-1.4 1.4c-1.38 1.37-3.44 1.53-4.6.36-1.18-1.17-1.02-3.23.35-4.6l1.4-1.4c.2-.2.4-.36.6-.5l.66 1.66-.04.04-1.4 1.4c-.6.6-.66 1.48-.16 1.98s1.38.44 1.97-.15l1.4-1.44.05-.04 1.67.66z"/><path d="M12.66 9.2h2c.27 0 .5.23.5.5v.06c0 .27-.23.5-.5.5h-2c-.28 0-.5-.23-.5-.5V9.7c0-.27.22-.5.5-.5zm-.4 2.12l1.43 1.42c.16.2.16.5 0 .7l-.07.04c-.2.2-.5.2-.7 0l-1.42-1.4c-.2-.2-.2-.53 0-.72l.05-.04c.2-.2.5-.2.7 0zm-2.8 1.1c0-.28.2-.5.5-.5H10c.28 0 .5.22.5.5v2c0 .27-.22.5-.5.5h-.05c-.28 0-.5-.23-.5-.5v-2zM6.7 3.24v-2c0-.27-.22-.5-.5-.5h-.05c-.27 0-.5.23-.5.5v2c0 .28.23.5.5.5h.05c.28 0 .5-.22.5-.5zm-2.1.4L3.16 2.2c-.2-.2-.5-.2-.7 0l-.04.04c-.2.2-.2.5 0 .7l1.4 1.42c.2.2.52.2.72 0l.04-.04c.18-.2.18-.5 0-.7zm-1.1 2.8c.27 0 .5-.2.5-.5V5.9c0-.27-.23-.5-.5-.5h-2c-.28 0-.5.23-.5.5v.06c0 .28.22.5.5.5h2z"/></g></svg>
...@@ -85,6 +85,7 @@ Manage files and branches from the UI (user interface): ...@@ -85,6 +85,7 @@ Manage files and branches from the UI (user interface):
- [Cherry-pick](user/project/merge_requests/cherry_pick_changes.md) - [Cherry-pick](user/project/merge_requests/cherry_pick_changes.md)
- [Milestones](user/project/milestones/index.md): Organize issues and merge requests into a cohesive group, optionally setting a due date. - [Milestones](user/project/milestones/index.md): Organize issues and merge requests into a cohesive group, optionally setting a due date.
- **(EES/EEP)** [Burndown Charts](user/project/milestones/index.md#burndown-charts): Watch your project's progress throughout a specific milestone. - **(EES/EEP)** [Burndown Charts](user/project/milestones/index.md#burndown-charts): Watch your project's progress throughout a specific milestone.
- [Related issues](user/project/issues/related_issues.md)
- [Todos](workflow/todos.md): A chronological list of to-dos that are waiting for your input, all in a simple dashboard. - [Todos](workflow/todos.md): A chronological list of to-dos that are waiting for your input, all in a simple dashboard.
### Git and GitLab ### Git and GitLab
......
# Related issues # Related issues
> [Introduced][ee-1797] in GitLab 9.2. > [Introduced][ee-1797] in [GitLab Enterprise Edition Starter][ee] 9.3.
Related issues are a bi-directional relationship between any two issues. Related issues are a bi-directional relationship between any two issues
Related issues will appear in a block below the issue description. and appear in a block below the issue description. Issues can be across groups
and projects.
# Adding related issues The relationship only shows up in the UI if the user has write [permissions]
to see both issues (`> Guest`).
## Adding a related issue
You can relate one issue to the other by clicking the issue count badge "+" button You can relate one issue to the other by clicking the issue count badge "+" button
in the header of the related issue block. Then use the input that will appear in the header of the related issue block. Then use the input that will appear
where you can type in the issue reference. where you can type in the issue reference or paste in a link to an issue.
Valid references will be added to a temporary list that you can review. Valid references will be added to a temporary list that you can review.
When ready, click the green "Add related issues" button to submit. When ready, click the green "Add related issues" button to submit.
![Adding a related issue](img/related_issues_add.png) ![Adding a related issue](img/related_issues_add.png)
# Removing a related issue ## Removing a related issue
In the related issues block, click the "x" icon on the right-side of every issue In the related issues block, click the "x" icon on the right-side of every issue
token. Because of the bi-directional relationship, it will no longer appear in token. Because of the bi-directional relationship, it will no longer appear in
...@@ -24,4 +28,6 @@ either issue. ...@@ -24,4 +28,6 @@ either issue.
![Removing a related issue](img/related_issues_remove.png) ![Removing a related issue](img/related_issues_remove.png)
[ee]: https://about.gitlab.com/gitlab-ee/
[ee-1797]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/1797 [ee-1797]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/1797
[permissions]: ../../permissions.md
require 'rails_helper'
describe 'Related issues', feature: true, js: true do
let(:project) { create(:project_empty_repo, :public) }
let(:project_b) { create(:project_empty_repo, :public) }
let(:project_unauthorized) { create(:project_empty_repo, :public) }
let(:issue_a) { create(:issue, project: project) }
let(:issue_b) { create(:issue, project: project) }
let(:issue_c) { create(:issue, project: project) }
let(:issue_d) { create(:issue, project: project) }
let(:issue_project_b_a) { create(:issue, project: project_b) }
let(:issue_project_unauthorized_a) { create(:issue, project: project_unauthorized) }
let(:user) { create(:user) }
context 'when user has no permission to update related issues' do
before do
login_as(user)
end
context 'with related_issues enabled' do
before do
allow_any_instance_of(License).to receive(:feature_available?).and_call_original
allow_any_instance_of(License).to receive(:feature_available?).with(:related_issues) { true }
end
context 'with existing related issues' do
let!(:issue_link_b) { create :issue_link, source: issue_a, target: issue_b }
let!(:issue_link_c) { create :issue_link, source: issue_a, target: issue_c }
context 'visiting issue_a' do
before do
visit namespace_project_issue_path(project.namespace, project, issue_a)
wait_for_requests
end
it 'shows related issues count' do
expect(find('.js-related-issues-header-issue-count')).to have_content('2')
end
it 'does not show add related issue badge button' do
expect(page).not_to have_selector('.js-issue-count-badge-add-button')
end
end
context 'visiting issue_b which was targeted by issue_a' do
before do
visit namespace_project_issue_path(project.namespace, project, issue_b)
wait_for_requests
end
it 'shows related issues count' do
expect(find('.js-related-issues-header-issue-count')).to have_content('1')
end
end
end
end
end
context 'when user has permission to update related issues' do
before do
project.add_master(user)
project_b.add_master(user)
login_as(user)
end
context 'with related_issues disabled' do
let!(:issue_link_b) { create :issue_link, source: issue_a, target: issue_b }
let!(:issue_link_c) { create :issue_link, source: issue_a, target: issue_c }
before do
visit namespace_project_issue_path(project.namespace, project, issue_a)
wait_for_requests
end
it 'does not show the related issues block' do
expect(page).not_to have_selector('.js-related-issues-root')
end
end
context 'with related_issues enabled' do
before do
allow_any_instance_of(License).to receive(:feature_available?).and_call_original
allow_any_instance_of(License).to receive(:feature_available?).with(:related_issues) { true }
end
context 'without existing related issues' do
before do
visit namespace_project_issue_path(project.namespace, project, issue_a)
wait_for_requests
end
it 'shows related issues count' do
expect(find('.js-related-issues-header-issue-count')).to have_content('0')
end
it 'shows add related issue badge button' do
expect(page).to have_selector('.js-issue-count-badge-add-button')
end
it 'add related issue' do
find('.js-issue-count-badge-add-button').click
find('.js-add-issuable-form-input').set "#{issue_b.to_reference(project)} "
find('.js-add-issuable-form-add-button').click
wait_for_requests
items = all('.js-related-issues-token-list-item .js-issue-token-title')
# Form gets hidden after submission
expect(page).not_to have_selector('.js-add-related-issues-form-area')
# Check if related issues are present
expect(items.count).to eq(1)
expect(items[0].text).to eq(issue_b.title)
expect(find('.js-related-issues-header-issue-count')).to have_content('1')
end
it 'add cross-project related issue' do
find('.js-issue-count-badge-add-button').click
find('.js-add-issuable-form-input').set "#{issue_project_b_a.to_reference(project)} "
find('.js-add-issuable-form-add-button').click
wait_for_requests
items = all('.js-related-issues-token-list-item .js-issue-token-title')
expect(items.count).to eq(1)
expect(items[0].text).to eq(issue_project_b_a.title)
expect(find('.js-related-issues-header-issue-count')).to have_content('1')
end
end
context 'with existing related issues' do
let!(:issue_link_b) { create :issue_link, source: issue_a, target: issue_b }
let!(:issue_link_c) { create :issue_link, source: issue_a, target: issue_c }
before do
visit namespace_project_issue_path(project.namespace, project, issue_a)
wait_for_requests
end
it 'shows related issues count' do
expect(find('.js-related-issues-header-issue-count')).to have_content('2')
end
it 'shows related issues' do
items = all('.js-related-issues-token-list-item .js-issue-token-title')
expect(items.count).to eq(2)
expect(items[0].text).to eq(issue_b.title)
expect(items[1].text).to eq(issue_c.title)
end
it 'allows us to remove a related issues' do
items_before = all('.js-related-issues-token-list-item .js-issue-token-title')
expect(items_before.count).to eq(2)
first('.js-issue-token-remove-button').click
wait_for_requests
items_after = all('.js-related-issues-token-list-item .js-issue-token-title')
expect(items_after.count).to eq(1)
end
it 'add related issue' do
find('.js-issue-count-badge-add-button').click
find('.js-add-issuable-form-input').set "##{issue_d.iid} "
find('.js-add-issuable-form-add-button').click
wait_for_requests
items = all('.js-related-issues-token-list-item .js-issue-token-title')
expect(items.count).to eq(3)
expect(items[0].text).to eq(issue_b.title)
expect(items[1].text).to eq(issue_c.title)
expect(items[2].text).to eq(issue_d.title)
expect(find('.js-related-issues-header-issue-count')).to have_content('3')
end
it 'add invalid related issue' do
find('.js-issue-count-badge-add-button').click
find('.js-add-issuable-form-input').set "#9999999 "
find('.js-add-issuable-form-add-button').click
wait_for_requests
items = all('.js-related-issues-token-list-item .js-issue-token-title')
expect(items.count).to eq(2)
expect(items[0].text).to eq(issue_b.title)
expect(items[1].text).to eq(issue_c.title)
expect(find('.js-related-issues-header-issue-count')).to have_content('2')
end
it 'add unauthorized related issue' do
find('.js-issue-count-badge-add-button').click
find('.js-add-issuable-form-input').set "#{issue_project_unauthorized_a.to_reference(project)} "
find('.js-add-issuable-form-add-button').click
wait_for_requests
items = all('.js-related-issues-token-list-item .js-issue-token-title')
expect(items.count).to eq(2)
expect(items[0].text).to eq(issue_b.title)
expect(items[1].text).to eq(issue_c.title)
expect(find('.js-related-issues-header-issue-count')).to have_content('2')
end
end
end
end
end
...@@ -3,14 +3,18 @@ import eventHub from '~/issuable/related_issues/event_hub'; ...@@ -3,14 +3,18 @@ import eventHub from '~/issuable/related_issues/event_hub';
import addIssuableForm from '~/issuable/related_issues/components/add_issuable_form.vue'; import addIssuableForm from '~/issuable/related_issues/components/add_issuable_form.vue';
const issuable1 = { const issuable1 = {
id: '200',
reference: 'foo/bar#123', reference: 'foo/bar#123',
displayReference: '#123',
title: 'some title', title: 'some title',
path: '/foo/bar/issues/123', path: '/foo/bar/issues/123',
state: 'opened', state: 'opened',
}; };
const issuable2 = { const issuable2 = {
id: '201',
reference: 'foo/bar#124', reference: 'foo/bar#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',
...@@ -26,6 +30,10 @@ describe('AddIssuableForm', () => { ...@@ -26,6 +30,10 @@ describe('AddIssuableForm', () => {
afterEach(() => { afterEach(() => {
if (vm) { if (vm) {
// 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(); vm.$destroy();
} }
}); });
...@@ -39,9 +47,9 @@ describe('AddIssuableForm', () => { ...@@ -39,9 +47,9 @@ describe('AddIssuableForm', () => {
propsData: { propsData: {
inputValue, inputValue,
addButtonLabel, addButtonLabel,
pendingIssuables: [ pendingReferences: [
issuable1, issuable1.reference,
issuable2, issuable2.reference,
], ],
}, },
}).$mount(); }).$mount();
...@@ -89,7 +97,6 @@ describe('AddIssuableForm', () => { ...@@ -89,7 +97,6 @@ describe('AddIssuableForm', () => {
], ],
}, },
}).$mount(el); }).$mount(el);
spyOn(vm, 'onInputWrapperClick').and.callThrough();
}); });
afterEach(() => { afterEach(() => {
...@@ -99,15 +106,16 @@ describe('AddIssuableForm', () => { ...@@ -99,15 +106,16 @@ describe('AddIssuableForm', () => {
eventHub.$off('addIssuableFormCancel', addIssuableFormCancelSpy); eventHub.$off('addIssuableFormCancel', addIssuableFormCancelSpy);
}); });
it('when clicking somewhere on the input wrapper should focus the input', () => { it('when clicking somewhere on the input wrapper should focus the input', (done) => {
expect(vm.onInputWrapperClick).not.toHaveBeenCalled(); vm.onInputWrapperClick();
vm.$refs.issuableFormWrapper.click(); setTimeout(() => {
Vue.nextTick(() => {
expect(vm.$refs.issuableFormWrapper.classList.contains('focus')).toEqual(true);
expect(document.activeElement).toEqual(vm.$refs.input);
Vue.nextTick(() => { done();
expect(vm.$refs.issuableFormWrapper.classList.contains('focus')).toEqual(true); });
expect(vm.onInputWrapperClick).toHaveBeenCalled();
expect(document.activeElement).toEqual(vm.$refs.input);
}); });
}); });
...@@ -121,16 +129,20 @@ describe('AddIssuableForm', () => { ...@@ -121,16 +129,20 @@ describe('AddIssuableForm', () => {
expect(addIssuableFormInputSpy).toHaveBeenCalledWith(newInputValue, newInputValue.length); expect(addIssuableFormInputSpy).toHaveBeenCalledWith(newInputValue, newInputValue.length);
}); });
it('when blurring the input', () => { it('when blurring the input', (done) => {
expect(addIssuableFormInputSpy).not.toHaveBeenCalled(); expect(addIssuableFormInputSpy).not.toHaveBeenCalled();
const newInputValue = 'filling in things'; const newInputValue = 'filling in things';
vm.$refs.input.value = newInputValue; vm.$refs.input.value = newInputValue;
vm.onBlur(); vm.onBlur();
Vue.nextTick(() => { setTimeout(() => {
expect(vm.$refs.issuableFormWrapper.classList.contains('focus')).toEqual(false); Vue.nextTick(() => {
expect(addIssuableFormBlurSpy).toHaveBeenCalledWith(newInputValue); expect(vm.$refs.issuableFormWrapper.classList.contains('focus')).toEqual(false);
expect(addIssuableFormBlurSpy).toHaveBeenCalledWith(newInputValue);
done();
});
}); });
}); });
......
import Vue from 'vue'; import Vue from 'vue';
import eventHub from '~/issuable/related_issues/event_hub'; import eventHub from '~/issuable/related_issues/event_hub';
import RelatedIssuesService from '~/issuable/related_issues/services/related_issues_service';
import issueToken from '~/issuable/related_issues/components/issue_token.vue'; import issueToken from '~/issuable/related_issues/components/issue_token.vue';
describe('IssueToken', () => { describe('IssueToken', () => {
const reference = 'foo/bar#123'; const idKey = '200';
const displayReference = 'foo/bar#123';
const title = 'some title'; const title = 'some title';
let IssueToken; let IssueToken;
let vm; let vm;
...@@ -23,13 +23,14 @@ describe('IssueToken', () => { ...@@ -23,13 +23,14 @@ describe('IssueToken', () => {
beforeEach(() => { beforeEach(() => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
reference, idKey,
displayReference,
}, },
}).$mount(); }).$mount();
}); });
it('shows reference', () => { it('shows reference', () => {
expect(vm.$el.textContent.trim()).toEqual(reference); expect(vm.$el.textContent.trim()).toEqual(displayReference);
}); });
}); });
...@@ -37,14 +38,15 @@ describe('IssueToken', () => { ...@@ -37,14 +38,15 @@ describe('IssueToken', () => {
beforeEach(() => { beforeEach(() => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
reference, idKey,
displayReference,
title, title,
}, },
}).$mount(); }).$mount();
}); });
it('shows reference and title', () => { it('shows reference and title', () => {
expect(vm.$refs.reference.textContent.trim()).toEqual(reference); expect(vm.$refs.reference.textContent.trim()).toEqual(displayReference);
expect(vm.$refs.title.textContent.trim()).toEqual(title); expect(vm.$refs.title.textContent.trim()).toEqual(title);
}); });
}); });
...@@ -54,7 +56,8 @@ describe('IssueToken', () => { ...@@ -54,7 +56,8 @@ describe('IssueToken', () => {
beforeEach(() => { beforeEach(() => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
reference, idKey,
displayReference,
title, title,
path, path,
}, },
...@@ -71,7 +74,8 @@ describe('IssueToken', () => { ...@@ -71,7 +74,8 @@ describe('IssueToken', () => {
beforeEach(() => { beforeEach(() => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
reference, idKey,
displayReference,
state: 'opened', state: 'opened',
}, },
}).$mount(); }).$mount();
...@@ -86,7 +90,8 @@ describe('IssueToken', () => { ...@@ -86,7 +90,8 @@ describe('IssueToken', () => {
beforeEach(() => { beforeEach(() => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
reference, idKey,
displayReference,
state: 'closed', state: 'closed',
}, },
}).$mount(); }).$mount();
...@@ -103,7 +108,8 @@ describe('IssueToken', () => { ...@@ -103,7 +108,8 @@ describe('IssueToken', () => {
beforeEach(() => { beforeEach(() => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
reference, idKey,
displayReference,
title, title,
state, state,
}, },
...@@ -112,49 +118,18 @@ describe('IssueToken', () => { ...@@ -112,49 +118,18 @@ describe('IssueToken', () => {
it('shows reference, title, and state', () => { it('shows reference, title, and state', () => {
expect(vm.$refs.stateIcon.getAttribute('aria-label')).toEqual(state); expect(vm.$refs.stateIcon.getAttribute('aria-label')).toEqual(state);
expect(vm.$refs.reference.textContent.trim()).toEqual(reference); expect(vm.$refs.reference.textContent.trim()).toEqual(displayReference);
expect(vm.$refs.title.textContent.trim()).toEqual(title); expect(vm.$refs.title.textContent.trim()).toEqual(title);
}); });
}); });
describe('with fetchStatus', () => {
describe('`canRemove: RelatedIssuesService.FETCHING_STATUS`', () => {
beforeEach(() => {
vm = new IssueToken({
propsData: {
reference,
fetchStatus: RelatedIssuesService.FETCHING_STATUS,
},
}).$mount();
});
it('shows loading indicator/spinner', () => {
expect(vm.$refs.fetchStatusIcon).toBeDefined();
});
});
describe('`canRemove: RelatedIssuesService.FETCH_ERROR_STATUS`', () => {
beforeEach(() => {
vm = new IssueToken({
propsData: {
reference,
fetchStatus: RelatedIssuesService.FETCH_ERROR_STATUS,
},
}).$mount();
});
it('tints the token red', () => {
expect(vm.$el.classList.contains('issue-token-error')).toEqual(true);
});
});
});
describe('with canRemove', () => { describe('with canRemove', () => {
describe('`canRemove: false` (default)', () => { describe('`canRemove: false` (default)', () => {
beforeEach(() => { beforeEach(() => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
reference, idKey,
displayReference,
}, },
}).$mount(); }).$mount();
}); });
...@@ -168,7 +143,8 @@ describe('IssueToken', () => { ...@@ -168,7 +143,8 @@ describe('IssueToken', () => {
beforeEach(() => { beforeEach(() => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
reference, idKey,
displayReference,
canRemove: true, canRemove: true,
}, },
}).$mount(); }).$mount();
...@@ -186,7 +162,8 @@ describe('IssueToken', () => { ...@@ -186,7 +162,8 @@ describe('IssueToken', () => {
beforeEach(() => { beforeEach(() => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
reference, idKey,
displayReference,
}, },
}).$mount(); }).$mount();
removeRequestSpy = jasmine.createSpy('spy'); removeRequestSpy = jasmine.createSpy('spy');
......
...@@ -3,6 +3,7 @@ import eventHub from '~/issuable/related_issues/event_hub'; ...@@ -3,6 +3,7 @@ import eventHub from '~/issuable/related_issues/event_hub';
import relatedIssuesBlock from '~/issuable/related_issues/components/related_issues_block.vue'; import relatedIssuesBlock from '~/issuable/related_issues/components/related_issues_block.vue';
const issuable1 = { const issuable1 = {
id: '200',
reference: 'foo/bar#123', reference: 'foo/bar#123',
displayReference: '#123', displayReference: '#123',
title: 'some title', title: 'some title',
...@@ -11,6 +12,7 @@ const issuable1 = { ...@@ -11,6 +12,7 @@ const issuable1 = {
}; };
const issuable2 = { const issuable2 = {
id: '201',
reference: 'foo/bar#124', reference: 'foo/bar#124',
displayReference: '#124', displayReference: '#124',
title: 'some other thing', title: 'some other thing',
...@@ -44,6 +46,24 @@ describe('RelatedIssuesBlock', () => { ...@@ -44,6 +46,24 @@ describe('RelatedIssuesBlock', () => {
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(vm.$el.querySelector('.js-add-related-issues-form-area')).toBeNull();
}); });
it('should not show loading icon', () => {
expect(vm.$refs.loadingIcon).toBeUndefined();
});
});
describe('with isFetching=true', () => {
beforeEach(() => {
vm = new RelatedIssuesBlock({
propsData: {
isFetching: true,
},
}).$mount();
});
it('should show loading icon', () => {
expect(vm.$refs.loadingIcon).toBeDefined();
});
}); });
describe('with canAddRelatedIssues=true', () => { describe('with canAddRelatedIssues=true', () => {
...@@ -92,7 +112,7 @@ describe('RelatedIssuesBlock', () => { ...@@ -92,7 +112,7 @@ describe('RelatedIssuesBlock', () => {
}); });
describe('methods', () => { describe('methods', () => {
let showAddRelatedIssuesFormSpy; let toggleAddRelatedIssuesFormSpy;
beforeEach(() => { beforeEach(() => {
vm = new RelatedIssuesBlock({ vm = new RelatedIssuesBlock({
...@@ -102,18 +122,18 @@ describe('RelatedIssuesBlock', () => { ...@@ -102,18 +122,18 @@ describe('RelatedIssuesBlock', () => {
], ],
}, },
}).$mount(); }).$mount();
showAddRelatedIssuesFormSpy = jasmine.createSpy('spy'); toggleAddRelatedIssuesFormSpy = jasmine.createSpy('spy');
eventHub.$on('showAddRelatedIssuesForm', showAddRelatedIssuesFormSpy); eventHub.$on('toggleAddRelatedIssuesForm', toggleAddRelatedIssuesFormSpy);
}); });
afterEach(() => { afterEach(() => {
eventHub.$off('showAddRelatedIssuesForm', showAddRelatedIssuesFormSpy); eventHub.$off('toggleAddRelatedIssuesForm', toggleAddRelatedIssuesFormSpy);
}); });
it('when expanding add related issue form', () => { it('when expanding add related issue form', () => {
expect(showAddRelatedIssuesFormSpy).not.toHaveBeenCalled(); expect(toggleAddRelatedIssuesFormSpy).not.toHaveBeenCalled();
vm.showAddRelatedIssuesForm(); vm.toggleAddRelatedIssuesForm();
expect(showAddRelatedIssuesFormSpy).toHaveBeenCalled(); expect(toggleAddRelatedIssuesFormSpy).toHaveBeenCalled();
}); });
}); });
}); });
...@@ -17,34 +17,6 @@ describe('RelatedIssuesService', () => { ...@@ -17,34 +17,6 @@ describe('RelatedIssuesService', () => {
service = new RelatedIssuesService(''); service = new RelatedIssuesService('');
}); });
describe('fetchIssueInfo', () => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify(issuable1), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('fetch issue info', (done) => {
service.fetchIssueInfo('...')
.then(res => res.json())
.then((issue) => {
expect(issue).toEqual(issuable1);
done();
})
.catch((err) => {
done.fail(`Failed to fetch issue:\n${err}`);
});
});
});
describe('fetchRelatedIssues', () => { describe('fetchRelatedIssues', () => {
const interceptor = (request, next) => { const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify([issuable1]), { next(request.respondWith(JSON.stringify([issuable1]), {
......
import _ from 'underscore';
import {
FETCH_SUCCESS_STATUS,
FETCH_ERROR_STATUS,
} from '~/issuable/related_issues/constants';
import RelatedIssuesStore from '~/issuable/related_issues/stores/related_issues_store'; import RelatedIssuesStore from '~/issuable/related_issues/stores/related_issues_store';
const issuable1 = { const issuable1 = {
namespace_full_path: 'foo', id: '200',
project_path: 'bar', reference: 'foo/bar#123',
iid: '123',
title: 'issue1', title: 'issue1',
path: '/foo/bar/issues/123', path: '/foo/bar/issues/123',
state: 'opened', state: 'opened',
fetchStatus: FETCH_SUCCESS_STATUS,
destroy_relation_path: '/foo/bar/issues/123/related_issues/1', destroy_relation_path: '/foo/bar/issues/123/related_issues/1',
}; };
const issuable1Reference = `${issuable1.namespace_full_path}/${issuable1.project_path}#${issuable1.iid}`;
const issuable2 = { const issuable2 = {
namespace_full_path: 'foo', id: '201',
project_path: 'bar', reference: 'foo/bar#124',
iid: '124',
title: 'issue1', title: 'issue1',
path: '/foo/bar/issues/124', path: '/foo/bar/issues/124',
state: 'opened', state: 'opened',
fetchStatus: FETCH_SUCCESS_STATUS,
destroy_relation_path: '/foo/bar/issues/124/related_issues/1', destroy_relation_path: '/foo/bar/issues/124/related_issues/1',
}; };
const issuable2Reference = `${issuable2.namespace_full_path}/${issuable2.project_path}#${issuable2.iid}`;
describe('RelatedIssuesStore', () => { describe('RelatedIssuesStore', () => {
let store; let store;
...@@ -36,92 +25,69 @@ describe('RelatedIssuesStore', () => { ...@@ -36,92 +25,69 @@ describe('RelatedIssuesStore', () => {
store = new RelatedIssuesStore(); store = new RelatedIssuesStore();
}); });
describe('getIssueFromReference', () => { describe('setRelatedIssues', () => {
it('get issue with issueMap populated', () => { it('defaults to empty array', () => {
store.state.issueMap = { expect(store.state.relatedIssues).toEqual([]);
[issuable1Reference]: issuable1,
};
expect(store.getIssueFromReference(issuable1Reference, 'foo', 'bar')).toEqual({
..._.omit(issuable1, 'namespace_full_path', 'project_path', 'iid', 'destroy_relation_path'),
reference: issuable1Reference,
displayReference: '#123',
fetchStatus: FETCH_SUCCESS_STATUS,
canRemove: true,
});
}); });
it('get issue with issue missing in issueMap', () => { it('add issue', () => {
expect(store.getIssueFromReference(issuable1Reference, 'foo', 'bar')).toEqual({ const relatedIssues = [issuable1];
reference: issuable1Reference, store.setRelatedIssues(relatedIssues);
displayReference: issuable1Reference,
title: undefined,
path: undefined,
state: undefined,
fetchStatus: FETCH_ERROR_STATUS,
canRemove: undefined,
});
});
});
describe('getIssuesFromReferences', () => { expect(store.state.relatedIssues).toEqual(relatedIssues);
it('get issues with issueMap populated', () => {
store.state.issueMap = {
[issuable1Reference]: issuable1,
[issuable2Reference]: issuable2,
};
expect(store.getIssuesFromReferences([issuable1Reference, issuable2Reference], 'foo', 'bar')).toEqual([{
..._.omit(issuable1, 'namespace_full_path', 'project_path', 'iid', 'destroy_relation_path'),
reference: issuable1Reference,
displayReference: '#123',
fetchStatus: FETCH_SUCCESS_STATUS,
canRemove: true,
}, {
..._.omit(issuable2, 'namespace_full_path', 'project_path', 'iid', 'destroy_relation_path'),
reference: issuable2Reference,
displayReference: '#124',
fetchStatus: FETCH_SUCCESS_STATUS,
canRemove: true,
}]);
}); });
}); });
describe('addToIssueMap', () => { describe('removeRelatedIssue', () => {
it('defaults to empty object hash', () => { it('remove issue', () => {
expect(store.state.issueMap).toEqual({}); const relatedIssues = [issuable1];
store.state.relatedIssues = relatedIssues;
store.removeRelatedIssue(issuable1.id);
expect(store.state.relatedIssues).toEqual([]);
}); });
it('add issue', () => { it('remove issue with multiple in store', () => {
store.addToIssueMap(issuable1Reference, issuable1); const relatedIssues = [issuable1, issuable2];
store.state.relatedIssues = relatedIssues;
store.removeRelatedIssue(issuable1.id);
expect(store.state.issueMap).toEqual({ expect(store.state.relatedIssues).toEqual([issuable2]);
[issuable1Reference]: issuable1,
});
}); });
}); });
describe('setRelatedIssues', () => { describe('setPendingReferences', () => {
it('defaults to empty array', () => { it('defaults to empty array', () => {
expect(store.state.relatedIssues).toEqual([]); expect(store.state.pendingReferences).toEqual([]);
}); });
it('add reference', () => { it('add reference', () => {
const relatedIssues = ['#123']; const relatedIssues = [issuable1.reference];
store.setRelatedIssues(relatedIssues); store.setPendingReferences(relatedIssues);
expect(store.state.relatedIssues).toEqual(relatedIssues); expect(store.state.pendingReferences).toEqual(relatedIssues);
}); });
}); });
describe('setPendingRelatedIssues', () => { describe('removePendingRelatedIssue', () => {
it('defaults to empty array', () => { it('remove issue', () => {
expect(store.state.pendingRelatedIssues).toEqual([]); const relatedIssues = [issuable1.reference];
store.state.pendingReferences = relatedIssues;
store.removePendingRelatedIssue(0);
expect(store.state.pendingReferences).toEqual([]);
}); });
it('add reference', () => { it('remove issue with multiple in store', () => {
const relatedIssues = ['#123']; const relatedIssues = [issuable1.reference, issuable2.reference];
store.setPendingRelatedIssues(relatedIssues); store.state.pendingReferences = relatedIssues;
store.removePendingRelatedIssue(0);
expect(store.state.pendingRelatedIssues).toEqual(relatedIssues); expect(store.state.pendingReferences).toEqual([issuable2.reference]);
}); });
}); });
}); });
import {
getReferencePieces,
assembleDisplayIssuableReference,
assembleFullIssuableReference,
} from '~/lib/utils/issuable_reference_utils';
describe('issuable_reference_utils', () => {
describe('getReferencePieces', () => {
it('should work with only issue number reference', () => {
expect(getReferencePieces('#111', 'foo', 'bar')).toEqual({
namespace: 'foo',
project: 'bar',
issue: '111',
});
});
it('should work with project and issue number reference', () => {
expect(getReferencePieces('qux#111', 'foo', 'bar')).toEqual({
namespace: 'foo',
project: 'qux',
issue: '111',
});
});
it('should work with full reference', () => {
expect(getReferencePieces('foo/garply#111', 'foo', 'bar')).toEqual({
namespace: 'foo',
project: 'garply',
issue: '111',
});
});
it('should work with sub-groups', () => {
expect(getReferencePieces('some/with/sub/groups/other#111', 'foo', 'bar')).toEqual({
namespace: 'some/with/sub/groups',
project: 'other',
issue: '111',
});
});
it('does not mangle other group references', () => {
expect(getReferencePieces('some/other#111', 'foo', 'bar')).toEqual({
namespace: 'some',
project: 'other',
issue: '111',
});
});
it('does not mangle other group even with partial match', () => {
expect(getReferencePieces('bar/baz/fido#111', 'foo/bar/baz', 'garply')).toEqual({
namespace: 'bar/baz',
project: 'fido',
issue: '111',
});
});
});
describe('assembleDisplayIssuableReference', () => {
it('should work with only issue number reference', () => {
expect(assembleDisplayIssuableReference({ iid: 111 }, 'foo', 'bar')).toEqual('#111');
});
it('should work with project and issue number reference', () => {
expect(assembleDisplayIssuableReference({ project_path: 'qux', iid: 111 }, 'foo', 'bar')).toEqual('qux#111');
});
it('should work with full reference to current project', () => {
expect(assembleDisplayIssuableReference({ namespace_full_path: 'foo', project_path: 'garply', iid: 111 }, 'foo', 'bar')).toEqual('garply#111');
});
it('should work with sub-groups', () => {
expect(assembleDisplayIssuableReference({ namespace_full_path: 'some/with/sub/groups', project_path: 'other', iid: 111 }, 'foo', 'bar')).toEqual('some/with/sub/groups/other#111');
});
it('does not mangle other group references', () => {
expect(assembleDisplayIssuableReference({ namespace_full_path: 'some', project_path: 'other', iid: 111 }, 'foo', 'bar')).toEqual('some/other#111');
});
it('does not mangle other group even with partial match', () => {
expect(assembleDisplayIssuableReference({ namespace_full_path: 'bar/baz', project_path: 'fido', iid: 111 }, 'foo/bar/baz', 'garply')).toEqual('bar/baz/fido#111');
});
});
describe('assembleFullIssuableReference', () => {
it('should work with only issue number reference', () => {
expect(assembleFullIssuableReference('#111', 'foo', 'bar')).toEqual('foo/bar#111');
});
it('should work with project and issue number reference', () => {
expect(assembleFullIssuableReference('qux#111', 'foo', 'bar')).toEqual('foo/qux#111');
});
it('should work with full reference', () => {
expect(assembleFullIssuableReference('foo/garply#111', 'foo', 'bar')).toEqual('foo/garply#111');
});
it('should work with sub-groups', () => {
expect(assembleFullIssuableReference('some/with/sub/groups/other#111', 'foo', 'bar')).toEqual('some/with/sub/groups/other#111');
});
it('does not mangle other group references', () => {
expect(assembleFullIssuableReference('some/other#111', 'foo', 'bar')).toEqual('some/other#111');
});
it('does not mangle other group even with partial match', () => {
expect(assembleFullIssuableReference('bar/baz/fido#111', 'foo/bar/baz', 'garply')).toEqual('bar/baz/fido#111');
});
});
});
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