Commit a3218bf4 authored by Phil Hughes's avatar Phil Hughes

Merge branch '10795-add-epic-tree' into 'master'

Show tree within Epic containing child Epics and Issues

Closes #10795

See merge request gitlab-org/gitlab-ee!10999
parents 7731492e 17430137
<script>
import { GlButton } from '@gitlab/ui';
import Icon from './icon.vue';
export default {
components: {
Icon,
GlButton,
},
props: {
size: {
type: String,
required: false,
default: '',
},
primaryButtonClass: {
type: String,
required: false,
default: '',
},
dropdownClass: {
type: String,
required: false,
default: '',
},
actions: {
type: Array,
required: true,
},
defaultAction: {
type: Number,
required: true,
},
},
data() {
return {
selectedAction: this.defaultAction,
};
},
computed: {
selectedActionTitle() {
return this.actions[this.selectedAction].title;
},
buttonSizeClass() {
return `btn-${this.size}`;
},
},
methods: {
handlePrimaryActionClick() {
this.$emit('onActionClick', this.actions[this.selectedAction]);
},
handleActionClick(selectedAction) {
this.selectedAction = selectedAction;
this.$emit('onActionSelect', selectedAction);
},
},
};
</script>
<template>
<div class="btn-group droplab-dropdown comment-type-dropdown">
<gl-button :class="primaryButtonClass" :size="size" @click.prevent="handlePrimaryActionClick">
{{ selectedActionTitle }}
</gl-button>
<button
:class="buttonSizeClass"
type="button"
class="btn dropdown-toggle pl-2 pr-2"
data-display="static"
data-toggle="dropdown"
>
<icon name="arrow-down" aria-label="toggle dropdown" />
</button>
<ul :class="dropdownClass" class="dropdown-menu dropdown-open-top">
<template v-for="(action, index) in actions">
<li :key="index" :class="{ 'droplab-item-selected': selectedAction === index }">
<gl-button class="btn-transparent" @click.prevent="handleActionClick(index)">
<i aria-hidden="true" class="fa fa-check icon"> </i>
<div class="description">
<strong>{{ action.title }}</strong>
<p>{{ action.description }}</p>
</div>
</gl-button>
</li>
<li v-if="index === 0" :key="`${index}-separator`" class="divider droplab-item-ignore"></li>
</template>
</ul>
</div>
</template>
...@@ -62,6 +62,15 @@ export default { ...@@ -62,6 +62,15 @@ export default {
assigneeName: assignee.name, assigneeName: assignee.name,
}); });
}, },
// This method is for backward compat
// since Graph query would return camelCase
// props while Rails would return snake_case
webUrl(assignee) {
return assignee.web_url || assignee.webUrl;
},
avatarUrl(assignee) {
return assignee.avatar_url || assignee.avatarUrl;
},
}, },
}; };
</script> </script>
...@@ -70,9 +79,9 @@ export default { ...@@ -70,9 +79,9 @@ export default {
<user-avatar-link <user-avatar-link
v-for="assignee in assigneesToShow" v-for="assignee in assigneesToShow"
:key="assignee.id" :key="assignee.id"
:link-href="assignee.web_url" :link-href="webUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)" :img-alt="avatarUrlTitle(assignee)"
:img-src="assignee.avatar_url" :img-src="avatarUrl(assignee)"
:img-size="24" :img-size="24"
class="js-no-trigger" class="js-no-trigger"
tooltip-placement="bottom" tooltip-placement="bottom"
......
...@@ -19,10 +19,14 @@ export default { ...@@ -19,10 +19,14 @@ export default {
}, },
computed: { computed: {
milestoneDue() { milestoneDue() {
return this.milestone.due_date ? parsePikadayDate(this.milestone.due_date) : null; const dueDate = this.milestone.due_date || this.milestone.dueDate;
return dueDate ? parsePikadayDate(dueDate) : null;
}, },
milestoneStart() { milestoneStart() {
return this.milestone.start_date ? parsePikadayDate(this.milestone.start_date) : null; const startDate = this.milestone.start_date || this.milestone.startDate;
return startDate ? parsePikadayDate(startDate) : null;
}, },
isMilestoneStarted() { isMilestoneStarted() {
if (!this.milestoneStart) { if (!this.milestoneStart) {
......
...@@ -6,6 +6,7 @@ export default { ...@@ -6,6 +6,7 @@ export default {
geoNodesPath: '/api/:version/geo_nodes', geoNodesPath: '/api/:version/geo_nodes',
ldapGroupsPath: '/api/:version/ldap/:provider/groups.json', ldapGroupsPath: '/api/:version/ldap/:provider/groups.json',
subscriptionPath: '/api/:version/namespaces/:id/gitlab_subscription', subscriptionPath: '/api/:version/namespaces/:id/gitlab_subscription',
childEpicPath: '/api/:version/groups/:id/epics/:epic_iid/epics',
approverUsers(search, options, callback = () => {}) { approverUsers(search, options, callback = () => {}) {
const url = Api.buildUrl('/autocomplete/users.json'); const url = Api.buildUrl('/autocomplete/users.json');
...@@ -48,4 +49,14 @@ export default { ...@@ -48,4 +49,14 @@ export default {
return data; return data;
}); });
}, },
createChildEpic({ groupId, parentEpicIid, title }) {
const url = Api.buildUrl(this.childEpicPath)
.replace(':id', groupId)
.replace(':epic_iid', parentEpicIid);
return axios.post(url, {
title,
});
},
}; };
...@@ -32,6 +32,9 @@ export default { ...@@ -32,6 +32,9 @@ export default {
'initialDescriptionText', 'initialDescriptionText',
'lockVersion', 'lockVersion',
]), ]),
isEpicTreeEnabled() {
return gon.features && gon.features.epicTrees;
},
}, },
}; };
</script> </script>
...@@ -61,7 +64,7 @@ export default { ...@@ -61,7 +64,7 @@ export default {
/> />
</div> </div>
<related-items <related-items
v-if="subepicsSupported" v-if="subepicsSupported && !isEpicTreeEnabled"
:endpoint="epicLinksEndpoint" :endpoint="epicLinksEndpoint"
:can-admin="canAdmin" :can-admin="canAdmin"
:can-reorder="canAdmin" :can-reorder="canAdmin"
...@@ -72,6 +75,7 @@ export default { ...@@ -72,6 +75,7 @@ export default {
css-class="js-related-epics-block" css-class="js-related-epics-block"
/> />
<related-items <related-items
v-if="!isEpicTreeEnabled"
:endpoint="issueLinksEndpoint" :endpoint="issueLinksEndpoint"
:can-admin="canAdmin" :can-admin="canAdmin"
:can-reorder="canAdmin" :can-reorder="canAdmin"
......
import $ from 'jquery'; import $ from 'jquery';
import initRelatedItemsTree from 'ee/related_items_tree/related_items_tree_bundle';
import initRoadmap from 'ee/roadmap/roadmap_bundle'; import initRoadmap from 'ee/roadmap/roadmap_bundle';
export default class EpicTabs { export default class EpicTabs {
...@@ -7,17 +8,34 @@ export default class EpicTabs { ...@@ -7,17 +8,34 @@ export default class EpicTabs {
this.epicTabs = this.wrapper.querySelector('.js-epic-tabs-container'); this.epicTabs = this.wrapper.querySelector('.js-epic-tabs-container');
this.discussionFilterContainer = this.epicTabs.querySelector('.js-discussion-filter-container'); this.discussionFilterContainer = this.epicTabs.querySelector('.js-discussion-filter-container');
this.treeTabLoaded = false;
this.roadmapTabLoaded = false; this.roadmapTabLoaded = false;
this.bindEvents(); this.bindEvents();
} }
bindEvents() { bindEvents() {
const $treeTab = $('#tree-tab', this.epicTabs);
const $roadmapTab = $('#roadmap-tab', this.epicTabs); const $roadmapTab = $('#roadmap-tab', this.epicTabs);
$treeTab.on('show.bs.tab', this.onTreeShow.bind(this));
$treeTab.on('hide.bs.tab', this.onTreeHide.bind(this));
$roadmapTab.on('show.bs.tab', this.onRoadmapShow.bind(this)); $roadmapTab.on('show.bs.tab', this.onRoadmapShow.bind(this));
$roadmapTab.on('hide.bs.tab', this.onRoadmapHide.bind(this)); $roadmapTab.on('hide.bs.tab', this.onRoadmapHide.bind(this));
} }
onTreeShow() {
this.discussionFilterContainer.classList.add('hidden');
if (!this.treeTabLoaded) {
initRelatedItemsTree();
this.treeTabLoaded = true;
}
}
onTreeHide() {
this.discussionFilterContainer.classList.remove('hidden');
}
onRoadmapShow() { onRoadmapShow() {
this.wrapper.classList.remove('container-limited'); this.wrapper.classList.remove('container-limited');
this.discussionFilterContainer.classList.add('hidden'); this.discussionFilterContainer.classList.add('hidden');
......
...@@ -5,6 +5,8 @@ import { GlLoadingIcon } from '@gitlab/ui'; ...@@ -5,6 +5,8 @@ import { GlLoadingIcon } from '@gitlab/ui';
import issueToken from './issue_token.vue'; import issueToken from './issue_token.vue';
import { autoCompleteTextMap, inputPlaceholderTextMap } from '../constants'; import { autoCompleteTextMap, inputPlaceholderTextMap } from '../constants';
const SPACE_FACTOR = 1;
export default { export default {
name: 'AddIssuableForm', name: 'AddIssuableForm',
components: { components: {
...@@ -71,6 +73,7 @@ export default { ...@@ -71,6 +73,7 @@ export default {
this.gfmAutoComplete = new GfmAutoComplete(this.autoCompleteSources); this.gfmAutoComplete = new GfmAutoComplete(this.autoCompleteSources);
this.gfmAutoComplete.setup($input, { this.gfmAutoComplete.setup($input, {
issues: true, issues: true,
epics: true,
}); });
$input.on('shown-issues.atwho', this.onAutoCompleteToggled.bind(this, true)); $input.on('shown-issues.atwho', this.onAutoCompleteToggled.bind(this, true));
$input.on('hidden-issues.atwho', this.onAutoCompleteToggled.bind(this, false)); $input.on('hidden-issues.atwho', this.onAutoCompleteToggled.bind(this, false));
...@@ -89,9 +92,31 @@ export default { ...@@ -89,9 +92,31 @@ export default {
methods: { methods: {
onInput() { onInput() {
const { value } = this.$refs.input; const { value } = this.$refs.input;
const caretPos = $(this.$refs.input).caret('pos');
const rawRefs = value.split(/\s/);
let touchedReference;
let position = 0;
const untouchedRawRefs = rawRefs
.filter(ref => {
let isTouched = false;
if (caretPos >= position && caretPos <= position + ref.length) {
touchedReference = ref;
isTouched = true;
}
// `+ SPACE_FACTOR` to factor in the missing space we split at earlier
position = position + ref.length + SPACE_FACTOR;
return !isTouched;
})
.filter(ref => ref.trim().length > 0);
this.$emit('addIssuableFormInput', { this.$emit('addIssuableFormInput', {
newValue: value, newValue: value,
caretPos: $(this.$refs.input).caret('pos'), untouchedRawReferences: untouchedRawRefs,
touchedReference,
caretPos,
}); });
}, },
onFocus() { onFocus() {
...@@ -166,6 +191,7 @@ export default { ...@@ -166,6 +191,7 @@ export default {
@input="onInput" @input="onInput"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
@keyup.escape.exact="onFormCancel"
/> />
</li> </li>
</ul> </ul>
......
...@@ -34,8 +34,6 @@ import { ...@@ -34,8 +34,6 @@ import {
addRelatedIssueErrorMap, addRelatedIssueErrorMap,
} from '../constants'; } from '../constants';
const SPACE_FACTOR = 1;
export default { export default {
name: 'RelatedIssuesRoot', name: 'RelatedIssuesRoot',
components: { components: {
...@@ -200,25 +198,7 @@ export default { ...@@ -200,25 +198,7 @@ export default {
}); });
} }
}, },
onInput({ newValue, caretPos }) { onInput({ untouchedRawReferences, touchedReference }) {
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.store.setPendingReferences(this.state.pendingReferences.concat(untouchedRawReferences));
this.inputValue = `${touchedReference}`; this.inputValue = `${touchedReference}`;
}, },
......
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlButton,
GlLoadingIcon,
},
props: {
isSubmitting: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
inputValue: '',
};
},
computed: {
isSubmitButtonDisabled() {
return this.inputValue.length === 0 || this.isSubmitting;
},
buttonLabel() {
return this.isSubmitting ? __('Creating epic') : __('Create epic');
},
},
mounted() {
this.$refs.input.focus();
},
methods: {
onFormSubmit() {
this.$emit('createItemFormSubmit', this.inputValue.trim());
},
onFormCancel() {
this.$emit('createItemFormCancel');
},
},
};
</script>
<template>
<form @submit.prevent="onFormSubmit">
<input
ref="input"
v-model="inputValue"
:placeholder="__('New epic title')"
type="text"
class="form-control"
@keyup.escape.exact="onFormCancel"
/>
<div class="add-issuable-form-actions clearfix">
<gl-button
:disabled="isSubmitButtonDisabled"
variant="success"
type="submit"
class="float-left"
>
{{ buttonLabel }}
<gl-loading-icon v-if="isSubmitting" :inline="true" />
</gl-button>
<gl-button class="float-right" @click="onFormCancel">{{ __('Cancel') }}</gl-button>
</div>
</form>
</template>
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import AddItemForm from 'ee/related_issues/components/add_issuable_form.vue';
import CreateItemForm from './create_item_form.vue';
import TreeItemRemoveModal from './tree_item_remove_modal.vue';
import RelatedItemsTreeHeader from './related_items_tree_header.vue';
import RelatedItemsTreeBody from './related_items_tree_body.vue';
import { PathIdSeparator, ActionType, OVERFLOW_AFTER } from '../constants';
export default {
PathIdSeparator,
ActionType,
OVERFLOW_AFTER,
components: {
GlLoadingIcon,
RelatedItemsTreeHeader,
RelatedItemsTreeBody,
AddItemForm,
CreateItemForm,
TreeItemRemoveModal,
},
computed: {
...mapState([
'parentItem',
'itemsFetchInProgress',
'itemsFetchResultEmpty',
'itemAddInProgress',
'itemCreateInProgress',
'showAddItemForm',
'showCreateItemForm',
'autoCompleteEpics',
'autoCompleteIssues',
'pendingReferences',
'itemInputValue',
'actionType',
'epicsEndpoint',
'issuesEndpoint',
]),
...mapGetters(['itemAutoCompleteSources', 'itemPathIdSeparator', 'directChildren']),
disableContents() {
return this.itemAddInProgress || this.itemCreateInProgress;
},
},
mounted() {
this.fetchItems({
parentItem: this.parentItem,
});
},
methods: {
...mapActions([
'fetchItems',
'toggleAddItemForm',
'toggleCreateItemForm',
'setPendingReferences',
'addPendingReferences',
'removePendingReference',
'setItemInputValue',
'addItem',
'createItem',
]),
getRawRefs(value) {
return value.split(/\s+/).filter(ref => ref.trim().length > 0);
},
handlePendingItemRemove(index) {
this.removePendingReference(index);
},
handleAddItemFormInput({ untouchedRawReferences, touchedReference }) {
this.addPendingReferences(untouchedRawReferences);
this.setItemInputValue(`${touchedReference}`);
},
handleAddItemFormBlur(newValue) {
this.addPendingReferences(this.getRawRefs(newValue));
this.setItemInputValue('');
},
handleAddItemFormSubmit(newValue) {
this.handleAddItemFormBlur(newValue);
if (this.pendingReferences.length > 0) {
this.addItem();
}
},
handleCreateItemFormSubmit(newValue) {
this.createItem({
itemTitle: newValue,
});
},
handleAddItemFormCancel() {
this.toggleAddItemForm({ toggleState: false, actionType: this.actionType });
this.setPendingReferences([]);
this.setItemInputValue('');
},
handleCreateItemFormCancel() {
this.toggleCreateItemForm({ toggleState: false, actionType: this.actionType });
this.setItemInputValue('');
},
},
};
</script>
<template>
<div class="related-items-tree-container">
<div v-if="itemsFetchInProgress" class="mt-2">
<gl-loading-icon size="md" />
</div>
<div
v-else
class="related-items-tree card-slim mt-2"
:class="{
'disabled-content': disableContents,
'overflow-auto': directChildren.length > $options.OVERFLOW_AFTER,
}"
>
<related-items-tree-header :class="{ 'border-bottom-0': itemsFetchResultEmpty }" />
<div v-if="showAddItemForm || showCreateItemForm" class="card-body add-item-form-container">
<add-item-form
v-if="showAddItemForm"
:issuable-type="actionType"
:input-value="itemInputValue"
:is-submitting="itemAddInProgress"
:pending-references="pendingReferences"
:auto-complete-sources="itemAutoCompleteSources"
:path-id-separator="itemPathIdSeparator"
@pendingIssuableRemoveRequest="handlePendingItemRemove"
@addIssuableFormInput="handleAddItemFormInput"
@addIssuableFormBlur="handleAddItemFormBlur"
@addIssuableFormSubmit="handleAddItemFormSubmit"
@addIssuableFormCancel="handleAddItemFormCancel"
/>
<create-item-form
v-if="showCreateItemForm"
:is-submitting="itemCreateInProgress"
@createItemFormSubmit="handleCreateItemFormSubmit"
@createItemFormCancel="handleCreateItemFormCancel"
/>
</div>
<related-items-tree-body
v-if="!itemsFetchResultEmpty"
:parent-item="parentItem"
:children="directChildren"
/>
<tree-item-remove-modal />
</div>
</div>
</template>
<script>
export default {
props: {
parentItem: {
type: Object,
required: true,
},
children: {
type: Array,
required: false,
default: () => [],
},
},
};
</script>
<template>
<div class="related-items-tree-body sortable-container">
<tree-root :parent-item="parentItem" :children="children" />
</div>
</template>
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import DroplabDropdownButton from '~/vue_shared/components/droplab_dropdown_button.vue';
import { EpicDropdownActions, ActionType } from '../constants';
export default {
EpicDropdownActions,
ActionType,
components: {
Icon,
GlButton,
DroplabDropdownButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
computed: {
...mapGetters(['headerItems']),
...mapState(['parentItem']),
badgeTooltip() {
return sprintf(s__('Epics|%{epicsCount} epics and %{issuesCount} issues'), {
epicsCount: this.headerItems[0].count,
issuesCount: this.headerItems[1].count,
});
},
},
methods: {
...mapActions(['toggleAddItemForm', 'toggleCreateItemForm']),
handleActionClick({ id, actionType }) {
if (id === 0) {
this.toggleAddItemForm({
actionType,
toggleState: true,
});
} else {
this.toggleCreateItemForm({
actionType,
toggleState: true,
});
}
},
},
};
</script>
<template>
<div class="card-header d-flex px-2">
<div class="d-inline-flex flex-grow-1 lh-100 align-middle">
<div
v-gl-tooltip.hover:tooltipcontainer.bottom
class="issue-count-badge"
:title="badgeTooltip"
>
<span
v-for="(item, index) in headerItems"
:key="index"
:class="{ 'ml-2': index }"
class="d-inline-flex align-items-center"
>
<icon :size="16" :name="item.iconName" css-classes="text-secondary mr-1" />
{{ item.count }}
</span>
</div>
</div>
<div class="d-inline-flex">
<template v-if="parentItem.userPermissions.adminEpic">
<droplab-dropdown-button
:actions="$options.EpicDropdownActions"
:default-action="0"
:primary-button-class="`${headerItems[0].qaClass} js-add-epics-button`"
class="btn-create-epic"
size="sm"
@onActionClick="handleActionClick"
/>
<gl-button
:class="headerItems[1].qaClass"
class="ml-1 js-add-issues-button"
size="sm"
@click="handleActionClick({ id: 0, actionType: 'issue' })"
>{{ __('Add an issue') }}</gl-button
>
</template>
</div>
</div>
</template>
<script>
import { GlTooltip } from '@gitlab/ui';
import { __ } from '~/locale';
import { formatDate } from '~/lib/utils/datetime_utility';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
components: {
GlTooltip,
},
mixins: [timeagoMixin],
props: {
getTargetRef: {
type: Function,
required: true,
},
isOpen: {
type: Boolean,
required: true,
},
state: {
type: String,
required: true,
},
createdAt: {
type: String,
required: true,
},
closedAt: {
type: String,
required: true,
},
},
computed: {
stateText() {
return this.isOpen ? __('Opened') : __('Closed');
},
createdAtInWords() {
return this.getTimestampInWords(this.createdAt);
},
closedAtInWords() {
return this.getTimestampInWords(this.closedAt);
},
createdAtTimestamp() {
return this.getTimestamp(this.createdAt);
},
closedAtTimestamp() {
return this.getTimestamp(this.closedAt);
},
stateTimeInWords() {
return this.isOpen ? this.createdAtInWords : this.closedAtInWords;
},
stateTimestamp() {
return this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp;
},
},
methods: {
getTimestamp(rawTimestamp) {
return rawTimestamp ? formatDate(new Date(rawTimestamp)) : '';
},
getTimestampInWords(rawTimestamp) {
return rawTimestamp ? this.timeFormated(rawTimestamp) : '';
},
},
};
</script>
<template>
<gl-tooltip :target="getTargetRef()">
<span class="bold">
{{ stateText }}
</span>
{{ stateTimeInWords }}
<br />
<span class="text-tertiary">
{{ stateTimestamp }}
</span>
</gl-tooltip>
</template>
<script>
import { mapGetters, mapActions, mapState } from 'vuex';
import { GlTooltipDirective, GlLoadingIcon, GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import TreeItemBody from './tree_item_body.vue';
import { ChildType } from '../constants';
export default {
ChildType,
components: {
Icon,
TreeItemBody,
GlLoadingIcon,
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
parentItem: {
type: Object,
required: true,
},
item: {
type: Object,
required: true,
},
},
computed: {
...mapState(['children', 'childrenFlags']),
...mapGetters(['anyParentHasChildren']),
itemReference() {
return this.item.reference;
},
hasChildren() {
return this.childrenFlags[this.itemReference].itemHasChildren;
},
chevronType() {
return this.childrenFlags[this.itemReference].itemExpanded ? 'chevron-down' : 'chevron-right';
},
chevronTooltip() {
return this.childrenFlags[this.itemReference].itemExpanded ? __('Collapse') : __('Expand');
},
childrenFetchInProgress() {
return (
this.hasChildren && !this.childrenFlags[this.itemReference].itemChildrenFetchInProgress
);
},
itemExpanded() {
return this.hasChildren && this.childrenFlags[this.itemReference].itemExpanded;
},
hasNoChildren() {
return (
this.anyParentHasChildren &&
!this.hasChildren &&
!this.childrenFlags[this.itemReference].itemChildrenFetchInProgress
);
},
},
methods: {
...mapActions(['toggleItem']),
handleChevronClick() {
this.toggleItem({
parentItem: this.item,
});
},
},
};
</script>
<template>
<li
class="tree-item list-item pt-0 pb-0"
:class="{
'has-children': hasChildren,
'item-expanded': childrenFlags[itemReference].itemExpanded,
'js-item-type-epic': item.type === $options.ChildType.Epic,
'js-item-type-issue': item.type === $options.ChildType.Issue,
}"
>
<div class="list-item-body d-flex align-items-center">
<gl-button
v-if="childrenFetchInProgress"
v-gl-tooltip.hover
:title="chevronTooltip"
:class="chevronType"
variant="link"
class="btn-svg btn-tree-item-chevron"
@click="handleChevronClick"
>
<icon :name="chevronType" />
</gl-button>
<gl-loading-icon v-if="childrenFlags[itemReference].itemChildrenFetchInProgress" size="sm" />
<tree-item-body
class="tree-item-row"
:parent-item="parentItem"
:item="item"
:class="{
'tree-item-noexpand': hasNoChildren,
}"
/>
</div>
<tree-root
v-if="itemExpanded"
:parent-item="item"
:children="children[itemReference]"
class="sub-tree-root"
/>
</li>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import { GlTooltipDirective, GlModalDirective, GlLink, GlButton } from '@gitlab/ui';
import _ from 'underscore';
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import ItemMilestone from '~/vue_shared/components/issue/issue_milestone.vue';
import ItemAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import ItemDueDate from '~/boards/components/issue_due_date.vue';
import ItemWeight from 'ee/boards/components/issue_card_weight.vue';
import StateTooltip from './state_tooltip.vue';
import { ChildType, ChildState, itemRemoveModalId } from '../constants';
export default {
itemRemoveModalId,
components: {
Icon,
GlLink,
GlButton,
StateTooltip,
ItemMilestone,
ItemAssignees,
ItemDueDate,
ItemWeight,
},
directives: {
GlTooltip: GlTooltipDirective,
GlModalDirective,
},
props: {
parentItem: {
type: Object,
required: true,
},
item: {
type: Object,
required: true,
},
},
computed: {
...mapState(['childrenFlags']),
itemReference() {
return this.item.reference;
},
isOpen() {
return this.item.state === ChildState.Open;
},
isClosed() {
return this.item.state === ChildState.Closed;
},
hasMilestone() {
return !_.isEmpty(this.item.milestone);
},
hasAssignees() {
return this.item.assignees && this.item.assignees.length > 0;
},
stateText() {
return this.isOpen ? __('Opened') : __('Closed');
},
stateIconName() {
return this.item.type === ChildType.Epic ? 'epic' : 'issues';
},
stateIconClass() {
return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed';
},
itemPath() {
return this.itemReference.split(this.item.pathIdSeparator)[0];
},
itemId() {
return this.itemReference.split(this.item.pathIdSeparator).pop();
},
computedPath() {
return this.item.webPath.length ? this.item.webPath : null;
},
itemActionInProgress() {
return (
this.childrenFlags[this.itemReference].itemChildrenFetchInProgress ||
this.childrenFlags[this.itemReference].itemRemoveInProgress
);
},
},
methods: {
...mapActions(['setRemoveItemModalProps']),
handleRemoveClick() {
const { parentItem, item } = this;
this.setRemoveItemModalProps({
parentItem,
item,
});
},
},
};
</script>
<template>
<div class="card-slim sortable-row flex-grow-1">
<div class="item-body card-body d-flex align-items-center p-2 p-xl-1 pl-xl-3">
<div class="item-contents d-flex align-items-center flex-wrap flex-grow-1 flex-xl-nowrap">
<div class="item-title d-flex align-items-center mb-1 mb-xl-0">
<icon
ref="stateIconLg"
:css-classes="stateIconClass"
:name="stateIconName"
:size="16"
:aria-label="stateText"
/>
<state-tooltip
:get-target-ref="() => $refs.stateIconLg"
:is-open="isOpen"
:state="item.state"
:created-at="item.createdAt"
:closed-at="item.closedAt || ''"
/>
<icon
v-if="item.confidential"
v-gl-tooltip.hover
:size="16"
:title="__('Confidential')"
:aria-label="__('Confidential')"
name="eye-slash"
css-classes="confidential-icon append-right-4 align-self-baseline align-self-md-auto mt-xl-0"
/>
<gl-link :href="computedPath" class="sortable-link">{{ item.title }}</gl-link>
</div>
<div class="item-meta d-flex flex-wrap mt-xl-0 justify-content-xl-end flex-xl-nowrap">
<div
class="d-flex align-items-center item-path-id order-md-0 mt-md-0 mt-1 ml-xl-2 mr-xl-auto"
>
<icon
ref="stateIconMd"
:css-classes="stateIconClass"
:name="stateIconName"
:size="16"
:aria-label="stateText"
class="d-xl-none"
/>
<state-tooltip
:get-target-ref="() => $refs.stateIconMd"
:is-open="isOpen"
:state="item.state"
:created-at="item.createdAt"
:closed-at="item.closedAt || ''"
/>
<span v-gl-tooltip :title="itemPath" class="path-id-text d-inline-block">{{
itemPath
}}</span
>{{ item.pathIdSeparator }}{{ itemId }}
</div>
<div
class="item-meta-child d-flex align-items-center order-0 flex-wrap mr-md-1 ml-md-auto ml-xl-2 flex-xl-nowrap"
>
<item-milestone
v-if="hasMilestone"
:milestone="item.milestone"
class="d-flex align-items-center item-milestone"
/>
<item-due-date
v-if="item.dueDate"
:date="item.dueDate"
tooltip-placement="top"
css-class="item-due-date d-flex align-items-center ml-2 mr-0"
/>
<item-weight
v-if="item.weight"
:weight="item.weight"
class="item-weight d-flex align-items-center ml-2 mr-0"
tag-name="span"
/>
</div>
<item-assignees
v-if="hasAssignees"
:assignees="item.assignees"
class="item-assignees d-inline-flex align-items-center align-self-end ml-auto ml-md-0 mb-md-0 order-2 flex-xl-grow-0 mt-xl-0 mr-xl-1"
/>
</div>
<gl-button
v-if="parentItem.userPermissions.adminEpic"
v-gl-tooltip.hover
v-gl-modal-directive="$options.itemRemoveModalId"
:title="__('Remove')"
:disabled="itemActionInProgress"
class="btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button"
@click="handleRemoveClick"
>
<icon :size="16" name="close" css-classes="btn-item-remove-icon" />
</gl-button>
<span v-if="!parentItem.userPermissions.adminEpic" class="p-3"></span>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import _ from 'underscore';
import { GlModal } from '@gitlab/ui';
import { sprintf } from '~/locale';
import { ChildType, RemoveItemModalProps, itemRemoveModalId } from '../constants';
export default {
itemRemoveModalId,
components: {
GlModal,
},
computed: {
...mapState(['parentItem', 'removeItemModalProps']),
removeItemType() {
return this.removeItemModalProps.item.type;
},
modalTitle() {
return this.removeItemType ? RemoveItemModalProps[this.removeItemType].title : '';
},
modalBody() {
if (this.removeItemType) {
const sprintfParams = {
bStart: '<b>',
bEnd: '</b>',
};
if (this.removeItemType === ChildType.Epic) {
Object.assign(sprintfParams, {
targetEpicTitle: _.escape(this.removeItemModalProps.item.title),
parentEpicTitle: _.escape(this.parentItem.title),
});
} else {
Object.assign(sprintfParams, {
targetIssueTitle: _.escape(this.removeItemModalProps.item.title),
parentEpicTitle: _.escape(this.parentItem.title),
});
}
return sprintf(RemoveItemModalProps[this.removeItemType].body, sprintfParams, false);
}
return '';
},
},
methods: {
...mapActions(['removeItem']),
},
};
</script>
<template>
<gl-modal
:modal-id="$options.itemRemoveModalId"
:title="modalTitle"
:ok-title="__('Remove')"
ok-variant="danger"
no-fade
@ok="
removeItem({
parentItem: removeItemModalProps.parentItem,
item: removeItemModalProps.item,
})
"
>
<p v-html="modalBody"></p>
</gl-modal>
</template>
<script>
export default {
props: {
parentItem: {
type: Object,
required: true,
},
children: {
type: Array,
required: true,
},
},
};
</script>
<template>
<ul class="list-unstyled related-items-list tree-root">
<tree-item
v-for="(item, index) in children"
:key="index"
:parent-item="parentItem"
:item="item"
/>
</ul>
</template>
import { s__ } from '~/locale';
export const ChildType = {
Epic: 'Epic',
Issue: 'Issue',
};
export const ChildState = {
Open: 'opened',
Closed: 'closed',
};
export const PathIdSeparator = {
Epic: '&',
Issue: '#',
};
export const ActionType = {
Epic: 'epic',
Issue: 'issue',
};
export const RemoveItemModalProps = {
Epic: {
title: s__('Epics|Remove epic'),
body: s__(
'Epics|This will also remove any descendents of %{bStart}%{targetEpicTitle}%{bEnd} from %{bStart}%{parentEpicTitle}%{bEnd}. Are you sure?',
),
},
Issue: {
title: s__('Epics|Remove issue'),
body: s__(
'Epics|Are you sure you want to remove %{bStart}%{targetIssueTitle}%{bEnd} from %{bStart}%{parentEpicTitle}%{bEnd}?',
),
},
};
export const EpicDropdownActions = [
{
id: 0,
actionType: ActionType.Epic,
title: s__('Epics|Add an epic'),
description: s__('Epics|Add an existing epic as a child epic.'),
},
{
id: 1,
actionType: ActionType.Epic,
title: s__('Epics|Create new epic'),
description: s__('Epics|Create an epic within this group and add it as a child epic.'),
},
];
export const OVERFLOW_AFTER = 5;
export const itemRemoveModalId = 'item-remove-confirmation';
query childItems($fullPath: ID!, $iid: ID) {
group(fullPath: $fullPath) {
id
path
fullPath
epic(iid: $iid) {
id
iid
title
webPath
userPermissions {
adminEpic
createEpic
}
children(first: 50) {
edges {
node {
id
iid
title
state
webPath
reference(full: true)
relationPath
createdAt
closedAt
hasChildren
hasIssues
userPermissions {
adminEpic
createEpic
}
group {
fullPath
}
}
}
}
issues(first: 50) {
edges {
node {
iid
title
closedAt
state
createdAt
confidential
dueDate
weight
webPath
reference
relationPath
assignees {
edges {
node {
webUrl
name
username
avatarUrl
}
}
}
milestone {
title
startDate
dueDate
}
}
}
}
}
}
}
import Vue from 'vue';
import { mapActions } from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
import createStore from './store';
import RelatedItemsTreeApp from './components/related_items_tree_app.vue';
import TreeRoot from './components/tree_root.vue';
import TreeItem from './components/tree_item.vue';
export default () => {
const el = document.getElementById('js-tree');
if (!el) {
return false;
}
const { iid, fullPath, autoCompleteEpics, autoCompleteIssues } = el.dataset;
const initialData = JSON.parse(el.dataset.initial);
Vue.component('tree-root', TreeRoot);
Vue.component('tree-item', TreeItem);
return new Vue({
el,
store: createStore(),
components: { RelatedItemsTreeApp },
created() {
this.setInitialParentItem({
fullPath,
iid: Number(iid),
title: initialData.initialTitleText,
reference: `${initialData.fullPath}${initialData.issuableRef}`,
userPermissions: {
adminEpic: initialData.canAdmin,
createEpic: initialData.canUpdate,
},
});
this.setInitialConfig({
epicsEndpoint: initialData.epicLinksEndpoint,
issuesEndpoint: initialData.issueLinksEndpoint,
autoCompleteEpics: parseBoolean(autoCompleteEpics),
autoCompleteIssues: parseBoolean(autoCompleteIssues),
});
},
methods: {
...mapActions(['setInitialParentItem', 'setInitialConfig']),
},
render: createElement => createElement('related-items-tree-app'),
});
};
import flash from '~/flash';
import { s__ } from '~/locale';
import Api from 'ee/api';
import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import {
addRelatedIssueErrorMap,
pathIndeterminateErrorMap,
relatedIssuesRemoveErrorMap,
} from 'ee/related_issues/constants';
import { processQueryResponse, formatChildItem, gqClient } from '../utils/epic_utils';
import { ActionType, ChildType, ChildState } from '../constants';
import childItems from '../queries/child_items.graphql';
import * as types from './mutation_types';
export const setInitialConfig = ({ commit }, data) => commit(types.SET_INITIAL_CONFIG, data);
export const setInitialParentItem = ({ commit }, data) =>
commit(types.SET_INITIAL_PARENT_ITEM, data);
export const expandItem = ({ commit }, data) => commit(types.EXPAND_ITEM, data);
export const collapseItem = ({ commit }, data) => commit(types.COLLAPSE_ITEM, data);
export const setItemChildren = ({ commit, dispatch }, { parentItem, children, isSubItem }) => {
commit(types.SET_ITEM_CHILDREN, {
parentItem,
children,
isSubItem,
});
if (isSubItem) {
dispatch('expandItem', {
parentItem,
});
}
};
export const setItemChildrenFlags = ({ commit }, data) =>
commit(types.SET_ITEM_CHILDREN_FLAGS, data);
export const requestItems = ({ commit }, data) => commit(types.REQUEST_ITEMS, data);
export const receiveItemsSuccess = ({ commit }, data) => commit(types.RECEIVE_ITEMS_SUCCESS, data);
export const receiveItemsFailure = ({ commit }, data) => {
flash(s__('Epics|Something went wrong while fetching child epics.'));
commit(types.RECEIVE_ITEMS_FAILURE, data);
};
export const fetchItems = ({ dispatch }, { parentItem, isSubItem = false }) => {
dispatch('requestItems', {
parentItem,
isSubItem,
});
gqClient
.query({
query: childItems,
variables: { iid: parentItem.iid, fullPath: parentItem.fullPath },
})
.then(({ data }) => {
const children = processQueryResponse(data.group);
dispatch('receiveItemsSuccess', {
parentItem,
children,
isSubItem,
});
dispatch('setItemChildren', {
parentItem,
children,
isSubItem,
});
dispatch('setItemChildrenFlags', {
children,
isSubItem,
});
})
.catch(() => {
dispatch('receiveItemsFailure', {
parentItem,
isSubItem,
});
});
};
export const toggleItem = ({ state, dispatch }, { parentItem }) => {
if (!state.childrenFlags[parentItem.reference].itemExpanded) {
if (!state.children[parentItem.reference]) {
dispatch('fetchItems', {
parentItem,
isSubItem: true,
});
} else {
dispatch('expandItem', {
parentItem,
});
}
} else {
dispatch('collapseItem', {
parentItem,
});
}
};
export const setRemoveItemModalProps = ({ commit }, data) =>
commit(types.SET_REMOVE_ITEM_MODAL_PROPS, data);
export const requestRemoveItem = ({ commit }, data) => commit(types.REQUEST_REMOVE_ITEM, data);
export const receiveRemoveItemSuccess = ({ commit }, data) =>
commit(types.RECEIVE_REMOVE_ITEM_SUCCESS, data);
export const receiveRemoveItemFailure = ({ commit }, { item, status }) => {
commit(types.RECEIVE_REMOVE_ITEM_FAILURE, item);
flash(
status === httpStatusCodes.NOT_FOUND
? pathIndeterminateErrorMap[ActionType[item.type]]
: relatedIssuesRemoveErrorMap[ActionType[item.type]],
);
};
export const removeItem = ({ dispatch }, { parentItem, item }) => {
dispatch('requestRemoveItem', {
item,
});
axios
.delete(item.relationPath)
.then(() => {
dispatch('receiveRemoveItemSuccess', {
parentItem,
item,
});
})
.catch(({ status }) => {
dispatch('receiveRemoveItemFailure', {
item,
status,
});
});
};
export const toggleAddItemForm = ({ commit }, data) => commit(types.TOGGLE_ADD_ITEM_FORM, data);
export const toggleCreateItemForm = ({ commit }, data) =>
commit(types.TOGGLE_CREATE_ITEM_FORM, data);
export const setPendingReferences = ({ commit }, data) =>
commit(types.SET_PENDING_REFERENCES, data);
export const addPendingReferences = ({ commit }, data) =>
commit(types.ADD_PENDING_REFERENCES, data);
export const removePendingReference = ({ commit }, data) =>
commit(types.REMOVE_PENDING_REFERENCE, data);
export const setItemInputValue = ({ commit }, data) => commit(types.SET_ITEM_INPUT_VALUE, data);
export const requestAddItem = ({ commit }) => commit(types.REQUEST_ADD_ITEM);
export const receiveAddItemSuccess = ({ dispatch, commit, getters }, { actionType, rawItems }) => {
const isEpic = actionType === ActionType.Epic;
const items = rawItems.map(item =>
formatChildItem({
...convertObjectPropsToCamelCase(item, { deep: !isEpic }),
type: isEpic ? ChildType.Epic : ChildType.Issue,
userPermissions: isEpic ? { adminEpic: item.can_admin } : {},
}),
);
commit(types.RECEIVE_ADD_ITEM_SUCCESS, {
insertAt: isEpic ? getters.epicsBeginAtIndex : 0,
items,
});
dispatch('setItemChildrenFlags', {
children: items,
isSubItem: false,
});
dispatch('setPendingReferences', []);
dispatch('setItemInputValue', '');
dispatch('toggleAddItemForm', {
actionType,
toggleState: false,
});
};
export const receiveAddItemFailure = ({ commit, state }, data = {}) => {
commit(types.RECEIVE_ADD_ITEM_FAILURE);
let errorMessage = addRelatedIssueErrorMap[state.actionType];
if (data.message) {
errorMessage = data.message;
}
flash(errorMessage);
};
export const addItem = ({ state, dispatch }) => {
dispatch('requestAddItem');
axios
.post(state.actionType === ActionType.Epic ? state.epicsEndpoint : state.issuesEndpoint, {
issuable_references: state.pendingReferences,
})
.then(({ data }) => {
dispatch('receiveAddItemSuccess', {
actionType: state.actionType,
// Newly added item is always first in the list
rawItems: data.issuables.slice(0, state.pendingReferences.length),
});
})
.catch(({ data }) => {
dispatch('receiveAddItemFailure', data);
});
};
export const requestCreateItem = ({ commit }) => commit(types.REQUEST_CREATE_ITEM);
export const receiveCreateItemSuccess = (
{ commit, dispatch, getters },
{ actionType, rawItem },
) => {
const isEpic = actionType === ActionType.Epic;
const item = formatChildItem({
...convertObjectPropsToCamelCase(rawItem, { deep: !isEpic }),
type: isEpic ? ChildType.Epic : ChildType.Issue,
});
commit(types.RECEIVE_CREATE_ITEM_SUCCESS, {
insertAt: isEpic ? getters.epicsBeginAtIndex : 0,
item,
});
dispatch('setItemChildrenFlags', {
children: [item],
isSubItem: false,
});
dispatch('toggleCreateItemForm', {
actionType,
toggleState: false,
});
};
export const receiveCreateItemFailure = ({ commit }) => {
commit(types.RECEIVE_CREATE_ITEM_FAILURE);
flash(s__('Epics|Something went wrong while creating child epics.'));
};
export const createItem = ({ state, dispatch }, { itemTitle }) => {
dispatch('requestCreateItem');
Api.createChildEpic({
groupId: state.parentItem.fullPath,
parentEpicIid: state.parentItem.iid,
title: itemTitle,
})
.then(({ data }) => {
Object.assign(data, {
// TODO: API response is missing these 3 keys.
// Once support is added, we need to remove it from here.
path: data.url ? `/groups/${data.url.split('/groups/').pop()}` : '',
state: ChildState.Open,
created_at: '',
});
dispatch('receiveCreateItemSuccess', {
actionType: state.actionType,
rawItem: data,
});
})
.catch(() => {
dispatch('receiveCreateItemFailure');
});
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import { ChildType, ActionType, PathIdSeparator } from '../constants';
export const autoCompleteSources = () => gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources;
export const directChildren = state => state.children[state.parentItem.reference] || [];
export const anyParentHasChildren = (state, getters) =>
getters.directChildren.some(item => item.hasChildren || item.hasIssues);
export const headerItems = (state, getters) => {
const children = getters.directChildren || [];
let totalEpics = 0;
let totalIssues = 0;
children.forEach(item => {
if (item.type === ChildType.Epic) {
totalEpics += 1;
} else {
totalIssues += 1;
}
});
return [
{
iconName: 'epic',
count: totalEpics,
qaClass: 'qa-add-epics-button',
type: ChildType.Epic,
},
{
iconName: 'issues',
count: totalIssues,
qaClass: 'qa-add-issues-button',
type: ChildType.Issue,
},
];
};
export const epicsBeginAtIndex = (state, getters) =>
getters.directChildren.findIndex(item => item.type === ChildType.Epic);
export const itemAutoCompleteSources = (state, getters) => {
if (state.actionType === ActionType.Epic) {
return state.autoCompleteEpics ? getters.autoCompleteSources : {};
}
return state.autoCompleteIssues ? getters.autoCompleteSources : {};
};
export const itemPathIdSeparator = state =>
state.actionType === ActionType.Epic ? PathIdSeparator.Epic : PathIdSeparator.Issue;
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import getDefaultState from './state';
Vue.use(Vuex);
const createStore = () =>
new Vuex.Store({
state: getDefaultState(),
actions,
getters,
mutations,
});
export default createStore;
export const SET_INITIAL_CONFIG = 'SET_INITIAL_CONFIG';
export const SET_INITIAL_PARENT_ITEM = 'SET_INITIAL_PARENT_ITEM';
export const SET_ITEM_CHILDREN = 'SET_ITEM_CHILDREN';
export const SET_ITEM_CHILDREN_FLAGS = 'SET_ITEM_CHILDREN_FLAGS';
export const REQUEST_ITEMS = 'REQUEST_ITEMS';
export const RECEIVE_ITEMS_SUCCESS = 'RECEIVE_ITEMS_SUCCESS';
export const RECEIVE_ITEMS_FAILURE = 'RECEIVE_ITEMS_FAILURE';
export const SET_REMOVE_ITEM_MODAL_PROPS = 'SET_REMOVE_ITEM_MODAL_PROPS';
export const REQUEST_REMOVE_ITEM = 'REQUEST_REMOVE_ITEM';
export const RECEIVE_REMOVE_ITEM_SUCCESS = 'RECEIVE_REMOVE_ITEM_SUCCESS';
export const RECEIVE_REMOVE_ITEM_FAILURE = 'RECEIVE_REMOVE_ITEM_FAILURE';
export const EXPAND_ITEM = 'EXPAND_ITEM';
export const COLLAPSE_ITEM = 'COLLAPSE_ITEM';
export const TOGGLE_ADD_ITEM_FORM = 'TOGGLE_ADD_ITEM_FORM';
export const TOGGLE_CREATE_ITEM_FORM = 'TOGGLE_CREATE_ITEM_FORM';
export const SET_PENDING_REFERENCES = 'SET_PENDING_REFERENCES';
export const ADD_PENDING_REFERENCES = 'ADD_PENDING_REFERENCES';
export const REMOVE_PENDING_REFERENCE = 'REMOVE_PENDING_REFERENCE';
export const SET_ITEM_INPUT_VALUE = 'SET_ITEM_INPUT_VALUE';
export const REQUEST_ADD_ITEM = 'REQUEST_ADD_ITEM';
export const RECEIVE_ADD_ITEM_SUCCESS = 'RECEIVE_ADD_ITEM_SUCCESS';
export const RECEIVE_ADD_ITEM_FAILURE = 'RECEIVE_ADD_ITEM_FAILURE';
export const REQUEST_CREATE_ITEM = 'REQUEST_CREATE_ITEM';
export const RECEIVE_CREATE_ITEM_SUCCESS = 'RECEIVE_CREATE_ITEM_SUCCESS';
export const RECEIVE_CREATE_ITEM_FAILURE = 'RECEIVE_CREATE_ITEM_FAILURE';
import Vue from 'vue';
import * as types from './mutation_types';
export default {
[types.SET_INITIAL_CONFIG](
state,
{ epicsEndpoint, issuesEndpoint, autoCompleteEpics, autoCompleteIssues },
) {
state.epicsEndpoint = epicsEndpoint;
state.issuesEndpoint = issuesEndpoint;
state.autoCompleteEpics = autoCompleteEpics;
state.autoCompleteIssues = autoCompleteIssues;
},
[types.SET_INITIAL_PARENT_ITEM](state, data) {
state.parentItem = { ...data };
state.childrenFlags[state.parentItem.reference] = {};
},
[types.SET_ITEM_CHILDREN](state, { parentItem, children }) {
Vue.set(state.children, parentItem.reference, children);
},
[types.SET_ITEM_CHILDREN_FLAGS](state, { children }) {
children.forEach(item => {
Vue.set(state.childrenFlags, item.reference, {
itemExpanded: false,
itemChildrenFetchInProgress: false,
itemRemoveInProgress: false,
itemHasChildren: item.hasChildren || item.hasIssues,
});
});
},
[types.REQUEST_ITEMS](state, { parentItem, isSubItem }) {
if (isSubItem) {
state.childrenFlags[parentItem.reference].itemChildrenFetchInProgress = true;
} else {
state.itemsFetchInProgress = true;
}
},
[types.RECEIVE_ITEMS_SUCCESS](state, { parentItem, children, isSubItem }) {
if (isSubItem) {
state.childrenFlags[parentItem.reference].itemChildrenFetchInProgress = false;
} else {
state.itemsFetchInProgress = false;
state.itemsFetchResultEmpty = children.length === 0;
}
},
[types.RECEIVE_ITEMS_FAILURE](state, { parentItem, isSubItem }) {
if (isSubItem) {
state.childrenFlags[parentItem.reference].itemChildrenFetchInProgress = false;
} else {
state.itemsFetchInProgress = false;
}
},
[types.EXPAND_ITEM](state, { parentItem }) {
state.childrenFlags[parentItem.reference].itemExpanded = true;
},
[types.COLLAPSE_ITEM](state, { parentItem }) {
state.childrenFlags[parentItem.reference].itemExpanded = false;
},
[types.SET_REMOVE_ITEM_MODAL_PROPS](state, { parentItem, item }) {
state.removeItemModalProps = {
parentItem,
item,
};
},
[types.REQUEST_REMOVE_ITEM](state, { item }) {
state.childrenFlags[item.reference].itemRemoveInProgress = true;
},
[types.RECEIVE_REMOVE_ITEM_SUCCESS](state, { parentItem, item }) {
state.childrenFlags[item.reference].itemRemoveInProgress = false;
// Remove the children from array
const targetChildren = state.children[parentItem.reference];
targetChildren.splice(targetChildren.indexOf(item), 1);
// Update flag for parentItem so that expand/collapse
// button visibility is refreshed correctly.
state.childrenFlags[parentItem.reference].itemHasChildren = Boolean(targetChildren.length);
// In case item removed belonged to main epic
// we also set results empty.
if (
state.children[state.parentItem.reference] &&
!state.children[state.parentItem.reference].length
) {
state.itemsFetchResultEmpty = true;
}
},
[types.RECEIVE_REMOVE_ITEM_FAILURE](state, { item }) {
state.childrenFlags[item.reference].itemRemoveInProgress = false;
},
[types.TOGGLE_ADD_ITEM_FORM](state, { actionType, toggleState }) {
state.actionType = actionType;
state.showAddItemForm = toggleState;
state.showCreateItemForm = false;
},
[types.TOGGLE_CREATE_ITEM_FORM](state, { actionType, toggleState }) {
state.actionType = actionType;
state.showCreateItemForm = toggleState;
state.showAddItemForm = false;
},
[types.SET_PENDING_REFERENCES](state, references) {
state.pendingReferences = references;
},
[types.ADD_PENDING_REFERENCES](state, references) {
state.pendingReferences.push(...references);
},
[types.REMOVE_PENDING_REFERENCE](state, indexToRemove) {
state.pendingReferences = state.pendingReferences.filter(
(ref, index) => index !== indexToRemove,
);
},
[types.SET_ITEM_INPUT_VALUE](state, itemInputValue) {
state.itemInputValue = itemInputValue;
},
[types.REQUEST_ADD_ITEM](state) {
state.itemAddInProgress = true;
},
[types.RECEIVE_ADD_ITEM_SUCCESS](state, { insertAt, items }) {
state.children[state.parentItem.reference].splice(insertAt, 0, ...items);
state.itemAddInProgress = false;
state.itemsFetchResultEmpty = false;
},
[types.RECEIVE_ADD_ITEM_FAILURE](state) {
state.itemAddInProgress = false;
},
[types.REQUEST_CREATE_ITEM](state) {
state.itemCreateInProgress = true;
},
[types.RECEIVE_CREATE_ITEM_SUCCESS](state, { insertAt, item }) {
state.children[state.parentItem.reference].splice(insertAt, 0, item);
state.itemCreateInProgress = false;
state.itemsFetchResultEmpty = false;
},
[types.RECEIVE_CREATE_ITEM_FAILURE](state) {
state.itemCreateInProgress = false;
},
};
export default () => ({
// Initial Data
parentItem: {},
epicsEndpoint: '',
issuesEndpoint: '',
children: {},
childrenFlags: {},
// Add Item Form Data
actionType: '',
itemInputValue: '',
pendingReferences: [],
itemAutoCompleteSources: {},
// UI Flags
itemsFetchInProgress: false,
itemsFetchFailure: false,
itemsFetchResultEmpty: false,
itemAddInProgress: false,
itemCreateInProgress: false,
showAddItemForm: false,
showCreateItemForm: false,
autoCompleteEpics: false,
autoCompleteIssues: false,
removeItemModalProps: {
parentItem: {},
item: {},
},
});
import createGqClient from '~/lib/graphql';
import { ChildType, PathIdSeparator } from '../constants';
export const gqClient = createGqClient(
{},
{
cacheConfig: {
addTypename: false,
},
},
);
/**
* Returns formatted child item to include additional
* flags and properties to use while rendering tree.
* @param {Object} item
*/
export const formatChildItem = item =>
Object.assign({}, item, {
pathIdSeparator: PathIdSeparator[item.type],
});
/**
* Returns formatted array of Epics that doesn't contain
* `edges`->`node` nesting
*
* @param {Array} children
*/
export const extractChildEpics = children =>
children.edges.map(({ node, epicNode = node }) =>
formatChildItem({
...epicNode,
fullPath: epicNode.group.fullPath,
type: ChildType.Epic,
}),
);
/**
* Returns formatted array of Assignees that doesn't contain
* `edges`->`node` nesting
*
* @param {Array} assignees
*/
export const extractIssueAssignees = assignees =>
assignees.edges.map(assigneeNode => ({
...assigneeNode.node,
}));
/**
* Returns formatted array of Issues that doesn't contain
* `edges`->`node` nesting
*
* @param {Array} issues
*/
export const extractChildIssues = issues =>
issues.edges.map(({ node, issueNode = node }) =>
formatChildItem({
...issueNode,
type: ChildType.Issue,
assignees: extractIssueAssignees(issueNode.assignees),
}),
);
/**
* Parses Graph query response and updates
* children array to include issues within it
* @param {Object} responseRoot
*/
export const processQueryResponse = ({ epic }) =>
[].concat(extractChildIssues(epic.issues), extractChildEpics(epic.children));
.related-items-tree {
.btn-create-epic {
.dropdown-menu {
top: 100%;
right: 0;
bottom: auto;
left: auto;
}
}
.add-item-form-container {
border-bottom: 1px solid $border-color;
}
.related-items-tree-body {
> .tree-root {
padding-top: $gl-vert-padding;
padding-bottom: 0;
> .list-item:last-child .tree-root:last-child {
margin-bottom: 0;
}
}
}
.sub-tree-root {
margin-left: $gl-padding-24;
padding: 0;
}
.tree-item {
&.has-children.item-expanded {
> .list-item-body > .card-slim,
> .tree-root .tree-item:last-child .card-slim {
margin-bottom: $gl-vert-padding;
}
}
.btn-tree-item-chevron {
margin-bottom: $gl-padding-4;
margin-right: $gl-padding-4;
padding: $gl-padding-8 0;
line-height: 0;
border-radius: $gl-bar-padding;
color: $gl-text-color;
&:hover {
border-color: $border-color;
background-color: $border-color;
}
}
.tree-item-noexpand {
margin-left: $gl-sidebar-padding;
}
.loading-container {
margin-left: $gl-padding-4 / 2;
margin-right: $gl-padding-4;
}
}
}
...@@ -14,6 +14,10 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -14,6 +14,10 @@ class Groups::EpicsController < Groups::ApplicationController
before_action :authorize_update_issuable!, only: :update before_action :authorize_update_issuable!, only: :update
before_action :authorize_create_epic!, only: [:create] before_action :authorize_create_epic!, only: [:create]
before_action do
push_frontend_feature_flag(:epic_trees)
end
def index def index
@epics = @issuables @epics = @issuables
......
...@@ -25,6 +25,10 @@ ...@@ -25,6 +25,10 @@
%li.notes-tab.qa-notes-tab %li.notes-tab.qa-notes-tab
%a#discussion-tab.active{ href: '#discussion', data: { toggle: 'tab' } } %a#discussion-tab.active{ href: '#discussion', data: { toggle: 'tab' } }
= _('Discussion') = _('Discussion')
- if Feature.enabled?(:epic_trees)
%li.tree-tab
%a#tree-tab{ href: '#tree', data: { toggle: 'tab' } }
= _('Tree')
%li.roadmap-tab %li.roadmap-tab
%a#roadmap-tab{ href: '#roadmap', data: { toggle: 'tab' } } %a#roadmap-tab{ href: '#roadmap', data: { toggle: 'tab' } }
= _('Roadmap') = _('Roadmap')
...@@ -39,6 +43,15 @@ ...@@ -39,6 +43,15 @@
%script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe %script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe
.issuable-discussion .issuable-discussion
= render 'discussion' = render 'discussion'
- if Feature.enabled?(:epic_trees)
#tree.tab-pane
.row
%section.col-md-12
#js-tree{ data: { iid: @epic.iid,
full_path: @group.full_path,
auto_complete_epics: 'true',
auto_complete_issues: 'false',
initial: issuable_initial_data(@epic).to_json } }
#roadmap.tab-pane #roadmap.tab-pane
.row .row
%section.col-md-12 %section.col-md-12
......
...@@ -30,6 +30,11 @@ describe 'Epic Issues', :js do ...@@ -30,6 +30,11 @@ describe 'Epic Issues', :js do
sign_in(user) sign_in(user)
visit group_epic_path(group, epic) visit group_epic_path(group, epic)
wait_for_requests
find('.js-epic-tabs-container #tree-tab').click
wait_for_requests wait_for_requests
end end
...@@ -39,27 +44,19 @@ describe 'Epic Issues', :js do ...@@ -39,27 +44,19 @@ describe 'Epic Issues', :js do
end end
it 'user can see issues from public project but cannot delete the associations' do it 'user can see issues from public project but cannot delete the associations' do
within('.js-related-issues-block ul.related-items-list') do within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li', count: 1) expect(page).to have_selector('li', count: 3)
expect(page).to have_content(public_issue.title) expect(page).to have_content(public_issue.title)
expect(page).not_to have_selector('button.js-issue-item-remove-button') expect(page).not_to have_selector('button.js-issue-item-remove-button')
end end
end end
it 'user cannot add new issues to the epic' do it 'user cannot add new issues to the epic' do
expect(page).not_to have_selector('.js-related-issues-block h3.card-title button') expect(page).not_to have_selector('.related-items-tree-container .js-add-issues-button')
end end
it 'user cannot add new epics to the epic', :postgresql do it 'user cannot add new epics to the epic', :postgresql do
expect(page).not_to have_selector('.js-related-epics-block h3.card-title button') expect(page).not_to have_selector('.related-items-tree-container .js-add-epics-button')
end
it 'user cannot reorder issues in epic' do
expect(page).not_to have_selector('.js-related-issues-block .js-related-issues-token-list-item.user-can-drag')
end
it 'user cannot reorder epics in epic', :postgresql do
expect(page).not_to have_selector('.js-related-epics-block .js-related-epics-token-list-item.user-can-drag')
end end
end end
...@@ -69,23 +66,23 @@ describe 'Epic Issues', :js do ...@@ -69,23 +66,23 @@ describe 'Epic Issues', :js do
let(:epic_to_add) { create(:epic, group: group) } let(:epic_to_add) { create(:epic, group: group) }
def add_issues(references) def add_issues(references)
find('.js-related-issues-block h3.card-title button').click find('.related-items-tree-container .js-add-issues-button').click
find('.js-related-issues-block .js-add-issuable-form-input').set(references) find('.related-items-tree-container .js-add-issuable-form-input').set(references)
# When adding long references, for some reason the input gets stuck # When adding long references, for some reason the input gets stuck
# waiting for more text. Send a keystroke before clicking the button to # waiting for more text. Send a keystroke before clicking the button to
# get out of this mode. # get out of this mode.
find('.js-related-issues-block .js-add-issuable-form-input').send_keys(:tab) find('.related-items-tree-container .js-add-issuable-form-input').send_keys(:tab)
find('.js-related-issues-block .js-add-issuable-form-add-button').click find('.related-items-tree-container .js-add-issuable-form-add-button').click
wait_for_requests wait_for_requests
end end
def add_epics(references) def add_epics(references)
find('.js-related-epics-block h3.card-title button').click find('.related-items-tree-container .js-add-epics-button').click
find('.js-related-epics-block .js-add-issuable-form-input').set(references) find('.related-items-tree-container .js-add-issuable-form-input').set(references)
find('.js-related-epics-block .js-add-issuable-form-input').send_keys(:tab) find('.related-items-tree-container .js-add-issuable-form-input').send_keys(:tab)
find('.js-related-epics-block .js-add-issuable-form-add-button').click find('.related-items-tree-container .js-add-issuable-form-add-button').click
wait_for_requests wait_for_requests
end end
...@@ -96,34 +93,36 @@ describe 'Epic Issues', :js do ...@@ -96,34 +93,36 @@ describe 'Epic Issues', :js do
end end
it 'user can see all issues of the group and delete the associations' do it 'user can see all issues of the group and delete the associations' do
within('.js-related-issues-block ul.related-items-list') do within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li', count: 2) expect(page).to have_selector('li.js-item-type-issue', count: 2)
expect(page).to have_content(public_issue.title) expect(page).to have_content(public_issue.title)
expect(page).to have_content(private_issue.title) expect(page).to have_content(private_issue.title)
first('li button.js-issue-item-remove-button').click first('li.js-item-type-issue button.js-issue-item-remove-button').click
end end
first('#item-remove-confirmation .modal-footer .btn-danger').click
wait_for_requests wait_for_requests
within('.js-related-issues-block ul.related-items-list') do within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li', count: 1) expect(page).to have_selector('li.js-item-type-issue', count: 1)
end end
end end
it 'user can see all epics of the group and delete the associations', :postgresql do it 'user can see all epics of the group and delete the associations', :postgresql do
within('.js-related-epics-block ul.related-items-list') do within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li', count: 2) expect(page).to have_selector('li.js-item-type-epic', count: 2)
expect(page).to have_content(nested_epics[0].title) expect(page).to have_content(nested_epics[0].title)
expect(page).to have_content(nested_epics[1].title) expect(page).to have_content(nested_epics[1].title)
first('li button.js-issue-item-remove-button').click first('li.js-item-type-epic button.js-issue-item-remove-button').click
end end
first('#item-remove-confirmation .modal-footer .btn-danger').click
wait_for_requests wait_for_requests
within('.js-related-epics-block ul.related-items-list') do within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li', count: 1) expect(page).to have_selector('li.js-item-type-epic', count: 1)
end end
end end
...@@ -131,20 +130,19 @@ describe 'Epic Issues', :js do ...@@ -131,20 +130,19 @@ describe 'Epic Issues', :js do
add_issues("#{issue_invalid.to_reference(full: true)}") add_issues("#{issue_invalid.to_reference(full: true)}")
expect(page).to have_selector('.content-wrapper .alert-wrapper .flash-text') expect(page).to have_selector('.content-wrapper .alert-wrapper .flash-text')
expect(find('.flash-alert')).to have_text('No Issue found for given params') expect(find('.flash-alert')).to have_text("We can't find an issue that matches what you are looking for.")
end end
it 'user can add new issues to the epic' do it 'user can add new issues to the epic' do
references = "#{issue_to_add.to_reference(full: true)} #{issue_invalid.to_reference(full: true)}" references = "#{issue_to_add.to_reference(full: true)}"
add_issues(references) add_issues(references)
expect(page).not_to have_selector('.content-wrapper .alert-wrapper .flash-text') expect(page).not_to have_selector('.content-wrapper .alert-wrapper .flash-text')
expect(page).not_to have_content('No Issue found for given params') expect(page).not_to have_content("We can't find an issue that matches what you are looking for.")
within('.js-related-issues-block ul.related-items-list') do within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li', count: 3) expect(page).to have_selector('li.js-item-type-issue', count: 3)
expect(page).to have_content(issue_to_add.title)
end end
end end
...@@ -153,32 +151,11 @@ describe 'Epic Issues', :js do ...@@ -153,32 +151,11 @@ describe 'Epic Issues', :js do
add_epics(references) add_epics(references)
expect(page).not_to have_selector('.content-wrapper .alert-wrapper .flash-text') expect(page).not_to have_selector('.content-wrapper .alert-wrapper .flash-text')
expect(page).not_to have_content('No Epic found for given params') expect(page).not_to have_content("We can't find an epic that matches what you are looking for.")
within('.js-related-epics-block ul.related-items-list') do within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li', count: 3) expect(page).to have_selector('li.js-item-type-epic', count: 3)
expect(page).to have_content(epic_to_add.title)
end end
end end
it 'user can reorder issues in epic' do
expect(first('.js-related-issues-block .js-related-issues-token-list-item')).to have_content(public_issue.title)
expect(page.all('.js-related-issues-block .js-related-issues-token-list-item').last).to have_content(private_issue.title)
drag_to(selector: '.js-related-issues-block .related-items-list', to_index: 1)
expect(first('.js-related-issues-block .js-related-issues-token-list-item')).to have_content(private_issue.title)
expect(page.all('.js-related-issues-block .js-related-issues-token-list-item').last).to have_content(public_issue.title)
end
it 'user can reorder epics in epic', :postgresql do
expect(first('.js-related-epics-block .js-related-issues-token-list-item')).to have_content(nested_epics[0].title)
expect(page.all('.js-related-epics-block .js-related-issues-token-list-item').last).to have_content(nested_epics[1].title)
drag_to(selector: '.js-related-epics-block .related-items-list', to_index: 1)
expect(first('.js-related-epics-block .js-related-issues-token-list-item')).to have_content(nested_epics[1].title)
expect(page.all('.js-related-epics-block .js-related-issues-token-list-item').last).to have_content(nested_epics[0].title)
end
end end
end end
...@@ -49,6 +49,7 @@ describe 'Epic show', :js do ...@@ -49,6 +49,7 @@ describe 'Epic show', :js do
it 'shows epic tabs' do it 'shows epic tabs' do
page.within('.js-epic-tabs-container') do page.within('.js-epic-tabs-container') do
expect(find('.epic-tabs #discussion-tab')).to have_content('Discussion') expect(find('.epic-tabs #discussion-tab')).to have_content('Discussion')
expect(find('.epic-tabs #tree-tab')).to have_content('Tree')
expect(find('.epic-tabs #roadmap-tab')).to have_content('Roadmap') expect(find('.epic-tabs #roadmap-tab')).to have_content('Roadmap')
end end
end end
...@@ -60,12 +61,21 @@ describe 'Epic show', :js do ...@@ -60,12 +61,21 @@ describe 'Epic show', :js do
end end
end end
describe 'Epic child epics' do describe 'Tree tab' do
it 'shows child epics list' do before do
page.within('.js-related-epics-block') do find('.js-epic-tabs-container #tree-tab').click
expect(find('.issue-count-badge-count')).to have_content('2') wait_for_requests
expect(find('.js-related-issues-token-list-item:nth-child(1) .sortable-link')).to have_content('Child epic B') end
expect(find('.js-related-issues-token-list-item:nth-child(2) .sortable-link')).to have_content('Child epic A')
it 'shows Related items tree with child epics' do
page.within('.js-epic-tabs-content #tree') do
expect(page).to have_selector('.related-items-tree-container')
page.within('.related-items-tree-container') do
expect(page.find('.issue-count-badge')).to have_content('2')
expect(find('.tree-item:nth-child(1) .sortable-link')).to have_content('Child epic B')
expect(find('.tree-item:nth-child(2) .sortable-link')).to have_content('Child epic A')
end
end end
end end
end end
......
...@@ -45,4 +45,29 @@ describe('Api', () => { ...@@ -45,4 +45,29 @@ describe('Api', () => {
.catch(done.fail); .catch(done.fail);
}); });
}); });
describe('createChildEpic', () => {
it('calls `axios.post` using params `groupId`, `parentEpicIid` and title', done => {
const groupId = 'gitlab-org';
const parentEpicIid = 1;
const title = 'Sample epic';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/epics/${parentEpicIid}/epics`;
const expectedRes = {
title,
id: 20,
iid: 5,
};
mock.onPost(expectedUrl).reply(200, expectedRes);
Api.createChildEpic({ groupId, parentEpicIid, title })
.then(({ data }) => {
expect(data.title).toBe(expectedRes.title);
expect(data.id).toBe(expectedRes.id);
expect(data.iid).toBe(expectedRes.iid);
})
.then(done)
.catch(done.fail);
});
});
}); });
import * as getters from 'ee/related_items_tree/store/getters';
import createDefaultState from 'ee/related_items_tree/store/state';
import { ChildType, ActionType } from 'ee/related_items_tree/constants';
import {
mockEpic1,
mockEpic2,
mockIssue1,
mockIssue2,
} from '../../../javascripts/related_items_tree/mock_data';
window.gl = window.gl || {};
describe('RelatedItemsTree', () => {
describe('store', () => {
describe('getters', () => {
const { GfmAutoComplete } = gl;
let state;
let mockGetters;
beforeAll(() => {
gl.GfmAutoComplete = {
dataSources: 'foo/bar',
};
mockGetters = {
directChildren: [mockIssue1, mockIssue2, mockEpic1, mockEpic2].map(item => ({
...item,
type: item.reference.indexOf('&') > -1 ? ChildType.Epic : ChildType.Issue,
})),
};
});
beforeEach(() => {
state = createDefaultState();
});
afterAll(() => {
gl.GfmAutoComplete = GfmAutoComplete;
});
describe('autoCompleteSources', () => {
it('returns GfmAutoComplete.dataSources from global `gl` object', () => {
expect(getters.autoCompleteSources()).toBe(gl.GfmAutoComplete.dataSources);
});
});
describe('directChild', () => {
it('returns array of children which belong to state.parentItem', () => {
state.parentItem = mockEpic1;
state.children[mockEpic1.reference] = [mockEpic2];
expect(getters.directChildren(state)).toEqual(expect.arrayContaining([mockEpic2]));
});
});
describe('anyParentHasChildren', () => {
it('returns boolean representing whether any epic has children', () => {
let mockGetter = {
directChildren: [mockEpic1],
};
expect(getters.anyParentHasChildren(state, mockGetter)).toBe(true);
mockGetter = {
directChildren: [mockEpic2],
};
expect(getters.anyParentHasChildren(state, mockGetter)).toBe(false);
});
});
describe('headerItems', () => {
it('returns an item within array containing Epic iconName, count, qaClass & type props', () => {
const epicHeaderItem = getters.headerItems(state, mockGetters)[0];
expect(epicHeaderItem).toEqual(
expect.objectContaining({
iconName: 'epic',
count: 2,
qaClass: 'qa-add-epics-button',
type: ChildType.Epic,
}),
);
});
it('returns an item within array containing Issue iconName, count, qaClass & type props', () => {
const epicHeaderItem = getters.headerItems(state, mockGetters)[1];
expect(epicHeaderItem).toEqual(
expect.objectContaining({
iconName: 'issues',
count: 2,
qaClass: 'qa-add-issues-button',
type: ChildType.Issue,
}),
);
});
});
describe('epicsBeginAtIndex', () => {
it('returns number representing index at which epics begin in direct children array', () => {
expect(getters.epicsBeginAtIndex(state, mockGetters)).toBe(2);
});
});
describe('itemAutoCompleteSources', () => {
it('returns autoCompleteSources value when `actionType` is set to `Epic` and `autoCompleteEpics` is true', () => {
const mockGetter = {
autoCompleteSources: 'foo',
};
state.actionType = ActionType.Epic;
state.autoCompleteEpics = true;
expect(getters.itemAutoCompleteSources(state, mockGetter)).toBe('foo');
state.autoCompleteEpics = false;
expect(getters.itemAutoCompleteSources(state, mockGetter)).toEqual(
expect.objectContaining({}),
);
});
it('returns autoCompleteSources value when `actionType` is set to `Issues` and `autoCompleteIssues` is true', () => {
const mockGetter = {
autoCompleteSources: 'foo',
};
state.actionType = ActionType.Issue;
state.autoCompleteIssues = true;
expect(getters.itemAutoCompleteSources(state, mockGetter)).toBe('foo');
state.autoCompleteIssues = false;
expect(getters.itemAutoCompleteSources(state, mockGetter)).toEqual(
expect.objectContaining({}),
);
});
});
describe('itemPathIdSeparator', () => {
it('returns string containing pathIdSeparator for `Epic` when `state.actionType` is set to `Epic`', () => {
state.actionType = ActionType.Epic;
expect(getters.itemPathIdSeparator(state)).toBe('&');
});
it('returns string containing pathIdSeparator for `Issue` when `state.actionType` is set to `Issue`', () => {
state.actionType = ActionType.Issue;
expect(getters.itemPathIdSeparator(state)).toBe('#');
});
});
});
});
});
import mutations from 'ee/related_items_tree/store/mutations';
import createDefaultState from 'ee/related_items_tree/store/state';
import * as types from 'ee/related_items_tree/store/mutation_types';
describe('RelatedItemsTree', () => {
describe('store', () => {
describe('mutations', () => {
let state;
beforeEach(() => {
state = createDefaultState();
});
describe(types.SET_INITIAL_CONFIG, () => {
it('should set provided `data` param props to state', () => {
const data = {
epicsEndpoint: '/foo',
issuesEndpoint: '/bar',
autoCompleteEpics: true,
autoCompleteIssues: false,
};
mutations[types.SET_INITIAL_CONFIG](state, data);
expect(state).toHaveProperty('epicsEndpoint', '/foo');
expect(state).toHaveProperty('issuesEndpoint', '/bar');
expect(state).toHaveProperty('autoCompleteEpics', true);
expect(state).toHaveProperty('autoCompleteIssues', false);
});
});
describe(types.SET_INITIAL_PARENT_ITEM, () => {
it('should set provided `data` param props to state.parentItem', () => {
const data = {
foo: 'bar',
bar: 'baz',
reference: '&1',
};
mutations[types.SET_INITIAL_PARENT_ITEM](state, data);
expect(state.parentItem).toHaveProperty('foo', 'bar');
expect(state.parentItem).toHaveProperty('bar', 'baz');
expect(state.childrenFlags[data.reference]).toBeDefined();
});
});
describe(types.SET_ITEM_CHILDREN, () => {
it('should set provided `data.children` to `state.children` with reference key as present in `data.parentItem`', () => {
const data = {
parentItem: { reference: '&1' },
children: [
{
reference: 'frontend&1',
},
{
reference: 'frontend&2',
},
],
};
mutations[types.SET_ITEM_CHILDREN](state, data);
expect(state.children[data.parentItem.reference]).toEqual(
expect.arrayContaining(data.children),
);
});
});
describe(types.SET_ITEM_CHILDREN_FLAGS, () => {
it('should set flags in `state.childrenFlags` for each item in `data.children`', () => {
const data = {
children: [
{
reference: '&1',
hasChildren: true,
hasIssues: false,
},
{
reference: '&2',
hasChildren: false,
hasIssues: true,
},
],
};
mutations[types.SET_ITEM_CHILDREN_FLAGS](state, data);
data.children.forEach(item => {
expect(state.childrenFlags[item.reference]).toEqual(
expect.objectContaining({
itemExpanded: false,
itemChildrenFetchInProgress: false,
itemRemoveInProgress: false,
itemHasChildren: true,
}),
);
});
});
});
describe(types.REQUEST_ITEMS, () => {
const data = {
parentItem: {
reference: '&1',
},
};
it('should set `itemChildrenFetchInProgress` to true for provided `parentItem` param within state.childrenFlags when `isSubItem` param is true', () => {
data.isSubItem = true;
state.childrenFlags[data.parentItem.reference] = {};
mutations[types.REQUEST_ITEMS](state, data);
expect(state.childrenFlags[data.parentItem.reference]).toHaveProperty(
'itemChildrenFetchInProgress',
true,
);
});
it('should set `state.itemsFetchInProgress` to true when `isSubItem` param is false', () => {
data.isSubItem = false;
mutations[types.REQUEST_ITEMS](state, data);
expect(state.itemsFetchInProgress).toBe(true);
});
});
describe(types.RECEIVE_ITEMS_SUCCESS, () => {
const data = {
parentItem: {
reference: '&1',
},
};
it('should set `itemChildrenFetchInProgress` to false for provided `parentItem` param within state.childrenFlags when `isSubItem` param is true', () => {
data.isSubItem = true;
state.childrenFlags[data.parentItem.reference] = {};
mutations[types.RECEIVE_ITEMS_SUCCESS](state, data);
expect(state.childrenFlags[data.parentItem.reference]).toHaveProperty(
'itemChildrenFetchInProgress',
false,
);
});
it('should set `state.itemsFetchInProgress` to false and `state.itemsFetchResultEmpty` based on provided children param count when `isSubItem` param is false', () => {
data.isSubItem = false;
data.children = [];
mutations[types.RECEIVE_ITEMS_SUCCESS](state, data);
expect(state.itemsFetchInProgress).toBe(false);
expect(state.itemsFetchResultEmpty).toBe(true);
});
});
describe(types.RECEIVE_ITEMS_FAILURE, () => {
const data = {
parentItem: {
reference: '&1',
},
};
it('should set `itemChildrenFetchInProgress` to false for provided `parentItem` param within state.childrenFlags when `isSubItem` param is true', () => {
data.isSubItem = true;
state.childrenFlags[data.parentItem.reference] = {};
mutations[types.RECEIVE_ITEMS_FAILURE](state, data);
expect(state.childrenFlags[data.parentItem.reference]).toHaveProperty(
'itemChildrenFetchInProgress',
false,
);
});
it('should set `state.itemsFetchInProgress` to false and `state.itemsFetchResultEmpty` based on provided children param count when `isSubItem` param is false', () => {
data.isSubItem = false;
mutations[types.RECEIVE_ITEMS_FAILURE](state, data);
expect(state.itemsFetchInProgress).toBe(false);
});
});
describe(types.EXPAND_ITEM, () => {
const data = {
parentItem: {
reference: '&1',
},
};
it('should set `itemExpanded` to true for provided `parentItem` param within state.childrenFlags', () => {
state.childrenFlags[data.parentItem.reference] = {};
mutations[types.EXPAND_ITEM](state, data);
expect(state.childrenFlags[data.parentItem.reference]).toHaveProperty(
'itemExpanded',
true,
);
});
});
describe(types.COLLAPSE_ITEM, () => {
const data = {
parentItem: {
reference: '&1',
},
};
it('should set `itemExpanded` to false for provided `parentItem` param within state.childrenFlags', () => {
state.childrenFlags[data.parentItem.reference] = {};
mutations[types.COLLAPSE_ITEM](state, data);
expect(state.childrenFlags[data.parentItem.reference]).toHaveProperty(
'itemExpanded',
false,
);
});
});
describe(types.SET_REMOVE_ITEM_MODAL_PROPS, () => {
it('should set `parentItem` & `item` to state.removeItemModalProps', () => {
const data = {
parentItem: 'foo',
item: 'bar',
};
mutations[types.SET_REMOVE_ITEM_MODAL_PROPS](state, data);
expect(state.removeItemModalProps).toEqual(
expect.objectContaining({
parentItem: data.parentItem,
item: data.item,
}),
);
});
});
describe(types.REQUEST_REMOVE_ITEM, () => {
it('should set `itemRemoveInProgress` to true for provided `item` param within state.childrenFlags', () => {
const data = {
item: {
reference: '&1',
},
};
state.childrenFlags[data.item.reference] = {};
mutations[types.REQUEST_REMOVE_ITEM](state, data);
expect(state.childrenFlags[data.item.reference]).toHaveProperty(
'itemRemoveInProgress',
true,
);
});
});
describe(types.RECEIVE_REMOVE_ITEM_SUCCESS, () => {
const data = {
parentItem: {
reference: 'gitlab-org&1',
},
item: {
reference: '&2',
},
};
it('should set `itemRemoveInProgress` to false for provided `item` param within state.childrenFlags and removes children for provided `parentItem`', () => {
state.children[data.parentItem.reference] = [data.item];
state.childrenFlags[data.item.reference] = {};
state.childrenFlags[data.parentItem.reference] = {};
mutations[types.RECEIVE_REMOVE_ITEM_SUCCESS](state, data);
expect(state.childrenFlags[data.item.reference]).toHaveProperty(
'itemRemoveInProgress',
false,
);
expect(state.children[data.parentItem.reference]).toEqual(expect.arrayContaining([]));
expect(state.childrenFlags[data.parentItem.reference].itemHasChildren).toBe(false);
});
});
describe(types.RECEIVE_REMOVE_ITEM_FAILURE, () => {
it('should set `itemRemoveInProgress` to false for provided `item` param within state.childrenFlags', () => {
const data = {
item: {
reference: '&1',
},
};
state.childrenFlags[data.item.reference] = {};
mutations[types.RECEIVE_REMOVE_ITEM_FAILURE](state, data);
expect(state.childrenFlags[data.item.reference]).toHaveProperty(
'itemRemoveInProgress',
false,
);
});
});
describe(types.TOGGLE_ADD_ITEM_FORM, () => {
it('should set value of `actionType`, `showAddItemForm` as it is and `showCreateItemForm` as false on state', () => {
const data = {
actionType: 'Epic',
toggleState: true,
};
mutations[types.TOGGLE_ADD_ITEM_FORM](state, data);
expect(state.actionType).toBe(data.actionType);
expect(state.showAddItemForm).toBe(data.toggleState);
expect(state.showCreateItemForm).toBe(false);
});
});
describe(types.TOGGLE_CREATE_ITEM_FORM, () => {
it('should set value of `actionType`, `showCreateItemForm` as it is and `showAddItemForm` as false on state', () => {
const data = {
actionType: 'Epic',
toggleState: true,
};
mutations[types.TOGGLE_CREATE_ITEM_FORM](state, data);
expect(state.actionType).toBe(data.actionType);
expect(state.showCreateItemForm).toBe(data.toggleState);
expect(state.showAddItemForm).toBe(false);
});
});
describe(types.SET_PENDING_REFERENCES, () => {
it('should set `pendingReferences` to state based on provided `references` param', () => {
const reference = ['foo'];
mutations[types.SET_PENDING_REFERENCES](state, reference);
expect(state.pendingReferences).toEqual(expect.arrayContaining(reference));
});
});
describe(types.ADD_PENDING_REFERENCES, () => {
it('should add value of provided `references` param to `pendingReferences` within state', () => {
const reference = ['bar'];
state.pendingReferences = ['foo'];
mutations[types.ADD_PENDING_REFERENCES](state, reference);
expect(state.pendingReferences).toEqual(
expect.arrayContaining(['foo'].concat(reference)),
);
});
});
describe(types.REMOVE_PENDING_REFERENCE, () => {
it('should remove value from `pendingReferences` based on provided `indexToRemove` param', () => {
state.pendingReferences = ['foo', 'bar'];
mutations[types.REMOVE_PENDING_REFERENCE](state, 1);
expect(state.pendingReferences).toEqual(expect.arrayContaining(['foo']));
});
});
describe(types.SET_ITEM_INPUT_VALUE, () => {
it('should set value of provided `itemInputValue` param to `itemInputValue` within state', () => {
mutations[types.SET_ITEM_INPUT_VALUE](state, 'foo');
expect(state.itemInputValue).toBe('foo');
});
});
describe(types.REQUEST_ADD_ITEM, () => {
it('should set `itemAddInProgress` to true within state', () => {
mutations[types.REQUEST_ADD_ITEM](state);
expect(state.itemAddInProgress).toBe(true);
});
});
describe(types.RECEIVE_ADD_ITEM_SUCCESS, () => {
it('should add provided `items` param to `state.children` and `itemAddInProgress` to false', () => {
state.parentItem = { reference: '&1' };
state.children[state.parentItem.reference] = ['foo', 'baz'];
mutations[types.RECEIVE_ADD_ITEM_SUCCESS](state, {
insertAt: 1,
items: ['bar'],
});
expect(state.children[state.parentItem.reference]).toEqual(
expect.arrayContaining(['foo', 'bar', 'baz']),
);
expect(state.itemAddInProgress).toBe(false);
expect(state.itemsFetchResultEmpty).toBe(false);
});
});
describe(types.RECEIVE_ADD_ITEM_FAILURE, () => {
it('should set `itemAddInProgress` to false within state', () => {
mutations[types.RECEIVE_ADD_ITEM_FAILURE](state);
expect(state.itemAddInProgress).toBe(false);
});
});
describe(types.REQUEST_CREATE_ITEM, () => {
it('should set `itemCreateInProgress` to true within state', () => {
mutations[types.REQUEST_CREATE_ITEM](state);
expect(state.itemCreateInProgress).toBe(true);
});
});
describe(types.RECEIVE_CREATE_ITEM_SUCCESS, () => {
it('should add provided `item` param to `state.children` and `itemCreateInProgress` to false', () => {
state.parentItem = { reference: '&1' };
state.children[state.parentItem.reference] = ['foo', 'baz'];
mutations[types.RECEIVE_CREATE_ITEM_SUCCESS](state, {
insertAt: 1,
item: 'bar',
});
expect(state.children[state.parentItem.reference]).toEqual(
expect.arrayContaining(['foo', 'bar', 'baz']),
);
expect(state.itemCreateInProgress).toBe(false);
expect(state.itemsFetchResultEmpty).toBe(false);
});
});
describe(types.RECEIVE_CREATE_ITEM_FAILURE, () => {
it('should set `itemCreateInProgress` to false within state', () => {
mutations[types.RECEIVE_CREATE_ITEM_FAILURE](state);
expect(state.itemCreateInProgress).toBe(false);
});
});
});
});
});
import * as epicUtils from 'ee/related_items_tree/utils/epic_utils';
import { ChildType, PathIdSeparator } from 'ee/related_items_tree/constants';
import {
mockQueryResponse,
mockEpic1,
mockIssue1,
} from '../../../javascripts/related_items_tree/mock_data';
jest.mock('~/lib/graphql', () => jest.fn());
describe('RelatedItemsTree', () => {
describe('epicUtils', () => {
describe('formatChildItem', () => {
it('returns new object from provided item object with pathIdSeparator assigned', () => {
const item = {
type: ChildType.Epic,
};
expect(epicUtils.formatChildItem(item)).toHaveProperty('type', ChildType.Epic);
expect(epicUtils.formatChildItem(item)).toHaveProperty(
'pathIdSeparator',
PathIdSeparator.Epic,
);
});
});
describe('extractChildEpics', () => {
it('returns updated epics array with `type` and `pathIdSeparator` assigned and `edges->node` nesting removed', () => {
const formattedChildren = epicUtils.extractChildEpics(
mockQueryResponse.data.group.epic.children,
);
expect(formattedChildren.length).toBe(
mockQueryResponse.data.group.epic.children.edges.length,
);
expect(formattedChildren[0]).toHaveProperty('type', ChildType.Epic);
expect(formattedChildren[0]).toHaveProperty('pathIdSeparator', PathIdSeparator.Epic);
expect(formattedChildren[0]).toHaveProperty('fullPath', mockEpic1.group.fullPath);
});
});
describe('extractIssueAssignees', () => {
it('returns updated assignees array with `edges->node` nesting removed', () => {
const formattedChildren = epicUtils.extractIssueAssignees(mockIssue1.assignees);
expect(formattedChildren.length).toBe(mockIssue1.assignees.edges.length);
expect(formattedChildren[0]).toHaveProperty(
'username',
mockIssue1.assignees.edges[0].node.username,
);
});
});
describe('extractChildIssues', () => {
it('returns updated issues array with `type` and `pathIdSeparator` assigned and `edges->node` nesting removed', () => {
const formattedChildren = epicUtils.extractChildIssues(
mockQueryResponse.data.group.epic.issues,
);
expect(formattedChildren.length).toBe(
mockQueryResponse.data.group.epic.issues.edges.length,
);
expect(formattedChildren[0]).toHaveProperty('type', ChildType.Issue);
expect(formattedChildren[0]).toHaveProperty('pathIdSeparator', PathIdSeparator.Issue);
});
});
describe('processQueryResponse', () => {
it('returns array of issues and epics from query response with issues being on top of the list', () => {
const formattedChildren = epicUtils.processQueryResponse(mockQueryResponse.data.group);
expect(formattedChildren.length).toBe(4); // 2 Issues and 2 Epics
expect(formattedChildren[0]).toHaveProperty('type', ChildType.Issue);
expect(formattedChildren[1]).toHaveProperty('type', ChildType.Issue);
expect(formattedChildren[2]).toHaveProperty('type', ChildType.Epic);
expect(formattedChildren[3]).toHaveProperty('type', ChildType.Epic);
});
});
});
});
...@@ -199,12 +199,17 @@ describe('AddIssuableForm', () => { ...@@ -199,12 +199,17 @@ describe('AddIssuableForm', () => {
it('when filling in the input', () => { it('when filling in the input', () => {
spyOn(vm, '$emit'); spyOn(vm, '$emit');
const newInputValue = 'filling in things'; const newInputValue = 'filling in things';
const untouchedRawReferences = newInputValue.trim().split(/\s/);
const touchedReference = untouchedRawReferences.pop();
vm.$refs.input.value = newInputValue; vm.$refs.input.value = newInputValue;
vm.onInput(); vm.onInput();
expect(vm.$emit).toHaveBeenCalledWith('addIssuableFormInput', { expect(vm.$emit).toHaveBeenCalledWith('addIssuableFormInput', {
newValue: newInputValue, newValue: newInputValue,
caretPos: newInputValue.length, caretPos: newInputValue.length,
untouchedRawReferences,
touchedReference,
}); });
}); });
......
...@@ -304,8 +304,8 @@ describe('RelatedIssuesRoot', () => { ...@@ -304,8 +304,8 @@ describe('RelatedIssuesRoot', () => {
it('fill in issue number reference and adds to pending related issues', () => { it('fill in issue number reference and adds to pending related issues', () => {
const input = '#123 '; const input = '#123 ';
vm.onInput({ vm.onInput({
newValue: input, untouchedRawReferences: [input.trim()],
caretPos: input.length, touchedReference: input,
}); });
expect(vm.state.pendingReferences.length).toEqual(1); expect(vm.state.pendingReferences.length).toEqual(1);
...@@ -314,7 +314,7 @@ describe('RelatedIssuesRoot', () => { ...@@ -314,7 +314,7 @@ describe('RelatedIssuesRoot', () => {
it('fill in with full reference', () => { it('fill in with full reference', () => {
const input = 'asdf/qwer#444 '; const input = 'asdf/qwer#444 ';
vm.onInput({ newValue: input, caretPos: input.length }); vm.onInput({ untouchedRawReferences: [input.trim()], touchedReference: input });
expect(vm.state.pendingReferences.length).toEqual(1); expect(vm.state.pendingReferences.length).toEqual(1);
expect(vm.state.pendingReferences[0]).toEqual('asdf/qwer#444'); expect(vm.state.pendingReferences[0]).toEqual('asdf/qwer#444');
...@@ -323,7 +323,7 @@ describe('RelatedIssuesRoot', () => { ...@@ -323,7 +323,7 @@ describe('RelatedIssuesRoot', () => {
it('fill in with issue link', () => { it('fill in with issue link', () => {
const link = 'http://localhost:3000/foo/bar/issues/111'; const link = 'http://localhost:3000/foo/bar/issues/111';
const input = `${link} `; const input = `${link} `;
vm.onInput({ newValue: input, caretPos: input.length }); vm.onInput({ untouchedRawReferences: [input.trim()], touchedReference: input });
expect(vm.state.pendingReferences.length).toEqual(1); expect(vm.state.pendingReferences.length).toEqual(1);
expect(vm.state.pendingReferences[0]).toEqual(link); expect(vm.state.pendingReferences[0]).toEqual(link);
...@@ -331,7 +331,7 @@ describe('RelatedIssuesRoot', () => { ...@@ -331,7 +331,7 @@ describe('RelatedIssuesRoot', () => {
it('fill in with multiple references', () => { it('fill in with multiple references', () => {
const input = 'asdf/qwer#444 #12 '; const input = 'asdf/qwer#444 #12 ';
vm.onInput({ newValue: input, caretPos: input.length }); vm.onInput({ untouchedRawReferences: input.trim().split(/\s/), touchedReference: 2 });
expect(vm.state.pendingReferences.length).toEqual(2); expect(vm.state.pendingReferences.length).toEqual(2);
expect(vm.state.pendingReferences[0]).toEqual('asdf/qwer#444'); expect(vm.state.pendingReferences[0]).toEqual('asdf/qwer#444');
...@@ -340,31 +340,12 @@ describe('RelatedIssuesRoot', () => { ...@@ -340,31 +340,12 @@ describe('RelatedIssuesRoot', () => {
it('fill in with some invalid things', () => { it('fill in with some invalid things', () => {
const input = 'something random '; const input = 'something random ';
vm.onInput({ newValue: input, caretPos: input.length }); vm.onInput({ untouchedRawReferences: input.trim().split(/\s/), touchedReference: 2 });
expect(vm.state.pendingReferences.length).toEqual(2); expect(vm.state.pendingReferences.length).toEqual(2);
expect(vm.state.pendingReferences[0]).toEqual('something'); expect(vm.state.pendingReferences[0]).toEqual('something');
expect(vm.state.pendingReferences[1]).toEqual('random'); expect(vm.state.pendingReferences[1]).toEqual('random');
}); });
it('fill in invalid and some legit references', () => {
const input = 'something random #123 ';
vm.onInput({ newValue: input, caretPos: 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({ newValue: input, caretPos: 3 });
expect(vm.state.pendingReferences.length).toEqual(2);
expect(vm.state.pendingReferences[0]).toEqual('a');
expect(vm.state.pendingReferences[1]).toEqual('b');
});
}); });
describe('onBlur', () => { describe('onBlur', () => {
......
import { mount, createLocalVue } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import CreateItemForm from 'ee/related_items_tree/components/create_item_form.vue';
const createComponent = (isSubmitting = false) => {
const localVue = createLocalVue();
return mount(CreateItemForm, {
localVue,
propsData: {
isSubmitting,
},
});
};
describe('RelatedItemsTree', () => {
describe('CreateItemForm', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('isSubmitButtonDisabled', () => {
it('returns true when either `inputValue` prop is empty or `isSubmitting` prop is true', () => {
expect(wrapper.vm.isSubmitButtonDisabled).toBe(true);
});
it('returns false when either `inputValue` prop is non-empty or `isSubmitting` prop is false', done => {
const wrapperWithInput = createComponent(false);
wrapperWithInput.setData({
inputValue: 'foo',
});
wrapperWithInput.vm.$nextTick(() => {
expect(wrapperWithInput.vm.isSubmitButtonDisabled).toBe(false);
wrapperWithInput.destroy();
done();
});
});
});
describe('buttonLabel', () => {
it('returns string "Creating epic" when `isSubmitting` prop is true', done => {
const wrapperSubmitting = createComponent(true);
wrapperSubmitting.vm.$nextTick(() => {
expect(wrapperSubmitting.vm.buttonLabel).toBe('Creating epic');
wrapperSubmitting.destroy();
done();
});
});
it('returns string "Create epic" when `isSubmitting` prop is false', () => {
expect(wrapper.vm.buttonLabel).toBe('Create epic');
});
});
});
describe('methods', () => {
describe('onFormSubmit', () => {
it('emits `createItemFormSubmit` event on component with input value as param', () => {
const value = 'foo';
wrapper.find('input.form-control').setValue(value);
wrapper.vm.onFormSubmit();
expect(wrapper.emitted().createItemFormSubmit).toBeTruthy();
expect(wrapper.emitted().createItemFormSubmit[0]).toEqual([value]);
});
});
describe('onFormCancel', () => {
it('emits `createItemFormCancel` event on component', () => {
wrapper.vm.onFormCancel();
expect(wrapper.emitted().createItemFormCancel).toBeTruthy();
});
});
});
describe('template', () => {
it('renders input element within form', () => {
const inputEl = wrapper.find('input.form-control');
expect(inputEl.attributes('placeholder')).toBe('New epic title');
});
it('renders form action buttons', () => {
const actionButtons = wrapper.findAll(GlButton);
expect(actionButtons.at(0).text()).toBe('Create epic');
expect(actionButtons.at(1).text()).toBe('Cancel');
});
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import RelatedItemsTreeApp from 'ee/related_items_tree/components/related_items_tree_app.vue';
import RelatedItemsTreeHeader from 'ee/related_items_tree/components/related_items_tree_header.vue';
import createDefaultStore from 'ee/related_items_tree/store';
import { ActionType } from 'ee/related_items_tree/constants';
import { mockInitialConfig, mockParentItem } from '../mock_data';
const createComponent = () => {
const store = createDefaultStore();
const localVue = createLocalVue();
store.dispatch('setInitialConfig', mockInitialConfig);
store.dispatch('setInitialParentItem', mockParentItem);
return shallowMount(RelatedItemsTreeApp, {
localVue,
store,
});
};
describe('RelatedItemsTreeApp', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('methods', () => {
describe('getRawRefs', () => {
it('returns array of references from provided string with spaces', () => {
const value = '&1 &2 &3';
const references = wrapper.vm.getRawRefs(value);
expect(references.length).toBe(3);
expect(references.join(' ')).toBe(value);
});
});
describe('handlePendingItemRemove', () => {
it('calls `removePendingReference` action with provided `index` param', () => {
spyOn(wrapper.vm, 'removePendingReference');
wrapper.vm.handlePendingItemRemove(0);
expect(wrapper.vm.removePendingReference).toHaveBeenCalledWith(0);
});
});
describe('handleAddItemFormInput', () => {
const untouchedRawReferences = ['&1'];
const touchedReference = '&2';
it('calls `addPendingReferences` action with provided `untouchedRawReferences` param', () => {
spyOn(wrapper.vm, 'addPendingReferences');
wrapper.vm.handleAddItemFormInput({ untouchedRawReferences, touchedReference });
expect(wrapper.vm.addPendingReferences).toHaveBeenCalledWith(untouchedRawReferences);
});
it('calls `setItemInputValue` action with provided `touchedReference` param', () => {
spyOn(wrapper.vm, 'setItemInputValue');
wrapper.vm.handleAddItemFormInput({ untouchedRawReferences, touchedReference });
expect(wrapper.vm.setItemInputValue).toHaveBeenCalledWith(touchedReference);
});
});
describe('handleAddItemFormBlur', () => {
const newValue = '&1 &2';
it('calls `addPendingReferences` action with provided `newValue` param', () => {
spyOn(wrapper.vm, 'addPendingReferences');
wrapper.vm.handleAddItemFormBlur(newValue);
expect(wrapper.vm.addPendingReferences).toHaveBeenCalledWith(newValue.split(/\s+/));
});
it('calls `setItemInputValue` action with empty string', () => {
spyOn(wrapper.vm, 'setItemInputValue');
wrapper.vm.handleAddItemFormBlur(newValue);
expect(wrapper.vm.setItemInputValue).toHaveBeenCalledWith('');
});
});
describe('handleAddItemFormSubmit', () => {
it('calls `addItem` action when `pendingReferences` prop in state is not empty', () => {
const newValue = '&1 &2';
spyOn(wrapper.vm, 'addItem');
wrapper.vm.handleAddItemFormSubmit(newValue);
expect(wrapper.vm.addItem).toHaveBeenCalled();
});
});
describe('handleCreateItemFormSubmit', () => {
it('calls `createItem` action with `itemTitle` param', () => {
const newValue = 'foo';
spyOn(wrapper.vm, 'createItem');
wrapper.vm.handleCreateItemFormSubmit(newValue);
expect(wrapper.vm.createItem).toHaveBeenCalledWith({
itemTitle: newValue,
});
});
});
describe('handleAddItemFormCancel', () => {
it('calls `toggleAddItemForm` actions with params `toggleState` as true and `actionType` as `ActionType.Epic`', () => {
spyOn(wrapper.vm, 'toggleAddItemForm');
wrapper.vm.handleAddItemFormCancel();
expect(wrapper.vm.toggleAddItemForm).toHaveBeenCalledWith({
toggleState: false,
actionType: '',
});
});
it('calls `setPendingReferences` action with empty array', () => {
spyOn(wrapper.vm, 'setPendingReferences');
wrapper.vm.handleAddItemFormCancel();
expect(wrapper.vm.setPendingReferences).toHaveBeenCalledWith([]);
});
it('calls `setItemInputValue` action with empty string', () => {
spyOn(wrapper.vm, 'setItemInputValue');
wrapper.vm.handleAddItemFormCancel();
expect(wrapper.vm.setItemInputValue).toHaveBeenCalledWith('');
});
});
describe('handleCreateItemFormCancel', () => {
it('calls `toggleCreateItemForm` actions with params `toggleState` and `actionType`', () => {
spyOn(wrapper.vm, 'toggleCreateItemForm');
wrapper.vm.handleCreateItemFormCancel();
expect(wrapper.vm.toggleCreateItemForm).toHaveBeenCalledWith({
toggleState: false,
actionType: '',
});
});
it('calls `setItemInputValue` action with empty string', () => {
spyOn(wrapper.vm, 'setItemInputValue');
wrapper.vm.handleCreateItemFormCancel();
expect(wrapper.vm.setItemInputValue).toHaveBeenCalledWith('');
});
});
});
describe('template', () => {
beforeEach(() => {
wrapper.vm.$store.dispatch('receiveItemsSuccess', {
parentItem: mockParentItem,
children: [],
isSubItem: false,
});
});
it('renders loading icon when `state.itemsFetchInProgress` prop is true', done => {
wrapper.vm.$store.dispatch('requestItems', {
parentItem: mockParentItem,
isSubItem: false,
});
wrapper.vm.$nextTick(() => {
expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true);
done();
});
});
it('renders tree container element when `state.itemsFetchInProgress` prop is false', done => {
wrapper.vm.$nextTick(() => {
expect(wrapper.find('.related-items-tree').isVisible()).toBe(true);
done();
});
});
it('renders tree container element with `disabled-content` class when `state.itemsFetchInProgress` prop is false and `state.itemAddInProgress` or `state.itemCreateInProgress` is true', done => {
wrapper.vm.$store.dispatch('requestAddItem');
wrapper.vm.$nextTick(() => {
expect(wrapper.find('.related-items-tree.disabled-content').isVisible()).toBe(true);
done();
});
});
it('renders tree header component', done => {
wrapper.vm.$nextTick(() => {
expect(wrapper.find(RelatedItemsTreeHeader).isVisible()).toBe(true);
done();
});
});
it('renders item add/create form container element', done => {
wrapper.vm.$store.dispatch('toggleAddItemForm', {
toggleState: true,
actionType: ActionType.Epic,
});
wrapper.vm.$nextTick(() => {
expect(wrapper.find('.add-item-form-container').isVisible()).toBe(true);
done();
});
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import RelatedItemsBody from 'ee/related_items_tree/components/related_items_tree_body.vue';
import TreeRoot from 'ee/related_items_tree/components/tree_root.vue';
import { mockParentItem } from '../mock_data';
const createComponent = (parentItem = mockParentItem, children = []) => {
const localVue = createLocalVue();
return shallowMount(RelatedItemsBody, {
localVue,
stubs: {
'tree-root': TreeRoot,
},
propsData: {
parentItem,
children,
},
});
};
describe('RelatedItemsTree', () => {
describe('RelatedTreeBody', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('renders component container element with class `related-items-tree-body`', () => {
expect(wrapper.vm.$el.classList.contains('related-items-tree-body')).toBe(true);
});
it('renders tree-root component', () => {
expect(wrapper.find('.related-items-list.tree-root').isVisible()).toBe(true);
});
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import RelatedItemsTreeHeader from 'ee/related_items_tree/components/related_items_tree_header.vue';
import Icon from '~/vue_shared/components/icon.vue';
import DroplabDropdownButton from '~/vue_shared/components/droplab_dropdown_button.vue';
import createDefaultStore from 'ee/related_items_tree/store';
import * as epicUtils from 'ee/related_items_tree/utils/epic_utils';
import { ActionType } from 'ee/related_items_tree/constants';
import { mockParentItem, mockQueryResponse } from '../mock_data';
const createComponent = () => {
const store = createDefaultStore();
const localVue = createLocalVue();
const children = epicUtils.processQueryResponse(mockQueryResponse.data.group);
store.dispatch('setInitialParentItem', mockParentItem);
store.dispatch('setItemChildren', {
parentItem: mockParentItem,
isSubItem: false,
children,
});
store.dispatch('setItemChildrenFlags', {
isSubItem: false,
children,
});
return shallowMount(RelatedItemsTreeHeader, {
localVue,
store,
});
};
describe('RelatedItemsTree', () => {
describe('RelatedItemsTreeHeader', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('badgeTooltip', () => {
it('returns string containing epic count and issues count based on available direct children within state', () => {
expect(wrapper.vm.badgeTooltip).toBe('2 epics and 2 issues');
});
});
});
describe('methods', () => {
describe('handleActionClick', () => {
const actionType = ActionType.Epic;
it('calls `toggleAddItemForm` action when provided `id` param as value `0`', () => {
spyOn(wrapper.vm, 'toggleAddItemForm');
wrapper.vm.handleActionClick({
id: 0,
actionType,
});
expect(wrapper.vm.toggleAddItemForm).toHaveBeenCalledWith({
actionType,
toggleState: true,
});
});
it('calls `toggleCreateItemForm` action when provided `id` param value is not `0`', () => {
spyOn(wrapper.vm, 'toggleCreateItemForm');
wrapper.vm.handleActionClick({
id: 1,
actionType,
});
expect(wrapper.vm.toggleCreateItemForm).toHaveBeenCalledWith({
actionType,
toggleState: true,
});
});
});
});
describe('template', () => {
it('renders item badges container', () => {
const badgesContainerEl = wrapper.find('.issue-count-badge');
expect(badgesContainerEl.isVisible()).toBe(true);
});
it('renders epics count and icon', () => {
const epicsEl = wrapper.findAll('.issue-count-badge > span').at(0);
const epicIcon = epicsEl.find(Icon);
expect(epicsEl.text().trim()).toBe('2');
expect(epicIcon.isVisible()).toBe(true);
expect(epicIcon.props('name')).toBe('epic');
});
it('renders issues count and icon', () => {
const issuesEl = wrapper.findAll('.issue-count-badge > span').at(1);
const issueIcon = issuesEl.find(Icon);
expect(issuesEl.text().trim()).toBe('2');
expect(issueIcon.isVisible()).toBe(true);
expect(issueIcon.props('name')).toBe('issues');
});
it('renders `Add an epic` dropdown button', () => {
expect(wrapper.find(DroplabDropdownButton).isVisible()).toBe(true);
});
it('renders `Add an issue` dropdown button', () => {
const addIssueBtn = wrapper.find(GlButton);
expect(addIssueBtn.isVisible()).toBe(true);
expect(addIssueBtn.text()).toBe('Add an issue');
});
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlTooltip } from '@gitlab/ui';
import StateTooltip from 'ee/related_items_tree/components/state_tooltip.vue';
// Ensure that mock dates dynamically computed from today
// so that test doesn't fail at any point in time.
const currentDate = new Date();
const mockCreatedAt = `${currentDate.getFullYear() - 2}-${currentDate.getMonth() +
1}-${currentDate.getDate()}`;
const mockCreatedAtYear = currentDate.getFullYear() - 2;
const mockClosedAt = `${currentDate.getFullYear() - 1}-${currentDate.getMonth() +
1}-${currentDate.getDate()}`;
const mockClosedAtYear = currentDate.getFullYear() - 1;
const createComponent = ({
getTargetRef = () => {},
isOpen = false,
state = 'closed',
createdAt = mockCreatedAt,
closedAt = mockClosedAt,
}) => {
const localVue = createLocalVue();
return shallowMount(StateTooltip, {
localVue,
propsData: {
getTargetRef,
isOpen,
state,
createdAt,
closedAt,
},
});
};
describe('RelatedItemsTree', () => {
describe('RelatedItemsTreeHeader', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent({});
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('stateText', () => {
it('returns string `Opened` when `isOpen` prop is true', done => {
wrapper.setProps({
isOpen: true,
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.stateText).toBe('Opened');
done();
});
});
it('returns string `Closed` when `isOpen` prop is false', done => {
wrapper.setProps({
isOpen: false,
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.stateText).toBe('Closed');
done();
});
});
});
describe('createdAtInWords', () => {
it('returns string containing date in words for `createdAt` prop', () => {
expect(wrapper.vm.createdAtInWords).toBe('2 years ago');
});
});
describe('closedAtInWords', () => {
it('returns string containing date in words for `closedAt` prop', () => {
expect(wrapper.vm.closedAtInWords).toBe('1 year ago');
});
});
describe('createdAtTimestamp', () => {
it('returns string containing date timestamp for `createdAt` prop', () => {
expect(wrapper.vm.createdAtTimestamp).toContain(mockCreatedAtYear);
});
});
describe('closedAtTimestamp', () => {
it('returns string containing date timestamp for `closedAt` prop', () => {
expect(wrapper.vm.closedAtTimestamp).toContain(mockClosedAtYear);
});
});
describe('stateTimeInWords', () => {
it('returns string using `createdAtInWords` prop when `isOpen` is true', done => {
wrapper.setProps({
isOpen: true,
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.stateTimeInWords).toBe('2 years ago');
done();
});
});
it('returns string using `closedAtInWords` prop when `isOpen` is false', done => {
wrapper.setProps({
isOpen: false,
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.stateTimeInWords).toBe('1 year ago');
done();
});
});
});
describe('stateTimestamp', () => {
it('returns string using `createdAtTimestamp` prop when `isOpen` is true', done => {
wrapper.setProps({
isOpen: true,
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.stateTimestamp).toContain(mockCreatedAtYear);
done();
});
});
it('returns string using `closedAtInWords` prop when `isOpen` is false', done => {
wrapper.setProps({
isOpen: false,
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.stateTimestamp).toContain(mockClosedAtYear);
done();
});
});
});
});
describe('methods', () => {
describe('getTimestamp', () => {
it('returns timestamp string from rawTimestamp', () => {
expect(wrapper.vm.getTimestamp(mockClosedAt)).toContain(mockClosedAtYear);
});
});
describe('getTimestampInWords', () => {
it('returns string date in words from rawTimestamp', () => {
expect(wrapper.vm.getTimestampInWords(mockClosedAt)).toContain('1 year ago');
});
});
});
describe('template', () => {
it('renders gl-tooltip as container element', () => {
expect(wrapper.find(GlTooltip).isVisible()).toBe(true);
});
it('renders stateText in bold', () => {
expect(
wrapper
.find('span.bold')
.text()
.trim(),
).toBe('Closed');
});
it('renders stateTimeInWords', () => {
expect(wrapper.text().trim()).toContain('1 year ago');
});
it('renders stateTimestamp in muted', () => {
expect(
wrapper
.find('span.text-tertiary')
.text()
.trim(),
).toContain(mockClosedAtYear);
});
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton, GlLink } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import ItemMilestone from '~/vue_shared/components/issue/issue_milestone.vue';
import ItemDueDate from '~/boards/components/issue_due_date.vue';
import ItemWeight from 'ee/boards/components/issue_card_weight.vue';
import ItemAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import TreeItemBody from 'ee/related_items_tree/components/tree_item_body.vue';
import StateTooltip from 'ee/related_items_tree/components/state_tooltip.vue';
import createDefaultStore from 'ee/related_items_tree/store';
import * as epicUtils from 'ee/related_items_tree/utils/epic_utils';
import { ChildType, ChildState } from 'ee/related_items_tree/constants';
import { mockParentItem, mockQueryResponse, mockIssue1 } from '../mock_data';
const mockItem = Object.assign({}, mockIssue1, {
type: ChildType.Issue,
pathIdSeparator: '#',
assignees: epicUtils.extractIssueAssignees(mockIssue1.assignees),
});
const createComponent = (parentItem = mockParentItem, item = mockItem) => {
const store = createDefaultStore();
const localVue = createLocalVue();
const children = epicUtils.processQueryResponse(mockQueryResponse.data.group);
store.dispatch('setInitialParentItem', mockParentItem);
store.dispatch('setItemChildren', {
parentItem: mockParentItem,
isSubItem: false,
children,
});
store.dispatch('setItemChildrenFlags', {
isSubItem: false,
children,
});
return shallowMount(TreeItemBody, {
localVue,
store,
propsData: {
parentItem,
item,
},
});
};
describe('RelatedItemsTree', () => {
describe('TreeItemBody', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('itemReference', () => {
it('returns value of `item.reference` prop', () => {
expect(wrapper.vm.itemReference).toBe(mockItem.reference);
});
});
describe('isOpen', () => {
it('returns true when `item.state` value is `opened`', done => {
wrapper.setProps({
item: Object.assign({}, mockItem, {
state: ChildState.Open,
}),
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.isOpen).toBe(true);
done();
});
});
});
describe('isClosed', () => {
it('returns true when `item.state` value is `closed`', done => {
wrapper.setProps({
item: Object.assign({}, mockItem, {
state: ChildState.Closed,
}),
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.isClosed).toBe(true);
done();
});
});
});
describe('hasMilestone', () => {
it('returns true when `item.milestone` is defined and has values', () => {
expect(wrapper.vm.hasMilestone).toBe(true);
});
});
describe('hasAssignees', () => {
it('returns true when `item.assignees` is defined and has values', () => {
expect(wrapper.vm.hasAssignees).toBe(true);
});
});
describe('stateText', () => {
it('returns string `Opened` when `item.state` value is `opened`', done => {
wrapper.setProps({
item: Object.assign({}, mockItem, {
state: ChildState.Open,
}),
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.stateText).toBe('Opened');
done();
});
});
it('returns string `Closed` when `item.state` value is `closed`', done => {
wrapper.setProps({
item: Object.assign({}, mockItem, {
state: ChildState.Closed,
}),
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.stateText).toBe('Closed');
done();
});
});
});
describe('stateIconName', () => {
it('returns string `epic` when `item.type` value is `epic`', done => {
wrapper.setProps({
item: Object.assign({}, mockItem, {
type: ChildType.Epic,
}),
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.stateIconName).toBe('epic');
done();
});
});
it('returns string `issues` when `item.type` value is `issue`', done => {
wrapper.setProps({
item: Object.assign({}, mockItem, {
type: ChildType.Issue,
}),
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.stateIconName).toBe('issues');
done();
});
});
});
describe('stateIconClass', () => {
it('returns string `issue-token-state-icon-open` when `item.state` value is `opened`', done => {
wrapper.setProps({
item: Object.assign({}, mockItem, {
state: ChildState.Open,
}),
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.stateIconClass).toBe('issue-token-state-icon-open');
done();
});
});
it('returns string `issue-token-state-icon-closed` when `item.state` value is `closed`', done => {
wrapper.setProps({
item: Object.assign({}, mockItem, {
state: ChildState.Closed,
}),
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.stateIconClass).toBe('issue-token-state-icon-closed');
done();
});
});
});
describe('itemPath', () => {
it('returns string containing item path', () => {
expect(wrapper.vm.itemPath).toBe('gitlab-org/gitlab-shell');
});
});
describe('itemId', () => {
it('returns string containing item id', () => {
expect(wrapper.vm.itemId).toBe('8');
});
});
describe('computedPath', () => {
it('returns value of `item.webPath` when it is defined', () => {
expect(wrapper.vm.computedPath).toBe(mockItem.webPath);
});
it('returns `null` when `item.webPath` is empty', done => {
wrapper.setProps({
item: Object.assign({}, mockItem, {
webPath: '',
}),
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.computedPath).toBeNull();
done();
});
});
});
});
describe('methods', () => {
describe('handleRemoveClick', () => {
it('calls `setRemoveItemModalProps` action with params `parentItem` and `item`', () => {
spyOn(wrapper.vm, 'setRemoveItemModalProps');
wrapper.vm.handleRemoveClick();
expect(wrapper.vm.setRemoveItemModalProps).toHaveBeenCalledWith({
parentItem: mockParentItem,
item: mockItem,
});
});
});
});
describe('template', () => {
it('renders item state icon for large screens', () => {
const statusIcon = wrapper.findAll(Icon).at(0);
expect(statusIcon.props('name')).toBe('issues');
});
it('renders item state tooltip for large screens', () => {
const stateTooltip = wrapper.findAll(StateTooltip).at(0);
expect(stateTooltip.props('state')).toBe(mockItem.state);
});
it('renders confidential icon when `item.confidential` is true', () => {
const confidentialIcon = wrapper.findAll(Icon).at(1);
expect(confidentialIcon.isVisible()).toBe(true);
expect(confidentialIcon.props('name')).toBe('eye-slash');
});
it('renders item link', () => {
const link = wrapper.find(GlLink);
expect(link.attributes('href')).toBe(mockItem.webPath);
expect(link.text()).toBe(mockItem.title);
});
it('renders item state icon for medium and small screens', () => {
const statusIcon = wrapper.findAll(Icon).at(2);
expect(statusIcon.props('name')).toBe('issues');
});
it('renders item state tooltip for medium and small screens', () => {
const stateTooltip = wrapper.findAll(StateTooltip).at(1);
expect(stateTooltip.props('state')).toBe(mockItem.state);
});
it('renders item path', () => {
const pathEl = wrapper.find('.path-id-text');
expect(pathEl.attributes('data-original-title')).toBe('gitlab-org/gitlab-shell');
expect(pathEl.text()).toBe('gitlab-org/gitlab-shell');
});
it('renders item id with separator', () => {
const pathIdEl = wrapper.find('.item-path-id');
expect(pathIdEl.text()).toBe(mockItem.reference);
});
it('renders item milestone when it has milestone', () => {
const milestone = wrapper.find(ItemMilestone);
expect(milestone.isVisible()).toBe(true);
});
it('renders item due date when it has due date', () => {
const dueDate = wrapper.find(ItemDueDate);
expect(dueDate.isVisible()).toBe(true);
});
it('renders item weight when it has weight', () => {
const weight = wrapper.find(ItemWeight);
expect(weight.isVisible()).toBe(true);
});
it('renders item assignees when it has assignees', () => {
const assignees = wrapper.find(ItemAssignees);
expect(assignees.isVisible()).toBe(true);
});
it('renders item remove button when `item.userPermissions.adminEpic` is true', () => {
const removeButton = wrapper.find(GlButton);
expect(removeButton.isVisible()).toBe(true);
expect(removeButton.attributes('data-original-title')).toBe('Remove');
});
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui';
import TreeItemRemoveModal from 'ee/related_items_tree/components/tree_item_remove_modal.vue';
import createDefaultStore from 'ee/related_items_tree/store';
import * as epicUtils from 'ee/related_items_tree/utils/epic_utils';
import { ChildType } from 'ee/related_items_tree/constants';
import { mockParentItem, mockQueryResponse, mockIssue1 } from '../mock_data';
const mockItem = Object.assign({}, mockIssue1, {
type: ChildType.Issue,
pathIdSeparator: '#',
assignees: epicUtils.extractIssueAssignees(mockIssue1.assignees),
});
const createComponent = (parentItem = mockParentItem, item = mockItem) => {
const store = createDefaultStore();
const localVue = createLocalVue();
const children = epicUtils.processQueryResponse(mockQueryResponse.data.group);
store.dispatch('setInitialParentItem', mockParentItem);
store.dispatch('setItemChildren', {
parentItem: mockParentItem,
isSubItem: false,
children,
});
store.dispatch('setItemChildrenFlags', {
isSubItem: false,
children,
});
store.dispatch('setRemoveItemModalProps', {
parentItem,
item,
});
return shallowMount(TreeItemRemoveModal, {
localVue,
store,
});
};
describe('RelatedItemsTree', () => {
describe('TreeItemRemoveModal', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('removeItemType', () => {
it('returns value of `state.removeItemModalProps.item.type', () => {
expect(wrapper.vm.removeItemType).toBe(mockItem.type);
});
});
describe('modalTitle', () => {
it('returns title for modal when item.type is `Epic`', done => {
wrapper.vm.$store.dispatch('setRemoveItemModalProps', {
parentItem: mockParentItem,
item: Object.assign({}, mockItem, { type: ChildType.Epic }),
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.modalTitle).toBe('Remove epic');
done();
});
});
it('returns title for modal when item.type is `Issue`', done => {
wrapper.vm.$store.dispatch('setRemoveItemModalProps', {
parentItem: mockParentItem,
item: mockItem,
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.modalTitle).toBe('Remove issue');
done();
});
});
});
describe('modalBody', () => {
it('returns body text for modal when item.type is `Epic`', done => {
wrapper.vm.$store.dispatch('setRemoveItemModalProps', {
parentItem: mockParentItem,
item: Object.assign({}, mockItem, { type: ChildType.Epic }),
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.modalBody).toBe(
'This will also remove any descendents of <b>Nostrum cum mollitia quia recusandae fugit deleniti voluptatem delectus.</b> from <b>Some sample epic</b>. Are you sure?',
);
done();
});
});
it('returns body text for modal when item.type is `Issue`', done => {
wrapper.vm.$store.dispatch('setRemoveItemModalProps', {
parentItem: mockParentItem,
item: mockItem,
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.modalBody).toBe(
'Are you sure you want to remove <b>Nostrum cum mollitia quia recusandae fugit deleniti voluptatem delectus.</b> from <b>Some sample epic</b>?',
);
done();
});
});
});
});
describe('template', () => {
it('renders modal component', () => {
const modal = wrapper.find(GlModal);
expect(modal.isVisible()).toBe(true);
expect(modal.attributes('modalid')).toBe('item-remove-confirmation');
expect(modal.attributes('ok-title')).toBe('Remove');
expect(modal.attributes('ok-variant')).toBe('danger');
});
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import TreeItem from 'ee/related_items_tree/components/tree_item.vue';
import TreeItemBody from 'ee/related_items_tree/components/tree_item_body.vue';
import TreeRoot from 'ee/related_items_tree/components/tree_root.vue';
import createDefaultStore from 'ee/related_items_tree/store';
import * as epicUtils from 'ee/related_items_tree/utils/epic_utils';
import { ChildType } from 'ee/related_items_tree/constants';
import { mockParentItem, mockQueryResponse, mockEpic1 } from '../mock_data';
const mockItem = Object.assign({}, mockEpic1, {
type: ChildType.Epic,
pathIdSeparator: '&',
});
const createComponent = (parentItem = mockParentItem, item = mockItem) => {
const store = createDefaultStore();
const localVue = createLocalVue();
const children = epicUtils.processQueryResponse(mockQueryResponse.data.group);
store.dispatch('setInitialParentItem', mockParentItem);
store.dispatch('setItemChildren', {
parentItem: mockParentItem,
isSubItem: false,
children,
});
store.dispatch('setItemChildrenFlags', {
isSubItem: false,
children,
});
store.dispatch('setItemChildren', {
parentItem: mockItem,
children: [],
isSubItem: true,
});
return shallowMount(TreeItem, {
localVue,
store,
stubs: {
'tree-root': TreeRoot,
},
propsData: {
parentItem,
item,
},
});
};
describe('RelatedItemsTree', () => {
describe('TreeItemRemoveModal', () => {
let wrapper;
let wrapperExpanded;
let wrapperCollapsed;
beforeEach(() => {
wrapper = createComponent();
});
beforeAll(() => {
wrapperExpanded = createComponent();
wrapperExpanded.vm.$store.dispatch('expandItem', {
parentItem: mockItem,
});
wrapperCollapsed = createComponent();
wrapperCollapsed.vm.$store.dispatch('collapseItem', {
parentItem: mockItem,
});
});
afterEach(() => {
wrapper.destroy();
});
afterAll(() => {
wrapperExpanded.destroy();
wrapperCollapsed.destroy();
});
describe('computed', () => {
describe('itemReference', () => {
it('returns value of `item.reference`', () => {
expect(wrapper.vm.itemReference).toBe(mockItem.reference);
});
});
describe('chevronType', () => {
it('returns string `chevron-down` when `state.childrenFlags[itemReference].itemExpanded` is true', () => {
expect(wrapperExpanded.vm.chevronType).toBe('chevron-down');
});
it('returns string `chevron-right` when `state.childrenFlags[itemReference].itemExpanded` is false', () => {
expect(wrapperCollapsed.vm.chevronType).toBe('chevron-right');
});
});
describe('chevronTooltip', () => {
it('returns string `Collapse` when `state.childrenFlags[itemReference].itemExpanded` is true', () => {
expect(wrapperExpanded.vm.chevronTooltip).toBe('Collapse');
});
it('returns string `Expand` when `state.childrenFlags[itemReference].itemExpanded` is false', () => {
expect(wrapperCollapsed.vm.chevronTooltip).toBe('Expand');
});
});
});
describe('methods', () => {
describe('handleChevronClick', () => {
it('calls `toggleItem` action with `item` as a param', () => {
spyOn(wrapper.vm, 'toggleItem');
wrapper.vm.handleChevronClick();
expect(wrapper.vm.toggleItem).toHaveBeenCalledWith({
parentItem: mockItem,
});
});
});
});
describe('template', () => {
it('renders list item as component container element', () => {
expect(wrapper.vm.$el.classList.contains('tree-item')).toBe(true);
expect(wrapper.vm.$el.classList.contains('js-item-type-epic')).toBe(true);
expect(wrapperExpanded.vm.$el.classList.contains('item-expanded')).toBe(true);
});
it('renders expand/collapse button', () => {
const chevronButton = wrapper.find(GlButton);
expect(chevronButton.isVisible()).toBe(true);
expect(chevronButton.attributes('data-original-title')).toBe('Collapse');
});
it('renders expand/collapse icon', () => {
const expandedIcon = wrapperExpanded.find(Icon);
const collapsedIcon = wrapperCollapsed.find(Icon);
expect(expandedIcon.isVisible()).toBe(true);
expect(expandedIcon.props('name')).toBe('chevron-down');
expect(collapsedIcon.isVisible()).toBe(true);
expect(collapsedIcon.props('name')).toBe('chevron-right');
});
it('renders loading icon when item expand is in progress', done => {
wrapper.vm.$store.dispatch('requestItems', {
parentItem: mockItem,
isSubItem: true,
});
wrapper.vm.$nextTick(() => {
const loadingIcon = wrapper.find(GlLoadingIcon);
expect(loadingIcon.isVisible()).toBe(true);
done();
});
});
it('renders tree item body component', () => {
const itemBody = wrapper.find(TreeItemBody);
expect(itemBody.isVisible()).toBe(true);
});
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import TreeRoot from 'ee/related_items_tree/components/tree_root.vue';
import { ChildType } from 'ee/related_items_tree/constants';
import { mockParentItem, mockEpic1 } from '../mock_data';
const mockItem = Object.assign({}, mockEpic1, {
type: ChildType.Epic,
pathIdSeparator: '&',
});
const createComponent = (parentItem = mockParentItem, children = [mockItem]) => {
const localVue = createLocalVue();
return shallowMount(TreeRoot, {
localVue,
stubs: {
'tree-item': true,
},
propsData: {
parentItem,
children,
},
});
};
describe('RelatedItemsTree', () => {
describe('TreeRoot', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('renders tree item component', () => {
expect(wrapper.html()).toContain('tree-item-stub');
});
});
});
});
export const mockInitialConfig = {
epicsEndpoint: 'http://test.host',
issuesEndpoint: 'http://test.host',
autoCompleteEpics: true,
autoCompleteIssues: false,
};
export const mockParentItem = {
iid: 1,
fullPath: 'gitlab-org',
title: 'Some sample epic',
reference: 'gitlab-org&1',
userPermissions: {
adminEpic: true,
createEpic: true,
},
};
export const mockEpic1 = {
id: '4',
iid: '4',
title: 'Quo ea ipsa enim perferendis at omnis officia.',
state: 'opened',
webPath: '/groups/gitlab-org/-/epics/4',
reference: '&4',
relationPath: '/groups/gitlab-org/-/epics/1/links/4',
createdAt: '2019-02-18T14:13:06Z',
closedAt: null,
hasChildren: true,
hasIssues: true,
userPermissions: {
adminEpic: true,
createEpic: true,
},
group: {
fullPath: 'gitlab-org',
},
};
export const mockEpic2 = {
id: '3',
iid: '3',
title: 'A nisi mollitia explicabo quam soluta dolor hic.',
state: 'closed',
webPath: '/groups/gitlab-org/-/epics/3',
reference: '&3',
relationPath: '/groups/gitlab-org/-/epics/1/links/3',
createdAt: '2019-02-18T14:13:06Z',
closedAt: '2019-04-26T06:51:22Z',
hasChildren: false,
hasIssues: false,
userPermissions: {
adminEpic: true,
createEpic: true,
},
group: {
fullPath: 'gitlab-org',
},
};
export const mockIssue1 = {
iid: '8',
title: 'Nostrum cum mollitia quia recusandae fugit deleniti voluptatem delectus.',
closedAt: null,
state: 'opened',
createdAt: '2019-02-18T14:06:41Z',
confidential: true,
dueDate: '2019-06-14',
weight: 5,
webPath: '/gitlab-org/gitlab-shell/issues/8',
reference: 'gitlab-org/gitlab-shell#8',
relationPath: '/groups/gitlab-org/-/epics/1/issues/10',
assignees: {
edges: [
{
node: {
webUrl: 'http://127.0.0.1:3001/root',
name: 'Administrator',
username: 'root',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
},
},
],
},
milestone: {
title: 'v4.0',
startDate: '2019-02-01',
dueDate: '2019-06-30',
},
};
export const mockIssue2 = {
iid: '33',
title: 'Dismiss Cipher with no integrity',
closedAt: null,
state: 'opened',
createdAt: '2019-02-18T14:13:05Z',
confidential: false,
dueDate: null,
weight: null,
webPath: '/gitlab-org/gitlab-shell/issues/33',
reference: 'gitlab-org/gitlab-shell#33',
relationPath: '/groups/gitlab-org/-/epics/1/issues/27',
assignees: {
edges: [],
},
milestone: null,
};
export const mockEpics = [mockEpic1, mockEpic2];
export const mockQueryResponse = {
data: {
group: {
id: 1,
path: 'gitlab-org',
fullPath: 'gitlab-org',
epic: {
id: 1,
iid: 1,
title: 'Foo bar',
webPath: '/groups/gitlab-org/-/epics/1',
userPermissions: {
adminEpic: true,
createEpic: true,
},
children: {
edges: [
{
node: mockEpic1,
},
{
node: mockEpic2,
},
],
},
issues: {
edges: [
{
node: mockIssue1,
},
{
node: mockIssue2,
},
],
},
},
},
},
};
import MockAdapter from 'axios-mock-adapter';
import createDefaultState from 'ee/related_items_tree/store/state';
import * as actions from 'ee/related_items_tree/store/actions';
import * as types from 'ee/related_items_tree/store/mutation_types';
import * as epicUtils from 'ee/related_items_tree/utils/epic_utils';
import {
ChildType,
ChildState,
ActionType,
PathIdSeparator,
} from 'ee/related_items_tree/constants';
import axios from '~/lib/utils/axios_utils';
import testAction from 'spec/helpers/vuex_action_helper';
import {
mockInitialConfig,
mockParentItem,
mockQueryResponse,
mockEpics,
mockEpic1,
} from '../mock_data';
describe('RelatedItemTree', () => {
describe('store', () => {
describe('actions', () => {
let state;
const mockItems = mockEpics.map(item =>
epicUtils.formatChildItem(Object.assign(item, { type: ChildType.Epic })),
);
beforeEach(() => {
state = createDefaultState();
});
describe('setInitialConfig', () => {
it('should set initial config on state', done => {
testAction(
actions.setInitialConfig,
mockInitialConfig,
{},
[{ type: types.SET_INITIAL_CONFIG, payload: mockInitialConfig }],
[],
done,
);
});
});
describe('setInitialParentItem', () => {
it('should set initial parentItem on state', done => {
testAction(
actions.setInitialParentItem,
mockParentItem,
{},
[{ type: types.SET_INITIAL_PARENT_ITEM, payload: mockParentItem }],
[],
done,
);
});
});
describe('expandItem', () => {
it('should set `itemExpanded` to true on state.childrenFlags', done => {
testAction(
actions.expandItem,
{},
{},
[{ type: types.EXPAND_ITEM, payload: {} }],
[],
done,
);
});
});
describe('collapseItem', () => {
it('should set `itemExpanded` to false on state.childrenFlags', done => {
testAction(
actions.collapseItem,
{},
{},
[{ type: types.COLLAPSE_ITEM, payload: {} }],
[],
done,
);
});
});
describe('setItemChildren', () => {
const mockPayload = { children: ['foo'], parentItem: mockParentItem, isSubItem: false };
it('should set provided `children` values on state.children with provided parentItem.reference key', done => {
testAction(
actions.setItemChildren,
mockPayload,
{},
[
{
type: types.SET_ITEM_CHILDREN,
payload: mockPayload,
},
],
[],
done,
);
});
it('should set provided `children` values on state.children with provided parentItem.reference key and also dispatch action `expandItem` when isSubItem param is true', done => {
mockPayload.isSubItem = true;
testAction(
actions.setItemChildren,
mockPayload,
{},
[
{
type: types.SET_ITEM_CHILDREN,
payload: mockPayload,
},
],
[
{
type: 'expandItem',
payload: { parentItem: mockPayload.parentItem },
},
],
done,
);
});
});
describe('setItemChildrenFlags', () => {
it('should set `state.childrenFlags` for every item in provided children param', done => {
testAction(
actions.setItemChildrenFlags,
{ children: [{ reference: '&1' }] },
{},
[{ type: types.SET_ITEM_CHILDREN_FLAGS, payload: { children: [{ reference: '&1' }] } }],
[],
done,
);
});
});
describe('requestItems', () => {
it('should set `state.itemsFetchInProgress` to true', done => {
testAction(
actions.requestItems,
{},
{},
[{ type: types.REQUEST_ITEMS, payload: {} }],
[],
done,
);
});
});
describe('receiveItemsSuccess', () => {
it('should set `state.itemsFetchInProgress` to false', done => {
testAction(
actions.receiveItemsSuccess,
{},
{},
[{ type: types.RECEIVE_ITEMS_SUCCESS, payload: {} }],
[],
done,
);
});
});
describe('receiveItemsFailure', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('should set `state.itemsFetchInProgress` to false', done => {
testAction(
actions.receiveItemsFailure,
{},
{},
[{ type: types.RECEIVE_ITEMS_FAILURE, payload: {} }],
[],
done,
);
});
it('should show flash error with message "Something went wrong while fetching child epics."', () => {
const message = 'Something went wrong while fetching child epics.';
actions.receiveItemsFailure(
{
commit: () => {},
},
{},
);
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
message,
);
});
});
describe('fetchItems', () => {
it('should dispatch `requestItems` action', done => {
testAction(
actions.fetchItems,
{ parentItem: mockParentItem, isSubItem: false },
{},
[],
[
{
type: 'requestItems',
payload: { parentItem: mockParentItem, isSubItem: false },
},
],
done,
);
});
it('should dispatch `receiveItemsSuccess`, `setItemChildren` and `setItemChildrenFlags` on request success', done => {
spyOn(epicUtils.gqClient, 'query').and.returnValue(
Promise.resolve({
data: mockQueryResponse.data,
}),
);
const children = epicUtils.processQueryResponse(mockQueryResponse.data.group);
testAction(
actions.fetchItems,
{ parentItem: mockParentItem, isSubItem: false },
{},
[],
[
{
type: 'requestItems',
payload: { parentItem: mockParentItem, isSubItem: false },
},
{
type: 'receiveItemsSuccess',
payload: {
parentItem: mockParentItem,
isSubItem: false,
children,
},
},
{
type: 'setItemChildren',
payload: {
parentItem: mockParentItem,
isSubItem: false,
children,
},
},
{
type: 'setItemChildrenFlags',
payload: {
isSubItem: false,
children,
},
},
],
done,
);
});
it('should dispatch `receiveItemsFailure` on request failure', done => {
spyOn(epicUtils.gqClient, 'query').and.returnValue(Promise.reject());
testAction(
actions.fetchItems,
{ parentItem: mockParentItem, isSubItem: false },
{},
[],
[
{
type: 'requestItems',
payload: { parentItem: mockParentItem, isSubItem: false },
},
{
type: 'receiveItemsFailure',
payload: {
parentItem: mockParentItem,
isSubItem: false,
},
},
],
done,
);
});
});
describe('toggleItem', () => {
const data = {
parentItem: {
reference: '&1',
},
};
it('should dispatch `fetchItems` when a parent item is not expanded and does not have children present in state', done => {
state.childrenFlags[data.parentItem.reference] = {
itemExpanded: false,
};
testAction(
actions.toggleItem,
data,
state,
[],
[
{
type: 'fetchItems',
payload: { parentItem: data.parentItem, isSubItem: true },
},
],
done,
);
});
it('should dispatch `expandItem` when a parent item is not expanded but does have children present in state', done => {
state.childrenFlags[data.parentItem.reference] = {
itemExpanded: false,
};
state.children[data.parentItem.reference] = ['foo'];
testAction(
actions.toggleItem,
data,
state,
[],
[
{
type: 'expandItem',
payload: { parentItem: data.parentItem },
},
],
done,
);
});
it('should dispatch `collapseItem` when a parent item is expanded', done => {
state.childrenFlags[data.parentItem.reference] = {
itemExpanded: true,
};
testAction(
actions.toggleItem,
data,
state,
[],
[
{
type: 'collapseItem',
payload: { parentItem: data.parentItem },
},
],
done,
);
});
});
describe('setRemoveItemModalProps', () => {
it('should set values on `state.removeItemModalProps` for initializing modal', done => {
testAction(
actions.setRemoveItemModalProps,
{},
{},
[{ type: types.SET_REMOVE_ITEM_MODAL_PROPS, payload: {} }],
[],
done,
);
});
});
describe('requestRemoveItem', () => {
it('should set `state.childrenFlags[ref].itemRemoveInProgress` to true', done => {
testAction(
actions.requestRemoveItem,
{},
{},
[{ type: types.REQUEST_REMOVE_ITEM, payload: {} }],
[],
done,
);
});
});
describe('receiveRemoveItemSuccess', () => {
it('should set `state.childrenFlags[ref].itemRemoveInProgress` to false', done => {
testAction(
actions.receiveRemoveItemSuccess,
{},
{},
[{ type: types.RECEIVE_REMOVE_ITEM_SUCCESS, payload: {} }],
[],
done,
);
});
});
describe('receiveRemoveItemFailure', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('should set `state.childrenFlags[ref].itemRemoveInProgress` to false', done => {
testAction(
actions.receiveRemoveItemFailure,
{ item: { type: ChildType.Epic } },
{},
[
{
type: types.RECEIVE_REMOVE_ITEM_FAILURE,
payload: { type: ChildType.Epic },
},
],
[],
done,
);
});
it('should show flash error with message "An error occurred while removing epics."', () => {
actions.receiveRemoveItemFailure(
{
commit: () => {},
},
{
item: { type: ChildType.Epic },
},
);
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
'An error occurred while removing epics.',
);
});
});
describe('removeItem', () => {
let mock;
const data = {
parentItem: mockParentItem,
item: Object.assign({}, mockParentItem, {
iid: 2,
relationPath: '/foo/bar',
}),
};
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
it('should dispatch `requestRemoveItem` and `receiveRemoveItemSuccess` actions on request success', done => {
mock.onDelete(data.item.relationPath).replyOnce(200, {});
testAction(
actions.removeItem,
{ ...data },
state,
[],
[
{
type: 'requestRemoveItem',
payload: { item: data.item },
},
{
type: 'receiveRemoveItemSuccess',
payload: { parentItem: data.parentItem, item: data.item },
},
],
done,
);
});
it('should dispatch `requestRemoveItem` and `receiveRemoveItemFailure` actions on request failure', done => {
mock.onDelete(data.item.relationPath).replyOnce(500, {});
testAction(
actions.removeItem,
{ ...data },
state,
[],
[
{
type: 'requestRemoveItem',
payload: { item: data.item },
},
{
type: 'receiveRemoveItemFailure',
payload: { item: data.item, status: undefined },
},
],
done,
);
});
});
describe('toggleAddItemForm', () => {
it('should set `state.showAddItemForm` to true', done => {
testAction(
actions.toggleAddItemForm,
{},
{},
[{ type: types.TOGGLE_ADD_ITEM_FORM, payload: {} }],
[],
done,
);
});
});
describe('toggleCreateItemForm', () => {
it('should set `state.showCreateItemForm` to true', done => {
testAction(
actions.toggleCreateItemForm,
{},
{},
[{ type: types.TOGGLE_CREATE_ITEM_FORM, payload: {} }],
[],
done,
);
});
});
describe('setPendingReferences', () => {
it('should set param value to `state.pendingReference`', done => {
testAction(
actions.setPendingReferences,
{},
{},
[{ type: types.SET_PENDING_REFERENCES, payload: {} }],
[],
done,
);
});
});
describe('addPendingReferences', () => {
it('should add param value to `state.pendingReference`', done => {
testAction(
actions.addPendingReferences,
{},
{},
[{ type: types.ADD_PENDING_REFERENCES, payload: {} }],
[],
done,
);
});
});
describe('removePendingReference', () => {
it('should remove param value to `state.pendingReference`', done => {
testAction(
actions.removePendingReference,
{},
{},
[{ type: types.REMOVE_PENDING_REFERENCE, payload: {} }],
[],
done,
);
});
});
describe('setItemInputValue', () => {
it('should set param value to `state.itemInputValue`', done => {
testAction(
actions.setItemInputValue,
{},
{},
[{ type: types.SET_ITEM_INPUT_VALUE, payload: {} }],
[],
done,
);
});
});
describe('requestAddItem', () => {
it('should set `state.itemAddInProgress` to true', done => {
testAction(actions.requestAddItem, {}, {}, [{ type: types.REQUEST_ADD_ITEM }], [], done);
});
});
describe('receiveAddItemSuccess', () => {
it('should set `state.itemAddInProgress` to false and dispatches actions `setPendingReferences`, `setItemInputValue` and `toggleAddItemForm`', done => {
state.epicsBeginAtIndex = 0;
const mockEpicsWithoutPerm = mockEpics.map(item =>
Object.assign({}, item, {
pathIdSeparator: PathIdSeparator.Epic,
userPermissions: { adminEpic: undefined },
}),
);
testAction(
actions.receiveAddItemSuccess,
{ actionType: ActionType.Epic, rawItems: mockEpicsWithoutPerm },
state,
[
{
type: types.RECEIVE_ADD_ITEM_SUCCESS,
payload: {
insertAt: 0,
items: mockEpicsWithoutPerm,
},
},
],
[
{
type: 'setItemChildrenFlags',
payload: { children: mockEpicsWithoutPerm, isSubItem: false },
},
{
type: 'setPendingReferences',
payload: [],
},
{
type: 'setItemInputValue',
payload: '',
},
{
type: 'toggleAddItemForm',
payload: { actionType: ActionType.Epic, toggleState: false },
},
],
done,
);
});
});
describe('receiveAddItemFailure', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('should set `state.itemAddInProgress` to false', done => {
testAction(
actions.receiveAddItemFailure,
{},
{},
[
{
type: types.RECEIVE_ADD_ITEM_FAILURE,
},
],
[],
done,
);
});
it('should show flash error with message "Something went wrong while adding item."', () => {
const message = 'Something went wrong while adding item.';
actions.receiveAddItemFailure(
{
commit: () => {},
state: { actionType: ActionType.Epic },
},
{
message,
},
);
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
message,
);
});
});
describe('addItem', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
it('should dispatch `requestAddItem` and `receiveAddItemSuccess` actions on request success', done => {
state.actionType = ActionType.Epic;
state.epicsEndpoint = '/foo/bar';
state.pendingReferences = ['foo'];
mock.onPost(state.epicsEndpoint).replyOnce(200, { issuables: [mockEpic1] });
testAction(
actions.addItem,
{},
state,
[],
[
{
type: 'requestAddItem',
},
{
type: 'receiveAddItemSuccess',
payload: { actionType: ActionType.Epic, rawItems: [mockEpic1] },
},
],
done,
);
});
it('should dispatch `requestAddItem` and `receiveAddItemFailure` actions on request failure', done => {
state.actionType = ActionType.Epic;
state.epicsEndpoint = '/foo/bar';
state.pendingReferences = ['foo'];
mock.onPost(state.epicsEndpoint).replyOnce(500, {});
testAction(
actions.addItem,
{},
state,
[],
[
{
type: 'requestAddItem',
},
{
type: 'receiveAddItemFailure',
},
],
done,
);
});
});
describe('requestCreateItem', () => {
it('should set `state.itemCreateInProgress` to true', done => {
testAction(
actions.requestCreateItem,
{},
{},
[{ type: types.REQUEST_CREATE_ITEM }],
[],
done,
);
});
});
describe('receiveCreateItemSuccess', () => {
it('should set `state.itemCreateInProgress` to false', done => {
state.epicsBeginAtIndex = 0;
testAction(
actions.receiveCreateItemSuccess,
{ rawItem: mockEpic1, actionType: ActionType.Epic },
state,
[
{
type: types.RECEIVE_CREATE_ITEM_SUCCESS,
payload: { insertAt: 0, item: mockItems[0] },
},
],
[
{
type: 'setItemChildrenFlags',
payload: { children: [mockItems[0]], isSubItem: false },
},
{
type: 'toggleCreateItemForm',
payload: { actionType: ActionType.Epic, toggleState: false },
},
],
done,
);
});
});
describe('receiveCreateItemFailure', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('should set `state.itemCreateInProgress` to false', done => {
testAction(
actions.receiveCreateItemFailure,
{},
{},
[{ type: types.RECEIVE_CREATE_ITEM_FAILURE }],
[],
done,
);
});
it('should show flash error with message "Something went wrong while creating child epics."', () => {
const message = 'Something went wrong while creating child epics.';
actions.receiveCreateItemFailure(
{
commit: () => {},
state: { actionType: ActionType.Epic },
},
{
message,
},
);
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
message,
);
});
});
describe('createItem', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
state.parentItem = mockParentItem;
state.actionType = ActionType.Epic;
});
afterEach(() => {
mock.restore();
});
it('should dispatch `requestCreateItem` and `receiveCreateItemSuccess` actions on request success', done => {
mock.onPost(/(.*)/).replyOnce(200, mockEpic1);
testAction(
actions.createItem,
{ itemTitle: 'Sample child epic' },
state,
[],
[
{
type: 'requestCreateItem',
},
{
type: 'receiveCreateItemSuccess',
payload: {
actionType: ActionType.Epic,
rawItem: Object.assign({}, mockEpic1, {
path: '',
state: ChildState.Open,
created_at: '',
}),
},
},
],
done,
);
});
it('should dispatch `requestCreateItem` and `receiveCreateItemFailure` actions on request failure', done => {
mock.onPost(/(.*)/).replyOnce(500, {});
testAction(
actions.createItem,
{ itemTitle: 'Sample child epic' },
state,
[],
[
{
type: 'requestCreateItem',
},
{
type: 'receiveCreateItemFailure',
},
],
done,
);
});
});
});
});
});
...@@ -693,6 +693,9 @@ msgstr "" ...@@ -693,6 +693,9 @@ msgstr ""
msgid "Add an SSH key" msgid "Add an SSH key"
msgstr "" msgstr ""
msgid "Add an issue"
msgstr ""
msgid "Add approvers" msgid "Add approvers"
msgstr "" msgstr ""
...@@ -4915,18 +4918,51 @@ msgstr "" ...@@ -4915,18 +4918,51 @@ msgstr ""
msgid "Epics let you manage your portfolio of projects more efficiently and with less effort" msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
msgstr "" msgstr ""
msgid "Epics|%{epicsCount} epics and %{issuesCount} issues"
msgstr ""
msgid "Epics|Add an epic"
msgstr ""
msgid "Epics|Add an existing epic as a child epic."
msgstr ""
msgid "Epics|An error occurred while saving the %{epicDateType} date" msgid "Epics|An error occurred while saving the %{epicDateType} date"
msgstr "" msgstr ""
msgid "Epics|Are you sure you want to remove %{bStart}%{targetIssueTitle}%{bEnd} from %{bStart}%{parentEpicTitle}%{bEnd}?"
msgstr ""
msgid "Epics|Create an epic within this group and add it as a child epic."
msgstr ""
msgid "Epics|Create new epic"
msgstr ""
msgid "Epics|How can I solve this?" msgid "Epics|How can I solve this?"
msgstr "" msgstr ""
msgid "Epics|More information" msgid "Epics|More information"
msgstr "" msgstr ""
msgid "Epics|Remove epic"
msgstr ""
msgid "Epics|Remove issue"
msgstr ""
msgid "Epics|Something went wrong while creating child epics."
msgstr ""
msgid "Epics|Something went wrong while fetching child epics."
msgstr ""
msgid "Epics|These dates affect how your epics appear in the roadmap. Dates from milestones come from the milestones assigned to issues in the epic. You can also set fixed dates or remove them entirely." msgid "Epics|These dates affect how your epics appear in the roadmap. Dates from milestones come from the milestones assigned to issues in the epic. You can also set fixed dates or remove them entirely."
msgstr "" msgstr ""
msgid "Epics|This will also remove any descendents of %{bStart}%{targetEpicTitle}%{bEnd} from %{bStart}%{parentEpicTitle}%{bEnd}. Are you sure?"
msgstr ""
msgid "Epics|To schedule your epic's %{epicDateType} date based on milestones, assign a milestone with a %{epicDateType} date to any issue in the epic." msgid "Epics|To schedule your epic's %{epicDateType} date based on milestones, assign a milestone with a %{epicDateType} date to any issue in the epic."
msgstr "" msgstr ""
...@@ -8554,6 +8590,9 @@ msgstr "" ...@@ -8554,6 +8590,9 @@ msgstr ""
msgid "New epic" msgid "New epic"
msgstr "" msgstr ""
msgid "New epic title"
msgstr ""
msgid "New file" msgid "New file"
msgstr "" msgstr ""
...@@ -13900,6 +13939,9 @@ msgstr "" ...@@ -13900,6 +13939,9 @@ msgstr ""
msgid "TransferProject|Transfer failed, please contact an admin." msgid "TransferProject|Transfer failed, please contact an admin."
msgstr "" msgstr ""
msgid "Tree"
msgstr ""
msgid "Tree view" msgid "Tree view"
msgstr "" msgstr ""
......
import { mount, createLocalVue } from '@vue/test-utils';
import DroplabDropdownButton from '~/vue_shared/components/droplab_dropdown_button.vue';
const mockActions = [
{
title: 'Foo',
description: 'Some foo action',
},
{
title: 'Bar',
description: 'Some bar action',
},
];
const createComponent = ({
size = '',
dropdownClass = '',
actions = mockActions,
defaultAction = 0,
}) => {
const localVue = createLocalVue();
return mount(DroplabDropdownButton, {
localVue,
propsData: {
size,
dropdownClass,
actions,
defaultAction,
},
});
};
describe('DroplabDropdownButton', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent({});
});
afterEach(() => {
wrapper.destroy();
});
describe('data', () => {
it('contains `selectedAction` representing value of `defaultAction` prop', () => {
expect(wrapper.vm.selectedAction).toBe(0);
});
});
describe('computed', () => {
describe('selectedActionTitle', () => {
it('returns string containing title of selected action', () => {
wrapper.setData({ selectedAction: 0 });
expect(wrapper.vm.selectedActionTitle).toBe(mockActions[0].title);
wrapper.setData({ selectedAction: 1 });
expect(wrapper.vm.selectedActionTitle).toBe(mockActions[1].title);
});
});
describe('buttonSizeClass', () => {
it('returns string containing button sizing class based on `size` prop', done => {
const wrapperWithSize = createComponent({
size: 'sm',
});
wrapperWithSize.vm.$nextTick(() => {
expect(wrapperWithSize.vm.buttonSizeClass).toBe('btn-sm');
done();
wrapperWithSize.destroy();
});
});
});
});
describe('methods', () => {
describe('handlePrimaryActionClick', () => {
it('emits `onActionClick` event on component with selectedAction object as param', () => {
jest.spyOn(wrapper.vm, '$emit');
wrapper.setData({ selectedAction: 0 });
wrapper.vm.handlePrimaryActionClick();
expect(wrapper.vm.$emit).toHaveBeenCalledWith('onActionClick', mockActions[0]);
});
});
describe('handleActionClick', () => {
it('emits `onActionSelect` event on component with selectedAction index as param', () => {
jest.spyOn(wrapper.vm, '$emit');
wrapper.vm.handleActionClick(1);
expect(wrapper.vm.$emit).toHaveBeenCalledWith('onActionSelect', 1);
});
});
});
describe('template', () => {
it('renders default action button', () => {
const defaultButton = wrapper.findAll('.btn').at(0);
expect(defaultButton.text()).toBe(mockActions[0].title);
});
it('renders dropdown button', () => {
const dropdownButton = wrapper.findAll('.dropdown-toggle').at(0);
expect(dropdownButton.isVisible()).toBe(true);
});
it('renders dropdown actions', () => {
const dropdownActions = wrapper.findAll('.dropdown-menu li button');
Array(dropdownActions.length)
.fill()
.forEach((_, index) => {
const actionContent = dropdownActions.at(index).find('.description');
expect(actionContent.find('strong').text()).toBe(mockActions[index].title);
expect(actionContent.find('p').text()).toBe(mockActions[index].description);
});
});
it('renders divider between dropdown actions', () => {
const dropdownDivider = wrapper.find('.dropdown-menu .divider');
expect(dropdownDivider.isVisible()).toBe(true);
});
});
});
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