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', () => {
render: createElement => createElement('related-issues-root', {
props: {
endpoint: relatedIssuesRootElement.dataset.endpoint,
currentNamespacePath: relatedIssuesRootElement.dataset.namespace,
currentProjectPath: relatedIssuesRootElement.dataset.project,
canAddRelatedIssues: gl.utils.convertPermissionToBoolean(
relatedIssuesRootElement.dataset.canAddRelatedIssues,
),
......
<script>
import GfmAutoComplete from '~/gfm_auto_complete';
import eventHub from '../event_hub';
import IssueToken from './issue_token.vue';
......@@ -14,16 +15,22 @@ export default {
type: String,
required: true,
},
pendingIssuables: {
pendingReferences: {
type: Array,
required: false,
default: () => [],
},
autoCompleteSources: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
isInputFocused: false,
isAutoCompleteOpen: false,
};
},
......@@ -31,6 +38,12 @@ export default {
issueToken: IssueToken,
},
computed: {
isSubmitButtonDisabled() {
return this.pendingReferences.length === 0;
},
},
methods: {
onInput() {
const value = this.$refs.input.value;
......@@ -42,8 +55,14 @@ export default {
onBlur() {
this.isInputFocused = false;
// Avoid tokenizing partial input when clicking an autocomplete item
if (!this.isAutoCompleteOpen) {
const value = this.$refs.input.value;
eventHub.$emit('addIssuableFormBlur', value);
}
},
onAutoCompleteToggled(isOpen) {
this.isAutoCompleteOpen = isOpen;
},
onInputWrapperClick() {
this.$refs.input.focus();
......@@ -58,14 +77,18 @@ export default {
mounted() {
const $input = $(this.$refs.input);
gl.GfmAutoComplete.setup($input, {
new GfmAutoComplete(this.autoCompleteSources).setup($input, {
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);
},
beforeDestroy() {
const $input = $(this.$refs.input);
$input.off('shown-issues.atwho');
$input.off('hidden-issues.atwho');
$input.off('inserted-issues.atwho', this.onInput);
},
};
......@@ -81,24 +104,20 @@ export default {
@click="onInputWrapperClick">
<ul class="add-issuable-form-input-token-list">
<li
:key="issuable.reference"
v-for="issuable in pendingIssuables"
:key="reference"
v-for="(reference, index) in pendingReferences"
class="js-add-issuable-form-token-list-item add-issuable-form-token-list-item">
<issue-token
event-namespace="pendingIssuable"
:reference="issuable.reference"
:display-reference="issuable.displayReference"
:title="issuable.title"
:path="issuable.path"
:state="issuable.state"
:fetch-status="issuable.fetchStatus"
:id-key="index"
:display-reference="reference"
:can-remove="true" />
</li>
<li class="add-issuable-form-input-list-item">
<input
ref="input"
type="text"
class="add-issuable-form-input"
class="js-add-issuable-form-input add-issuable-form-input"
:value="inputValue"
placeholder="Search issues..."
@input="onInput"
......@@ -111,8 +130,9 @@ export default {
<button
ref="addButton"
type="button"
class="btn btn-new pull-left"
@click="onFormSubmit">
class="js-add-issuable-form-add-button btn btn-new pull-left"
@click="onFormSubmit"
:disabled="isSubmitButtonDisabled">
{{ addButtonLabel }}
</button>
<button
......
<script>
import eventHub from '../event_hub';
import {
FETCHING_STATUS,
FETCH_SUCCESS_STATUS,
FETCH_ERROR_STATUS,
} from '../constants';
export default {
name: 'IssueToken',
props: {
reference: {
type: String,
idKey: {
type: Number,
required: true,
},
displayReference: {
......@@ -38,11 +33,6 @@ export default {
required: false,
default: '',
},
fetchStatus: {
type: String,
required: false,
default: FETCH_SUCCESS_STATUS,
},
canRemove: {
type: Boolean,
required: false,
......@@ -51,20 +41,14 @@ export default {
},
computed: {
isFetching() {
return this.fetchStatus === FETCHING_STATUS;
},
hasFetchingError() {
return this.fetchStatus === FETCH_ERROR_STATUS;
},
removeButtonLabel() {
return `Remove related issue ${this.reference}`;
return `Remove related issue ${this.displayReference}`;
},
hasState() {
return this.state && this.state.length > 0;
},
hasTitle() {
return this.title.length > 0 || this.isFetching;
return this.title.length > 0;
},
},
......@@ -75,26 +59,33 @@ export default {
namespacePrefix = `${this.eventNamespace}-`;
}
eventHub.$emit(`${namespacePrefix}removeRequest`, this.reference);
eventHub.$emit(`${namespacePrefix}removeRequest`, this.idKey);
},
},
updated() {
const link = this.$refs.link;
const removeButton = this.$refs.removeButton;
if (link) {
$(link).tooltip('fixTitle');
}
if (removeButton) {
$(this.$refs.removeButton).tooltip('fixTitle');
$(removeButton).tooltip('fixTitle');
}
},
};
</script>
<template>
<div
class="issue-token"
:class="{ 'issue-token-error': hasFetchingError }">
<div class="issue-token">
<a
ref="link"
class="issue-token-link"
:href="path">
:href="path"
:title="title"
data-toggle="tooltip"
data-placement="top">
<span
ref="reference"
class="issue-token-reference">
......@@ -113,22 +104,18 @@ export default {
<span
v-if="hasTitle"
ref="title"
class="issue-token-title">
<i
ref="fetchStatusIcon"
v-if="isFetching"
class="fa fa-spinner fa-spin"
aria-label="Fetching info">
</i>
class="js-issue-token-title issue-token-title"
:class="{ 'issue-token-title-standalone': !canRemove }">
<span class="issue-token-title-text">
{{ title }}
</span>
</span>
</a>
<button
ref="removeButton"
v-if="canRemove"
type="button"
class="issue-token-remove-button"
:class="{ 'issue-token-remove-button-standalone': !hasTitle }"
class="js-issue-token-remove-button issue-token-remove-button"
:title="removeButtonLabel"
data-toggle="tooltip"
@click="onRemoveRequest">
......
<script>
import eventHub from '../event_hub';
import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
import issueToken from './issue_token.vue';
import addIssuableForm from './add_issuable_form.vue';
......@@ -7,6 +8,11 @@ export default {
name: 'RelatedIssuesBlock',
props: {
isFetching: {
type: Boolean,
required: false,
default: false,
},
relatedIssues: {
type: Array,
required: false,
......@@ -22,7 +28,7 @@ export default {
required: false,
default: false,
},
pendingRelatedIssues: {
pendingReferences: {
type: Array,
required: false,
default: () => [],
......@@ -37,9 +43,15 @@ export default {
required: false,
default: '',
},
autoCompleteSources: {
type: Object,
required: false,
default: () => ({}),
},
},
components: {
loadingIcon,
addIssuableForm,
issueToken,
},
......@@ -57,8 +69,8 @@ export default {
},
methods: {
showAddRelatedIssuesForm() {
eventHub.$emit('showAddRelatedIssuesForm');
toggleAddRelatedIssuesForm() {
eventHub.$emit('toggleAddRelatedIssuesForm');
},
},
......@@ -78,7 +90,8 @@ export default {
<div
class="panel-heading"
:class="{ 'panel-empty-heading': !this.hasRelatedIssues }">
<h3 class="panel-title">
<h3 class="panel-title related-issues-panel-title">
<div>
Related issues
<a
v-if="hasHelpPath"
......@@ -88,7 +101,7 @@ export default {
aria-label="Read more about related issues">
</i>
</a>
<div class="related-issues-header-issue-count issue-count-badge">
<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 }">
......@@ -98,27 +111,38 @@ export default {
ref="issueCountBadgeAddButton"
v-if="canAddRelatedIssues"
type="button"
class="issue-count-badge-add-button btn btn-small btn-default"
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="showAddRelatedIssuesForm">
@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>
</h3>
</div>
<div
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
:input-value="inputValue"
:pending-issuables="pendingRelatedIssues"
add-button-label="Add related issues" />
:pending-references="pendingReferences"
add-button-label="Add related issues"
:auto-complete-sources="autoCompleteSources" />
</div>
<div
v-if="hasRelatedIssues"
......@@ -126,18 +150,17 @@ export default {
<ul
class="related-issues-token-body">
<li
:key="issue.reference"
:key="issue.id"
v-for="issue in relatedIssues"
class="js-related-issues-token-list-item related-issues-token-list-item">
<issue-token
event-namespace="relatedIssue"
:reference="issue.reference"
:display-reference="issue.displayReference"
:id-key="issue.id"
:display-reference="issue.reference"
:title="issue.title"
:path="issue.path"
:state="issue.state"
:fetch-status="issue.fetchStatus"
:can-remove="issue.canRemove" />
:can-remove="true" />
</li>
</ul>
</div>
......
<script>
/* global Flash */
/*
`rawReferences` are separated by spaces.
Given `abc 123 zxc`, `rawReferences = ['abc', '123', 'zxc']`
Consider you are typing `abc 123 zxc` in the input and your caret position is
at position 4 right before the `123` `rawReference`. Then you type `#` and
it becomes a valid reference, `#123`, but we don't want to jump it straight into
`pendingReferences` because you could still want to type. Say you typed `999`
and now we have `#999123`. Only when you move your caret away from that `rawReference`
do we actually put it in the `pendingReferences`.
Your caret can stop touching a `rawReference` can happen in a variety of ways:
- As you type, we only tokenize after you type a space or move with the arrow keys
- On blur, we consider your caret not touching anything
---
- When you click the "Add related issues"(in the `AddIssuableForm`),
we submit the `pendingReferences` to the server and they come back as actual `relatedIssues`
- When you click the "Cancel"(in the `AddIssuableForm`), we clear out `pendingReferences`
and hide the `AddIssuableForm` area.
*/
import eventHub from '../event_hub';
import RelatedIssuesBlock from './related_issues_block.vue';
import RelatedIssuesStore from '../stores/related_issues_store';
import RelatedIssuesService from '../services/related_issues_service';
const SPACE_FACTOR = 1;
export default {
name: 'RelatedIssuesRoot',
......@@ -14,14 +41,6 @@ export default {
type: String,
required: true,
},
currentNamespacePath: {
type: String,
required: true,
},
currentProjectPath: {
type: String,
required: true,
},
canAddRelatedIssues: {
type: Boolean,
required: false,
......@@ -39,6 +58,7 @@ export default {
return {
state: this.store.state,
isFetching: false,
isFormVisible: false,
inputValue: '',
};
......@@ -49,93 +69,109 @@ export default {
},
computed: {
computedRelatedIssues() {
return this.store.getIssuesFromReferences(
this.state.relatedIssues,
this.currentNamespacePath,
this.currentProjectPath,
);
},
computedPendingRelatedIssues() {
return this.store.getIssuesFromReferences(
this.state.pendingRelatedIssues,
this.currentNamespacePath,
this.currentProjectPath,
);
autoCompleteSources() {
return gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources;
},
},
methods: {
onRelatedIssueRemoveRequest(reference) {
this.store.setRelatedIssues(this.state.relatedIssues.filter(ref => ref !== reference));
this.service.removeRelatedIssue(this.state.issueMap[reference].destroy_relation_path)
.catch(() => {
// Restore issue we were unable to delete
this.store.setRelatedIssues(this.state.relatedIssues.concat(reference));
onRelatedIssueRemoveRequest(idToRemove) {
const issueToRemove = _.find(this.state.relatedIssues, issue => issue.id === idToRemove);
if (issueToRemove) {
this.service.removeRelatedIssue(issueToRemove.destroy_relation_path)
.then(res => res.json())
.then((data) => {
this.store.setRelatedIssues(data.issues);
})
.catch(() => new Flash('An error occurred while removing related issues.'));
} else {
// eslint-disable-next-line no-new
new Flash('An error occurred while removing related issues.');
});
new Flash('We could not determine the path to remove the related issue');
}
},
onShowAddRelatedIssuesForm() {
this.isFormVisible = true;
onToggleAddRelatedIssuesForm() {
this.isFormVisible = !this.isFormVisible;
},
onAddIssuableFormIssuableRemoveRequest(reference) {
this.store.setPendingRelatedIssues(
this.state.pendingRelatedIssues.filter(ref => ref !== reference),
);
onPendingIssueRemoveRequest(indexToRemove) {
this.store.removePendingRelatedIssue(indexToRemove);
},
onAddIssuableFormSubmit() {
const currentPendingIssues = this.state.pendingRelatedIssues;
this.service.addRelatedIssues(currentPendingIssues)
onPendingFormSubmit() {
if (this.state.pendingReferences.length > 0) {
this.service.addRelatedIssues(this.state.pendingReferences)
.then(res => res.json())
.then(() => {
this.store.setRelatedIssues(this.state.relatedIssues.concat(currentPendingIssues));
})
.catch(() => {
// Restore issues we were unable to submit
this.store.setPendingRelatedIssues(
_.uniq(this.state.pendingRelatedIssues.concat(currentPendingIssues)),
);
.then((data) => {
// We could potentially lose some pending issues in the interim here
this.store.setPendingReferences([]);
this.store.setRelatedIssues(data.issues);
// eslint-disable-next-line no-new
new Flash('An error occurred while submitting related issues.');
});
this.store.setPendingRelatedIssues([]);
// Close the form on submission
this.isFormVisible = false;
})
.catch(res => new Flash(res.data.message || 'An error occurred while submitting related issues.'));
}
},
onAddIssuableFormCancel() {
onPendingFormCancel() {
this.isFormVisible = false;
this.store.setPendingRelatedIssues([]);
this.store.setPendingReferences([]);
this.inputValue = '';
},
fetchRelatedIssues() {
this.isFetching = true;
this.service.fetchRelatedIssues()
.then(res => res.json())
.then((issues) => {
const relatedIssueReferences = issues.map((issue) => {
const referenceKey = `${issue.namespace_full_path}/${issue.project_path}#${issue.iid}`;
this.store.setRelatedIssues(issues);
this.isFetching = false;
})
.catch(() => new Flash('An error occurred while fetching related issues.'));
},
onInput(newValue, caretPos) {
const rawReferences = newValue
.split(/\s/);
let touchedReference;
let iteratingPos = 0;
const untouchedRawReferences = rawReferences
.filter((reference) => {
let isTouched = false;
if (caretPos >= iteratingPos && caretPos <= (iteratingPos + reference.length)) {
touchedReference = reference;
isTouched = true;
}
// `+ SPACE_FACTOR` to factor in the missing space we split at earlier
iteratingPos = iteratingPos + reference.length + SPACE_FACTOR;
return !isTouched;
})
.filter(reference => reference.trim().length > 0);
this.store.addToIssueMap(referenceKey, issue);
this.store.setPendingReferences(
this.state.pendingReferences.concat(untouchedRawReferences),
);
this.inputValue = `${touchedReference}`;
},
onBlur(newValue) {
const rawReferences = newValue
.split(/\s+/)
.filter(reference => reference.trim().length > 0);
return referenceKey;
});
this.store.setRelatedIssues(relatedIssueReferences);
})
.catch(() => {
// eslint-disable-next-line no-new
new Flash('An error occurred while fetching related issues.');
});
this.store.setPendingReferences(
this.state.pendingReferences.concat(rawReferences),
);
this.inputValue = '';
},
},
created() {
eventHub.$on('relatedIssue-removeRequest', this.onRelatedIssueRemoveRequest);
eventHub.$on('showAddRelatedIssuesForm', this.onShowAddRelatedIssuesForm);
eventHub.$on('pendingIssuable-removeRequest', this.onAddIssuableFormIssuableRemoveRequest);
eventHub.$on('addIssuableFormSubmit', this.onAddIssuableFormSubmit);
eventHub.$on('addIssuableFormCancel', this.onAddIssuableFormCancel);
eventHub.$on('toggleAddRelatedIssuesForm', this.onToggleAddRelatedIssuesForm);
eventHub.$on('pendingIssuable-removeRequest', this.onPendingIssueRemoveRequest);
eventHub.$on('addIssuableFormSubmit', this.onPendingFormSubmit);
eventHub.$on('addIssuableFormCancel', this.onPendingFormCancel);
eventHub.$on('addIssuableFormInput', this.onInput);
eventHub.$on('addIssuableFormBlur', this.onBlur);
this.service = new RelatedIssuesService(this.endpoint);
this.fetchRelatedIssues();
......@@ -143,10 +179,12 @@ export default {
beforeDestroy() {
eventHub.$off('relatedIssue-removeRequest', this.onRelatedIssueRemoveRequest);
eventHub.$off('showAddRelatedIssuesForm', this.onShowAddRelatedIssuesForm);
eventHub.$off('pendingIssuable-removeRequest', this.onAddIssuableFormIssuableRemoveRequest);
eventHub.$off('addIssuableFormSubmit', this.onAddIssuableFormSubmit);
eventHub.$off('addIssuableFormCancel', this.onAddIssuableFormCancel);
eventHub.$off('toggleAddRelatedIssuesForm', this.onToggleAddRelatedIssuesForm);
eventHub.$off('pendingIssuable-removeRequest', this.onPendingIssueRemoveRequest);
eventHub.$off('addIssuableFormSubmit', this.onPendingFormSubmit);
eventHub.$off('addIssuableFormCancel', this.onPendingFormCancel);
eventHub.$off('addIssuableFormInput', this.onInput);
eventHub.$off('addIssuableFormBlur', this.onBlur);
},
};
</script>
......@@ -154,9 +192,11 @@ export default {
<template>
<related-issues-block
:help-path="helpPath"
:related-issues="computedRelatedIssues"
:isFetching="isFetching"
:related-issues="state.relatedIssues"
:can-add-related-issues="canAddRelatedIssues"
:pending-related-issues="computedPendingRelatedIssues"
:pending-references="state.pendingReferences"
:is-form-visible="isFormVisible"
:input-value="inputValue" />
:input-value="inputValue"
:auto-complete-sources="autoCompleteSources" />
</template>
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 {
this.relatedIssuesResource = Vue.resource(endpoint);
}
// eslint-disable-next-line class-methods-use-this
fetchIssueInfo(endpoint) {
return Vue.http.get(endpoint);
}
fetchRelatedIssues() {
return this.relatedIssuesResource.get();
}
......
import {
FETCH_SUCCESS_STATUS,
FETCH_ERROR_STATUS,
} from '../constants';
import { assembleDisplayIssuableReference } from '../../../lib/utils/issuable_reference_utils';
class RelatedIssuesStore {
constructor() {
this.state = {
// Stores issue objects that we can lookup by reference
issueMap: {},
// Stores references to the actual known related issues
// Stores issue objects of the known related issues
relatedIssues: [],
// Stores references to the "staging area" related issues that are planned to be added
pendingRelatedIssues: [],
// Stores references of the "staging area" related issues that are planned to be added
pendingReferences: [],
};
}
getIssueFromReference(reference, namespacePath, projectPath) {
const issue = this.state.issueMap[reference];
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,
};
setRelatedIssues(issues) {
this.state.relatedIssues = issues;
}
getIssuesFromReferences(references, namespacePath, projectPath) {
return references.map(reference =>
this.getIssueFromReference(reference, namespacePath, projectPath));
removeRelatedIssue(idToRemove) {
this.state.relatedIssues = this.state.relatedIssues.filter(issue => issue.id !== idToRemove);
}
addToIssueMap(reference, issue) {
this.state.issueMap = {
...this.state.issueMap,
[reference]: issue,
};
setPendingReferences(issues) {
this.state.pendingReferences = issues;
}
setRelatedIssues(value) {
this.state.relatedIssues = value;
removePendingRelatedIssue(indexToRemove) {
this.state.pendingReferences =
this.state.pendingReferences.filter((reference, index) => index !== indexToRemove);
}
setPendingRelatedIssues(issues) {
this.state.pendingRelatedIssues = issues;
}
}
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 @@
.add-issuable-form-input-token-list {
display: flex;
flex-wrap: wrap;
align-items: baseline;
list-style: none;
margin-bottom: 0;
padding-left: 0;
}
.add-issuable-form-token-list-item {
max-width: 100%;
margin-bottom: $gl-vert-padding;
margin-right: 1em;
}
......
......@@ -283,12 +283,14 @@ ul.related-merge-requests > li {
.issue-token {
display: inline-flex;
align-items: stretch;
max-width: 100%;
line-height: 1.75;
white-space: nowrap;
}
.issue-token-link {
display: inline-flex;
min-width: 0;
&:hover,
&:focus {
......@@ -299,28 +301,21 @@ ul.related-merge-requests > li {
.issue-token-reference {
display: flex;
align-items: baseline;
align-items: center;
margin-right: 1px;
padding-left: 0.5em;
padding-right: 0.5em;
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;
.issue-token-error & {
background-color: $red-50;
}
.issue-token:hover &,
.issue-token-link:focus > & {
background-color: issue-token-reference-hover-background-color;
background-color: $gray-normal;
color: $gl-link-hover-color;
text-decoration: none;
}
.issue-token-error:hover &,
.issue-token-link:focus > & {
background-color: $red-75;
}
}
@mixin issue-token-state-icon {
......@@ -339,60 +334,56 @@ ul.related-merge-requests > li {
}
.issue-token-title {
overflow: hidden;
display: flex;
align-items: baseline;
padding-left: 0.5em;
padding-right: 0.5em;
background-color: $gray-normal;
color: $gl-text-color-secondary;
transition: background $general-hover-transition-duration $general-hover-transition-curve;
.issue-token-error & {
background-color: $red-75;
}
.issue-token:hover &,
.issue-token-link:focus > & {
background-color: $border-gray-normal;
}
.issue-token-error:hover &,
.issue-token-link:focus > & {
background-color: $red-100;
}
& > .fa {
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 {
padding: 0 0.5em 0 0;
display: flex;
align-items: center;
padding: 0 0.5em;
background-color: $gray-normal;
border: 0;
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
color: $gl-text-color-secondary;
transition: background $general-hover-transition-duration $general-hover-transition-curve;
.issue-token-error & {
background-color: $red-75;
}
&:hover,
&:focus,
.issue-token:hover &,
.issue-token-link:focus + & {
background-color: $border-gray-normal;
}
.issue-token-error:hover &,
.issue-token-link:focus > & {
background-color: $red-100;
outline: none;
}
& > .fa {
font-size: 0.9em;
}
}
// When there isn't a title
.issue-token-remove-button-standalone {
padding-left: 0.5em;
}
.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 {
......@@ -11,7 +16,7 @@
margin-left: 0.5em;
}
.related-issues-add-related-issues-form {
.related-issues-add-related-issues-form-with-break {
border-bottom: 1px solid $border-color;
}
......@@ -24,6 +29,7 @@
}
.related-issues-token-list-item {
max-width: 100%;
margin-bottom: 0.5em;
margin-right: 1em;
}
......@@ -20,7 +20,9 @@ module SystemNoteHelper
'moved' => 'icon_arrow_circle_o_right',
'outdated' => 'icon_edit',
'approved' => 'icon_check',
'unapproved' => 'icon_fa_close'
'unapproved' => 'icon_fa_close',
'relate' => 'icon_anchor',
'unrelate' => 'icon_anchor_broken'
}.freeze
def icon_for_system_note(note)
......
......@@ -68,9 +68,8 @@
= edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago')
- if can?(current_user, :read_issue_link, @project)
.js-related-issues-root{ data: { endpoint: namespace_project_issue_links_path(@project.namespace, @project, @issue),
namespace: @project.namespace.path,
project: @project.path,
can_add_related_issues: "#{can?(current_user, :update_issue, @issue)}",
help_path: help_page_path('user/project/issues/related_issues') } }
......
<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):
- [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.
- **(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.
### Git and GitLab
......
# 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 will appear in a block below the issue description.
Related issues are a bi-directional relationship between any two issues
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
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.
When ready, click the green "Add related issues" button to submit.
![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
token. Because of the bi-directional relationship, it will no longer appear in
......@@ -24,4 +28,6 @@ either issue.
![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
[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';
import addIssuableForm from '~/issuable/related_issues/components/add_issuable_form.vue';
const issuable1 = {
id: '200',
reference: 'foo/bar#123',
displayReference: '#123',
title: 'some title',
path: '/foo/bar/issues/123',
state: 'opened',
};
const issuable2 = {
id: '201',
reference: 'foo/bar#124',
displayReference: '#124',
title: 'some other thing',
path: '/foo/bar/issues/124',
state: 'opened',
......@@ -26,6 +30,10 @@ describe('AddIssuableForm', () => {
afterEach(() => {
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();
}
});
......@@ -39,9 +47,9 @@ describe('AddIssuableForm', () => {
propsData: {
inputValue,
addButtonLabel,
pendingIssuables: [
issuable1,
issuable2,
pendingReferences: [
issuable1.reference,
issuable2.reference,
],
},
}).$mount();
......@@ -89,7 +97,6 @@ describe('AddIssuableForm', () => {
],
},
}).$mount(el);
spyOn(vm, 'onInputWrapperClick').and.callThrough();
});
afterEach(() => {
......@@ -99,15 +106,16 @@ describe('AddIssuableForm', () => {
eventHub.$off('addIssuableFormCancel', addIssuableFormCancelSpy);
});
it('when clicking somewhere on the input wrapper should focus the input', () => {
expect(vm.onInputWrapperClick).not.toHaveBeenCalled();
vm.$refs.issuableFormWrapper.click();
it('when clicking somewhere on the input wrapper should focus the input', (done) => {
vm.onInputWrapperClick();
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.$refs.issuableFormWrapper.classList.contains('focus')).toEqual(true);
expect(vm.onInputWrapperClick).toHaveBeenCalled();
expect(document.activeElement).toEqual(vm.$refs.input);
done();
});
});
});
......@@ -121,16 +129,20 @@ describe('AddIssuableForm', () => {
expect(addIssuableFormInputSpy).toHaveBeenCalledWith(newInputValue, newInputValue.length);
});
it('when blurring the input', () => {
it('when blurring the input', (done) => {
expect(addIssuableFormInputSpy).not.toHaveBeenCalled();
const newInputValue = 'filling in things';
vm.$refs.input.value = newInputValue;
vm.onBlur();
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.$refs.issuableFormWrapper.classList.contains('focus')).toEqual(false);
expect(addIssuableFormBlurSpy).toHaveBeenCalledWith(newInputValue);
done();
});
});
});
......
import Vue from 'vue';
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';
describe('IssueToken', () => {
const reference = 'foo/bar#123';
const idKey = '200';
const displayReference = 'foo/bar#123';
const title = 'some title';
let IssueToken;
let vm;
......@@ -23,13 +23,14 @@ describe('IssueToken', () => {
beforeEach(() => {
vm = new IssueToken({
propsData: {
reference,
idKey,
displayReference,
},
}).$mount();
});
it('shows reference', () => {
expect(vm.$el.textContent.trim()).toEqual(reference);
expect(vm.$el.textContent.trim()).toEqual(displayReference);
});
});
......@@ -37,14 +38,15 @@ describe('IssueToken', () => {
beforeEach(() => {
vm = new IssueToken({
propsData: {
reference,
idKey,
displayReference,
title,
},
}).$mount();
});
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);
});
});
......@@ -54,7 +56,8 @@ describe('IssueToken', () => {
beforeEach(() => {
vm = new IssueToken({
propsData: {
reference,
idKey,
displayReference,
title,
path,
},
......@@ -71,7 +74,8 @@ describe('IssueToken', () => {
beforeEach(() => {
vm = new IssueToken({
propsData: {
reference,
idKey,
displayReference,
state: 'opened',
},
}).$mount();
......@@ -86,7 +90,8 @@ describe('IssueToken', () => {
beforeEach(() => {
vm = new IssueToken({
propsData: {
reference,
idKey,
displayReference,
state: 'closed',
},
}).$mount();
......@@ -103,7 +108,8 @@ describe('IssueToken', () => {
beforeEach(() => {
vm = new IssueToken({
propsData: {
reference,
idKey,
displayReference,
title,
state,
},
......@@ -112,49 +118,18 @@ describe('IssueToken', () => {
it('shows reference, title, and 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);
});
});
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('`canRemove: false` (default)', () => {
beforeEach(() => {
vm = new IssueToken({
propsData: {
reference,
idKey,
displayReference,
},
}).$mount();
});
......@@ -168,7 +143,8 @@ describe('IssueToken', () => {
beforeEach(() => {
vm = new IssueToken({
propsData: {
reference,
idKey,
displayReference,
canRemove: true,
},
}).$mount();
......@@ -186,7 +162,8 @@ describe('IssueToken', () => {
beforeEach(() => {
vm = new IssueToken({
propsData: {
reference,
idKey,
displayReference,
},
}).$mount();
removeRequestSpy = jasmine.createSpy('spy');
......
......@@ -3,6 +3,7 @@ import eventHub from '~/issuable/related_issues/event_hub';
import relatedIssuesBlock from '~/issuable/related_issues/components/related_issues_block.vue';
const issuable1 = {
id: '200',
reference: 'foo/bar#123',
displayReference: '#123',
title: 'some title',
......@@ -11,6 +12,7 @@ const issuable1 = {
};
const issuable2 = {
id: '201',
reference: 'foo/bar#124',
displayReference: '#124',
title: 'some other thing',
......@@ -44,6 +46,24 @@ describe('RelatedIssuesBlock', () => {
it('add related issues form is hidden', () => {
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', () => {
......@@ -92,7 +112,7 @@ describe('RelatedIssuesBlock', () => {
});
describe('methods', () => {
let showAddRelatedIssuesFormSpy;
let toggleAddRelatedIssuesFormSpy;
beforeEach(() => {
vm = new RelatedIssuesBlock({
......@@ -102,18 +122,18 @@ describe('RelatedIssuesBlock', () => {
],
},
}).$mount();
showAddRelatedIssuesFormSpy = jasmine.createSpy('spy');
eventHub.$on('showAddRelatedIssuesForm', showAddRelatedIssuesFormSpy);
toggleAddRelatedIssuesFormSpy = jasmine.createSpy('spy');
eventHub.$on('toggleAddRelatedIssuesForm', toggleAddRelatedIssuesFormSpy);
});
afterEach(() => {
eventHub.$off('showAddRelatedIssuesForm', showAddRelatedIssuesFormSpy);
eventHub.$off('toggleAddRelatedIssuesForm', toggleAddRelatedIssuesFormSpy);
});
it('when expanding add related issue form', () => {
expect(showAddRelatedIssuesFormSpy).not.toHaveBeenCalled();
vm.showAddRelatedIssuesForm();
expect(showAddRelatedIssuesFormSpy).toHaveBeenCalled();
expect(toggleAddRelatedIssuesFormSpy).not.toHaveBeenCalled();
vm.toggleAddRelatedIssuesForm();
expect(toggleAddRelatedIssuesFormSpy).toHaveBeenCalled();
});
});
});
......@@ -17,34 +17,6 @@ describe('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', () => {
const interceptor = (request, next) => {
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';
const issuable1 = {
namespace_full_path: 'foo',
project_path: 'bar',
iid: '123',
id: '200',
reference: 'foo/bar#123',
title: 'issue1',
path: '/foo/bar/issues/123',
state: 'opened',
fetchStatus: FETCH_SUCCESS_STATUS,
destroy_relation_path: '/foo/bar/issues/123/related_issues/1',
};
const issuable1Reference = `${issuable1.namespace_full_path}/${issuable1.project_path}#${issuable1.iid}`;
const issuable2 = {
namespace_full_path: 'foo',
project_path: 'bar',
iid: '124',
id: '201',
reference: 'foo/bar#124',
title: 'issue1',
path: '/foo/bar/issues/124',
state: 'opened',
fetchStatus: FETCH_SUCCESS_STATUS,
destroy_relation_path: '/foo/bar/issues/124/related_issues/1',
};
const issuable2Reference = `${issuable2.namespace_full_path}/${issuable2.project_path}#${issuable2.iid}`;
describe('RelatedIssuesStore', () => {
let store;
......@@ -36,92 +25,69 @@ describe('RelatedIssuesStore', () => {
store = new RelatedIssuesStore();
});
describe('getIssueFromReference', () => {
it('get issue with issueMap populated', () => {
store.state.issueMap = {
[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,
});
describe('setRelatedIssues', () => {
it('defaults to empty array', () => {
expect(store.state.relatedIssues).toEqual([]);
});
it('get issue with issue missing in issueMap', () => {
expect(store.getIssueFromReference(issuable1Reference, 'foo', 'bar')).toEqual({
reference: issuable1Reference,
displayReference: issuable1Reference,
title: undefined,
path: undefined,
state: undefined,
fetchStatus: FETCH_ERROR_STATUS,
canRemove: undefined,
});
});
});
it('add issue', () => {
const relatedIssues = [issuable1];
store.setRelatedIssues(relatedIssues);
describe('getIssuesFromReferences', () => {
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,
}]);
expect(store.state.relatedIssues).toEqual(relatedIssues);
});
});
describe('addToIssueMap', () => {
it('defaults to empty object hash', () => {
expect(store.state.issueMap).toEqual({});
});
describe('removeRelatedIssue', () => {
it('remove issue', () => {
const relatedIssues = [issuable1];
store.state.relatedIssues = relatedIssues;
it('add issue', () => {
store.addToIssueMap(issuable1Reference, issuable1);
store.removeRelatedIssue(issuable1.id);
expect(store.state.issueMap).toEqual({
[issuable1Reference]: issuable1,
expect(store.state.relatedIssues).toEqual([]);
});
it('remove issue with multiple in store', () => {
const relatedIssues = [issuable1, issuable2];
store.state.relatedIssues = relatedIssues;
store.removeRelatedIssue(issuable1.id);
expect(store.state.relatedIssues).toEqual([issuable2]);
});
});
describe('setRelatedIssues', () => {
describe('setPendingReferences', () => {
it('defaults to empty array', () => {
expect(store.state.relatedIssues).toEqual([]);
expect(store.state.pendingReferences).toEqual([]);
});
it('add reference', () => {
const relatedIssues = ['#123'];
store.setRelatedIssues(relatedIssues);
const relatedIssues = [issuable1.reference];
store.setPendingReferences(relatedIssues);
expect(store.state.relatedIssues).toEqual(relatedIssues);
expect(store.state.pendingReferences).toEqual(relatedIssues);
});
});
describe('setPendingRelatedIssues', () => {
it('defaults to empty array', () => {
expect(store.state.pendingRelatedIssues).toEqual([]);
describe('removePendingRelatedIssue', () => {
it('remove issue', () => {
const relatedIssues = [issuable1.reference];
store.state.pendingReferences = relatedIssues;
store.removePendingRelatedIssue(0);
expect(store.state.pendingReferences).toEqual([]);
});
it('add reference', () => {
const relatedIssues = ['#123'];
store.setPendingRelatedIssues(relatedIssues);
it('remove issue with multiple in store', () => {
const relatedIssues = [issuable1.reference, issuable2.reference];
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