Commit 35997518 authored by Dmitriy Zaporozhets's avatar Dmitriy Zaporozhets

Merge remote-tracking branch 'ee-com/master' into ce-to-ee

Signed-off-by: default avatarDmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>
parents e1e618ac 6656d92a
import Vue from 'vue';
import RelatedIssuesRoot from './related_issues/components/related_issues_root.vue';
document.addEventListener('DOMContentLoaded', () => {
const relatedIssuesRootElement = document.querySelector('.js-related-issues-root');
if (relatedIssuesRootElement) {
// eslint-disable-next-line no-new
new Vue({
el: relatedIssuesRootElement,
components: {
relatedIssuesRoot: RelatedIssuesRoot,
},
render: createElement => createElement('related-issues-root', {
props: {
endpoint: relatedIssuesRootElement.dataset.endpoint,
canAddRelatedIssues: gl.utils.convertPermissionToBoolean(
relatedIssuesRootElement.dataset.canAddRelatedIssues,
),
helpPath: relatedIssuesRootElement.dataset.helpPath,
},
}),
});
}
});
<script>
import GfmAutoComplete from '~/gfm_auto_complete';
import eventHub from '../event_hub';
import IssueToken from './issue_token.vue';
export default {
name: 'AddIssuableForm',
props: {
inputValue: {
type: String,
required: true,
},
addButtonLabel: {
type: String,
required: true,
},
pendingReferences: {
type: Array,
required: false,
default: () => [],
},
autoCompleteSources: {
type: Object,
required: false,
default: () => ({}),
},
isSubmitting: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
isInputFocused: false,
isAutoCompleteOpen: false,
};
},
components: {
issueToken: IssueToken,
},
computed: {
isSubmitButtonDisabled() {
return this.pendingReferences.length === 0 || this.isSubmitting;
},
},
methods: {
onInput() {
const value = this.$refs.input.value;
eventHub.$emit('addIssuableFormInput', value, $(this.$refs.input).caret('pos'));
},
onFocus() {
this.isInputFocused = true;
},
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();
},
onFormSubmit() {
eventHub.$emit('addIssuableFormSubmit');
},
onFormCancel() {
eventHub.$emit('addIssuableFormCancel');
},
},
mounted() {
const $input = $(this.$refs.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);
},
};
</script>
<template>
<div>
<div
ref="issuableFormWrapper"
class="add-issuable-form-input-wrapper form-control"
:class="{ focus: isInputFocused }"
role="button"
@click="onInputWrapperClick">
<ul class="add-issuable-form-input-token-list">
<li
: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"
:id-key="index"
:display-reference="reference"
:can-remove="true" />
</li>
<li class="add-issuable-form-input-list-item">
<input
ref="input"
type="text"
class="js-add-issuable-form-input add-issuable-form-input"
:value="inputValue"
placeholder="Search issues..."
@input="onInput"
@focus="onFocus"
@blur="onBlur" />
</li>
</ul>
</div>
<div class="add-issuable-form-actions clearfix">
<button
ref="addButton"
type="button"
class="js-add-issuable-form-add-button btn btn-new pull-left"
@click="onFormSubmit"
:disabled="isSubmitButtonDisabled">
{{ addButtonLabel }}
</button>
<button
type="button"
class="btn btn-default pull-right"
@click="onFormCancel">
Cancel
</button>
</div>
</div>
</template>
<script>
import eventHub from '../event_hub';
export default {
name: 'IssueToken',
props: {
idKey: {
type: Number,
required: true,
},
displayReference: {
type: String,
required: true,
},
eventNamespace: {
type: String,
required: false,
default: '',
},
title: {
type: String,
required: false,
default: '',
},
path: {
type: String,
required: false,
default: '',
},
state: {
type: String,
required: false,
default: '',
},
canRemove: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
removeButtonLabel() {
return `Remove related issue ${this.displayReference}`;
},
hasState() {
return this.state && this.state.length > 0;
},
isOpen() {
return this.state === 'opened' || this.state === 'reopened';
},
isClosed() {
return this.state === 'closed';
},
hasTitle() {
return this.title.length > 0;
},
computedLinkElementType() {
return this.path.length > 0 ? 'a' : 'span';
},
computedPath() {
return this.path.length ? this.path : null;
},
},
methods: {
onRemoveRequest() {
let namespacePrefix = '';
if (this.eventNamespace && this.eventNamespace.length > 0) {
namespacePrefix = `${this.eventNamespace}-`;
}
eventHub.$emit(`${namespacePrefix}removeRequest`, this.idKey);
},
},
updated() {
const link = this.$refs.link;
const removeButton = this.$refs.removeButton;
if (link) {
$(link).tooltip('fixTitle');
}
if (removeButton) {
$(removeButton).tooltip('fixTitle');
}
},
};
</script>
<template>
<div class="issue-token">
<component
:is="this.computedLinkElementType"
ref="link"
class="issue-token-link"
:href="computedPath"
:title="title"
data-toggle="tooltip"
data-placement="top">
<span
ref="reference"
class="issue-token-reference">
<i
ref="stateIcon"
v-if="hasState"
class="fa"
:class="{
'issue-token-state-icon-open fa-circle-o': isOpen,
'issue-token-state-icon-closed fa-minus': isClosed,
}"
:aria-label="state">
</i>
{{ displayReference }}
</span>
<span
v-if="hasTitle"
ref="title"
class="js-issue-token-title issue-token-title"
:class="{ 'issue-token-title-standalone': !canRemove }">
<span class="issue-token-title-text">
{{ title }}
</span>
</span>
</component>
<button
ref="removeButton"
v-if="canRemove"
type="button"
class="js-issue-token-remove-button issue-token-remove-button"
:title="removeButtonLabel"
data-toggle="tooltip"
@click="onRemoveRequest">
<i
class="fa fa-times"
aria-hidden="true">
</i>
</button>
</div>
</template>
<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';
export default {
name: 'RelatedIssuesBlock',
props: {
isFetching: {
type: Boolean,
required: false,
default: false,
},
isSubmitting: {
type: Boolean,
required: false,
default: false,
},
relatedIssues: {
type: Array,
required: false,
default: () => [],
},
canAddRelatedIssues: {
type: Boolean,
required: false,
default: false,
},
isFormVisible: {
type: Boolean,
required: false,
default: false,
},
pendingReferences: {
type: Array,
required: false,
default: () => [],
},
inputValue: {
type: String,
required: false,
default: '',
},
helpPath: {
type: String,
required: false,
default: '',
},
autoCompleteSources: {
type: Object,
required: false,
default: () => ({}),
},
},
components: {
loadingIcon,
addIssuableForm,
issueToken,
},
computed: {
hasRelatedIssues() {
return this.relatedIssues.length > 0;
},
relatedIssueCount() {
return this.relatedIssues.length;
},
hasHelpPath() {
return this.helpPath.length > 0;
},
},
methods: {
toggleAddRelatedIssuesForm() {
eventHub.$emit('toggleAddRelatedIssuesForm');
},
},
updated() {
const addIssueButton = this.$refs.issueCountBadgeAddButton;
if (addIssueButton) {
$(addIssueButton).tooltip('fixTitle');
}
},
};
</script>
<template>
<div class="related-issues-block">
<div
class="panel-slim panel-default">
<div
class="panel-heading"
:class="{ 'panel-empty-heading': !this.hasRelatedIssues }">
<h3 class="panel-title related-issues-panel-title">
<div>
Related issues
<a
v-if="hasHelpPath"
:href="helpPath">
<i
class="related-issues-header-help-icon fa fa-question-circle"
aria-label="Read more about related issues">
</i>
</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>
</h3>
</div>
<div
v-if="isFormVisible"
class="js-add-related-issues-form-area panel-body"
:class="{
'related-issues-add-related-issues-form-with-break': hasRelatedIssues
}">
<add-issuable-form
:is-submitting="isSubmitting"
:input-value="inputValue"
:pending-references="pendingReferences"
add-button-label="Add related issues"
:auto-complete-sources="autoCompleteSources" />
</div>
<div
v-if="hasRelatedIssues"
class="related-issues-token-body panel-body">
<ul
class="related-issues-token-list">
<li
: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"
:id-key="issue.id"
:display-reference="issue.reference"
:title="issue.title"
:path="issue.path"
:state="issue.state"
:can-remove="true" />
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<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',
props: {
endpoint: {
type: String,
required: true,
},
canAddRelatedIssues: {
type: Boolean,
required: false,
default: false,
},
helpPath: {
type: String,
required: false,
default: '',
},
},
data() {
this.store = new RelatedIssuesStore();
return {
state: this.store.state,
isFetching: false,
isSubmitting: false,
isFormVisible: false,
inputValue: '',
};
},
components: {
relatedIssuesBlock: RelatedIssuesBlock,
},
computed: {
autoCompleteSources() {
return gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources;
},
},
methods: {
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((res) => {
if (res && res.status !== 404) {
// eslint-disable-next-line no-new
new Flash('An error occurred while removing related issues.');
}
});
} else {
// eslint-disable-next-line no-new
new Flash('We could not determine the path to remove the related issue');
}
},
onToggleAddRelatedIssuesForm() {
this.isFormVisible = !this.isFormVisible;
},
onPendingIssueRemoveRequest(indexToRemove) {
this.store.removePendingRelatedIssue(indexToRemove);
},
onPendingFormSubmit() {
if (this.state.pendingReferences.length > 0) {
this.isSubmitting = true;
this.service.addRelatedIssues(this.state.pendingReferences)
.then(res => res.json())
.then((data) => {
// We could potentially lose some pending issues in the interim here
this.store.setPendingReferences([]);
this.store.setRelatedIssues(data.issues);
this.isSubmitting = false;
// Close the form on submission
this.isFormVisible = false;
})
.catch((res) => {
this.isSubmitting = false;
// eslint-disable-next-line no-new
new Flash(res.data.message || 'An error occurred while submitting related issues.');
});
}
},
onPendingFormCancel() {
this.isFormVisible = false;
this.store.setPendingReferences([]);
this.inputValue = '';
},
fetchRelatedIssues() {
this.isFetching = true;
this.service.fetchRelatedIssues()
.then(res => res.json())
.then((issues) => {
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.setPendingReferences(
this.state.pendingReferences.concat(untouchedRawReferences),
);
this.inputValue = `${touchedReference}`;
},
onBlur(newValue) {
const rawReferences = newValue
.split(/\s+/)
.filter(reference => reference.trim().length > 0);
this.store.setPendingReferences(
this.state.pendingReferences.concat(rawReferences),
);
this.inputValue = '';
},
},
created() {
eventHub.$on('relatedIssue-removeRequest', this.onRelatedIssueRemoveRequest);
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();
},
beforeDestroy() {
eventHub.$off('relatedIssue-removeRequest', this.onRelatedIssueRemoveRequest);
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>
<template>
<related-issues-block
:help-path="helpPath"
:is-fetching="isFetching"
:is-submitting="isSubmitting"
:related-issues="state.relatedIssues"
:can-add-related-issues="canAddRelatedIssues"
:pending-references="state.pendingReferences"
:is-form-visible="isFormVisible"
:input-value="inputValue"
:auto-complete-sources="autoCompleteSources" />
</template>
import Vue from 'vue';
export default new Vue();
import Vue from 'vue';
import vueResource from 'vue-resource';
Vue.use(vueResource);
class RelatedIssuesService {
constructor(endpoint) {
this.relatedIssuesResource = Vue.resource(endpoint);
}
fetchRelatedIssues() {
return this.relatedIssuesResource.get();
}
addRelatedIssues(newIssueReferences) {
return this.relatedIssuesResource.save({}, {
issue_references: newIssueReferences,
});
}
// eslint-disable-next-line class-methods-use-this
removeRelatedIssue(endpoint) {
return Vue.http.delete(endpoint);
}
}
export default RelatedIssuesService;
class RelatedIssuesStore {
constructor() {
this.state = {
// Stores issue objects of the known related issues
relatedIssues: [],
// Stores references of the "staging area" related issues that are planned to be added
pendingReferences: [],
};
}
setRelatedIssues(issues) {
this.state.relatedIssues = issues;
}
removeRelatedIssue(idToRemove) {
this.state.relatedIssues = this.state.relatedIssues.filter(issue => issue.id !== idToRemove);
}
setPendingReferences(issues) {
this.state.pendingReferences = issues;
}
removePendingRelatedIssue(indexToRemove) {
this.state.pendingReferences =
this.state.pendingReferences.filter((reference, index) => index !== indexToRemove);
}
}
export default RelatedIssuesStore;
@mixin panel {
.panel-heading {
padding: $gl-vert-padding $gl-padding;
line-height: 36px;
.controls {
margin-top: -2px;
float: right;
}
.dropdown-menu-toggle {
line-height: 20px;
}
.badge {
margin-top: -2px;
margin-left: 5px;
}
&.split {
display: flex;
align-items: center;
}
.left {
flex: 1 1 auto;
}
.right {
flex: 0 0 auto;
text-align: right;
}
.panel {
margin-bottom: $gl-padding;
}
.panel-slim {
@extend .panel;
margin-bottom: $gl-vert-padding;
}
.panel-heading {
padding: $gl-vert-padding $gl-padding;
line-height: 36px;
.controls {
margin-top: -2px;
float: right;
}
.panel-body {
padding: $gl-padding;
.dropdown-menu-toggle {
line-height: 20px;
}
.form-actions {
margin: -$gl-padding;
margin-top: $gl-padding;
}
.badge {
margin-top: -2px;
margin-left: 5px;
}
.panel-title {
font-size: inherit;
line-height: inherit;
&.split {
display: flex;
align-items: center;
}
.left {
flex: 1 1 auto;
}
.right {
flex: 0 0 auto;
text-align: right;
}
}
.panel {
@include panel;
margin-bottom: $gl-padding;
.panel-empty-heading {
border-bottom: 0;
}
.panel-slim {
@extend .panel;
@include panel;
margin-bottom: $gl-vert-padding;
.panel-body {
padding: $gl-padding;
.form-actions {
margin: -$gl-padding;
margin-top: $gl-padding;
}
}
.panel-title {
font-size: inherit;
line-height: inherit;
}
......@@ -597,4 +597,3 @@ Convdev Index
$color-high-score: $green-400;
$color-average-score: $orange-400;
$color-low-score: $red-400;
@import "./issues/issue_count_badge";
[v-cloak] {
display: none;
}
......@@ -457,30 +459,6 @@
margin: 5px;
}
.board-issue-count-holder {
margin-top: -3px;
.btn {
line-height: 12px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
.board-issue-count {
padding-right: 10px;
padding-left: 10px;
line-height: 21px;
border-radius: $border-radius-base;
border: 1px solid $border-color;
&.has-btn {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-width: 1px 0 1px 1px;
}
}
.boards-title-holder {
padding: 25px 13px $gl-padding;
......
......@@ -757,3 +757,48 @@
}
}
}
.add-issuable-form-input-wrapper {
height: auto;
padding: $gl-vert-padding $gl-vert-padding 0 $gl-input-padding;
&.focus,
&.focus:hover {
border-color: $dropdown-input-focus-border;
box-shadow: 0 0 4px $search-input-focus-shadow-color;
}
}
.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: 5px;
}
.add-issuable-form-input-list-item {
flex: 1;
min-width: 200px;
margin-bottom: $gl-vert-padding;
}
.add-issuable-form-input {
width: 100%;
border: 0;
&:focus {
outline: none;
}
}
.add-issuable-form-actions {
margin-top: $gl-padding;
}
@import "./issues/issue_count_badge";
@import "./issues/related_issues";
.issues-list {
.issue {
padding: 10px 0 10px $gl-padding;
......@@ -280,3 +283,118 @@ 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;
color: $gl-text-color-secondary;
&[href] {
color: $gl-link-color;
}
&:hover,
&:focus {
outline: none;
text-decoration: none;
}
}
.issue-token-reference {
display: flex;
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:hover &,
.issue-token-link:focus > & {
background-color: $gray-normal;
color: $gl-link-hover-color;
text-decoration: none;
}
}
@mixin issue-token-state-icon {
margin-right: 0.35em;
font-size: 0.9em;
}
.issue-token-state-icon-open {
@include issue-token-state-icon;
color: $green-600;
}
.issue-token-state-icon-closed {
@include issue-token-state-icon;
color: $red-600;
}
.issue-token-title {
overflow: hidden;
display: flex;
align-items: baseline;
padding-left: 0.5em;
background-color: $gray-normal;
color: $gl-text-color-secondary;
transition: background $general-hover-transition-duration $general-hover-transition-curve;
.issue-token:hover &,
.issue-token-link:focus > & {
background-color: $border-gray-normal;
}
& > .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 {
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;
&:hover,
&:focus,
.issue-token:hover &,
.issue-token-link:focus + & {
background-color: $border-gray-normal;
outline: none;
}
& > .fa {
font-size: 0.9em;
}
}
.issue-count-badge {
display: inline-flex;
align-items: stretch;
height: 24px;
}
.issue-count-badge-count {
display: flex;
align-items: center;
padding-right: 10px;
padding-left: 10px;
border: 1px solid $border-color;
border-radius: $border-radius-base;
line-height: 1;
&.has-btn {
border-right: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
.issue-count-badge-add-button {
display: flex;
align-items: center;
border: 1px solid $border-color;
border-radius: 0 $border-radius-base $border-radius-base 0;
line-height: 1;
}
$token_spacing_bottom: 0.5em;
.related-issues-block {
margin-top: 3 * $gl-vert-padding;
}
.related-issues-panel-title {
display: flex;
justify-content: space-between;
}
.related-issues-header-help-icon {
margin-left: 0.25em;
color: $gl-text-color-secondary;
}
.related-issues-header-issue-count {
margin-left: 0.5em;
}
.related-issues-add-related-issues-form-with-break {
border-bottom: 1px solid $border-color;
}
.related-issues-token-body {
padding-bottom: calc(#{$gl-padding} - #{$token_spacing_bottom});
}
.related-issues-token-list {
display: flex;
flex-wrap: wrap;
margin-bottom: 0;
padding-left: 0;
list-style: none;
}
.related-issues-token-list-item {
max-width: 100%;
margin-bottom: $token_spacing_bottom;
margin-right: 5px;
}
......@@ -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)
......
......@@ -9,11 +9,11 @@
%span.has-tooltip{ ":title" => '(list.label ? list.label.description : "")',
data: { container: "body", placement: "bottom" } }
{{ list.title }}
.board-issue-count-holder.pull-right.clearfix{ "v-if" => 'list.type !== "blank"' }
%span.board-issue-count.pull-left{ ":class" => '{ "has-btn": list.type !== "closed" && !disabled }' }
.issue-count-badge.pull-right.clearfix{ "v-if" => 'list.type !== "blank"' }
%span.issue-count-badge-count{ ":class" => '{ "has-btn": list.type !== "closed" && !disabled }' }
{{ list.issuesSize }}
- if can?(current_user, :admin_issue, @project)
%button.btn.btn-small.btn-default.pull-right.has-tooltip.js-no-trigger-collapse{ type: "button",
%button.issue-count-badge-add-button.btn.btn-small.btn-default.has-tooltip.js-no-trigger-collapse{ type: "button",
"@click" => "showNewIssueForm",
"v-if" => 'list.type !== "closed"',
"aria-label" => "New issue",
......
......@@ -4,6 +4,9 @@
- page_card_attributes @issue.card_attributes
- can_update_issue = can?(current_user, :update_issue, @issue)
- can_report_spam = @issue.submittable_as_spam_by?(current_user)
- content_for :page_specific_javascripts do
= webpack_bundle_tag('common_vue')
= webpack_bundle_tag('issuable')
- if defined?(@issue) && @issue.confidential?
.confidential-issue-warning{ data: { spy: 'affix' } }
......@@ -71,6 +74,11 @@
= 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),
can_add_related_issues: "#{can?(current_user, :update_issue, @issue)}",
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) } }
// This element is filled in using JavaScript.
......
......@@ -5,6 +5,7 @@
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('common_vue')
= page_specific_javascript_bundle_tag('diff_notes')
= webpack_bundle_tag('issuable')
.merge-request{ 'data-url' => merge_request_path(@merge_request, format: :json), 'data-project-path' => project_path(@merge_request.project) }
= render "projects/merge_requests/show/mr_title"
......
<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>
......@@ -43,6 +43,7 @@ var config = {
group: './group.js',
groups: './groups/index.js',
groups_list: './groups_list.js',
issuable: './issuable/issuable_bundle.js',
issues: './issues/issues_bundle.js',
issue_show: './issue_show/index.js',
integrations: './integrations',
......@@ -161,6 +162,7 @@ var config = {
'environments_folder',
'filtered_search',
'groups',
'issuable',
'issue_show',
'merge_conflicts',
'notebook_viewer',
......
......@@ -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 Enterprise Edition Starter][ee] 9.4.
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.
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 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
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
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
......@@ -19,18 +19,18 @@ describe 'Issue Boards new issue', feature: true, js: true do
end
it 'displays new issue button' do
expect(first('.board')).to have_selector('.board-issue-count-holder .btn', count: 1)
expect(first('.board')).to have_selector('.issue-count-badge-add-button', count: 1)
end
it 'does not display new issue button in closed list' do
page.within('.board:nth-child(3)') do
expect(page).not_to have_selector('.board-issue-count-holder .btn')
expect(page).not_to have_selector('.issue-count-badge-add-button')
end
end
it 'shows form when clicking button' do
page.within(first('.board')) do
find('.board-issue-count-holder .btn').click
find('.issue-count-badge-add-button').click
expect(page).to have_selector('.board-new-issue-form')
end
......@@ -38,7 +38,7 @@ describe 'Issue Boards new issue', feature: true, js: true do
it 'hides form when clicking cancel' do
page.within(first('.board')) do
find('.board-issue-count-holder .btn').click
find('.issue-count-badge-add-button').click
expect(page).to have_selector('.board-new-issue-form')
......@@ -50,7 +50,7 @@ describe 'Issue Boards new issue', feature: true, js: true do
it 'creates new issue' do
page.within(first('.board')) do
find('.board-issue-count-holder .btn').click
find('.issue-count-badge-add-button').click
end
page.within(first('.board-new-issue-form')) do
......@@ -60,14 +60,14 @@ describe 'Issue Boards new issue', feature: true, js: true do
wait_for_requests
page.within(first('.board .board-issue-count')) do
page.within(first('.board .issue-count-badge-count')) do
expect(page).to have_content('1')
end
end
it 'shows sidebar when creating new issue' do
page.within(first('.board')) do
find('.board-issue-count-holder .btn').click
find('.issue-count-badge-add-button').click
end
page.within(first('.board-new-issue-form')) do
......@@ -88,7 +88,7 @@ describe 'Issue Boards new issue', feature: true, js: true do
end
it 'does not display new issue button' do
expect(page).to have_selector('.board-issue-count-holder .btn', count: 0)
expect(page).to have_selector('.issue-count-badge-add-button', count: 0)
end
end
end
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
import Vue from 'vue';
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',
};
describe('AddIssuableForm', () => {
let AddIssuableForm;
let vm;
beforeEach(() => {
AddIssuableForm = Vue.extend(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();
}
});
describe('with data', () => {
const inputValue = 'foo #123';
const addButtonLabel = 'Add issuable';
beforeEach(() => {
vm = new AddIssuableForm({
propsData: {
inputValue,
addButtonLabel,
pendingReferences: [
issuable1.reference,
issuable2.reference,
],
},
}).$mount();
});
it('should put button label in place', () => {
expect(vm.$refs.addButton.textContent.trim()).toEqual(addButtonLabel);
});
it('should put input value in place', () => {
expect(vm.$refs.input.value).toEqual(inputValue);
});
it('should render pending issuables items', () => {
expect(vm.$el.querySelectorAll('.js-add-issuable-form-token-list-item').length).toEqual(2);
});
});
describe('methods', () => {
let addIssuableFormInputSpy;
let addIssuableFormBlurSpy;
let addIssuableFormSubmitSpy;
let addIssuableFormCancelSpy;
beforeEach(() => {
addIssuableFormInputSpy = jasmine.createSpy('spy');
addIssuableFormBlurSpy = jasmine.createSpy('spy');
addIssuableFormSubmitSpy = jasmine.createSpy('spy');
addIssuableFormCancelSpy = jasmine.createSpy('spy');
eventHub.$on('addIssuableFormInput', addIssuableFormInputSpy);
eventHub.$on('addIssuableFormBlur', addIssuableFormBlurSpy);
eventHub.$on('addIssuableFormSubmit', addIssuableFormSubmitSpy);
eventHub.$on('addIssuableFormCancel', addIssuableFormCancelSpy);
const el = document.createElement('div');
// We need to append to body to get focus tests working
document.body.appendChild(el);
vm = new AddIssuableForm({
propsData: {
inputValue: '',
addButtonLabel: 'Add issuable',
pendingIssuables: [
issuable1,
],
},
}).$mount(el);
});
afterEach(() => {
eventHub.$off('addIssuableFormInput', addIssuableFormInputSpy);
eventHub.$off('addIssuableFormBlur', addIssuableFormBlurSpy);
eventHub.$off('addIssuableFormSubmit', addIssuableFormSubmitSpy);
eventHub.$off('addIssuableFormCancel', addIssuableFormCancelSpy);
});
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(document.activeElement).toEqual(vm.$refs.input);
done();
});
});
});
it('when filling in the input', () => {
expect(addIssuableFormInputSpy).not.toHaveBeenCalled();
const newInputValue = 'filling in things';
vm.$refs.input.value = newInputValue;
vm.onInput();
expect(addIssuableFormInputSpy).toHaveBeenCalledWith(newInputValue, newInputValue.length);
});
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();
});
});
});
it('when submitting pending issues', () => {
expect(addIssuableFormSubmitSpy).not.toHaveBeenCalled();
vm.onFormSubmit();
expect(addIssuableFormSubmitSpy).toHaveBeenCalled();
});
it('when canceling form to collapse', () => {
expect(addIssuableFormCancelSpy).not.toHaveBeenCalled();
vm.onFormCancel();
expect(addIssuableFormCancelSpy).toHaveBeenCalled();
});
});
});
import Vue from 'vue';
import eventHub from '~/issuable/related_issues/event_hub';
import issueToken from '~/issuable/related_issues/components/issue_token.vue';
describe('IssueToken', () => {
const idKey = 200;
const displayReference = 'foo/bar#123';
const title = 'some title';
let IssueToken;
let vm;
beforeEach(() => {
IssueToken = Vue.extend(issueToken);
});
afterEach(() => {
if (vm) {
vm.$destroy();
}
});
describe('with reference supplied', () => {
beforeEach(() => {
vm = new IssueToken({
propsData: {
idKey,
displayReference,
},
}).$mount();
});
it('shows reference', () => {
expect(vm.$el.textContent.trim()).toEqual(displayReference);
});
it('does not link without path specified', () => {
expect(vm.$refs.link.tagName.toLowerCase()).toEqual('span');
expect(vm.$refs.link.getAttribute('href')).toBeNull();
});
});
describe('with reference and title supplied', () => {
beforeEach(() => {
vm = new IssueToken({
propsData: {
idKey,
displayReference,
title,
},
}).$mount();
});
it('shows reference and title', () => {
expect(vm.$refs.reference.textContent.trim()).toEqual(displayReference);
expect(vm.$refs.title.textContent.trim()).toEqual(title);
});
});
describe('with path supplied', () => {
const path = '/foo/bar/issues/123';
beforeEach(() => {
vm = new IssueToken({
propsData: {
idKey,
displayReference,
title,
path,
},
}).$mount();
});
it('links reference and title', () => {
expect(vm.$refs.link.getAttribute('href')).toEqual(path);
});
});
describe('with state supplied', () => {
describe('`state: \'opened\'`', () => {
beforeEach(() => {
vm = new IssueToken({
propsData: {
idKey,
displayReference,
state: 'opened',
},
}).$mount();
});
it('shows green circle icon', () => {
expect(vm.$el.querySelector('.issue-token-state-icon-open.fa.fa-circle-o')).toBeDefined();
});
});
describe('`state: \'reopened\'`', () => {
beforeEach(() => {
vm = new IssueToken({
propsData: {
idKey,
displayReference,
state: 'reopened',
},
}).$mount();
});
it('shows green circle icon', () => {
expect(vm.$el.querySelector('.issue-token-state-icon-open.fa.fa-circle-o')).toBeDefined();
});
});
describe('`state: \'closed\'`', () => {
beforeEach(() => {
vm = new IssueToken({
propsData: {
idKey,
displayReference,
state: 'closed',
},
}).$mount();
});
it('shows red minus icon', () => {
expect(vm.$el.querySelector('.issue-token-state-icon-closed.fa.fa-minus')).toBeDefined();
});
});
});
describe('with reference, title, state', () => {
const state = 'opened';
beforeEach(() => {
vm = new IssueToken({
propsData: {
idKey,
displayReference,
title,
state,
},
}).$mount();
});
it('shows reference, title, and state', () => {
expect(vm.$refs.stateIcon.getAttribute('aria-label')).toEqual(state);
expect(vm.$refs.reference.textContent.trim()).toEqual(displayReference);
expect(vm.$refs.title.textContent.trim()).toEqual(title);
});
});
describe('with canRemove', () => {
describe('`canRemove: false` (default)', () => {
beforeEach(() => {
vm = new IssueToken({
propsData: {
idKey,
displayReference,
},
}).$mount();
});
it('does not have remove button', () => {
expect(vm.$el.querySelector('.issue-token-remove-button')).toBeNull();
});
});
describe('`canRemove: true`', () => {
beforeEach(() => {
vm = new IssueToken({
propsData: {
idKey,
displayReference,
canRemove: true,
},
}).$mount();
});
it('has remove button', () => {
expect(vm.$el.querySelector('.issue-token-remove-button')).toBeDefined();
});
});
});
describe('methods', () => {
let removeRequestSpy;
beforeEach(() => {
vm = new IssueToken({
propsData: {
idKey,
displayReference,
},
}).$mount();
removeRequestSpy = jasmine.createSpy('spy');
eventHub.$on('removeRequest', removeRequestSpy);
});
afterEach(() => {
eventHub.$off('removeRequest', removeRequestSpy);
});
it('when getting checked', () => {
expect(removeRequestSpy).not.toHaveBeenCalled();
vm.onRemoveRequest();
expect(removeRequestSpy).toHaveBeenCalled();
});
});
});
import Vue from 'vue';
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',
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',
};
describe('RelatedIssuesBlock', () => {
let RelatedIssuesBlock;
let vm;
beforeEach(() => {
RelatedIssuesBlock = Vue.extend(relatedIssuesBlock);
});
afterEach(() => {
if (vm) {
vm.$destroy();
}
});
describe('with defaults', () => {
beforeEach(() => {
vm = new RelatedIssuesBlock().$mount();
});
it('unable to add new related issues', () => {
expect(vm.$refs.issueCountBadgeAddButton).toBeUndefined();
});
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', () => {
beforeEach(() => {
vm = new RelatedIssuesBlock({
propsData: {
canAddRelatedIssues: true,
},
}).$mount();
});
it('can add new related issues', () => {
expect(vm.$refs.issueCountBadgeAddButton).toBeDefined();
});
});
describe('with isFormVisible=true', () => {
beforeEach(() => {
vm = new RelatedIssuesBlock({
propsData: {
isFormVisible: true,
},
}).$mount();
});
it('shows add related issues form', () => {
expect(vm.$el.querySelector('.js-add-related-issues-form-area')).toBeDefined();
});
});
describe('with relatedIssues', () => {
beforeEach(() => {
vm = new RelatedIssuesBlock({
propsData: {
relatedIssues: [
issuable1,
issuable2,
],
},
}).$mount();
});
it('should render issue tokens items', () => {
expect(vm.$el.querySelectorAll('.js-related-issues-token-list-item').length).toEqual(2);
});
});
describe('methods', () => {
let toggleAddRelatedIssuesFormSpy;
beforeEach(() => {
vm = new RelatedIssuesBlock({
propsData: {
relatedIssues: [
issuable1,
],
},
}).$mount();
toggleAddRelatedIssuesFormSpy = jasmine.createSpy('spy');
eventHub.$on('toggleAddRelatedIssuesForm', toggleAddRelatedIssuesFormSpy);
});
afterEach(() => {
eventHub.$off('toggleAddRelatedIssuesForm', toggleAddRelatedIssuesFormSpy);
});
it('when expanding add related issue form', () => {
expect(toggleAddRelatedIssuesFormSpy).not.toHaveBeenCalled();
vm.toggleAddRelatedIssuesForm();
expect(toggleAddRelatedIssuesFormSpy).toHaveBeenCalled();
});
});
});
import Vue from 'vue';
import relatedIssuesRoot from '~/issuable/related_issues/components/related_issues_root.vue';
const defaultProps = {
endpoint: '/foo/bar/issues/1/related_issues',
currentNamespacePath: 'foo',
currentProjectPath: 'bar',
};
const issuable1 = {
id: '200',
reference: 'foo/bar#123',
title: 'issue1',
path: '/foo/bar/issues/123',
state: 'opened',
destroy_relation_path: '/foo/bar/issues/123/related_issues/1',
};
const issuable2 = {
id: '201',
reference: 'foo/bar#124',
title: 'issue1',
path: '/foo/bar/issues/124',
state: 'opened',
destroy_relation_path: '/foo/bar/issues/124/related_issues/1',
};
describe('RelatedIssuesRoot', () => {
let RelatedIssuesRoot;
let vm;
beforeEach(() => {
RelatedIssuesRoot = Vue.extend(relatedIssuesRoot);
});
afterEach(() => {
if (vm) {
vm.$destroy();
}
});
describe('methods', () => {
describe('onRelatedIssueRemoveRequest', () => {
beforeEach(() => {
vm = new RelatedIssuesRoot({
propsData: defaultProps,
}).$mount();
vm.store.setRelatedIssues([issuable1]);
});
it('remove related issue and succeeds', (done) => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify({
issues: [],
}), {
status: 200,
}));
};
Vue.http.interceptors.push(interceptor);
vm.onRelatedIssueRemoveRequest(issuable1.id);
setTimeout(() => {
expect(vm.state.relatedIssues).toEqual([]);
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
done();
});
});
it('remove related issue, fails, and restores to related issues', (done) => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify({}), {
status: 422,
}));
};
Vue.http.interceptors.push(interceptor);
vm.onRelatedIssueRemoveRequest(issuable1.id);
setTimeout(() => {
expect(vm.state.relatedIssues.length).toEqual(1);
expect(vm.state.relatedIssues[0].id).toEqual(issuable1.id);
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
done();
});
});
});
describe('onToggleAddRelatedIssuesForm', () => {
beforeEach(() => {
vm = new RelatedIssuesRoot({
propsData: defaultProps,
}).$mount();
});
it('toggle related issues form to visible', () => {
vm.onToggleAddRelatedIssuesForm();
expect(vm.isFormVisible).toEqual(true);
});
it('show add related issues form to hidden', () => {
vm.isFormVisible = true;
vm.onToggleAddRelatedIssuesForm();
expect(vm.isFormVisible).toEqual(false);
});
});
describe('onPendingIssueRemoveRequest', () => {
beforeEach(() => {
vm = new RelatedIssuesRoot({
propsData: defaultProps,
}).$mount();
vm.store.setPendingReferences([issuable1.reference]);
});
it('remove pending related issue', () => {
expect(vm.state.pendingReferences.length).toEqual(1);
vm.onPendingIssueRemoveRequest(0);
expect(vm.state.pendingReferences.length).toEqual(0);
});
});
describe('onPendingFormSubmit', () => {
beforeEach(() => {
vm = new RelatedIssuesRoot({
propsData: defaultProps,
}).$mount();
});
it('submit zero pending issue as related issue', (done) => {
vm.store.setPendingReferences([]);
vm.onPendingFormSubmit();
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.state.pendingReferences.length).toEqual(0);
expect(vm.state.relatedIssues.length).toEqual(0);
done();
});
});
});
it('submit pending issue as related issue', (done) => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify({
issues: [issuable1],
result: {
message: 'something was successfully related',
status: 'success',
},
}), {
status: 200,
}));
};
Vue.http.interceptors.push(interceptor);
vm.store.setPendingReferences([issuable1.reference]);
vm.onPendingFormSubmit();
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.state.pendingReferences.length).toEqual(0);
expect(vm.state.relatedIssues.length).toEqual(1);
expect(vm.state.relatedIssues[0].id).toEqual(issuable1.id);
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
done();
});
});
});
it('submit multiple pending issues as related issues', (done) => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify({
issues: [issuable1, issuable2],
result: {
message: 'something was successfully related',
status: 'success',
},
}), {
status: 200,
}));
};
Vue.http.interceptors.push(interceptor);
vm.store.setPendingReferences([issuable1.reference, issuable2.reference]);
vm.onPendingFormSubmit();
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.state.pendingReferences.length).toEqual(0);
expect(vm.state.relatedIssues.length).toEqual(2);
expect(vm.state.relatedIssues[0].id).toEqual(issuable1.id);
expect(vm.state.relatedIssues[1].id).toEqual(issuable2.id);
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
done();
});
});
});
});
describe('onPendingFormCancel', () => {
beforeEach(() => {
vm = new RelatedIssuesRoot({
propsData: defaultProps,
}).$mount();
vm.isFormVisible = true;
vm.inputValue = 'foo';
});
it('when canceling and hiding add issuable form', () => {
vm.onPendingFormCancel();
expect(vm.isFormVisible).toEqual(false);
expect(vm.inputValue).toEqual('');
expect(vm.state.pendingReferences.length).toEqual(0);
});
});
describe('fetchRelatedIssues', () => {
beforeEach(() => {
vm = new RelatedIssuesRoot({
propsData: defaultProps,
}).$mount();
});
describe('when the network has not responded yet', () => {
it('should be fetching', (done) => {
vm.fetchRelatedIssues();
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.isFetching).toEqual(true);
done();
});
});
});
});
describe('when the network responds', () => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify([issuable1, issuable2]), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('should be done fetching', (done) => {
vm.fetchRelatedIssues();
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.isFetching).toEqual(false);
done();
});
});
});
it('should fetch related issues', (done) => {
vm.fetchRelatedIssues();
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.state.relatedIssues.length).toEqual(2);
expect(vm.state.relatedIssues[0].id).toEqual(issuable1.id);
expect(vm.state.relatedIssues[1].id).toEqual(issuable2.id);
done();
});
});
});
});
});
describe('onInput', () => {
beforeEach(() => {
vm = new RelatedIssuesRoot({
propsData: defaultProps,
}).$mount();
});
it('fill in issue number reference and adds to pending related issues', () => {
const input = '#123 ';
vm.onInput(input, input.length);
expect(vm.state.pendingReferences.length).toEqual(1);
expect(vm.state.pendingReferences[0]).toEqual('#123');
});
it('fill in with full reference', () => {
const input = 'asdf/qwer#444 ';
vm.onInput(input, input.length);
expect(vm.state.pendingReferences.length).toEqual(1);
expect(vm.state.pendingReferences[0]).toEqual('asdf/qwer#444');
});
it('fill in with issue link', () => {
const link = 'http://localhost:3000/foo/bar/issues/111';
const input = `${link} `;
vm.onInput(input, input.length);
expect(vm.state.pendingReferences.length).toEqual(1);
expect(vm.state.pendingReferences[0]).toEqual(link);
});
it('fill in with multiple references', () => {
const input = 'asdf/qwer#444 #12 ';
vm.onInput(input, input.length);
expect(vm.state.pendingReferences.length).toEqual(2);
expect(vm.state.pendingReferences[0]).toEqual('asdf/qwer#444');
expect(vm.state.pendingReferences[1]).toEqual('#12');
});
it('fill in with some invalid things', () => {
const input = 'something random ';
vm.onInput(input, input.length);
expect(vm.state.pendingReferences.length).toEqual(2);
expect(vm.state.pendingReferences[0]).toEqual('something');
expect(vm.state.pendingReferences[1]).toEqual('random');
});
it('fill in invalid and some legit references', () => {
const input = 'something random #123 ';
vm.onInput(input, input.length);
expect(vm.state.pendingReferences.length).toEqual(3);
expect(vm.state.pendingReferences[0]).toEqual('something');
expect(vm.state.pendingReferences[1]).toEqual('random');
expect(vm.state.pendingReferences[2]).toEqual('#123');
});
it('keep reference piece in input while we are touching it', () => {
const input = 'a #123 b ';
vm.onInput(input, 3);
expect(vm.state.pendingReferences.length).toEqual(2);
expect(vm.state.pendingReferences[0]).toEqual('a');
expect(vm.state.pendingReferences[1]).toEqual('b');
});
});
describe('onBlur', () => {
beforeEach(() => {
vm = new RelatedIssuesRoot({
propsData: defaultProps,
}).$mount();
});
it('add valid reference to pending when blurring', () => {
const input = '#123';
vm.onBlur(input);
expect(vm.state.pendingReferences.length).toEqual(1);
expect(vm.state.pendingReferences[0]).toEqual('#123');
});
it('add any valid references to pending when blurring', () => {
const input = 'asdf #123';
vm.onBlur(input);
expect(vm.state.pendingReferences.length).toEqual(2);
expect(vm.state.pendingReferences[0]).toEqual('asdf');
expect(vm.state.pendingReferences[1]).toEqual('#123');
});
});
});
});
import _ from 'underscore';
import Vue from 'vue';
import RelatedIssuesService from '~/issuable/related_issues/services/related_issues_service';
const issuable1 = {
reference: 'foo/bar#123',
title: 'some title',
path: '/foo/bar/issues/123',
state: 'opened',
destroy_relation_path: '/foo/bar/issues/123/related_issues/1',
};
describe('RelatedIssuesService', () => {
let service;
beforeEach(() => {
service = new RelatedIssuesService('');
});
describe('fetchRelatedIssues', () => {
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 related issues', (done) => {
service.fetchRelatedIssues()
.then(res => res.json())
.then((relatedIssues) => {
expect(relatedIssues).toEqual([issuable1]);
done();
})
.catch((err) => {
done.fail(`Failed to fetch related issues:\n${err}`);
});
});
});
describe('addRelatedIssues', () => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify({
message: `${issuable1.reference} was successfully related`,
status: 'success',
}), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('add related issues', (done) => {
service.addRelatedIssues([issuable1.reference])
.then(res => res.json())
.then((resData) => {
expect(resData.status).toEqual('success');
done();
})
.catch((err) => {
done.fail(`Failed to add related issues:\n${err}`);
});
});
});
describe('removeRelatedIssue', () => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify({
message: 'Relation was removed',
status: 'success',
}), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('remove related issue', (done) => {
service.removeRelatedIssue('...')
.then(res => res.json())
.then((resData) => {
expect(resData.status).toEqual('success');
done();
})
.catch((err) => {
done.fail(`Failed to fetch issue:\n${err}`);
});
});
});
});
import RelatedIssuesStore from '~/issuable/related_issues/stores/related_issues_store';
const issuable1 = {
id: '200',
reference: 'foo/bar#123',
title: 'issue1',
path: '/foo/bar/issues/123',
state: 'opened',
destroy_relation_path: '/foo/bar/issues/123/related_issues/1',
};
const issuable2 = {
id: '201',
reference: 'foo/bar#124',
title: 'issue1',
path: '/foo/bar/issues/124',
state: 'opened',
destroy_relation_path: '/foo/bar/issues/124/related_issues/1',
};
describe('RelatedIssuesStore', () => {
let store;
beforeEach(() => {
store = new RelatedIssuesStore();
});
describe('setRelatedIssues', () => {
it('defaults to empty array', () => {
expect(store.state.relatedIssues).toEqual([]);
});
it('add issue', () => {
const relatedIssues = [issuable1];
store.setRelatedIssues(relatedIssues);
expect(store.state.relatedIssues).toEqual(relatedIssues);
});
});
describe('removeRelatedIssue', () => {
it('remove issue', () => {
const relatedIssues = [issuable1];
store.state.relatedIssues = relatedIssues;
store.removeRelatedIssue(issuable1.id);
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('setPendingReferences', () => {
it('defaults to empty array', () => {
expect(store.state.pendingReferences).toEqual([]);
});
it('add reference', () => {
const relatedIssues = [issuable1.reference];
store.setPendingReferences(relatedIssues);
expect(store.state.pendingReferences).toEqual(relatedIssues);
});
});
describe('removePendingRelatedIssue', () => {
it('remove issue', () => {
const relatedIssues = [issuable1.reference];
store.state.pendingReferences = relatedIssues;
store.removePendingRelatedIssue(0);
expect(store.state.pendingReferences).toEqual([]);
});
it('remove issue with multiple in store', () => {
const relatedIssues = [issuable1.reference, issuable2.reference];
store.state.pendingReferences = relatedIssues;
store.removePendingRelatedIssue(0);
expect(store.state.pendingReferences).toEqual([issuable2.reference]);
});
});
});
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