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 {
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>
......@@ -70,9 +79,9 @@ export default {
<user-avatar-link
v-for="assignee in assigneesToShow"
:key="assignee.id"
:link-href="assignee.web_url"
:link-href="webUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)"
:img-src="assignee.avatar_url"
:img-src="avatarUrl(assignee)"
:img-size="24"
class="js-no-trigger"
tooltip-placement="bottom"
......
......@@ -19,10 +19,14 @@ export default {
},
computed: {
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() {
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() {
if (!this.milestoneStart) {
......
......@@ -6,6 +6,7 @@ export default {
geoNodesPath: '/api/:version/geo_nodes',
ldapGroupsPath: '/api/:version/ldap/:provider/groups.json',
subscriptionPath: '/api/:version/namespaces/:id/gitlab_subscription',
childEpicPath: '/api/:version/groups/:id/epics/:epic_iid/epics',
approverUsers(search, options, callback = () => {}) {
const url = Api.buildUrl('/autocomplete/users.json');
......@@ -48,4 +49,14 @@ export default {
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 {
'initialDescriptionText',
'lockVersion',
]),
isEpicTreeEnabled() {
return gon.features && gon.features.epicTrees;
},
},
};
</script>
......@@ -61,7 +64,7 @@ export default {
/>
</div>
<related-items
v-if="subepicsSupported"
v-if="subepicsSupported && !isEpicTreeEnabled"
:endpoint="epicLinksEndpoint"
:can-admin="canAdmin"
:can-reorder="canAdmin"
......@@ -72,6 +75,7 @@ export default {
css-class="js-related-epics-block"
/>
<related-items
v-if="!isEpicTreeEnabled"
:endpoint="issueLinksEndpoint"
:can-admin="canAdmin"
:can-reorder="canAdmin"
......
import $ from 'jquery';
import initRelatedItemsTree from 'ee/related_items_tree/related_items_tree_bundle';
import initRoadmap from 'ee/roadmap/roadmap_bundle';
export default class EpicTabs {
......@@ -7,17 +8,34 @@ export default class EpicTabs {
this.epicTabs = this.wrapper.querySelector('.js-epic-tabs-container');
this.discussionFilterContainer = this.epicTabs.querySelector('.js-discussion-filter-container');
this.treeTabLoaded = false;
this.roadmapTabLoaded = false;
this.bindEvents();
}
bindEvents() {
const $treeTab = $('#tree-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('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() {
this.wrapper.classList.remove('container-limited');
this.discussionFilterContainer.classList.add('hidden');
......
......@@ -5,6 +5,8 @@ import { GlLoadingIcon } from '@gitlab/ui';
import issueToken from './issue_token.vue';
import { autoCompleteTextMap, inputPlaceholderTextMap } from '../constants';
const SPACE_FACTOR = 1;
export default {
name: 'AddIssuableForm',
components: {
......@@ -71,6 +73,7 @@ export default {
this.gfmAutoComplete = new GfmAutoComplete(this.autoCompleteSources);
this.gfmAutoComplete.setup($input, {
issues: true,
epics: true,
});
$input.on('shown-issues.atwho', this.onAutoCompleteToggled.bind(this, true));
$input.on('hidden-issues.atwho', this.onAutoCompleteToggled.bind(this, false));
......@@ -89,9 +92,31 @@ export default {
methods: {
onInput() {
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', {
newValue: value,
caretPos: $(this.$refs.input).caret('pos'),
untouchedRawReferences: untouchedRawRefs,
touchedReference,
caretPos,
});
},
onFocus() {
......@@ -166,6 +191,7 @@ export default {
@input="onInput"
@focus="onFocus"
@blur="onBlur"
@keyup.escape.exact="onFormCancel"
/>
</li>
</ul>
......
......@@ -34,8 +34,6 @@ import {
addRelatedIssueErrorMap,
} from '../constants';
const SPACE_FACTOR = 1;
export default {
name: 'RelatedIssuesRoot',
components: {
......@@ -200,25 +198,7 @@ export default {
});
}
},
onInput({ newValue, caretPos }) {
const rawReferences = newValue.split(/\s/);
let touchedReference;
let iteratingPos = 0;
const untouchedRawReferences = rawReferences
.filter(reference => {
let isTouched = false;
if (caretPos >= iteratingPos && caretPos <= iteratingPos + reference.length) {
touchedReference = reference;
isTouched = true;
}
// `+ SPACE_FACTOR` to factor in the missing space we split at earlier
iteratingPos = iteratingPos + reference.length + SPACE_FACTOR;
return !isTouched;
})
.filter(reference => reference.trim().length > 0);
onInput({ untouchedRawReferences, touchedReference }) {
this.store.setPendingReferences(this.state.pendingReferences.concat(untouchedRawReferences));
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
before_action :authorize_update_issuable!, only: :update
before_action :authorize_create_epic!, only: [:create]
before_action do
push_frontend_feature_flag(:epic_trees)
end
def index
@epics = @issuables
......
......@@ -25,6 +25,10 @@
%li.notes-tab.qa-notes-tab
%a#discussion-tab.active{ href: '#discussion', data: { toggle: 'tab' } }
= _('Discussion')
- if Feature.enabled?(:epic_trees)
%li.tree-tab
%a#tree-tab{ href: '#tree', data: { toggle: 'tab' } }
= _('Tree')
%li.roadmap-tab
%a#roadmap-tab{ href: '#roadmap', data: { toggle: 'tab' } }
= _('Roadmap')
......@@ -39,6 +43,15 @@
%script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe
.issuable-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
.row
%section.col-md-12
......
......@@ -30,6 +30,11 @@ describe 'Epic Issues', :js do
sign_in(user)
visit group_epic_path(group, epic)
wait_for_requests
find('.js-epic-tabs-container #tree-tab').click
wait_for_requests
end
......@@ -39,27 +44,19 @@ describe 'Epic Issues', :js do
end
it 'user can see issues from public project but cannot delete the associations' do
within('.js-related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 1)
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li', count: 3)
expect(page).to have_content(public_issue.title)
expect(page).not_to have_selector('button.js-issue-item-remove-button')
end
end
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
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')
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')
expect(page).not_to have_selector('.related-items-tree-container .js-add-epics-button')
end
end
......@@ -69,23 +66,23 @@ describe 'Epic Issues', :js do
let(:epic_to_add) { create(:epic, group: group) }
def add_issues(references)
find('.js-related-issues-block h3.card-title button').click
find('.js-related-issues-block .js-add-issuable-form-input').set(references)
find('.related-items-tree-container .js-add-issues-button').click
find('.related-items-tree-container .js-add-issuable-form-input').set(references)
# When adding long references, for some reason the input gets stuck
# waiting for more text. Send a keystroke before clicking the button to
# get out of this mode.
find('.js-related-issues-block .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-input').send_keys(:tab)
find('.related-items-tree-container .js-add-issuable-form-add-button').click
wait_for_requests
end
def add_epics(references)
find('.js-related-epics-block h3.card-title button').click
find('.js-related-epics-block .js-add-issuable-form-input').set(references)
find('.related-items-tree-container .js-add-epics-button').click
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('.js-related-epics-block .js-add-issuable-form-add-button').click
find('.related-items-tree-container .js-add-issuable-form-input').send_keys(:tab)
find('.related-items-tree-container .js-add-issuable-form-add-button').click
wait_for_requests
end
......@@ -96,34 +93,36 @@ describe 'Epic Issues', :js do
end
it 'user can see all issues of the group and delete the associations' do
within('.js-related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 2)
within('.related-items-tree-container ul.related-items-list') do
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(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
first('#item-remove-confirmation .modal-footer .btn-danger').click
wait_for_requests
within('.js-related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 1)
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-issue', count: 1)
end
end
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
expect(page).to have_selector('li', count: 2)
within('.related-items-tree-container ul.related-items-list') do
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[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
first('#item-remove-confirmation .modal-footer .btn-danger').click
wait_for_requests
within('.js-related-epics-block ul.related-items-list') do
expect(page).to have_selector('li', count: 1)
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-epic', count: 1)
end
end
......@@ -131,20 +130,19 @@ describe 'Epic Issues', :js do
add_issues("#{issue_invalid.to_reference(full: true)}")
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
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)
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
expect(page).to have_selector('li', count: 3)
expect(page).to have_content(issue_to_add.title)
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-issue', count: 3)
end
end
......@@ -153,32 +151,11 @@ describe 'Epic Issues', :js do
add_epics(references)
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
expect(page).to have_selector('li', count: 3)
expect(page).to have_content(epic_to_add.title)
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-epic', count: 3)
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
......@@ -49,6 +49,7 @@ describe 'Epic show', :js do
it 'shows epic tabs' do
page.within('.js-epic-tabs-container') do
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')
end
end
......@@ -60,12 +61,21 @@ describe 'Epic show', :js do
end
end
describe 'Epic child epics' do
it 'shows child epics list' do
page.within('.js-related-epics-block') do
expect(find('.issue-count-badge-count')).to have_content('2')
expect(find('.js-related-issues-token-list-item:nth-child(1) .sortable-link')).to have_content('Child epic B')
expect(find('.js-related-issues-token-list-item:nth-child(2) .sortable-link')).to have_content('Child epic A')
describe 'Tree tab' do
before do
find('.js-epic-tabs-container #tree-tab').click
wait_for_requests
end
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
......
......@@ -45,4 +45,29 @@ describe('Api', () => {
.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('#');
});
});
});
});
});
This diff is collapsed.
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', () => {
it('when filling in the input', () => {
spyOn(vm, '$emit');
const newInputValue = 'filling in things';
const untouchedRawReferences = newInputValue.trim().split(/\s/);
const touchedReference = untouchedRawReferences.pop();
vm.$refs.input.value = newInputValue;
vm.onInput();
expect(vm.$emit).toHaveBeenCalledWith('addIssuableFormInput', {
newValue: newInputValue,
caretPos: newInputValue.length,
untouchedRawReferences,
touchedReference,
});
});
......
......@@ -304,8 +304,8 @@ describe('RelatedIssuesRoot', () => {
it('fill in issue number reference and adds to pending related issues', () => {
const input = '#123 ';
vm.onInput({
newValue: input,
caretPos: input.length,
untouchedRawReferences: [input.trim()],
touchedReference: input,
});
expect(vm.state.pendingReferences.length).toEqual(1);
......@@ -314,7 +314,7 @@ describe('RelatedIssuesRoot', () => {
it('fill in with full reference', () => {
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[0]).toEqual('asdf/qwer#444');
......@@ -323,7 +323,7 @@ describe('RelatedIssuesRoot', () => {
it('fill in with issue link', () => {
const link = 'http://localhost:3000/foo/bar/issues/111';
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[0]).toEqual(link);
......@@ -331,7 +331,7 @@ describe('RelatedIssuesRoot', () => {
it('fill in with multiple references', () => {
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[0]).toEqual('asdf/qwer#444');
......@@ -340,31 +340,12 @@ describe('RelatedIssuesRoot', () => {
it('fill in with some invalid things', () => {
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[0]).toEqual('something');
expect(vm.state.pendingReferences[1]).toEqual('random');
});
it('fill in invalid and some legit references', () => {
const input = 'something random #123 ';
vm.onInput({ 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', () => {
......
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 { 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,
},
],
},
},
},
},
};
This diff is collapsed.
......@@ -693,6 +693,9 @@ msgstr ""
msgid "Add an SSH key"
msgstr ""
msgid "Add an issue"
msgstr ""
msgid "Add approvers"
msgstr ""
......@@ -4915,18 +4918,51 @@ msgstr ""
msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
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"
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?"
msgstr ""
msgid "Epics|More information"
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."
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."
msgstr ""
......@@ -8554,6 +8590,9 @@ msgstr ""
msgid "New epic"
msgstr ""
msgid "New epic title"
msgstr ""
msgid "New file"
msgstr ""
......@@ -13900,6 +13939,9 @@ msgstr ""
msgid "TransferProject|Transfer failed, please contact an admin."
msgstr ""
msgid "Tree"
msgstr ""
msgid "Tree view"
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